Compare commits
25
Commits
v1.0.0
..
c9d726dd64
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9d726dd64 | ||
|
|
f59befdb00 | ||
|
|
15baa1e5d0 | ||
|
|
c9076655b9 | ||
|
|
e34d81498e | ||
|
|
155b721438 | ||
|
|
efaf016c5f | ||
|
|
2bdc0ea9a8 | ||
|
|
6c8bf0d3b3 | ||
|
|
c713f51f24 | ||
|
|
0ed36e9624 | ||
|
|
5178f80fdf | ||
|
|
10c41677bd | ||
|
|
01c754f857 | ||
|
|
691d3c255f | ||
|
|
295d1afb7d | ||
|
|
06c2014384 | ||
|
|
a4cfa11323 | ||
|
|
236cb07769 | ||
|
|
04993e0da3 | ||
|
|
7eb168f418 | ||
|
|
a1b6209470 | ||
|
|
44683a4996 | ||
|
|
870012514a | ||
|
|
012802d83d |
No files matched your search
@@ -7,5 +7,4 @@ docs
|
|||||||
bin
|
bin
|
||||||
*.exe
|
*.exe
|
||||||
README.md
|
README.md
|
||||||
LICENSE
|
|
||||||
Makefile
|
Makefile
|
||||||
+120
-3
@@ -5,10 +5,125 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- panel: **domain-admin role** — global administrators manage panel users and
|
||||||
|
assign domains; domain administrators see only their domains (applications,
|
||||||
|
DKIM/DNS, per-domain DMARC, deliveries, export, L2 limits). Status, full
|
||||||
|
backup, mail queue, system log, domain add/delete, and `/reload` stay
|
||||||
|
global-only. SQLite migration `0005_panel_users` migrates the single
|
||||||
|
administrator into a global user; sessions and full backup restore carry users
|
||||||
|
and domain bindings.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- panel: `/` redirects domain administrators to `/domains`; global users still
|
||||||
|
land on `/status`. Navigation hides global-only sections for domain
|
||||||
|
administrators.
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-08-10
|
||||||
|
|
||||||
|
The first MINOR after 1.0.0: send-only DMARC guidance in the panel, AGPL
|
||||||
|
packaging on every page, and an internal split of `internal/web` ahead of
|
||||||
|
domain-admin work. Upgrading runs one SQLite migration (empty defaults;
|
||||||
|
existing DNS guidance is unchanged until you set a report address).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `internal/web` split into subpackages (`web/view`, `web/auth`, `web/validate`,
|
||||||
|
`web/handlers`); the composition root (`web.New`, `web.Config`, `Server.Handler`)
|
||||||
|
is unchanged for `cmd/panel`. Templates and static assets moved under
|
||||||
|
`internal/web/view/`.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- panel: DMARC guidance for send-only relays — the suggested `_dmarc` record
|
||||||
|
is now `p=none` without `rua=` by default; *Settings* and each domain page
|
||||||
|
let you configure an optional aggregate-report address (profile default plus
|
||||||
|
per-domain inherit / none / custom). When `rua=` targets another domain, the
|
||||||
|
panel shows and DNS-checks the hub's `_report._dmarc` authorisation record.
|
||||||
|
Domain export/import carries per-domain overrides.
|
||||||
|
- AGPL packaging hygiene: [NOTICE](NOTICE) names the copyright holder and the
|
||||||
|
bundled third-party works (htmx 0BSD, IBM Plex OFL in outlined logos); the
|
||||||
|
panel footer on every page — including login and setup — shows copyright, a
|
||||||
|
link to `/license` (embedded AGPL text), a Source link to the public
|
||||||
|
repository, and "No warranty"; the runtime image ships `LICENSE` and
|
||||||
|
`NOTICE` under `/usr/share/doc/selfpost/`. `docs/development.md` now lists
|
||||||
|
the vendored htmx asset beside the Go module licences.
|
||||||
|
- [docs/roadmap.md](docs/roadmap.md) — candidate item **visual-style** (panel
|
||||||
|
visual refresh: typography, colour tokens, spacing, and component styling
|
||||||
|
without behaviour changes). Starting reference:
|
||||||
|
[docs/assets/selfpost-proof.html](docs/assets/selfpost-proof.html). No semver
|
||||||
|
impact; explicit agreement required before coding, like other candidates.
|
||||||
|
|
||||||
|
## [1.0.1] - 2026-08-09
|
||||||
|
|
||||||
|
A documentation and packaging release: no change to the mail path, the
|
||||||
|
database, or the on-disk layout. Upgrading is a tag bump.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `SECURITY.md` — how to report a vulnerability privately (GitHub private
|
||||||
|
vulnerability reporting, `public@mixeme.ru` as fallback), which releases get
|
||||||
|
fixes, and what is in and out of scope for a relay. No response time is
|
||||||
|
promised. Without it a finder's default move is a public issue, which
|
||||||
|
discloses a relay flaw to everyone the moment it is filed.
|
||||||
|
- [docs/plans/](docs/plans/) — one document per agreed extension: the optional
|
||||||
|
inbound relay, the domain-admin role, and splitting the oversized `web`
|
||||||
|
package. Each states scope, open questions, and what has to be true before
|
||||||
|
coding starts. [docs/roadmap.md](docs/roadmap.md) is restructured around them
|
||||||
|
as a 1.x+ tracker instead of a 2.x wishlist, and now says how to read it from
|
||||||
|
outside the project: nothing in it is a commitment, there are no dates, and
|
||||||
|
the stated order is a recommendation.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The panel's **Account** entry is now called **Settings** — nav link, page
|
||||||
|
heading, browser title, and the operator guide. The route stays `/account`,
|
||||||
|
so existing links and bookmarks are unaffected.
|
||||||
|
- The signed-in name in the panel's nav is now labelled `User:`, so it reads as
|
||||||
|
the current account rather than as a stray word above the Settings link.
|
||||||
|
- [docs/product.md](docs/product.md) reframes the future line: the inbound
|
||||||
|
relay and the domain-admin role are agreed **1.x+** extensions tracked in the
|
||||||
|
roadmap and the plans, with the inbound relay targeting a MINOR bump by
|
||||||
|
default and a 2.x major still possible pending implementation. Only items the
|
||||||
|
roadmap still marks *candidate* need explicit approval before coding. It
|
||||||
|
previously put the whole line behind a 2.x.x that nothing had committed to.
|
||||||
|
- [docs/security.md](docs/security.md) is now in English, matching the rest of
|
||||||
|
the published docs — it is linked from the README table and from
|
||||||
|
`SECURITY.md`, so a reader following either landed in Russian. Content is
|
||||||
|
unchanged: same requirements, same accepted risks, same ADR. The reviewing
|
||||||
|
model is no longer named in the text; the fact that a pre-release review ran,
|
||||||
|
and its date, stay. The roadmap and the plans are in English for the same
|
||||||
|
reason, and neither records the model assigned to an item any more.
|
||||||
|
- The README documentation table now points at `SECURITY.md` for reporting a
|
||||||
|
vulnerability, and the `docs/security.md` row is renamed *Security design* —
|
||||||
|
with two files a reader could reasonably call "security", the table said
|
||||||
|
which is which only by accident. The roadmap row no longer calls the file
|
||||||
|
internal and Russian, because it is neither. `development.md` lists
|
||||||
|
`SECURITY.md` among the user-facing deliverables.
|
||||||
|
- `docs/development.md` records the decision on authorship: SelfPost is written
|
||||||
|
by AI agents under a maintainer's direction and the project discloses that,
|
||||||
|
so the `Co-Authored-By` trailers, the model routing table, and the agent
|
||||||
|
rules file all stay. Written down to settle the question rather than have it
|
||||||
|
reopened at each release.
|
||||||
|
- The two remaining Russian source comments are in English:
|
||||||
|
`deploy/traefik/extract-cert.sh` (quote from spec 10.3) and
|
||||||
|
`internal/app/sasl.go`, where the quotation from the closed plan is dropped
|
||||||
|
rather than translated — rendered in English it restated the sentence it was
|
||||||
|
attached to. The Cyrillic that remains is test data, where it is the point.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Release CI: retry `docker push` / `imagetools create` on transient GHCR
|
- The panel's static assets are served with a content ETag and
|
||||||
`unknown blob` (and similar) errors after layers already uploaded.
|
`Cache-Control: no-cache`. They are embedded in the binary, so their
|
||||||
|
modification times are the zero value and no `Last-Modified` was sent; with
|
||||||
|
no validator at all the browser was free to guess how long to keep them,
|
||||||
|
which is why a tab kept showing the previous favicon after the new mark
|
||||||
|
shipped. Each asset is now hashed once at startup, so an unchanged one costs
|
||||||
|
a bodyless 304 and a changed one is picked up on the next load. A browser
|
||||||
|
that cached an asset *before* this release still has nothing to revalidate
|
||||||
|
against, so that one copy has to be cleared by hand.
|
||||||
|
|
||||||
## [1.0.0] - 2026-08-09
|
## [1.0.0] - 2026-08-09
|
||||||
|
|
||||||
@@ -59,6 +174,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
- E2e gate: wait for host-published `/healthz` before panel setup, and stop
|
- E2e gate: wait for host-published `/healthz` before panel setup, and stop
|
||||||
ordered `TestE2E` subtests after a failure so a nil panel client cannot panic
|
ordered `TestE2E` subtests after a failure so a nil panel client cannot panic
|
||||||
and mask the real error (release CI on both amd64 and arm64).
|
and mask the real error (release CI on both amd64 and arm64).
|
||||||
|
- Release CI: retry `docker push` / `imagetools create` on transient GHCR
|
||||||
|
`unknown blob` (and similar) errors after layers already uploaded.
|
||||||
- A send-log row could stay `queued` forever after the container was recreated.
|
- A send-log row could stay `queued` forever after the container was recreated.
|
||||||
`mail.log` moved from the ephemeral `/var/log` into the data volume
|
`mail.log` moved from the ephemeral `/var/log` into the data volume
|
||||||
(`/data/log/mail.log`, `./data/log/` on the host), so the delivery lines that
|
(`/data/log/mail.log`, `./data/log/` on the host), so the delivery lines that
|
||||||
@@ -368,7 +485,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
null recipient of a double bounce, `orig_to=` alongside `to=`, an
|
null recipient of a double bounce, `orig_to=` alongside `to=`, an
|
||||||
unrecognised status word, a capitalised one, and a cleanup line.
|
unrecognised status word, a capitalised one, and a cleanup line.
|
||||||
- docs: README *Encrypting a backup or export*; `docs/security.md` §
|
- docs: README *Encrypting a backup or export*; `docs/security.md` §
|
||||||
*Резервная копия и экспорт домена* + accepted risk (encryption is opt-in);
|
*Backup and domain export* + accepted risk (encryption is opt-in);
|
||||||
`docs/architecture.md` persistence § envelope summary.
|
`docs/architecture.md` persistence § envelope summary.
|
||||||
- docs: `docs/roadmap.md` v1.x tail — retire `implementation-plan.md` in the
|
- docs: `docs/roadmap.md` v1.x tail — retire `implementation-plan.md` in the
|
||||||
release commit (move to `docs/archive/`, retarget its references in README,
|
release commit (move to `docs/archive/`, retarget its references in README,
|
||||||
|
|||||||
@@ -0,0 +1,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.
|
||||||
@@ -35,11 +35,14 @@ send log and DNS checks in the panel, encrypted backups.
|
|||||||
| [**Operator guide**](docs/guide.md) | Reverse proxy, environment variables, DNS, IP warmup, panel operations, rate limiting, backup/restore, ports, image tag |
|
| [**Operator guide**](docs/guide.md) | Reverse proxy, environment variables, DNS, IP warmup, panel operations, rate limiting, backup/restore, ports, image tag |
|
||||||
| [Product boundaries](docs/product.md) | Purpose, deployment assumptions, out-of-scope items, multi-domain model |
|
| [Product boundaries](docs/product.md) | Purpose, deployment assumptions, out-of-scope items, multi-domain model |
|
||||||
| [Architecture](docs/architecture.md) | As-built technical design |
|
| [Architecture](docs/architecture.md) | As-built technical design |
|
||||||
| [Security](docs/security.md) | Accepted security trade-offs and requirements |
|
| [Security design](docs/security.md) | Mandatory requirements, accepted risks, the CSRF ADR |
|
||||||
| [Development](docs/development.md) | Building, testing, docs rules, model routing, commits |
|
| [Development](docs/development.md) | Building, testing, docs rules, model routing, commits |
|
||||||
| [Roadmap](docs/roadmap.md) | Open work (v1.x tail, 2.x) — internal, Russian |
|
| [Roadmap](docs/roadmap.md) | Open work (1.x+) — direction, not commitments |
|
||||||
| [CHANGELOG](CHANGELOG.md) | Release history |
|
| [CHANGELOG](CHANGELOG.md) | Release history |
|
||||||
|
|
||||||
|
Found a vulnerability? Do not open an issue — [SECURITY.md](SECURITY.md) has
|
||||||
|
the private reporting channel and the scope.
|
||||||
|
|
||||||
Repository: <https://github.com/mixeme/selfpost> — source, issues, releases, and
|
Repository: <https://github.com/mixeme/selfpost> — source, issues, releases, and
|
||||||
the `ghcr.io/mixeme/selfpost` image.
|
the `ghcr.io/mixeme/selfpost` image.
|
||||||
|
|
||||||
@@ -92,7 +95,7 @@ docker run --rm -d --name selfpost-try \
|
|||||||
-e SELFPOST_HOSTNAME=mail.local.test \
|
-e SELFPOST_HOSTNAME=mail.local.test \
|
||||||
-e PANEL_COOKIE_SECURE=false \
|
-e PANEL_COOKIE_SECURE=false \
|
||||||
-v selfpost-try-data:/data \
|
-v selfpost-try-data:/data \
|
||||||
ghcr.io/mixeme/selfpost:1.0.0
|
ghcr.io/mixeme/selfpost:1.1.0
|
||||||
```
|
```
|
||||||
|
|
||||||
**Get the setup URL** (pick one):
|
**Get the setup URL** (pick one):
|
||||||
@@ -259,6 +262,9 @@ Optional variables (`TRUSTED_PROXY_CIDR`, rate limits, retention): see
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
Copyright © 2026 Mikhail Yenuchenko.
|
||||||
|
|
||||||
[AGPL-3.0](LICENSE). The AGPL closes the "SaaS loophole": if you run a modified
|
[AGPL-3.0](LICENSE). The AGPL closes the "SaaS loophole": if you run a modified
|
||||||
version as a network-accessible service, you must make the modified source
|
version as a network-accessible service, you must make the modified source
|
||||||
available to its users — not only when you distribute copies of the code.
|
available to its users — not only when you distribute copies of the code.
|
||||||
|
Third-party notices: [NOTICE](NOTICE).
|
||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
# Security policy
|
||||||
|
|
||||||
|
## Supported versions
|
||||||
|
|
||||||
|
SelfPost follows SemVer. Fixes are issued for the **latest minor release of the
|
||||||
|
1.x line** only; there is no backporting to earlier minors. Upgrade before
|
||||||
|
reporting if you are behind — the image tag is `ghcr.io/mixeme/selfpost:X.Y.Z`.
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
|---|---|
|
||||||
|
| latest 1.x | yes |
|
||||||
|
| earlier 1.x | no — upgrade first |
|
||||||
|
| 0.x | no (pre-release) |
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
**Do not open a public issue.** Use GitHub's private vulnerability reporting:
|
||||||
|
the *Report a vulnerability* button under the repository's
|
||||||
|
[Security tab](https://github.com/mixeme/selfpost/security). If you cannot use
|
||||||
|
it, mail `public@mixeme.ru` instead.
|
||||||
|
|
||||||
|
Useful in a report: the image tag, the reverse proxy in front of the panel, the
|
||||||
|
steps to reproduce, and what an attacker gains. A relevant excerpt of
|
||||||
|
`mail.log` or the panel's system log helps; strip recipient addresses first.
|
||||||
|
|
||||||
|
**No response time is promised.** SelfPost is maintained by one person, and a
|
||||||
|
deadline that cannot be honoured is worse than none. Reports are read and
|
||||||
|
answered as soon as the maintainer is able; a fix ships in a patch release,
|
||||||
|
with the timeline agreed in the thread.
|
||||||
|
|
||||||
|
Disclosure is coordinated by request, not by demand: please hold public details
|
||||||
|
until a patch is out. If you get no reply, that is not a request for a
|
||||||
|
continued embargo — disclose at your own discretion. Reporters are credited in
|
||||||
|
the CHANGELOG unless they ask not to be.
|
||||||
|
|
||||||
|
## In scope
|
||||||
|
|
||||||
|
The relay's job is to accept authenticated mail from an application and hand it
|
||||||
|
to the internet as the operator's domain, and nothing else. Breaking that is in
|
||||||
|
scope:
|
||||||
|
|
||||||
|
- **Open relay** — mail accepted from an unauthenticated sender, or relayed for
|
||||||
|
a domain the sending application is not bound to
|
||||||
|
- **SASL bypass** — sending without valid credentials, or credential recovery
|
||||||
|
from anything the container exposes
|
||||||
|
- **Cross-domain access** — an application or a panel session reaching a domain
|
||||||
|
it was not granted
|
||||||
|
- **Secret disclosure** — DKIM private keys, the admin password hash, session
|
||||||
|
tokens, or backup encryption material leaking to an unauthorised party
|
||||||
|
- **Panel authentication and session flaws** — login bypass, session fixation,
|
||||||
|
CSRF on state-changing routes, privilege escalation
|
||||||
|
- **Rate-limit bypass** — evading either the Postfix-level backstop or the
|
||||||
|
per-domain and per-application limits
|
||||||
|
- **Container escape** or privilege escalation from the panel's unprivileged
|
||||||
|
user to root
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
These are the operator's responsibility or accepted trade-offs, documented in
|
||||||
|
[docs/security.md](docs/security.md) and the
|
||||||
|
[operator guide](docs/guide.md):
|
||||||
|
|
||||||
|
- Host configuration the operator controls: a blocked port 25, a missing or
|
||||||
|
wrong PTR record, DNS records not published, a self-signed or expired
|
||||||
|
certificate on the reverse proxy
|
||||||
|
- Anything requiring the attacker to already have root on the host or write
|
||||||
|
access to the `./data` bind mount
|
||||||
|
- Missing hardening headers or TLS options on the reverse proxy — SelfPost
|
||||||
|
never terminates HTTPS itself
|
||||||
|
- Deliverability outcomes: mail rejected or filtered by a receiving provider is
|
||||||
|
a policy decision of that provider, not a defect
|
||||||
|
- Denial of service through sheer volume against a single-tenant relay
|
||||||
|
- Vulnerabilities in upstream Postfix, OpenDKIM, or the base image — report
|
||||||
|
those upstream; if SelfPost's configuration makes an upstream issue
|
||||||
|
exploitable when it otherwise would not be, that *is* in scope
|
||||||
|
|
||||||
|
## Reports we cannot act on
|
||||||
|
|
||||||
|
Automated scanner output with no demonstrated impact, and reports whose only
|
||||||
|
content is a version number compared against a CVE list, are closed without
|
||||||
|
investigation.
|
||||||
@@ -86,6 +86,10 @@ RUN mkdir -p /run/opendkim /run/selfpost /data \
|
|||||||
COPY --from=build /out/panel /usr/local/bin/panel
|
COPY --from=build /out/panel /usr/local/bin/panel
|
||||||
COPY --from=build /out/selfpost-backup /usr/local/bin/selfpost-backup
|
COPY --from=build /out/selfpost-backup /usr/local/bin/selfpost-backup
|
||||||
|
|
||||||
|
# Licence text shipped with the image (AGPL-3.0 conveyance). The panel also
|
||||||
|
# serves the same text at /license from an embedded copy.
|
||||||
|
COPY LICENSE NOTICE /usr/share/doc/selfpost/
|
||||||
|
|
||||||
COPY build/opendkim.conf /etc/opendkim.conf
|
COPY build/opendkim.conf /etc/opendkim.conf
|
||||||
COPY build/logrotate-mail.conf /etc/logrotate.d/mail
|
COPY build/logrotate-mail.conf /etc/logrotate.d/mail
|
||||||
COPY build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh
|
COPY build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
// several roles (architecture.md § Image and processes) as a supervised
|
// several roles (architecture.md § Image and processes) as a supervised
|
||||||
// process: the HTTP panel server, the journal-milter, the mail.log tailer and
|
// process: the HTTP panel server, the journal-milter, the mail.log tailer and
|
||||||
// the rate-limit checks.
|
// the rate-limit checks.
|
||||||
|
//
|
||||||
|
// Copyright (C) 2026 Mikhail Yenuchenko
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -16,6 +16,9 @@
|
|||||||
// the same password:
|
// the same password:
|
||||||
//
|
//
|
||||||
// docker exec -i <container> selfpost-backup -decrypt < backup.spbk > backup.tar.gz
|
// docker exec -i <container> selfpost-backup -decrypt < backup.spbk > backup.tar.gz
|
||||||
|
//
|
||||||
|
// Copyright (C) 2026 Mikhail Yenuchenko
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
selfpost:
|
selfpost:
|
||||||
image: ghcr.io/mixeme/selfpost:1.0.0
|
image: ghcr.io/mixeme/selfpost:1.1.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json
|
# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json
|
||||||
# (spec 10.3: "Traefik — сертификаты в acme.json, потребуется шаг извлечения
|
# (spec 10.3: "Traefik keeps certificates in acme.json, so a PEM extraction
|
||||||
# PEM"). Run this on the host, after Traefik has issued or renewed the
|
# step is required"). Run this on the host, after Traefik has issued or
|
||||||
# certificate, and again on a schedule (cron/systemd timer) since acme.json
|
# renewed the certificate, and again on a schedule (cron/systemd timer) since
|
||||||
# is not itself watched by SelfPost/Postfix.
|
# acme.json is not itself watched by SelfPost/Postfix.
|
||||||
#
|
#
|
||||||
# Requires jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
|
# Requires jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
|
||||||
set -eu
|
set -eu
|
||||||
|
|||||||
+11
-3
@@ -188,10 +188,10 @@ holds the cookie works after process restart, redeploy, or full backup restore.
|
|||||||
- **Idle timeout** — sliding window, `PANEL_SESSION_IDLE_DAYS` (default 7); no
|
- **Idle timeout** — sliding window, `PANEL_SESSION_IDLE_DAYS` (default 7); no
|
||||||
absolute cap (regular use keeps the session alive indefinitely).
|
absolute cap (regular use keeps the session alive indefinitely).
|
||||||
- **Renewal** — DB `last_seen` and cookie `Max-Age` update at most once per hour
|
- **Renewal** — DB `last_seen` and cookie `Max-Age` update at most once per hour
|
||||||
(`renewThreshold` in [internal/web/session.go](../internal/web/session.go)).
|
(`renewThreshold` in [internal/web/auth/session.go](../internal/web/auth/session.go)).
|
||||||
- **Password change** — all other sessions are deleted; the current session stays
|
- **Password change** — all other sessions are deleted; the current session stays
|
||||||
active ([internal/store/sessions.go](../internal/store/sessions.go),
|
active ([internal/store/sessions.go](../internal/store/sessions.go),
|
||||||
[handlers_account.go](../internal/web/handlers_account.go)).
|
[handlers_account.go](../internal/web/handlers/handlers_account.go)).
|
||||||
|
|
||||||
Restoring an **older** backup also restores session rows: a session invalidated
|
Restoring an **older** backup also restores session rows: a session invalidated
|
||||||
after that backup was taken can become valid again if the browser still has the
|
after that backup was taken can become valid again if the browser still has the
|
||||||
@@ -218,7 +218,15 @@ flowchart TB
|
|||||||
backupcli["selfpost-backup CLI"]
|
backupcli["selfpost-backup CLI"]
|
||||||
end
|
end
|
||||||
subgraph web ["internal/web — HTTP surface"]
|
subgraph web ["internal/web — HTTP surface"]
|
||||||
handlers["handlers_*.go, templates, session/security"]
|
webRoot["web.go — router, security"]
|
||||||
|
viewPkg["web/view — templates, static"]
|
||||||
|
authPkg["web/auth — session, login, setup"]
|
||||||
|
handlersPkg["web/handlers — authenticated pages"]
|
||||||
|
webRoot --> viewPkg
|
||||||
|
webRoot --> authPkg
|
||||||
|
webRoot --> handlersPkg
|
||||||
|
handlersPkg --> authPkg
|
||||||
|
handlersPkg --> viewPkg
|
||||||
end
|
end
|
||||||
subgraph services ["Services — multi-store operations + rollback"]
|
subgraph services ["Services — multi-store operations + rollback"]
|
||||||
domainSvc["internal/domain"]
|
domainSvc["internal/domain"]
|
||||||
|
|||||||
+53
-10
@@ -1,8 +1,9 @@
|
|||||||
# SelfPost — development
|
# SelfPost — development
|
||||||
|
|
||||||
**What this file is.** How to build, test, document, and ship changes. Open
|
**What this file is.** How to build, test, document, and ship changes. Open
|
||||||
work for 2.x lives in [roadmap.md](roadmap.md). Product boundaries:
|
work after 1.0 (1.x+) lives in [roadmap.md](roadmap.md) and linked
|
||||||
[product.md](product.md). As-built layout: [architecture.md](architecture.md).
|
[plans/](plans/). Product boundaries: [product.md](product.md). As-built layout:
|
||||||
|
[architecture.md](architecture.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -11,16 +12,46 @@ work for 2.x lives in [roadmap.md](roadmap.md). Product boundaries:
|
|||||||
After `/clear` or a fresh chat:
|
After `/clear` or a fresh chat:
|
||||||
|
|
||||||
1. Read this file (process, docs rules, model routing).
|
1. Read this file (process, docs rules, model routing).
|
||||||
2. Open [roadmap.md](roadmap.md) for open work. Accepted risks —
|
2. Open [roadmap.md](roadmap.md) for the index of open work; follow the linked
|
||||||
[security.md](security.md); as-built — [architecture.md](architecture.md).
|
plan file for the active item. Accepted risks — [security.md](security.md);
|
||||||
|
as-built — [architecture.md](architecture.md).
|
||||||
3. Skim [product.md](product.md) if scope is in doubt.
|
3. Skim [product.md](product.md) if scope is in doubt.
|
||||||
4. Continue from the next unchecked step in the active plan.
|
4. Continue from the next unchecked step in the **active** plan file (not the
|
||||||
|
roadmap index).
|
||||||
|
|
||||||
History of closed phases is in `git log` and [CHANGELOG.md](../CHANGELOG.md),
|
History of closed phases is in `git log` and [CHANGELOG.md](../CHANGELOG.md),
|
||||||
not duplicated here.
|
not duplicated here.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Authorship and disclosure
|
||||||
|
|
||||||
|
**Decided, not open for re-litigation.** SelfPost is written by AI agents under
|
||||||
|
a maintainer's direction, and the project says so rather than hiding it.
|
||||||
|
|
||||||
|
Concretely, this is what "says so" means, and none of it is an oversight to be
|
||||||
|
tidied away later:
|
||||||
|
|
||||||
|
- `Co-Authored-By: Claude <model>` trailers stay in commit messages, including
|
||||||
|
the ~140 commits that predate v1.0.
|
||||||
|
- The model routing table below is public, in a file the README links to.
|
||||||
|
- [.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc) ships in
|
||||||
|
the repository.
|
||||||
|
- Process notes written for an agent — "after a context reset, pick an item
|
||||||
|
marked `agreed`" in [roadmap.md](roadmap.md) — stay as they are.
|
||||||
|
|
||||||
|
**Why not quietly drop it.** Once the trailers are in the history, removing the
|
||||||
|
routing table or the rules file would not conceal authorship, it would only
|
||||||
|
make the project look like it was trying to. Partial concealment reads worse
|
||||||
|
than the plain statement, and the plain statement costs nothing: the code is
|
||||||
|
reviewed, tested, and shipped under the same rules either way, and the
|
||||||
|
[security design](security.md) records what was audited and what was accepted.
|
||||||
|
|
||||||
|
**Revisit if:** the disclosure ever conflicts with the licence or a downstream
|
||||||
|
obligation — not because the convention around AI authorship shifts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Model routing
|
## Model routing
|
||||||
|
|
||||||
| Kind of work | Model | Examples |
|
| Kind of work | Model | Examples |
|
||||||
@@ -61,8 +92,9 @@ mechanics → Haiku. Reviewers must not be the author of the code under review.
|
|||||||
|
|
||||||
## External libraries
|
## External libraries
|
||||||
|
|
||||||
The project is **AGPL-3.0** ([LICENSE](../LICENSE)). New Go dependencies must
|
The project is **AGPL-3.0** ([LICENSE](../LICENSE)). Copyright holder and
|
||||||
be permissive or GPL-family (see
|
third-party notices: [NOTICE](../NOTICE). New Go dependencies must be
|
||||||
|
permissive or GPL-family (see
|
||||||
[.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)).
|
[.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)).
|
||||||
|
|
||||||
### Main module (`go.mod`)
|
### Main module (`go.mod`)
|
||||||
@@ -76,6 +108,12 @@ be permissive or GPL-family (see
|
|||||||
Transitive dependencies — `go mod graph` / `go.sum`; all indirect packages in
|
Transitive dependencies — `go mod graph` / `go.sum`; all indirect packages in
|
||||||
the tree are AGPL-3.0-compatible.
|
the tree are AGPL-3.0-compatible.
|
||||||
|
|
||||||
|
### Vendored front-end
|
||||||
|
|
||||||
|
| Asset | Version | Repository | License |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `internal/web/view/static/htmx.min.js` | 2.0.4 | <https://github.com/bigskysoftware/htmx> | 0BSD |
|
||||||
|
|
||||||
### E2e module (`test/e2e/go.mod`)
|
### E2e module (`test/e2e/go.mod`)
|
||||||
|
|
||||||
| Package | Version | Repository | License |
|
| Package | Version | Repository | License |
|
||||||
@@ -90,6 +128,8 @@ the image.
|
|||||||
Postfix, OpenDKIM, `supervisord`, `sasl2-bin`, `logrotate`, and others come
|
Postfix, OpenDKIM, `supervisord`, `sasl2-bin`, `logrotate`, and others come
|
||||||
from Debian bookworm repositories; licenses are in each package's `copyright`
|
from Debian bookworm repositories; licenses are in each package's `copyright`
|
||||||
file on <https://packages.debian.org/bookworm/>.
|
file on <https://packages.debian.org/bookworm/>.
|
||||||
|
The image also ships [LICENSE](../LICENSE) and [NOTICE](../NOTICE) under
|
||||||
|
`/usr/share/doc/selfpost/`. The panel serves the AGPL text at `/license`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -101,7 +141,7 @@ Requires Go 1.26+ and `CGO_ENABLED=0`.
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
make build # bin/panel, bin/selfpost-backup (VERSION=dev by default)
|
make build # bin/panel, bin/selfpost-backup (VERSION=dev by default)
|
||||||
make build VERSION=1.0.0
|
make build VERSION=1.1.0
|
||||||
```
|
```
|
||||||
|
|
||||||
Or directly:
|
Or directly:
|
||||||
@@ -273,7 +313,8 @@ There is no `docs/archive/` directory.
|
|||||||
| As-built design | [architecture.md](architecture.md) |
|
| As-built design | [architecture.md](architecture.md) |
|
||||||
| Development process (this file) | [development.md](development.md) |
|
| Development process (this file) | [development.md](development.md) |
|
||||||
| Security requirements and accepted risks | [security.md](security.md) |
|
| Security requirements and accepted risks | [security.md](security.md) |
|
||||||
| Internal roadmap (2.x) | [roadmap.md](roadmap.md) |
|
| Roadmap (1.x+) | [roadmap.md](roadmap.md) |
|
||||||
|
| Active design plans | [plans/](plans/) |
|
||||||
| Release history | [CHANGELOG.md](../CHANGELOG.md) |
|
| Release history | [CHANGELOG.md](../CHANGELOG.md) |
|
||||||
|
|
||||||
### User-facing deliverables
|
### User-facing deliverables
|
||||||
@@ -282,7 +323,9 @@ There is no `docs/archive/` directory.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| [README.md](../README.md) | Overview, requirements, quick start, docs index, reference deploy, licence |
|
| [README.md](../README.md) | Overview, requirements, quick start, docs index, reference deploy, licence |
|
||||||
| [guide.md](guide.md) | Proxy, env, DNS, IP warmup, operations, rate limiting, backup, ports, image tag |
|
| [guide.md](guide.md) | Proxy, env, DNS, IP warmup, operations, rate limiting, backup, ports, image tag |
|
||||||
|
| [SECURITY.md](../SECURITY.md) | Private reporting channel, supported versions, scope |
|
||||||
| [LICENSE](../LICENSE) | AGPL-3.0 full text |
|
| [LICENSE](../LICENSE) | AGPL-3.0 full text |
|
||||||
|
| [NOTICE](../NOTICE) | Copyright holder and third-party attributions |
|
||||||
| [deploy/docker-compose.yml](../deploy/docker-compose.yml) + proxies | Apache + nginx/Caddy/Traefik under [deploy/](../deploy/) |
|
| [deploy/docker-compose.yml](../deploy/docker-compose.yml) + proxies | Apache + nginx/Caddy/Traefik under [deploy/](../deploy/) |
|
||||||
| [deploy/.env.example](../deploy/.env.example) | Public env template; full reference in [guide.md](guide.md) |
|
| [deploy/.env.example](../deploy/.env.example) | Public env template; full reference in [guide.md](guide.md) |
|
||||||
| [CHANGELOG.md](../CHANGELOG.md) | Keep a Changelog |
|
| [CHANGELOG.md](../CHANGELOG.md) | Keep a Changelog |
|
||||||
@@ -311,7 +354,7 @@ to prose.
|
|||||||
| Mail path | [build/postfix-config.sh](../build/postfix-config.sh) |
|
| Mail path | [build/postfix-config.sh](../build/postfix-config.sh) |
|
||||||
| Panel routes | [internal/web/web.go](../internal/web/web.go) |
|
| Panel routes | [internal/web/web.go](../internal/web/web.go) |
|
||||||
| Backup / restore, domain export | [internal/backup/](../internal/backup/), [cmd/selfpost-backup/](../cmd/selfpost-backup/) |
|
| Backup / restore, domain export | [internal/backup/](../internal/backup/), [cmd/selfpost-backup/](../cmd/selfpost-backup/) |
|
||||||
| Sessions | [internal/store/sessions.go](../internal/store/sessions.go), [internal/web/session.go](../internal/web/session.go) |
|
| Sessions | [internal/store/sessions.go](../internal/store/sessions.go), [internal/web/auth/session.go](../internal/web/auth/session.go) |
|
||||||
| Log rotation, reload | [build/logrotate-mail.conf](../build/logrotate-mail.conf), [build/logrotate-loop.sh](../build/logrotate-loop.sh), [build/postfix-cert-reload.sh](../build/postfix-cert-reload.sh) |
|
| Log rotation, reload | [build/logrotate-mail.conf](../build/logrotate-mail.conf), [build/logrotate-loop.sh](../build/logrotate-loop.sh), [build/postfix-cert-reload.sh](../build/postfix-cert-reload.sh) |
|
||||||
| Deploy | [deploy/docker-compose.yml](../deploy/docker-compose.yml), [build/Dockerfile](../build/Dockerfile) |
|
| Deploy | [deploy/docker-compose.yml](../deploy/docker-compose.yml), [build/Dockerfile](../build/Dockerfile) |
|
||||||
| Operator checklist | [§ User-facing deliverables](#user-facing-deliverables); detail — [guide.md](guide.md) |
|
| Operator checklist | [§ User-facing deliverables](#user-facing-deliverables); detail — [guide.md](guide.md) |
|
||||||
|
|||||||
+11
-5
@@ -148,8 +148,13 @@ Two different scopes — don't confuse them:
|
|||||||
behalf (e.g. `v=spf1 a mx ip4:<server IP> -all`, adjusted to your setup).
|
behalf (e.g. `v=spf1 a mx ip4:<server IP> -all`, adjusted to your setup).
|
||||||
- **DKIM** — a TXT record with the exact value the panel shows on that
|
- **DKIM** — a TXT record with the exact value the panel shows on that
|
||||||
domain's page (`domain page → DKIM TXT record`), one selector per domain.
|
domain's page (`domain page → DKIM TXT record`), one selector per domain.
|
||||||
- **DMARC** — a `_dmarc` TXT record (even a conservative `p=none` starts
|
- **DMARC** — a `_dmarc` TXT record. The panel suggests `p=none` (monitoring
|
||||||
building reporting/reputation history).
|
only, safe to publish immediately). On a send-only relay the sending domain
|
||||||
|
often has no inbox, so `rua=` is optional — configure a default report address
|
||||||
|
in *Settings* or per domain when you have a mailbox that receives inbound mail
|
||||||
|
elsewhere. If `rua=` points at another domain, publish `_report._dmarc` on that
|
||||||
|
hub domain too; the panel checks it. Public mail hosts (Gmail, Outlook, …)
|
||||||
|
cannot be used as external report destinations.
|
||||||
|
|
||||||
Skipping any of the three per-domain records is the single most common reason
|
Skipping any of the three per-domain records is the single most common reason
|
||||||
mail lands in spam even though SelfPost delivered it correctly — DKIM passing
|
mail lands in spam even though SelfPost delivered it correctly — DKIM passing
|
||||||
@@ -160,7 +165,8 @@ The panel checks both scopes for you and tells you what is actually published:
|
|||||||
the *Status* page verifies the server's hostname and its reverse record
|
the *Status* page verifies the server's hostname and its reverse record
|
||||||
(forward-confirmed reverse DNS), and each domain's page shows a *DNS status*
|
(forward-confirmed reverse DNS), and each domain's page shows a *DNS status*
|
||||||
card comparing the published DKIM record against the key this server signs with,
|
card comparing the published DKIM record against the key this server signs with,
|
||||||
plus the domain's SPF and DMARC records. Results are cached for a few minutes;
|
plus the domain's SPF, DMARC, and (when configured) DMARC report-authorisation
|
||||||
|
records. Results are cached for a few minutes;
|
||||||
use *Re-check* right after publishing a record. The SPF check is deliberately
|
use *Re-check* right after publishing a record. The SPF check is deliberately
|
||||||
shallow — it looks for a mechanism that literally covers this server's address
|
shallow — it looks for a mechanism that literally covers this server's address
|
||||||
and does not follow `include:` or `redirect=`, so a record that authorizes the
|
and does not follow `include:` or `redirect=`, so a record that authorizes the
|
||||||
@@ -223,7 +229,7 @@ service healthy and will mail be accepted?"
|
|||||||
- **Backup** (`/backup`) — download a full-server backup; the same page hosts
|
- **Backup** (`/backup`) — download a full-server backup; the same page hosts
|
||||||
the domain-import form (`POST /domains/import`). See
|
the domain-import form (`POST /domains/import`). See
|
||||||
[Backup, restore, and moving a single domain](#backup-restore-and-moving-a-single-domain).
|
[Backup, restore, and moving a single domain](#backup-restore-and-moving-a-single-domain).
|
||||||
- **Account** (`/account`) — change the administrator username and/or password.
|
- **Settings** (`/account`) — change the administrator username and/or password.
|
||||||
Application SASL logins are separate and are not changed here.
|
Application SASL logins are separate and are not changed here.
|
||||||
|
|
||||||
**Sessions.** A login survives a container restart: sessions live in SQLite, not
|
**Sessions.** A login survives a container restart: sessions live in SQLite, not
|
||||||
@@ -375,7 +381,7 @@ but it can look like an open port in external scans.
|
|||||||
## Fixed image tag
|
## Fixed image tag
|
||||||
|
|
||||||
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
|
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
|
||||||
deliberately never `:latest`. The current pin is `1.0.0`. Intermediate
|
deliberately never `:latest`. The current pin is `1.1.0`. Intermediate
|
||||||
CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that
|
CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that
|
||||||
image was published. Pinning matters because of the backup version check above:
|
image was published. Pinning matters because of the backup version check above:
|
||||||
the panel binary's embedded version and the image tag that produced it are the
|
the panel binary's embedded version and the image tag that produced it are the
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Plan: dmarc-reports
|
||||||
|
|
||||||
|
**Status:** candidate
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
SelfPost **receives** DMARC aggregate reports on SMTP, parses them inside the
|
||||||
|
image, and **shows summaries in the panel** — pass/fail by source, hints when
|
||||||
|
`tighten p=` is reasonable. No external DMARC SaaS and no IMAP workflow for the
|
||||||
|
operator.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In:**
|
||||||
|
- Inbound SMTP for configured report addresses only (not a general backup-MX).
|
||||||
|
- gzip + XML aggregate parsing → SQLite summaries per sending domain.
|
||||||
|
- Panel page and/or per-domain section: recent reports, third-party senders,
|
||||||
|
delivery health of report ingestion.
|
||||||
|
- Reuse `admin.dmarc_report_email` and `domains.dmarc_rua` for DNS templates;
|
||||||
|
when enabled, suggest a SelfPost-hosted report address.
|
||||||
|
|
||||||
|
**Out:**
|
||||||
|
- Forensic reports (`ruf=`).
|
||||||
|
- Full dashboards, APIs, email alerting.
|
||||||
|
- Mailboxes for people (IMAP/POP3/webmail).
|
||||||
|
|
||||||
|
## Architecture (sketch)
|
||||||
|
|
||||||
|
1. Receiving MTAs → SMTP to SelfPost (hub MX).
|
||||||
|
2. Postfix virtual alias or dedicated listener → panel ingest worker.
|
||||||
|
3. Parse XML → `dmarc_reports` table (domain, reporter, counts, date).
|
||||||
|
4. Panel reads SQLite; links from domain DNS card.
|
||||||
|
|
||||||
|
May share port-25 plumbing with [inbound-relay.md](inbound-relay.md) but must
|
||||||
|
remain a separate, opt-in feature that does not forward mail upstream.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- Operator can point `rua=` at an address SelfPost accepts and see parsed
|
||||||
|
summaries in the panel within one reporting cycle.
|
||||||
|
- With the feature off, outbound-only behaviour is unchanged.
|
||||||
|
- Documented in [guide.md](../guide.md); migrations are backward-compatible.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Attack surface of accepting mail (mitigate: strict recipient allow-list).
|
||||||
|
- Report volume and retention (mitigate: caps + pruning).
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
# Plan: domain-admin (domain administrator role)
|
||||||
|
|
||||||
|
**Status:** agreed
|
||||||
|
**Version:** target bump **1.x** MINOR, given a compatible migration of the
|
||||||
|
current administrator into a global one.
|
||||||
|
**Order:** recommended after [web-split](web-split.md) (done), before
|
||||||
|
[inbound-relay](inbound-relay.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
Today the panel has exactly one subject: `RequireAuth` is a boolean gate, not a
|
||||||
|
role ([web.go](../../internal/web/web.go) — the
|
||||||
|
`mux.Handle("/", s.auth.RequireAuth(authed))` wrapper), and the session carries
|
||||||
|
nothing beyond the fact of being signed in.
|
||||||
|
|
||||||
|
Two panel roles:
|
||||||
|
|
||||||
|
| Role | Scope |
|
||||||
|
|------|-------|
|
||||||
|
| **global** | Full panel except nothing new — same powers as today's single admin |
|
||||||
|
| **domain_admin** | Only **assigned** domains (one or several; list set by global admin) |
|
||||||
|
|
||||||
|
For each assigned domain, a domain-admin can:
|
||||||
|
|
||||||
|
- applications (create, sender mode, password regeneration, delete, L2 limit);
|
||||||
|
- DKIM/DNS status and recheck;
|
||||||
|
- per-domain DMARC `rua=` (inherit / none / custom) — full control on the
|
||||||
|
domain page;
|
||||||
|
- send log filtered to assigned domains;
|
||||||
|
- domain export (encrypted `.spde` optional, same as today);
|
||||||
|
- domain-level L2 rate limit.
|
||||||
|
|
||||||
|
What stays **global-only** (domain-admin gets 404 or redirect):
|
||||||
|
|
||||||
|
- adding and removing domains;
|
||||||
|
- domain import;
|
||||||
|
- creating/editing/deleting panel users and assigning domains;
|
||||||
|
- `/reload`;
|
||||||
|
- full backup (`/backup` — all of `/data` including every domain's `sasldb2`);
|
||||||
|
- mail queue (`/mail-queue*`);
|
||||||
|
- system log tail (`/system-log*`);
|
||||||
|
- status page (`/status*`) — server-wide health, queue summary, reload, DNS
|
||||||
|
recheck of the **hostname**; same treatment as queue and system log.
|
||||||
|
|
||||||
|
Domain-admin **self-service** on `/account`: username and password only (not
|
||||||
|
global DMARC report email).
|
||||||
|
|
||||||
|
## Why this extends v1.0
|
||||||
|
|
||||||
|
[product.md](../product.md) puts "multiple panel users, roles" out of scope
|
||||||
|
(one administrator). A second subject is a deliberate widening of the project's
|
||||||
|
boundary, as inbound-relay is.
|
||||||
|
|
||||||
|
The cost is phase-sized, not patch-sized:
|
||||||
|
|
||||||
|
- a users table and their binding to domains;
|
||||||
|
- the role in the session;
|
||||||
|
- authorisation in every handler (not only on the route — today `{id}`/`{aid}`
|
||||||
|
are checked for nothing beyond existence);
|
||||||
|
- reworking first-run setup and password change for several users;
|
||||||
|
- accounting for the new subject in backup and domain export.
|
||||||
|
|
||||||
|
*(The earlier wording of this item — "2FA and multiple administrators" — has
|
||||||
|
been replaced: 2FA is off the table, and "multiple administrators" is narrowed
|
||||||
|
to one specific role, because what is needed is not a second all-powerful admin
|
||||||
|
but limited access for the owner of one or several domains, with the list set
|
||||||
|
by the global administrator.)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schema and migration
|
||||||
|
|
||||||
|
**New migration** `0005_panel_users.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('global', 'domain_admin')),
|
||||||
|
dmarc_report_email TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_domains (
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (user_id, domain_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Migrate existing administrator → global user (idempotent guard via admin count).
|
||||||
|
INSERT INTO users (username, password_hash, role, dmarc_report_email, created_at)
|
||||||
|
SELECT username, password_hash, 'global', dmarc_report_email, created_at
|
||||||
|
FROM admin WHERE id = 1;
|
||||||
|
|
||||||
|
DROP TABLE admin;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sessions:** keep `sessions.username` (no schema change). On login and
|
||||||
|
`RequireAuth`, resolve username → `User` row (role + domain IDs). Stale session
|
||||||
|
after username change behaves as today (`Lookup` fails → redirect login).
|
||||||
|
|
||||||
|
**Backup/restore:** full backup already snapshots `selfpost.db` via
|
||||||
|
`VACUUM INTO`; users and bindings restore with the DB. No manifest format change
|
||||||
|
required (same `selfpost-full-backup`).
|
||||||
|
|
||||||
|
**DMARC two levels:**
|
||||||
|
|
||||||
|
- **Global** `users.dmarc_report_email` — only on global user's `/account`;
|
||||||
|
default `rua=` when a domain uses *inherit*.
|
||||||
|
- **Per-domain** `domains.dmarc_rua` — domain-admin edits on the domain page
|
||||||
|
(existing handler); domain-admin never sees the global default field.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Principal model
|
||||||
|
|
||||||
|
Request context carries a `Principal` (in `internal/web/auth`):
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Role string // "global" | "domain_admin"
|
||||||
|
|
||||||
|
type Principal struct {
|
||||||
|
ID int64
|
||||||
|
Username string
|
||||||
|
Role Role
|
||||||
|
Domains []int64 // assigned domain IDs; empty for global (meaning "all")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Helpers:
|
||||||
|
|
||||||
|
- `CurrentPrincipal(r)` — from context;
|
||||||
|
- `IsGlobal(p)` — `p.Role == "global"`;
|
||||||
|
- `CanAccessDomain(p, domainID)` — global or `domainID` in `p.Domains`;
|
||||||
|
- `CanAccessApp(p, app)` — `CanAccessDomain(p, app.DomainID)`.
|
||||||
|
|
||||||
|
`lookupDomain` / `lookupApplication` in handlers call `CanAccess*` after
|
||||||
|
existence check; return 404 (not 403) to avoid leaking IDs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Route matrix
|
||||||
|
|
||||||
|
| Method | Path | global | domain_admin |
|
||||||
|
|--------|------|--------|--------------|
|
||||||
|
| GET | `/` | → `/status` | → `/domains` |
|
||||||
|
| GET | `/status`, `/status/fragment` | yes | **no** (404) |
|
||||||
|
| POST | `/status/recheck` | yes | **no** |
|
||||||
|
| GET | `/domains` | all domains | assigned only |
|
||||||
|
| POST | `/domains` | yes | **no** |
|
||||||
|
| POST | `/domains/import` | yes | **no** |
|
||||||
|
| GET | `/domains/{id}` | yes | assigned |
|
||||||
|
| POST | `/domains/{id}/dns-recheck` | yes | assigned |
|
||||||
|
| GET/POST | `/domains/{id}/delete` | yes | **no** |
|
||||||
|
| POST | `/domains/{id}/applications` | yes | assigned |
|
||||||
|
| POST | `/domains/{id}/ratelimit` | yes | assigned |
|
||||||
|
| POST | `/domains/{id}/dmarc` | yes | assigned |
|
||||||
|
| POST | `/domains/{id}/export` | yes | assigned |
|
||||||
|
| POST | `/applications/{aid}/*` | yes | if app in assigned domain |
|
||||||
|
| POST | `/reload` | yes | **no** |
|
||||||
|
| GET/POST | `/account` | username, password, global DMARC email | username, password only |
|
||||||
|
| GET/POST | `/backup` | yes | **no** |
|
||||||
|
| GET | `/deliveries*` | all (optional filter) | clamped to assigned domains |
|
||||||
|
| GET | `/mail-queue*` | yes | **no** |
|
||||||
|
| GET | `/system-log*` | yes | **no** |
|
||||||
|
| GET | `/users` | list users | **no** |
|
||||||
|
| GET/POST | `/users/new` | create user | **no** |
|
||||||
|
| GET/POST | `/users/{uid}` | edit/delete user | **no** |
|
||||||
|
| POST | `/logout` | yes | yes |
|
||||||
|
|
||||||
|
**Deliveries:** for domain-admin, `sendLogData` forces filter to assigned
|
||||||
|
domain set; dropdowns list only assigned domains/apps; reject `domain` query
|
||||||
|
param outside assignment; `HandleDelivery` checks log row's `domain` field.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User management UI (global only)
|
||||||
|
|
||||||
|
New routes under `/users`:
|
||||||
|
|
||||||
|
- **List** — username, role, assigned domain names (or "all" for global).
|
||||||
|
- **Create** — username, password, role (`domain_admin` default), multi-select
|
||||||
|
domains (required when role is `domain_admin`).
|
||||||
|
- **Edit** — change password (optional), reassign domains, delete user.
|
||||||
|
- **Guards:** cannot delete the last `global` user; cannot demote self to
|
||||||
|
`domain_admin` without another global user; domain-admin role cannot access
|
||||||
|
these routes.
|
||||||
|
|
||||||
|
Templates: `users.html`, `user_form.html`; nav link visible only for global
|
||||||
|
users.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth / setup / sessions
|
||||||
|
|
||||||
|
- **Setup** (`/setup/{token}`): unchanged semantics — creates first **global**
|
||||||
|
user via `CreateGlobalUser`; `AdminExists` → `UserExists`.
|
||||||
|
- **Login:** authenticate against `users` by username + bcrypt.
|
||||||
|
- **Password change:** per-user `UpdateUser`; domain-admin cannot change
|
||||||
|
another user's password.
|
||||||
|
- **Session rename / destroy others:** unchanged behaviour keyed by username.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
[layout.html](../../internal/web/view/templates/layout.html) `nav` template:
|
||||||
|
|
||||||
|
- **global:** all items today (status, domains, deliveries, mail queue, system
|
||||||
|
log, backup, settings) + **Users**.
|
||||||
|
- **domain_admin:** domains, deliveries, settings only.
|
||||||
|
|
||||||
|
Pass `IsGlobal` (or `Principal`) into every rendered page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- **CSRF:** keep origin-check-only for now ([security.md](../security.md) ADR);
|
||||||
|
note in CHANGELOG that multi-user panel reopens the ADR — no CSRF tokens in
|
||||||
|
this phase.
|
||||||
|
- **Export encryption:** optional password on domain export remains; full backup
|
||||||
|
encryption trigger ("second administrator") is satisfied by domain-admin
|
||||||
|
existing — no change required.
|
||||||
|
- **Authorization tests:** table-driven tests for global vs domain-admin on
|
||||||
|
representative handlers; explicit `{aid}` cross-domain mutation blocked.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation order
|
||||||
|
|
||||||
|
1. Migration `0005_panel_users.sql` + `store/users.go` (CRUD, domain bindings).
|
||||||
|
2. Auth: login against `users`, `Principal` in context, setup creates global user.
|
||||||
|
3. `CanAccessDomain` / `CanAccessApp`; harden `lookupDomain` / `lookupApplication`.
|
||||||
|
4. Route guards: global-only middleware or per-handler checks.
|
||||||
|
5. Filter lists: dashboard, deliveries, domain detail DMARC inherit source.
|
||||||
|
6. User management handlers + templates.
|
||||||
|
7. Nav visibility + default redirect (`/`).
|
||||||
|
8. Tests + `go build` / `go vet` / `go test`; CHANGELOG `[Unreleased]`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- A global administrator and a domain-admin with different rights both work
|
||||||
|
through the panel; the domain-admin cannot reach past the **assigned**
|
||||||
|
domains;
|
||||||
|
- the current single admin migrates into a global one without losing access;
|
||||||
|
- backup/restore accounts for users and their bindings;
|
||||||
|
- `build`/`vet`/`test`/image green.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- An incomplete `{id}`/`{aid}` check in a handler — access leaking to someone
|
||||||
|
else's domain;
|
||||||
|
- breaking setup or backup — that would be a semver major, not 1.x.
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# Plan: inbound-relay (inbound relay)
|
||||||
|
|
||||||
|
**Status:** agreed
|
||||||
|
**Version:** target bump **1.x** MINOR; **`2.x` possible** — to be settled once
|
||||||
|
the implementation lands (do not fix a major in advance).
|
||||||
|
**Order:** recommended after [web-split](web-split.md) and
|
||||||
|
[domain-admin](domain-admin.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
The ability to accept mail on port 25 for explicitly configured domains and
|
||||||
|
forward it to a given upstream backend (a backup-MX / relay-forwarder role), as
|
||||||
|
a **module disabled by default** that changes neither the behaviour nor the
|
||||||
|
attack surface of the base outbound relay.
|
||||||
|
|
||||||
|
## What it is for (scenarios)
|
||||||
|
|
||||||
|
- **Backup-MX** — accept mail while the domain's primary mail server is
|
||||||
|
temporarily unreachable, and hand it over when it comes back.
|
||||||
|
- **A front for a server without a public IP** — the operator runs their own
|
||||||
|
mail server which, for whatever reason, **cannot accept mail from the
|
||||||
|
internet itself** (no static or public IP, behind NAT, a private address,
|
||||||
|
inbound port 25 blocked, and so on). SelfPost, with a public IP and a correct
|
||||||
|
PTR, acts as the domain's public entry node (the MX points at it) and
|
||||||
|
forwards mail to that internal or otherwise unreachable server.
|
||||||
|
|
||||||
|
## Scope boundary (critical — what this is NOT)
|
||||||
|
|
||||||
|
- **IT IS:** acceptance on 25 for domains from an explicit list, plus
|
||||||
|
forwarding (relay/forward) to an upstream (`relay_domains` +
|
||||||
|
`transport_maps` + `relay_recipient_maps`). Postfix here is a pure forwarder,
|
||||||
|
with no local delivery.
|
||||||
|
- **IT IS NOT (out of scope, [product.md](../product.md)):** local delivery to
|
||||||
|
mailboxes, IMAP/POP3, webmail, Dovecot. No mailboxes at all. SelfPost also
|
||||||
|
**neither implements nor bundles** an anti-spam or anti-virus engine
|
||||||
|
(rspamd/ClamAV) — but, unlike the earlier wording, it does **not** push
|
||||||
|
filtering onto the backend either (see the "Anti-spam" section below): it
|
||||||
|
provides an attachment point for an external filter.
|
||||||
|
|
||||||
|
## Why as an option / plugin
|
||||||
|
|
||||||
|
- Accepting on port 25 changes the threat model (open relay for inbound,
|
||||||
|
backscatter, spam ingress). So it is **off** by default behind the
|
||||||
|
`INBOUND_RELAY_ENABLE=false` env flag; turning it on is a deliberate step by
|
||||||
|
the operator.
|
||||||
|
- Isolation: separate SQLite tables, separate panel handlers and pages, a
|
||||||
|
separate branch of config generation. With the flag off, the inbound
|
||||||
|
listener, the tables and the UI are absent — the base outbound path is
|
||||||
|
byte-for-byte unchanged.
|
||||||
|
|
||||||
|
## What to do
|
||||||
|
|
||||||
|
- The `INBOUND_RELAY_ENABLE` env flag (default false); when `true`, generate
|
||||||
|
the inbound service and its config from panel state the same way the rest of
|
||||||
|
the config is generated (`postfix-config.sh`).
|
||||||
|
- **`master.cf`:** an inbound `smtp inet` on 25 for accepting from the internet
|
||||||
|
(today 25 is used only for outbound delivery). Separate from 465/587: on 25
|
||||||
|
SASL is **not** offered and sending outwards is **not** allowed — inbound
|
||||||
|
only, for `relay_domains`.
|
||||||
|
- **Anti-open-relay for inbound (mandatory):** the inbound smtpd's
|
||||||
|
`smtpd_relay_restrictions` / `smtpd_recipient_restrictions` accept mail
|
||||||
|
**only** for domains in `relay_domains` and **only** for known recipients
|
||||||
|
(`relay_recipient_maps`); everything else gets
|
||||||
|
`reject_unauth_destination` / `reject_unlisted_recipient`. An open relay, or
|
||||||
|
accepting "for anyone", is impossible.
|
||||||
|
- **Backscatter:** knowing the valid recipients is preferable (reject unknown
|
||||||
|
recipient at RCPT stage) so that bounces to non-existent addresses are never
|
||||||
|
generated.
|
||||||
|
- **The panel manages:** the list of inbound domains; for each one the upstream
|
||||||
|
destination (`host:port`, transport), an optional list of valid recipients,
|
||||||
|
and optional TLS to the upstream. Strict validation of domain, host and port
|
||||||
|
(whitelist), injection-safe writing of map files (as with
|
||||||
|
`sender_login_maps` in Phase 4), `os/exec` without a shell
|
||||||
|
([security.md](../security.md)).
|
||||||
|
- **Milters:** OpenDKIM is not needed on the inbound path (we do not sign
|
||||||
|
someone else's inbound mail). The journal-milter can optionally be reused for
|
||||||
|
an inbound journal (extra work), or the inbound path can go without it in the
|
||||||
|
first stage; fail-open behaviour is preserved.
|
||||||
|
- **Rate limit / size:** a coarse per-client-IP limit (`anvil`, as L1) and
|
||||||
|
`message_size_limit` on the inbound smtpd.
|
||||||
|
|
||||||
|
## Anti-spam (important, but optional)
|
||||||
|
|
||||||
|
This is a valuable option, but it is **not mandatory**: some operators will be
|
||||||
|
perfectly served by **blind forwarding without filtering** — when the backend
|
||||||
|
can filter on content itself, when the upstream is trusted, or when the volume
|
||||||
|
and risk are low. So the anti-spam hook is **off** by default (an empty
|
||||||
|
`INBOUND_ANTISPAM_MILTER`), and the inbound relay is fully functional without
|
||||||
|
it.
|
||||||
|
|
||||||
|
What matters is something else: where filtering is technically possible. With a
|
||||||
|
"blind" relay the destination backend sees **SelfPost's** address as the
|
||||||
|
connecting IP, not the original sender's, so everything on the backend that
|
||||||
|
depends on the origin IP breaks (DNSBL and reputation are checked against
|
||||||
|
SelfPost's IP; SPF returns fail, since SelfPost is not in the sending domain's
|
||||||
|
SPF). **The only point where the real client IP is still visible is the inbound
|
||||||
|
hop at SelfPost** — so for those who need filtering, it has to be *attachable
|
||||||
|
right here*, not delegated to a backend that has already lost the information.
|
||||||
|
|
||||||
|
The attachment design:
|
||||||
|
|
||||||
|
- **The anti-spam engine is a separate optional container** (rspamd or
|
||||||
|
similar), which the operator runs **only if this option is wanted** (the same
|
||||||
|
principle as the reverse proxy — a separate container outside the SelfPost
|
||||||
|
image). SelfPost **neither contains nor starts it** — the image and the "one
|
||||||
|
container, three processes" principle are unchanged, and
|
||||||
|
[product.md](../product.md)'s out-of-scope list is not violated (SelfPost
|
||||||
|
does not implement anti-spam).
|
||||||
|
- **SelfPost provides the attachment point:** a milter hook on the inbound
|
||||||
|
smtpd. The engine's address is set via env (for example,
|
||||||
|
`INBOUND_ANTISPAM_MILTER=inet:antispam:11332`, empty → the hook is off) and
|
||||||
|
is added to `smtpd_milters` for the **inbound path only** (not on 465/587).
|
||||||
|
Postfix passes the milter the real client IP, HELO and PTR — the filter sees
|
||||||
|
the true origin. `milter_default_action` for that milter is configurable
|
||||||
|
(fail-open vs tempfail); the default is to be decided during implementation.
|
||||||
|
- **A native backstop with no dependencies:** on that same inbound hop,
|
||||||
|
Postfix's own origin-IP facilities are available — `reject_rbl_client`
|
||||||
|
(DNSBL) and HELO/PTR checks — and they work even without an external
|
||||||
|
container. Plus preserving authentication results for downstream through ARC
|
||||||
|
or `Received`, where part of the filtering does remain on the backend.
|
||||||
|
- **docker-compose:** document an optional anti-spam sidecar fragment (like the
|
||||||
|
alternative reverse-proxy fragments) — the container comes up with the stack
|
||||||
|
only when the option is enabled.
|
||||||
|
- **Persistence:** new tables and map files under `/data` — they land in the
|
||||||
|
full backup automatically (Phase 9). Domain export/import can be extended
|
||||||
|
with the inbound configuration — optional, to be flagged.
|
||||||
|
- **DNS documentation:** an inbound domain needs an `MX` record pointing at the
|
||||||
|
server (unlike outbound, where no MX is required) — to be reflected in the
|
||||||
|
README's DNS section.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
[security.md](../security.md): server-side input validation, escaped writes to
|
||||||
|
config files, `exec` without interpolation, no open relay, protection against
|
||||||
|
backscatter.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
With `INBOUND_RELAY_ENABLE=true` and a configured domain, mail arriving on port
|
||||||
|
25 for that domain is forwarded to the given upstream; mail for unconfigured
|
||||||
|
domains or recipients is rejected (not an open relay, no backscatter); with
|
||||||
|
`INBOUND_ANTISPAM_MILTER` set, inbound mail passes through the external filter
|
||||||
|
with the real origin IP (verified with a sidecar container), and with it empty
|
||||||
|
the hook stays out of the way; with `INBOUND_RELAY_ENABLE=false` the inbound
|
||||||
|
port, tables and UI are absent and the base outbound relay is unchanged;
|
||||||
|
`build`/`vet`/`test`/image green.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- open relay / backscatter — removed by `relay_domains` +
|
||||||
|
`relay_recipient_maps` + `reject_unauth_destination`;
|
||||||
|
- the loss of the origin IP for filtering on the backend when forwarding —
|
||||||
|
removed by the anti-spam milter hook plus native DNSBL on the inbound hop,
|
||||||
|
where the origin IP is still visible;
|
||||||
|
- port 25 accepting mail widens the attack surface (off by default);
|
||||||
|
- semver: if the contract turns out incompatible (ports, backup, behaviour with
|
||||||
|
the flag off) a major `2.x` is possible; the decision comes after the
|
||||||
|
implementation.
|
||||||
|
|
||||||
|
**External deployment dependency:** the optional anti-spam container — outside
|
||||||
|
the SelfPost image, brought up by the operator when the option is enabled.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
A finished outbound path (already implemented). Agreement obtained — see the
|
||||||
|
status above.
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
# Plan: visual-style
|
||||||
|
|
||||||
|
**Status:** agreed
|
||||||
|
**Version:** no bearing on semver — presentation only, no schema and no route
|
||||||
|
changes.
|
||||||
|
**Order:** independent of the feature roadmap; may be taken up between feature
|
||||||
|
items.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Bring the control panel's surface in line with the mark that was approved in
|
||||||
|
[selfpost-proof.html](../assets/selfpost-proof.html): its palette, its
|
||||||
|
typography, and the plainness of its components. Today the panel is a default
|
||||||
|
blue-on-cool-grey admin theme standing next to a warm brick stamp, so the mark
|
||||||
|
reads as pasted onto someone else's page.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In:**
|
||||||
|
- `internal/web/view/static/panel.css` — colour tokens, typography, spacing,
|
||||||
|
every component rule.
|
||||||
|
- `internal/web/view/static/` — three self-hosted font files.
|
||||||
|
- Templates, only where a class has to be added or a wrapper introduced for a
|
||||||
|
rule to have something to attach to.
|
||||||
|
- `NOTICE` — the OFL attribution the font files oblige.
|
||||||
|
|
||||||
|
**Out:**
|
||||||
|
- Any change to what a page does, which pages exist, or what an operator has to
|
||||||
|
click. No new features, no copy rewriting.
|
||||||
|
- The navigation's position and the two-column shell. The proof's panel mock
|
||||||
|
shows a horizontal bar on a dark header; the panel's left column also carries
|
||||||
|
the per-page section index (`.sections` plus the scroll-spy in `panel.js`),
|
||||||
|
which that layout has nowhere to put. Keeping the column is a deliberate
|
||||||
|
divergence from the mock, not an oversight.
|
||||||
|
- The mark files themselves (`logo.svg`, `logo-compact.svg`, `favicon.*`) —
|
||||||
|
already drawn, already converted to outlines.
|
||||||
|
|
||||||
|
## Constraint that shapes everything
|
||||||
|
|
||||||
|
The panel's Content-Security-Policy is a plain `default-src 'self'` with no
|
||||||
|
inline-style exemption ([security.md](../security.md)). Every rule lives in
|
||||||
|
`panel.css`; a `style="..."` attribute in a template is blocked and silently
|
||||||
|
does nothing. Self-hosted fonts are served from the panel's own origin and are
|
||||||
|
therefore already covered — no CSP change is needed, and none may be made.
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
IBM Plex, self-hosted. The mark is Plex converted to outlines, so the panel
|
||||||
|
setting its own name in Segoe UI or Cantarell is the seam this whole item
|
||||||
|
exists to close.
|
||||||
|
|
||||||
|
| File | Covers | Size |
|
||||||
|
|---|---|---|
|
||||||
|
| `static/ibm-plex-sans.woff2` | variable, weights 100–700, latin | 45.7 KB |
|
||||||
|
| `static/ibm-plex-mono-400.woff2` | mono regular, latin | 14.8 KB |
|
||||||
|
| `static/ibm-plex-mono-600.woff2` | mono semibold, latin | 15.7 KB |
|
||||||
|
|
||||||
|
76 KB in total, in a 20 MB binary. The variable file replaces what would
|
||||||
|
otherwise be five static weights and lets the scale below use 300 and 500
|
||||||
|
without paying per weight.
|
||||||
|
|
||||||
|
Monospace is the one the operator actually reads: DKIM records, `mail.log`
|
||||||
|
lines, application logins, socket paths, generated passwords. `ui-monospace`
|
||||||
|
resolves to Consolas, SF Mono or DejaVu Sans Mono depending on the operator's
|
||||||
|
machine, and those differ in advance width — the six-column send log wraps
|
||||||
|
differently for each. A shipped mono makes those tables one layout.
|
||||||
|
|
||||||
|
| Role | Family | Size | Weight |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Body | sans | 15px / 1.5 | 400 |
|
||||||
|
| `h1` | sans | 1.55rem, tracking −0.01em | 300 |
|
||||||
|
| `h2` | sans | 1.05rem | 600 |
|
||||||
|
| `label` | sans | 0.9rem | 600 |
|
||||||
|
| Nav entry / active | sans | 0.95rem | 400 / 600 |
|
||||||
|
| `th` | **mono**, uppercase, tracking 0.08em | 0.75rem | 500 |
|
||||||
|
| `.st` status badge | **mono** | 0.78rem | 500 |
|
||||||
|
| `.code`, `.mono`, `.metric` | **mono** | 0.85rem | 400 |
|
||||||
|
|
||||||
|
`font-display: swap`, so a cold load shows the system stack for a frame rather
|
||||||
|
than blank text.
|
||||||
|
|
||||||
|
## Colour tokens
|
||||||
|
|
||||||
|
Names stay as they are wherever they already exist: the dark scheme reassigns
|
||||||
|
the same custom properties, which is why no rule in the file needs
|
||||||
|
`!important`. Warm neutrals replace the cool greys; brick becomes the accent.
|
||||||
|
|
||||||
|
| Token | Light | Dark |
|
||||||
|
|---|---|---|
|
||||||
|
| `--bg` | `#F4F2ED` | `#16181B` |
|
||||||
|
| `--fg` | `#12161C` | `#E9E6E0` |
|
||||||
|
| `--muted` | `#6B7684` | `#9AA1A9` |
|
||||||
|
| `--card-bg` | `#FFFFFF` | `#1D2024` |
|
||||||
|
| `--border` | `#DEDCD7` | `#2C2F34` |
|
||||||
|
| `--control-border` | `#CBC8C1` | `#3A3E44` |
|
||||||
|
| `--input-bg` | `#FFFFFF` | `#14161A` |
|
||||||
|
| `--code-bg` | `#EFEDE9` | `#14161A` |
|
||||||
|
| `--surface-bg` | `#EAE7E0` | `#23262B` |
|
||||||
|
| `--accent-fill` / `--on-accent` | `#7A3B2E` / `#FFFFFF` | `#8E4535` / `#FFFFFF` |
|
||||||
|
| `--accent-text` | `#7A3B2E` | `#CE7B66` |
|
||||||
|
| `--nav-active-bg` | `#EDE4DE` | `#2A1F1B` |
|
||||||
|
|
||||||
|
Brick splits into a fill and a text value because `#7A3B2E` on `#16181B` is
|
||||||
|
about 2:1 — unreadable as a dark-scheme link. The fill lightens just enough to
|
||||||
|
keep white on it above 4.5:1; the text value lightens further.
|
||||||
|
|
||||||
|
Status families (`--st-ok-*`, `--st-warn-*`, `--st-error-*`, `--st-unknown-*`),
|
||||||
|
the flash, the credential card and `--danger-*` keep their hues and are only
|
||||||
|
warmed to sit on paper. The one thing to watch is brick against `st-error` red:
|
||||||
|
the proof rejected several candidate colours precisely so that the mark would
|
||||||
|
not read as a status, and the same test now applies to every brick button
|
||||||
|
standing in a row of `error` badges.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
Everything already in `panel.css`, in the order it appears there: card, form
|
||||||
|
controls, buttons (filled, outlined, danger), flash, table, status badge,
|
||||||
|
`.code`, nav (brand, links, sections, session), application list and its
|
||||||
|
disclosure panels, credential card, status page meters and facts, delivery
|
||||||
|
timeline, log tables, split layout, encrypt fields, footer.
|
||||||
|
|
||||||
|
Two component-level changes rather than pure repaints, both forced by the
|
||||||
|
accent:
|
||||||
|
|
||||||
|
- Row actions (`td.actions a.danger`, `Delete`) become outlined instead of
|
||||||
|
filled. A filled red button in a table row next to a filled brick button
|
||||||
|
reads as one block of colour.
|
||||||
|
- Nav entries carry the active state as brick text on a warm tint rather than
|
||||||
|
the current blue tint.
|
||||||
|
|
||||||
|
## Order of work
|
||||||
|
|
||||||
|
1. Fonts into `static/`, `@font-face` and the type scale in `panel.css`,
|
||||||
|
`NOTICE` attribution. Nothing else changes shape.
|
||||||
|
2. Token block: light and dark, both schemes in one pass.
|
||||||
|
3. Chrome: `layout.html`'s nav, footer, shell.
|
||||||
|
4. Signed-out pages: `login`, `setup` — the mark and one card, where the seam
|
||||||
|
is worst.
|
||||||
|
5. `dashboard`, `domain_detail`, `domain_delete`.
|
||||||
|
6. `deliveries`, `deliveries_rows`, `delivery`.
|
||||||
|
7. `status` + `status_body`, `mail_queue*`, `system_log*`.
|
||||||
|
8. `backup`, `account`, `users`, `user_form`, `encrypt_fields`.
|
||||||
|
9. `CHANGELOG.md` under `[Unreleased]`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Every page rendered locally and screenshotted in both schemes before and
|
||||||
|
after (`panel.exe` on Windows, headless Edge), including a 375px-wide pass —
|
||||||
|
the nav column and the wide tables are where a repaint breaks layout.
|
||||||
|
- Contrast: body text and every status badge at 4.5:1 or better against its own
|
||||||
|
background, UI borders at 3:1. Brick on white is 7.3:1 by the proof's own
|
||||||
|
measurement; the dark-scheme values above are the ones to re-check.
|
||||||
|
- `go build ./... && go vet ./... && go test ./...`; the template guards in
|
||||||
|
`internal/web/view/templates_test.go` must stay green, and the static-asset
|
||||||
|
ETag test grows to cover the three font files.
|
||||||
|
- No `style=` attribute anywhere in `templates/` — the CSP would drop it.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- The panel and the mark read as one design in both schemes, at the reading
|
||||||
|
measure and on the wide data pages.
|
||||||
|
- Nothing an operator does changed: same pages, same controls, same copy.
|
||||||
|
- Fonts are served from the panel's own origin under the unchanged CSP, and the
|
||||||
|
image works with no network access.
|
||||||
|
- `NOTICE` credits IBM Plex (OFL-1.1); build, vet and tests are green.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Visual regression across 21 templates.** The panel has pages that are only
|
||||||
|
reachable mid-workflow (the credential card, the delete confirmation, the
|
||||||
|
encrypt fields). Mitigation: the page order above is a checklist, and each
|
||||||
|
step is screenshotted rather than assumed.
|
||||||
|
- **Brick against the error red.** If the two fight in a real row of the send
|
||||||
|
log, the accent gets pulled back to the mark and the buttons stay neutral —
|
||||||
|
the mark's colour is fixed, the panel's accent is the negotiable one.
|
||||||
|
- **Thin weights on dark.** `h1` at 300 is the one place a variable font makes
|
||||||
|
it easy to go too light; check it on the dark scheme before keeping it.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Plan: web-split (splitting `internal/web`)
|
||||||
|
|
||||||
|
**Status:** done (see [CHANGELOG](../CHANGELOG.md) `[1.1.0]`)
|
||||||
|
**Version:** `1.x`; an internal refactor, it does not force a break on its own.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
`internal/web` is the project's largest package: ~50 files (templates and
|
||||||
|
static assets included), ~25 `.go` files and ~4000 lines of Go, with the
|
||||||
|
handlers for every panel section, sessions, security headers, origin checking,
|
||||||
|
form validation and template rendering all sitting in one flat namespace.
|
||||||
|
|
||||||
|
The candidates to split out are `web/handlers` and `web/auth`, or a cut along
|
||||||
|
the panel's own domains.
|
||||||
|
|
||||||
|
## Why now
|
||||||
|
|
||||||
|
At its current size the flat package reads fine: the file names
|
||||||
|
(`handlers_domains.go`, `handlers_apps.go`, `handlers_monitor.go`) do the work
|
||||||
|
directories would, and splitting would force exporting what is package-private
|
||||||
|
today — widening the internal API for cosmetics.
|
||||||
|
|
||||||
|
It starts to pay off once the package grows: **domain-admin** and
|
||||||
|
**inbound-relay** both add code to it — the role brings authorisation into
|
||||||
|
every handler, the inbound relay brings its own pages and handlers for inbound
|
||||||
|
domains. The refactor is cheaper before that growth than after it.
|
||||||
|
|
||||||
|
## Recommended order
|
||||||
|
|
||||||
|
**web-split → domain-admin → inbound-relay** (see the
|
||||||
|
[roadmap](../roadmap.md)).
|
||||||
|
|
||||||
|
1. **web-split** — lay down the package structure (including a place for
|
||||||
|
`web/auth`) while there are no cross-cutting edits from the role and no new
|
||||||
|
inbound handlers.
|
||||||
|
2. **domain-admin** — authorisation in every handler builds on a package layout
|
||||||
|
already chosen.
|
||||||
|
3. **inbound-relay** — a new vertical slice; easier to add to an already split
|
||||||
|
package than to refactor alongside the two features before it.
|
||||||
|
|
||||||
|
The order is a recommendation, not a blocker.
|
||||||
|
|
||||||
|
## Chosen scheme
|
||||||
|
|
||||||
|
**Horizontal split into four packages** (decided at implementation):
|
||||||
|
|
||||||
|
```
|
||||||
|
internal/web/ # Config, Server, New, Handler — composition root; security.go
|
||||||
|
internal/web/view/ # embed templates/static, render/renderFragment, staticHandler
|
||||||
|
internal/web/auth/ # session, login/logout/setup, requireAuth, currentUser
|
||||||
|
internal/web/validate/ # shared form validation (avoids auth ↔ handlers import cycle)
|
||||||
|
internal/web/handlers/ # all authenticated page handlers (handlers_*.go)
|
||||||
|
```
|
||||||
|
|
||||||
|
`cmd/panel` keeps importing only `internal/web`. Subpackages are not exported
|
||||||
|
beyond what the composition root needs.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
The package is split along the scheme above. After the split: `build`/`vet`/`test`
|
||||||
|
green, the panel's behaviour unchanged.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Splitting too early — a superfluous internal API and churn with nothing to
|
||||||
|
show for it;
|
||||||
|
- leaving it until after the growth — a harder refactor, tangled up with the
|
||||||
|
features.
|
||||||
+5
-2
@@ -53,8 +53,11 @@ Explicitly excluded to prevent scope creep:
|
|||||||
- A custom MTA — Postfix is used as-is
|
- A custom MTA — Postfix is used as-is
|
||||||
- Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only
|
- Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only
|
||||||
|
|
||||||
Future line **2.x.x** (optional inbound relay, domain-admin role) is tracked in
|
Agreed **1.x+** extensions (optional inbound relay, domain-admin role) are
|
||||||
[roadmap.md](roadmap.md) and requires explicit approval before implementation.
|
tracked in [roadmap.md](roadmap.md) and [plans/](plans/). Inbound relay targets
|
||||||
|
a 1.x MINOR bump by default; a 2.x major remains possible pending
|
||||||
|
implementation. Items still marked *candidate* in the roadmap require explicit
|
||||||
|
approval before coding.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+119
-100
@@ -1,133 +1,152 @@
|
|||||||
# Дорожная карта: SelfPost 2.x.x
|
# Roadmap: open work (1.x+)
|
||||||
|
|
||||||
**Статус:** здесь собран объём, отнесённый к релизной линии **2.x.x** — вне
|
**Status:** a working tracker of extensions to the v1.0 boundary, each taken up
|
||||||
базового объёма v1.0/v1.x (v1.x — только исходящий релей). Реализация —
|
only after explicit agreement ([product.md](product.md),
|
||||||
только после явного согласования ([product.md](product.md),
|
[.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)). Detailed
|
||||||
[.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)):
|
design lives in [plans/](plans/). Items marked `candidate` need an OK before
|
||||||
[product.md](product.md) явно исключает часть этого объёма (приём входящей
|
any code is written.
|
||||||
почты; несколько пользователей/роли), поэтому включение — сознательное
|
|
||||||
расширение границ проекта, а не доработка по своей инициативе. Присутствие
|
|
||||||
пункта здесь фиксирует намерение и дизайн; кодирование начинается отдельным
|
|
||||||
решением.
|
|
||||||
|
|
||||||
**Основа:** [product.md](product.md) v1.0. Процесс и правила документации —
|
**Reading this from outside the project:** nothing here is a commitment or a
|
||||||
[development.md](development.md). История закрытых фаз v1.x — в `git log` и
|
release promise. There are no dates, the order is a recommendation rather than
|
||||||
[CHANGELOG.md](../CHANGELOG.md).
|
a schedule, and an item can be dropped or reshaped once its plan is written.
|
||||||
|
What the project *will not* do is a separate question, answered in
|
||||||
|
[product.md](product.md) — an item's absence from this file does not mean it is
|
||||||
|
planned but unlisted.
|
||||||
|
|
||||||
|
**Versioning:** SemVer MINOR in the **1.x+** line by default (`1.1.0`…), as long
|
||||||
|
as defaults and migrations stay compatible with `1.0.0`. A major `2.x` only for
|
||||||
|
an explicit break.
|
||||||
|
|
||||||
|
**Process:** [development.md](development.md). The history of closed phases is
|
||||||
|
in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.x — хвост документации и деплоя
|
## Index
|
||||||
|
|
||||||
**Статус: закрыто** в релизе `1.0.0` / git-тег `v1.0.0`
|
| ID | Topic | Status | Plan |
|
||||||
(`ghcr.io/mixeme/selfpost:1.0.0`). План закрытия и `implementation-plan.md`
|
|---|---|---|---|
|
||||||
удалены — история в git и CHANGELOG; `docs/archive/` не храним.
|
| domain-admin | Domain administrator role | **agreed** | [plans/domain-admin.md](plans/domain-admin.md) |
|
||||||
|
| inbound-relay | Inbound relay (backup-MX / forwarding) | **agreed** | [plans/inbound-relay.md](plans/inbound-relay.md) |
|
||||||
|
| contributing | `CONTRIBUTING.md` | candidate | — |
|
||||||
|
| visual-style | Panel visual style | **agreed** | [plans/visual-style.md](plans/visual-style.md) |
|
||||||
|
| dmarc-reports | DMARC aggregate report ingestion and panel UI | candidate | [plans/dmarc-reports.md](plans/dmarc-reports.md) |
|
||||||
|
|
||||||
| Тема | Итог |
|
**Recommended order** (not binding): **domain-admin →
|
||||||
|---|---|
|
inbound-relay** — role-wide authorisation first, then the inbound relay vertical
|
||||||
| Адаптивный опрос мониторинга | 5 с / 30 с / 0 (скрытая вкладка) в `panel.js` |
|
slice. Deviating is allowed; there are no hard phases here.
|
||||||
| `mail.log` + reconcile | `/data/log/mail.log`; сверка с `postqueue -p` |
|
|
||||||
| Docs consolidation | процесс в [development.md](development.md); README Documentation |
|
|
||||||
| Compose pin + git tag | `1.0.0` / `v1.0.0` в одном релизном коммите |
|
|
||||||
|
|
||||||
Открытая работа дальше — только секции 2.x ниже.
|
After a context reset, pick an item marked `agreed` or `in progress`, then work
|
||||||
|
the checklist in its linked plan.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Фаза O1 (→ 2.x.x) — Входящий релей (backup-MX / пересылка) — опция/плагин
|
## inbound-relay
|
||||||
|
|
||||||
**Цель:** возможность принимать почту на порт 25 для явно настроенных доменов и пересылать её на заданный вышестоящий backend (роль backup-MX / relay-forwarder), **как выключаемый по умолчанию модуль**, не затрагивающий поведение и поверхность атаки базового исходящего релея.
|
**Goal:** optional acceptance of mail on port 25 for explicitly configured
|
||||||
|
domains, forwarded to an upstream (backup-MX / relay-forwarder). Off by default
|
||||||
|
(`INBOUND_RELAY_ENABLE=false`); without the flag the outbound path is
|
||||||
|
unchanged.
|
||||||
|
|
||||||
**Зачем это нужно (сценарии):**
|
**Boundary:** an extension of v1.0 — [product.md](product.md) excludes inbound
|
||||||
- **Backup-MX** — принять почту, когда основной почтовый сервер домена временно недоступен, и передать её, когда он вернётся.
|
mail and mailboxes. This is relay/forward, not IMAP/POP3/webmail; an anti-spam
|
||||||
- **Фронт для сервера без внешнего IP** — у оператора есть свой почтовый сервер, который по каким-то причинам **сам не может принимать почту из интернета** (нет статического/внешнего IP, за NAT, серый адрес, закрытый порт 25 на входящую и т.п.). SelfPost с публичным IP и корректным PTR выступает публичным входным узлом для домена (MX указывает на него) и пересылает почту на этот внутренний/недоступный извне сервер.
|
engine stays outside the image, only the attachment point is provided.
|
||||||
|
|
||||||
**Граница объёма (критично — что это НЕ):**
|
**Done when:** see the criteria in
|
||||||
- **ЭТО:** приём на 25 для доменов из явного списка + пересылка (relay/forward) на upstream (`relay_domains` + `transport_maps` + `relay_recipient_maps`). Postfix здесь — чистый пересыльщик, без локальной доставки.
|
[plans/inbound-relay.md](plans/inbound-relay.md).
|
||||||
- **ЭТО НЕ (out of scope, [product.md](product.md)):** локальная доставка в почтовые ящики, IMAP/POP3, webmail, Dovecot. Никаких mailbox'ов. SelfPost также **не реализует и не тянет в свой образ** движок антиспама/антивируса (rspamd/ClamAV) — но, в отличие от прежней формулировки, и **не** перекладывает фильтрацию на backend (см. блок «Антиспам» ниже): предоставляет точку подключения внешнего фильтра.
|
|
||||||
|
|
||||||
**Почему как опция/плагин:**
|
**Dependencies / risks:** a finished outbound path; open relay and backscatter;
|
||||||
- Приём на порт 25 меняет модель угроз (open relay для входящей, backscatter, spam-ingress). Поэтому по умолчанию **выключено** флагом env `INBOUND_RELAY_ENABLE=false`; включение — осознанный шаг оператора.
|
a wider attack surface (port 25 accepting mail).
|
||||||
- Изоляция: отдельные таблицы SQLite, отдельные хендлеры/страницы панели, отдельная ветка генерации конфига. При выключенном флаге входной listener, таблицы и UI отсутствуют — базовый исходящий тракт байт-в-байт неизменен.
|
**Order:** recommended after [domain-admin](plans/domain-admin.md).
|
||||||
|
**Version:** target bump `1.x`; `2.x` possible — to be settled once the
|
||||||
**Что делать:**
|
implementation lands.
|
||||||
- Env-флаг `INBOUND_RELAY_ENABLE` (default false); при `true` — генерировать входной сервис и его конфиг из состояния панели тем же путём, что остальной конфиг (`postfix-config.sh`).
|
|
||||||
- **`master.cf`:** входной `smtp inet` на 25 для приёма из интернета (сейчас 25 используется только на исходящую доставку). Отдельный от 465/587: на 25 **не** предлагается SASL и **не** разрешается отправка наружу — только приём для `relay_domains`.
|
|
||||||
- **Анти-open-relay для входящей (обязательно):** `smtpd_relay_restrictions`/`smtpd_recipient_restrictions` входного smtpd принимают почту **только** для доменов из `relay_domains` и **только** для известных получателей (`relay_recipient_maps`); всё прочее — `reject_unauth_destination`/`reject_unlisted_recipient`. Открытый релей и приём «для кого угодно» невозможны.
|
|
||||||
- **Backscatter:** предпочтительно знать валидных получателей (reject unknown recipient на этапе RCPT), чтобы не порождать bounce на несуществующие адреса.
|
|
||||||
- **Панель управляет:** список входящих доменов; для каждого — upstream destination (`host:port`, транспорт), опциональный список валидных получателей, опциональный TLS к upstream. Строгая валидация домена/хоста/порта (whitelist), injection-safe запись map-файлов (как `sender_login_maps` в Фазе 4), `os/exec` без shell ([security.md](security.md)).
|
|
||||||
- **Милтеры:** OpenDKIM на входящем тракте не нужен (чужую входящую не подписываем). journal-milter опционально переиспользовать для журнала входящих (доп. работа) либо на первом этапе оставить входящий без него; поведение fail-open сохраняется.
|
|
||||||
- **Rate-limit/размер:** грубый лимит по client IP (`anvil`, как L1) и `message_size_limit` на входном smtpd.
|
|
||||||
|
|
||||||
**Антиспам (важная, но опциональная возможность).** Это ценная опция, но она **не обязательна**: часть операторов вполне устроит **слепая пересылка без фильтрации** — например, когда backend сам умеет фильтровать по содержимому, стоит доверенный upstream, или объём/риск невелик. Поэтому антиспам-хук по умолчанию **выключен** (пустой `INBOUND_ANTISPAM_MILTER`), и входящий релей полностью работоспособен без него. Важно другое — где фильтрация возможна технически: при «слепом» relay целевой backend видит подключающимся IP адрес **SelfPost**, а не исходного отправителя, поэтому на backend'е ломается всё, что завязано на origin IP (DNSBL/репутация проверяются против IP SelfPost, SPF даёт fail — SelfPost не входит в SPF домена-отправителя). **Единственная точка, где ещё виден настоящий client IP — входной хоп на SelfPost**; поэтому тем, кому фильтрация нужна, она должна быть *подключаема именно здесь*, а не переложена на backend, который эту информацию уже потерял. Дизайн подключения:
|
|
||||||
- **Движок антиспама — отдельный опциональный контейнер** (rspamd и т.п.), который оператор запускает **только если нужна эта опция** (тот же принцип, что reverse-proxy — отдельный контейнер вне образа SelfPost). SelfPost его **не содержит и не запускает** — образ и принцип «один контейнер, три процесса» неизменны, [product.md](product.md) out of scope не нарушается (SelfPost не реализует антиспам).
|
|
||||||
- **SelfPost предоставляет точку подключения:** milter-хук на входном smtpd. Адрес движка задаётся env (например, `INBOUND_ANTISPAM_MILTER=inet:antispam:11332`, пусто → хук выключен) и добавляется в `smtpd_milters` **только входного** тракта (не на 465/587). Postfix передаёт milter'у настоящий client IP/HELO/PTR — фильтр видит истинный origin. `milter_default_action` для этого milter'а — конфигурируемый (fail-open vs tempfail); дефолт определить при реализации.
|
|
||||||
- **Нативный backstop без зависимостей:** на том же входном хопе доступны средства Postfix по origin IP — `reject_rbl_client` (DNSBL), проверки HELO/PTR — работают даже без внешнего контейнера. Плюс сохранение аутентификации для downstream через ARC/`Received` там, где часть фильтрации всё же остаётся на backend.
|
|
||||||
- **docker-compose:** задокументировать опциональный фрагмент antispam-сайдкара (как альтернативные фрагменты reverse-proxy) — контейнер поднимается вместе со стеком только при включённой опции.
|
|
||||||
- **Персистентность:** новые таблицы и map-файлы под `/data` — попадают в полный бэкап автоматически (Фаза 9). Экспорт/импорт домена можно расширить входящей конфигурацией — опционально, пометить.
|
|
||||||
- **DNS-документация:** для входящего домена нужна `MX`-запись, указывающая на сервер (в отличие от исходящего, где MX не требуется) — отразить в разделе DNS README.
|
|
||||||
|
|
||||||
**Безопасность ([security.md](security.md)):** валидация ввода на сервере, экранирование записи в конфиги, `exec` без интерполяции, никакого open relay, защита от backscatter.
|
|
||||||
|
|
||||||
**Готово, когда:** при `INBOUND_RELAY_ENABLE=true` и настроенном домене письмо на порт 25 для этого домена пересылается на заданный upstream; почта для ненастроенных доменов/получателей отклоняется (не open relay, не backscatter); при заданном `INBOUND_ANTISPAM_MILTER` входящая проходит через внешний фильтр с настоящим origin IP (проверено сайдкар-контейнером), при пустом — хук не мешает; при `INBOUND_RELAY_ENABLE=false` — входной порт/таблицы/UI отсутствуют, базовый исходящий релей неизменён; `build`/`vet`/`test`/образ зелёные.
|
|
||||||
|
|
||||||
**Риски:** open relay/backscatter (снимается `relay_domains` + `relay_recipient_maps` + `reject_unauth_destination`); потеря origin IP для фильтрации на backend'е при пересылке (снимается milter-хуком антиспама + нативным DNSBL на входном хопе, где origin IP ещё виден); порт 25 на приём расширяет поверхность атаки (по умолчанию выключено). **Модель:** Opus (инфра/безопасность, риск open relay). **Внешняя зависимость деплоя:** опциональный antispam-контейнер — вне образа SelfPost, поднимается оператором при включении опции.
|
|
||||||
|
|
||||||
**Зависимости:** не является частью v1.0, зависит только от готового исходящего тракта (уже реализован) и требует отдельного согласования ([.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)) до кодирования.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Роль администратора домена — кандидат на 2.x
|
## domain-admin
|
||||||
|
|
||||||
**Что это.** Сейчас в панели ровно один субъект: `requireAuth` — булев гейт, а не роль ([web.go](../internal/web/web.go) — обёртка `mux.Handle("/", s.requireAuth(authed))`), сессия не несёт ничего, кроме факта входа. Роль выдаёт доступ к одному домену и только к нему: приложения этого домена (создание, режим отправителя, перегенерация пароля, удаление, свой L2-лимит), DKIM/DNS-статус домена и журнал отправки, отфильтрованный по домену — фильтр в журнале уже есть ([sendLogData](../internal/web/handlers_monitor.go)). Вне роли остаётся то, что глобально по своей природе: добавление и удаление доменов, `/reload`, полный бэкап (это весь `/data` вместе с `sasldb2`, то есть все домены сразу), очередь и хвост `mail.log` — они серверные и к домену не привязаны.
|
**Goal:** a role with access to one or several assigned domains (the list is
|
||||||
|
set by the global administrator) — applications, DKIM/DNS, and the send log for
|
||||||
|
each of them; without global operations (adding domains, full backup, the
|
||||||
|
queue, `mail.log`).
|
||||||
|
|
||||||
**Почему 2.x, а не v1.x.** [product.md](product.md) относит «несколько пользователей
|
**Boundary:** an extension of v1.0 — [product.md](product.md) fixes a single
|
||||||
панели, роли» к out of scope (один администратор), поэтому появление второго
|
administrator. Not a second all-powerful admin, but limited access to the
|
||||||
субъекта — расширение границ проекта, как и Фаза O1: сначала согласование
|
assigned domains (one or several).
|
||||||
([.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)), только потом код. Цена — уровня фазы, а не патча: таблица пользователей и их привязка к доменам, роль в сессии, авторизация в каждом хендлере (а не только на маршруте — сейчас `{id}`/`{aid}` не сверяются ни с чем, кроме существования), пересмотр первичного setup'а и смены пароля под нескольких пользователей, учёт нового субъекта в бэкапе и экспорте домена.
|
|
||||||
|
|
||||||
*(Прежняя формулировка этого пункта — «2FA и несколько администраторов» — заменена: 2FA снята с рассмотрения, а «несколько администраторов» уточнено до одной конкретной роли, потому что нужна не вторая копия всевластного админа, а ограниченный доступ владельца отдельного домена.)*
|
**Done when:** see [plans/domain-admin.md](plans/domain-admin.md).
|
||||||
|
|
||||||
|
**Dependencies / risks:** a users table, the role in the session, authorisation
|
||||||
|
in every handler, setup and backup. **Order:** recommended **before**
|
||||||
|
[inbound-relay](plans/inbound-relay.md).
|
||||||
|
**Version:** `1.x` MINOR, given a compatible migration of the current
|
||||||
|
administrator into a global one.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## `CONTRIBUTING.md` — кандидат на 2.x
|
## contributing
|
||||||
|
|
||||||
**Что это.** Точка входа для стороннего контрибьютора: dev loop, маршрутизация
|
**Goal:** `CONTRIBUTING.md` in the root — the dev loop, the checks to run
|
||||||
моделей по типу работы, протокол коммитов, требование
|
before a PR, the commit protocol; [development.md](development.md) links to it
|
||||||
`gofmt`/`vet`/`test`/`make e2e` до PR. Сейчас всё это есть в
|
rather than repeating it.
|
||||||
[development.md](development.md) (английский процесс) и в этом файле (открытая
|
|
||||||
работа, русский).
|
|
||||||
|
|
||||||
**Почему 2.x, а не v1.x.** Файл имеет смысл, когда есть кому его читать: у
|
**Boundary:** process documentation; worth writing once there is an external
|
||||||
проекта один разработчик и внешнего потока PR нет, поэтому сейчас
|
flow of PRs.
|
||||||
`CONTRIBUTING.md` был бы документом без аудитории и ещё одним местом, где
|
|
||||||
расходится правда о dev loop. Уместен вместе с тем, что реально открывает
|
|
||||||
проект вовне: английская документация процесса ([development.md](development.md),
|
|
||||||
README, `architecture.md`; [roadmap.md](roadmap.md) — внутренний трекер, на
|
|
||||||
русском) и первый внешний интерес после публикации релиза.
|
|
||||||
|
|
||||||
**Готово, когда:** `CONTRIBUTING.md` в корне описывает dev loop, требования к
|
**Done when:** the file is in the root and development.md does not duplicate
|
||||||
проверкам перед PR и протокол коммитов; [development.md](development.md) не
|
it.
|
||||||
дублирует его, а ссылается.
|
|
||||||
|
**Dependencies / risks:** with a single developer and no PRs, this is low
|
||||||
|
priority.
|
||||||
|
**Version:** no bearing on semver.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Разбиение `internal/web` на подпакеты — кандидат на 2.x
|
## visual-style
|
||||||
|
|
||||||
**Что это.** `internal/web` — самый крупный пакет проекта: ~50 файлов
|
**Goal:** refresh the control panel's visual design — typography, colour tokens,
|
||||||
(включая шаблоны и static), ~25 `.go` / ~4000 строк Go, в одной плоскости
|
spacing, and component styling — without changing operator workflows or panel
|
||||||
лежат хендлеры всех разделов панели, сессии,
|
behaviour.
|
||||||
security-заголовки, проверка Origin, валидация форм и рендер шаблонов.
|
|
||||||
Кандидаты на выделение — `web/handlers` и `web/auth`, либо разрез по доменам
|
|
||||||
панели.
|
|
||||||
|
|
||||||
**Почему 2.x, а не сейчас.** На нынешнем размере плоский пакет читается: имена
|
**Boundary:** presentation only (`panel.css`, templates, static assets); no new
|
||||||
файлов (`handlers_domains.go`, `handlers_apps.go`, `handlers_monitor.go`)
|
features. Styling must stay compatible with the panel CSP — rules live in
|
||||||
работают не хуже каталогов, а разбиение потянуло бы за собой экспорт того, что
|
`panel.css`, not inline (see [security.md](security.md) and the stylesheet
|
||||||
сейчас пакетно-приватно, — то есть расширение внутреннего API ради
|
header).
|
||||||
косметики. Смысл появляется ровно тогда, когда пакет начнёт расти: обе задачи
|
|
||||||
2.x выше добавляют в него код — роль администратора домена приносит
|
**Done when:** see [plans/visual-style.md](plans/visual-style.md). The agreed
|
||||||
авторизацию в каждый хендлер, входящий релей — отдельные страницы и хендлеры
|
direction is the mark's own — brick, warm paper, IBM Plex — taken from
|
||||||
входящих доменов. Рефакторинг дешевле делать перед этим ростом, чем после.
|
[assets/selfpost-proof.html](assets/selfpost-proof.html); light and dark schemes
|
||||||
|
remain supported; readability and contrast are preserved.
|
||||||
|
|
||||||
|
**Dependencies / risks:** CSP constraints on how styles are applied (rules in
|
||||||
|
`panel.css`, never inline); visual regression across pages; the accent colour
|
||||||
|
must not read as a status badge.
|
||||||
|
**Order:** independent of the feature items; may be taken up between them.
|
||||||
|
**Version:** no bearing on semver.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## dmarc-reports
|
||||||
|
|
||||||
|
**Goal:** SelfPost receives DMARC aggregate reports (RFC 7489) on SMTP,
|
||||||
|
parses the gzip/XML payloads, and shows pass/fail summaries in the panel — so
|
||||||
|
the operator does not need an external DMARC service or a separate mailbox
|
||||||
|
workflow.
|
||||||
|
|
||||||
|
**Boundary:** an extension of v1.0 — not IMAP/webmail and not a general
|
||||||
|
inbound relay. A dedicated inbound path for report messages only; forensic
|
||||||
|
reports (`ruf=`) out of scope for v1.
|
||||||
|
|
||||||
|
**Done when:** see [plans/dmarc-reports.md](plans/dmarc-reports.md).
|
||||||
|
|
||||||
|
**Dependencies / risks:** inbound SMTP in the image (may share infrastructure
|
||||||
|
with [inbound-relay](plans/inbound-relay.md) but must not require backup-MX);
|
||||||
|
storage and retention of parsed summaries; the `admin.dmarc_report_email` and
|
||||||
|
`domains.dmarc_rua` settings added in the DMARC template work must stay the
|
||||||
|
source of truth for `rua=` in DNS guidance.
|
||||||
|
|
||||||
|
**Order:** after the DMARC `rua=` settings ship; may follow or overlap with
|
||||||
|
inbound-relay depending on how port 25 acceptance is structured.
|
||||||
|
|
||||||
|
**Version:** `1.x` MINOR.
|
||||||
|
|
||||||
**Готово, когда:** решение принято осознанно в момент старта 2.x — либо пакет
|
|
||||||
разрезан, либо зафиксировано, что он остаётся плоским.
|
|
||||||
+157
-141
@@ -1,177 +1,193 @@
|
|||||||
# Безопасность
|
# Security
|
||||||
|
|
||||||
**Что здесь.** (1) **Обязательные требования** — чеклист, который v1.0 обязан
|
**What is here.** (1) **Mandatory requirements** — the checklist v1.0 has to
|
||||||
выполнять; полный аудит на v1.0 пройден. Предрелизная ревизия (план § D,
|
meet; the full v1.0 audit passed. The pre-release review (plan § D, 2026-08-06)
|
||||||
модель Fable, 2026-08-06) прошла по всему дифу от аудита v1.0 (Фаза 11) до
|
covered the whole diff from the v1.0 audit (Phase 11) to HEAD and the checklist
|
||||||
HEAD и по чек-листу целиком: эксплуатируемых находок нет; одна правка
|
in full: no exploitable findings; one defence-in-depth change — `--` before the
|
||||||
defence-in-depth — `--` перед логином в argv `saslpasswd2`
|
login in the `saslpasswd2` argv
|
||||||
([internal/app/sasl.go](../internal/app/sasl.go)). (2) **Принятые риски** —
|
([internal/app/sasl.go](../internal/app/sasl.go)). (2) **Accepted risks** —
|
||||||
сознательные отступления сверх обязательного, чтобы решение не потерялось.
|
deliberate departures beyond the mandatory, recorded so the decision is not
|
||||||
|
lost.
|
||||||
|
|
||||||
Hardening сверх обязательного (security-заголовки, проверка origin, cookie
|
Hardening beyond the mandatory (security headers, origin checking, `__Host-`
|
||||||
`__Host-` с обнаружением дублей — Фаза 14) закрыт; история — в
|
cookie with duplicate detection — Phase 14) is done; the history is in
|
||||||
[CHANGELOG.md](../CHANGELOG.md) и `git log`.
|
[CHANGELOG.md](../CHANGELOG.md) and `git log`.
|
||||||
|
|
||||||
Продуктовые границы: [product.md](product.md). Устройство as-built:
|
Product boundaries: [product.md](product.md). As-built design:
|
||||||
[architecture.md](architecture.md).
|
[architecture.md](architecture.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Обязательные требования
|
## Mandatory requirements
|
||||||
|
|
||||||
Панель публична из интернета — пункты ниже **не опциональны**.
|
The panel is exposed to the internet — the items below are **not optional**.
|
||||||
|
|
||||||
### Первичная инициализация администратора
|
### First-run administrator setup
|
||||||
|
|
||||||
- Одноразовая secret-ссылка `/setup/<token>`, **не** env с готовым хэшем пароля.
|
- A one-time secret link `/setup/<token>`, **not** an env variable holding a
|
||||||
- Токен ≥128 бит (`crypto/rand`); дублируется в `/data/setup-token`.
|
ready-made password hash.
|
||||||
- Срок жизни токена — **10 минут**; после истечения или рестарта без завершённой
|
- Token ≥128 bits (`crypto/rand`); mirrored to `/data/setup-token`.
|
||||||
настройки — перегенерация и новый вывод в лог.
|
- Token lifetime — **10 minutes**; after expiry, or after a restart with setup
|
||||||
- Rate limiting на `/setup/<token>` по IP, отдельно от логина.
|
unfinished, it is regenerated and logged again.
|
||||||
- Сравнение токена — **константное по времени** (`subtle.ConstantTimeCompare`).
|
- Rate limiting on `/setup/<token>` per IP, separate from login.
|
||||||
- Неудачные попытки **не** инвалидируют токен досрочно (защита от DoS настройки).
|
- Token comparison is **constant-time** (`subtle.ConstantTimeCompare`).
|
||||||
- После создания администратора — токен навсегда недействителен, `/setup/*` → 404.
|
- Failed attempts do **not** invalidate the token early (protects setup from
|
||||||
- Пароль администратора — только bcrypt (или argon2) в SQLite; без plaintext/MD5.
|
being DoS-ed).
|
||||||
- `PANEL_USERNAME` / `PANEL_PASSWORD_HASH` в env **не используются**.
|
- Once the administrator exists the token is void forever, `/setup/*` → 404.
|
||||||
|
- The administrator password is bcrypt (or argon2) in SQLite only; no plaintext
|
||||||
|
and no MD5.
|
||||||
|
- `PANEL_USERNAME` / `PANEL_PASSWORD_HASH` in env are **not used**.
|
||||||
|
|
||||||
### SASL-пароли приложений
|
### Application SASL passwords
|
||||||
|
|
||||||
- Панель **генерирует** пароль при создании/перевыпуске, показывает **один раз**.
|
- The panel **generates** the password on creation or reissue and shows it
|
||||||
- В `sasldb2` — в форме, требуемой SASL (не plaintext в панели); утерян — только
|
**once**.
|
||||||
перевыпуск.
|
- In `sasldb2` it is stored in the form SASL requires (not plaintext held by the
|
||||||
|
panel); a lost password can only be reissued.
|
||||||
|
|
||||||
### Ввод и конфигурация
|
### Input and configuration
|
||||||
|
|
||||||
- Серверная валидация email/доменов (whitelist символов); клиентская не считается
|
- Server-side validation of addresses and domains (character whitelist);
|
||||||
защитой.
|
client-side validation does not count as protection.
|
||||||
- Режим «список адресов» — каждый адрес принадлежит домену приложения до записи.
|
- In address-list mode every address is checked to belong to the application's
|
||||||
- `postfix reload` и любой `exec` — **без** shell-интерполяции пользовательского
|
domain before it is written.
|
||||||
ввода; аргументы отдельными элементами.
|
- `postfix reload` and any `exec` run **without** shell interpolation of user
|
||||||
- Запись в конфиг-файлы — с экранированием (нет инъекции директив Postfix).
|
input; arguments are passed as separate elements.
|
||||||
|
- Writes to config files are escaped (no injection of Postfix directives).
|
||||||
|
|
||||||
### Аутентификация и сессии
|
### Authentication and sessions
|
||||||
|
|
||||||
- Rate limiting на логин (по IP, с блокировкой/задержкой).
|
- Rate limiting on login (per IP, with lockout or delay).
|
||||||
- Сессии: криптографически случайный токен; cookie `HttpOnly`, `Secure`, `SameSite`.
|
- Sessions: cryptographically random token; cookie `HttpOnly`, `Secure`,
|
||||||
- Сессии в SQLite (SHA-256 токена, не сам токен); скользящий idle
|
`SameSite`.
|
||||||
(`PANEL_SESSION_IDLE_DAYS`).
|
- Sessions live in SQLite (SHA-256 of the token, not the token itself); sliding
|
||||||
|
idle timeout (`PANEL_SESSION_IDLE_DAYS`).
|
||||||
|
|
||||||
### Вывод и процесс
|
### Output and process
|
||||||
|
|
||||||
- Рендер через `html/template` с автоэкранированием (очередь, лог, журнал, темы).
|
- Rendering goes through `html/template` with auto-escaping (queue, log,
|
||||||
- Процесс панели **не root** (`user=panel` в supervisord); доступ к путям через
|
journal, themes).
|
||||||
группу `selfpost` и минимальные права.
|
- The panel process is **not root** (`user=panel` in supervisord); path access
|
||||||
|
is granted through the `selfpost` group with minimal permissions.
|
||||||
|
|
||||||
### Почтовый тракт (связанное с безопасностью)
|
### Mail path (security-relevant)
|
||||||
|
|
||||||
- **Не open relay** — только SASL; `reject_unauth_destination`;
|
- **Not an open relay** — SASL only; `reject_unauth_destination`;
|
||||||
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
|
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
|
||||||
- TLS обязателен до передачи кредов (465 wrapper / 587 `encrypt`).
|
- TLS is mandatory before credentials are transmitted (465 wrapper / 587
|
||||||
- `TRUSTED_PROXY_CIDR` — только явно доверенные прокси для `X-Forwarded-For`
|
`encrypt`).
|
||||||
при rate-limit логина; пусто = XFF игнорируется.
|
- `TRUSTED_PROXY_CIDR` — only explicitly trusted proxies may supply
|
||||||
|
`X-Forwarded-For` for login rate limiting; empty means XFF is ignored.
|
||||||
|
|
||||||
### Резервная копия и экспорт домена
|
### Backup and domain export
|
||||||
|
|
||||||
- Оба файла — секреты: полный бэкап несёт DKIM-ключи, `sasldb2` и хеш пароля
|
- Both files are secrets: a full backup carries DKIM keys, `sasldb2`, and the
|
||||||
админа; экспорт домена — DKIM-ключ и **рабочие** пароли приложений открытым
|
administrator's password hash; a domain export carries the DKIM key and
|
||||||
текстом (иначе перенос без пересоздания кредов невозможен).
|
**working** application passwords in the clear (otherwise a transfer without
|
||||||
- Оба скачивания можно зашифровать паролем (чекбокс в форме): scrypt
|
recreating credentials would be impossible).
|
||||||
(N=2¹⁵, r=8, p=1) → AES-256-GCM, поток из 64 KiB чанков, каждый
|
- Both downloads can be encrypted with a password (a checkbox on the form):
|
||||||
аутентифицирован заголовком, номером и флагом конца потока — обрезанный или
|
scrypt (N=2¹⁵, r=8, p=1) → AES-256-GCM, streamed in 64 KiB chunks, each
|
||||||
подменённый файл не открывается вместо тихого восстановления «хвоста».
|
authenticated with the header, the chunk number, and an end-of-stream flag —
|
||||||
Формат и обёртка: [internal/secretfile](../internal/secretfile/secretfile.go).
|
a truncated or substituted file fails to open instead of silently restoring a
|
||||||
- Расширения: `.spbk` (**S**elf**P**ost **b**ac**k**up — полный бэкап),
|
partial "tail". Format and wrapper:
|
||||||
`.spde` (**S**elf**P**ost **d**omain **e**xport — экспорт домена);
|
[internal/secretfile](../internal/secretfile/secretfile.go).
|
||||||
незашифрованные остаются `.tar.gz` / `.json`. Импорт домена определяет
|
- Extensions: `.spbk` (**S**elf**P**ost **b**ac**k**up — full backup), `.spde`
|
||||||
шифрование по magic файла, а не по расширению.
|
(**S**elf**P**ost **d**omain **e**xport — domain export); unencrypted files
|
||||||
- Пароль нигде не сохраняется: восстановить файл без него нельзя. Пароль в CLI —
|
stay `.tar.gz` / `.json`. Domain import detects encryption by the file's magic
|
||||||
только через `SELFPOST_BACKUP_PASSWORD` или `-password-file`, никогда
|
bytes, not by extension.
|
||||||
аргументом (список процессов читается любым процессом контейнера).
|
- The password is never stored: without it the file cannot be recovered. In the
|
||||||
- Минимальная длина пароля — как у пароля администратора (12): файл лежит
|
CLI the password comes only from `SELFPOST_BACKUP_PASSWORD` or
|
||||||
offline и подбирается без ограничений по времени.
|
`-password-file`, never as an argument (the process list is readable by any
|
||||||
|
process in the container).
|
||||||
|
- Minimum password length matches the administrator password (12): the file
|
||||||
|
sits offline and can be attacked without a time limit.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Принятые риски
|
## Accepted risks
|
||||||
|
|
||||||
Принятый риск — решение с условием возврата, а не отложенная задача из
|
An accepted risk is a decision with a condition for revisiting it, not a
|
||||||
дорожной карты.
|
deferred item from the roadmap.
|
||||||
|
|
||||||
- **`POST` без `Sec-Fetch-Site` и без `Origin` пропускается.**
|
- **A `POST` with neither `Sec-Fetch-Site` nor `Origin` is allowed through.**
|
||||||
Клиент, не посылающий ни одного из двух — по-настоящему старый браузер или
|
A client that sends neither — a genuinely old browser, or a webview with a
|
||||||
webview с замороженным движком, — остаётся уязвим к CSRF с любого сайта.
|
frozen engine — stays vulnerable to CSRF from any site. Accepted
|
||||||
Принято сознательно: панель однопользовательская, админ выбирает браузер
|
deliberately: the panel is single-user, the administrator picks the browser,
|
||||||
сам, а строгий режим не «защитил бы» такой клиент, а просто сломал бы в нём
|
and a strict mode would not "protect" such a client, it would simply break the
|
||||||
панель. Ужесточение — одна строка в `originAllowed`
|
panel in it. Tightening is one line in `originAllowed`
|
||||||
([internal/web/security.go](../internal/web/security.go)): вернуть `false`
|
([internal/web/security.go](../internal/web/security.go)): return `false`
|
||||||
вместо `true` в ветке «нет обоих заголовков».
|
instead of `true` in the "neither header present" branch.
|
||||||
- **CSRF-токены, привязанные к сессии, не делаются.** Проверка origin
|
- **Session-bound CSRF tokens are not implemented.** The origin check closes the
|
||||||
закрывает соседний поддомен, но зависит от поведения браузера; токен — нет.
|
neighbouring-subdomain case but depends on browser behaviour; a token does
|
||||||
Цена — скрытое поле примерно в двух десятках форм. Триггером вернуться к
|
not. The price is a hidden field in roughly two dozen forms. The trigger to
|
||||||
вопросу считать появление требования «устойчиво независимо от браузера».
|
revisit is a requirement for protection that holds regardless of the browser.
|
||||||
От XSS внутри самой панели не спас бы и токен: код, исполняющийся в origin
|
A token would not save the panel from XSS inside it either: code executing in
|
||||||
панели, отправит запрос сам — против этого работают автоэкранирование
|
the panel's origin sends the request itself — against that, `html/template`
|
||||||
`html/template` и CSP, поэтому шаблоны не должны содержать
|
auto-escaping and CSP do the work, which is why templates must contain no
|
||||||
inline-скриптов и inline-стилей.
|
inline scripts and no inline styles.
|
||||||
- **Шифрование бэкапа и экспорта — опция, а не умолчание.** Галочка снята —
|
- **Encrypting backups and exports is an option, not the default.** With the
|
||||||
файл скачивается открытым, как в 1.0. Иначе оператор, у которого нет места
|
checkbox cleared the file downloads in the clear, as in 1.0. Otherwise an
|
||||||
для хранения пароля, потерял бы возможность сделать бэкап вообще, а
|
operator with nowhere to keep a password would lose the ability to take a
|
||||||
безвозвратно нерасшифровываемый архив хуже незашифрованного: пароль SelfPost
|
backup at all, and a permanently undecryptable archive is worse than an
|
||||||
не хранит. Триггером сделать шифрование обязательным считать появление
|
unencrypted one: SelfPost does not store the password. The trigger to make
|
||||||
второго администратора (тогда «кто скачал» перестаёт быть одним человеком).
|
encryption mandatory is a second administrator (at which point "who
|
||||||
- **Строка журнала, оставшаяся без delivery-строк, закрывается как `bounced`, а
|
downloaded it" stops being one person).
|
||||||
не как есть.** Риск «вечный `queued`» снят: `mail.log` переехал в
|
- **A journal row left without delivery lines is closed as `bounced` rather
|
||||||
`/data/log/` и переживает пересоздание контейнера, а log-tailer сохраняет
|
than left as it is.** The "forever `queued`" risk is gone: `mail.log` moved to
|
||||||
позицию чтения (`logtail_state`, миграция `0003`), так что после старта хвост
|
`/data/log/` and survives container recreation, and the log tailer keeps its
|
||||||
дочитывается. Остаток — строки, delivery-строки которых потеряны
|
read position (`logtail_state`, migration `0003`), so the tail is read after a
|
||||||
безвозвратно (лог провернулся дальше 14 файлов, пока панель лежала, либо был
|
start. What remains are rows whose delivery lines are lost for good (the log
|
||||||
удалён): сверка с `postqueue -p` видит, что письма в очереди нет, и через
|
rotated past 14 files while the panel was down, or was deleted): the
|
||||||
2 минуты grace ставит `bounced`. Если письмо на самом деле ушло, статус
|
reconciliation against `postqueue -p` sees the message is not in the queue and
|
||||||
окажется ложно-отрицательным. Принято сознательно: доставка, которую панель
|
after a 2-minute grace marks it `bounced`. If the message did in fact go out,
|
||||||
не может подтвердить, не должна показываться как подтверждённая, а вечный
|
the status is a false negative. Accepted deliberately: a delivery the panel
|
||||||
`queued` не отличим от «висит прямо сейчас». Сверка не срабатывает, пока
|
cannot confirm must not be shown as confirmed, and a permanent `queued` is
|
||||||
tailer не дочитал лог до конца, и не трогает ничего, если `postqueue` не
|
indistinguishable from "in flight right now". Reconciliation does not run
|
||||||
читается. См. [architecture.md](architecture.md) § Log tailer.
|
until the tailer has read the log to the end, and touches nothing if
|
||||||
- **Доступ к `mail.log` из-под непривилегированной панели.** Каталог
|
`postqueue` is unreadable. See [architecture.md](architecture.md) § Log
|
||||||
`/data/log` — `2750 postfix:selfpost`, файл — `0640`: пишет `postlogd`
|
tailer.
|
||||||
(пользователь `postfix`), читает панель по общей группе `selfpost`, миру файл
|
- **Access to `mail.log` from the unprivileged panel.** The `/data/log`
|
||||||
недоступен. Лог содержит envelope-адреса и IP клиентов, но не тела и не
|
directory is `2750 postfix:selfpost` and the file is `0640`: `postlogd` (user
|
||||||
заголовки писем; в бэкап он не попадает (`log/` исключён), чтобы выгрузка
|
`postfix`) writes, the panel reads through the shared `selfpost` group, and
|
||||||
оставалась состоянием, а не диагностикой.
|
the file is inaccessible to others. The log holds envelope addresses and
|
||||||
|
client IPs, but neither message bodies nor headers; it is excluded from
|
||||||
|
backups (`log/` is skipped) so that a dump stays state rather than
|
||||||
|
diagnostics.
|
||||||
|
|
||||||
## ADR: CSRF через проверку Origin, без токенов
|
## ADR: CSRF via origin checking, without tokens
|
||||||
|
|
||||||
**Контекст.** Панель — формы (`POST`) с cookie-сессией; классическая CSRF-
|
**Context.** The panel is forms (`POST`) with a cookie session — the classic
|
||||||
поверхность. Нужен способ отличить запрос со страницы панели от запроса,
|
CSRF surface. What is needed is a way to tell a request from the panel's own
|
||||||
инициированного сторонним сайтом в браузере залогиненного админа.
|
page apart from one initiated by a third-party site in the logged-in
|
||||||
|
administrator's browser.
|
||||||
|
|
||||||
**Решение.** `originAllowed` в
|
**Decision.** `originAllowed` in
|
||||||
[internal/web/security.go](../internal/web/security.go) сверяет `Sec-Fetch-Site`
|
[internal/web/security.go](../internal/web/security.go) checks `Sec-Fetch-Site`
|
||||||
(если браузер его шлёт) либо `Origin` (fallback) с хостом панели; запрос без
|
(when the browser sends it) or `Origin` (fallback) against the panel's host; a
|
||||||
обоих заголовков **пропускается**, а не отклоняется. Токенов, привязанных к
|
request carrying neither header is **allowed through** rather than rejected.
|
||||||
сессии и встроенных в формы, нет.
|
There are no session-bound tokens embedded in forms.
|
||||||
|
|
||||||
**Почему не токены.** Панель однопользовательская (один администратор на
|
**Why not tokens.** The panel is single-user (one administrator per instance) —
|
||||||
инстанс) — модель угроз не включает межпользовательский CSRF внутри самой
|
the threat model does not include cross-user CSRF inside the panel itself, only
|
||||||
панели, только внешний сайт, заставляющий браузер админа отправить запрос.
|
an external site making the administrator's browser send a request. The origin
|
||||||
Origin-проверка закрывает это без изменения ни одного шаблона: токен потребовал
|
check covers that without touching a single template: a token would need a
|
||||||
бы скрытого поля примерно в двух десятках форм и синхронизации при каждой
|
hidden field in roughly two dozen forms and synchronisation with every new form,
|
||||||
новой форме, а от XSS внутри панели токен всё равно не защищает — код,
|
and it would still not protect against XSS inside the panel — code executing in
|
||||||
исполняющийся в origin панели, читает токен и отправляет запрос сам. От XSS
|
the panel's origin reads the token and sends the request itself. XSS is handled
|
||||||
защищают автоэкранирование `html/template` и CSP, поэтому это отдельная линия
|
by `html/template` auto-escaping and CSP, so that is a separate line of defence,
|
||||||
обороны, не CSRF-токен.
|
not a CSRF token.
|
||||||
|
|
||||||
**Компромисс.** Клиент, не посылающий ни `Sec-Fetch-Site`, ни `Origin`
|
**Trade-off.** A client that sends neither `Sec-Fetch-Site` nor `Origin` (a
|
||||||
(по-настоящему старый браузер или webview с замороженным движком), остаётся
|
genuinely old browser, or a webview with a frozen engine) stays vulnerable — see
|
||||||
уязвим — см. «Принятые риски» выше. Это осознанный выбор в пользу не ломать
|
"Accepted risks" above. This is a deliberate choice not to break the panel in
|
||||||
панель в таком клиенте ценой узкой остаточной поверхности.
|
such a client, at the price of a narrow residual surface.
|
||||||
|
|
||||||
**Пересмотр, если:** появится требование защиты, не зависящей от поведения
|
**Revisit if:** a requirement appears for protection that does not depend on
|
||||||
браузера, или панель станет многопользовательской.
|
browser behaviour, or the panel becomes multi-user.
|
||||||
|
|
||||||
## Как этот список пополняется
|
## How this list grows
|
||||||
|
|
||||||
Предрелизная проверка на уязвимости (модель Fable; история — CHANGELOG
|
The pre-release vulnerability review (history — CHANGELOG `[0.5.0]` Security)
|
||||||
`[0.5.0]` Security) закрывает каждую находку одним из двух способов: правка до
|
closes every finding in one of two ways: a fix before the tag, or an entry here
|
||||||
тега — либо запись сюда, с обоснованием и условием возврата, как у пунктов выше.
|
with its rationale and its condition for revisiting, like the items above.
|
||||||
Третьего варианта («посмотрели и ладно») нет.
|
There is no third option ("we looked at it and moved on").
|
||||||
@@ -12,8 +12,7 @@ import (
|
|||||||
// SASLDB manages the Cyrus SASL account database (sasldb2) the panel maintains
|
// SASLDB manages the Cyrus SASL account database (sasldb2) the panel maintains
|
||||||
// for application credentials (architecture.md § Mail path). The panel is the
|
// for application credentials (architecture.md § Mail path). The panel is the
|
||||||
// only writer; Postfix reads it to authenticate SMTP clients. Accounts are
|
// only writer; Postfix reads it to authenticate SMTP clients. Accounts are
|
||||||
// created and removed with the standard saslpasswd2 tool ("эквивалент
|
// created and removed with the standard saslpasswd2 tool.
|
||||||
// saslpasswd2", per the plan).
|
|
||||||
type SASLDB struct {
|
type SASLDB struct {
|
||||||
path string // sasldb2 file, under /data so it survives restarts
|
path string // sasldb2 file, under /data so it survives restarts
|
||||||
realm string // SASL realm, so lookups match what Postfix's SASL uses
|
realm string // SASL realm, so lookups match what Postfix's SASL uses
|
||||||
|
|||||||
@@ -56,12 +56,13 @@ type Server struct {
|
|||||||
|
|
||||||
// Domain is the published-DNS state of one sending domain.
|
// Domain is the published-DNS state of one sending domain.
|
||||||
type Domain struct {
|
type Domain struct {
|
||||||
Name string
|
Name string
|
||||||
DKIM Result
|
DKIM Result
|
||||||
SPF Result
|
SPF Result
|
||||||
DMARC Result
|
DMARC Result
|
||||||
Overall health.Status
|
DMARCReportAuth Result // zero when external rua= is not used
|
||||||
CheckedAt time.Time
|
Overall health.Status
|
||||||
|
CheckedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query describes the domain to check. ExpectedDKIM is the TXT value the panel
|
// Query describes the domain to check. ExpectedDKIM is the TXT value the panel
|
||||||
@@ -69,11 +70,12 @@ type Domain struct {
|
|||||||
// compares DNS against the key this server actually signs with. Hostname and
|
// compares DNS against the key this server actually signs with. Hostname and
|
||||||
// ServerIPs identify this server and come from a preceding Server check.
|
// ServerIPs identify this server and come from a preceding Server check.
|
||||||
type Query struct {
|
type Query struct {
|
||||||
Name string
|
Name string
|
||||||
Selector string
|
Selector string
|
||||||
ExpectedDKIM string
|
ExpectedDKIM string
|
||||||
Hostname string
|
Hostname string
|
||||||
ServerIPs []string
|
ServerIPs []string
|
||||||
|
DMARCReportEmail string // resolved rua= destination; empty = policy-only template
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolver is the slice of *net.Resolver this package uses, as an interface so
|
// resolver is the slice of *net.Resolver this package uses, as an interface so
|
||||||
@@ -178,12 +180,19 @@ func (c *Checker) Forget(domainName string) {
|
|||||||
func (c *Checker) checkDomain(ctx context.Context, q Query) Domain {
|
func (c *Checker) checkDomain(ctx context.Context, q Query) Domain {
|
||||||
d := Domain{Name: q.Name, CheckedAt: time.Now()}
|
d := Domain{Name: q.Name, CheckedAt: time.Now()}
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(3)
|
wg.Add(4)
|
||||||
go func() { defer wg.Done(); d.DKIM = c.checkDKIM(ctx, q) }()
|
go func() { defer wg.Done(); d.DKIM = c.checkDKIM(ctx, q) }()
|
||||||
go func() { defer wg.Done(); d.SPF = c.checkSPF(ctx, q) }()
|
go func() { defer wg.Done(); d.SPF = c.checkSPF(ctx, q) }()
|
||||||
go func() { defer wg.Done(); d.DMARC = c.checkDMARC(ctx, q.Name) }()
|
go func() { defer wg.Done(); d.DMARC = c.checkDMARC(ctx, q) }()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
hub := EmailDomain(q.DMARCReportEmail)
|
||||||
|
if hub != "" && !strings.EqualFold(hub, q.Name) {
|
||||||
|
d.DMARCReportAuth = c.checkReportAuth(ctx, hub)
|
||||||
|
}
|
||||||
|
}()
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
d.Overall = health.Worst(d.DKIM.Status, d.SPF.Status, d.DMARC.Status)
|
d.Overall = health.Worst(d.DKIM.Status, d.SPF.Status, d.DMARC.Status, d.DMARCReportAuth.Status)
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ func TestDMARC(t *testing.T) {
|
|||||||
txt["_dmarc.example.com"] = c.record
|
txt["_dmarc.example.com"] = c.record
|
||||||
}
|
}
|
||||||
f := &fakeResolver{txt: txt}
|
f := &fakeResolver{txt: txt}
|
||||||
got := newTestChecker(f).checkDMARC(context.Background(), "example.com")
|
got := newTestChecker(f).checkDMARC(context.Background(), Query{Name: "example.com"})
|
||||||
if got.Status != c.want {
|
if got.Status != c.want {
|
||||||
t.Errorf("status = %q, want %q (%s)", got.Status, c.want, got.Detail)
|
t.Errorf("status = %q, want %q (%s)", got.Status, c.want, got.Detail)
|
||||||
}
|
}
|
||||||
@@ -303,7 +303,7 @@ func TestDMARC(t *testing.T) {
|
|||||||
|
|
||||||
func TestDMARCNonePolicyIsExplained(t *testing.T) {
|
func TestDMARCNonePolicyIsExplained(t *testing.T) {
|
||||||
f := &fakeResolver{txt: map[string][]string{"_dmarc.example.com": {"v=DMARC1; p=none"}}}
|
f := &fakeResolver{txt: map[string][]string{"_dmarc.example.com": {"v=DMARC1; p=none"}}}
|
||||||
got := newTestChecker(f).checkDMARC(context.Background(), "example.com")
|
got := newTestChecker(f).checkDMARC(context.Background(), Query{Name: "example.com"})
|
||||||
if !strings.Contains(got.Detail, "monitoring only") {
|
if !strings.Contains(got.Detail, "monitoring only") {
|
||||||
t.Errorf("p=none is not explained: %s", got.Detail)
|
t.Errorf("p=none is not explained: %s", got.Detail)
|
||||||
}
|
}
|
||||||
@@ -357,3 +357,20 @@ func TestForgetDropsTheCachedDomain(t *testing.T) {
|
|||||||
t.Error("Forget did not drop the cached result")
|
t.Error("Forget did not drop the cached result")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReportAuth(t *testing.T) {
|
||||||
|
f := &fakeResolver{txt: map[string][]string{"_report._dmarc.hub.example": {"v=DMARC1;"}}}
|
||||||
|
got := newTestChecker(f).checkReportAuth(context.Background(), "hub.example")
|
||||||
|
if got.Status != health.StatusOK {
|
||||||
|
t.Fatalf("status = %q (%s)", got.Status, got.Detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
f = &fakeResolver{}
|
||||||
|
got = newTestChecker(f).checkReportAuth(context.Background(), "hub.example")
|
||||||
|
if got.Status != health.StatusWarn {
|
||||||
|
t.Fatalf("missing = %q, want warn", got.Status)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got.Detail, ReportAuthExample()) {
|
||||||
|
t.Errorf("advice %q should cite %q", got.Detail, ReportAuthExample())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package dnscheck
|
package dnscheck
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -16,6 +17,13 @@ import (
|
|||||||
// helper: it is published at the domain itself.)
|
// helper: it is published at the domain itself.)
|
||||||
func DMARCRecordName(domainName string) string { return "_dmarc." + domainName }
|
func DMARCRecordName(domainName string) string { return "_dmarc." + domainName }
|
||||||
|
|
||||||
|
// ReportAuthRecordName is where a report-receiving domain authorises external
|
||||||
|
// DMARC aggregate destinations (RFC 7489 §7.1).
|
||||||
|
func ReportAuthRecordName(hubDomain string) string { return "_report._dmarc." + hubDomain }
|
||||||
|
|
||||||
|
// ReportAuthExample is the TXT value a hub domain publishes to accept reports.
|
||||||
|
func ReportAuthExample() string { return "v=DMARC1;" }
|
||||||
|
|
||||||
// SPFExample is the SPF record this server expects for a sending domain: the
|
// SPFExample is the SPF record this server expects for a sending domain: the
|
||||||
// addresses its mail actually leaves from, and "-all" to say that nothing else
|
// addresses its mail actually leaves from, and "-all" to say that nothing else
|
||||||
// is authorised. When the server's own addresses are not known (its hostname
|
// is authorised. When the server's own addresses are not known (its hostname
|
||||||
@@ -40,10 +48,44 @@ func SPFExample(hostname string, serverIPs []string) string {
|
|||||||
return "v=spf1 " + strings.Join(mechanisms, " ") + " -all"
|
return "v=spf1 " + strings.Join(mechanisms, " ") + " -all"
|
||||||
}
|
}
|
||||||
|
|
||||||
// DMARCExample is the least a domain should publish: monitoring only, with an
|
// ResolveDMARCRua picks the rua= mailbox for a sending domain: per-domain
|
||||||
// address the aggregate reports go to. p=none is deliberate — it changes
|
// override wins, then the administrator profile, then policy-only (empty).
|
||||||
// nothing about delivery, so it is safe to publish before the reports have
|
func ResolveDMARCRua(domainRua sql.NullString, profileEmail string) string {
|
||||||
// shown that DKIM and SPF pass everywhere.
|
if domainRua.Valid {
|
||||||
func DMARCExample(domainName string) string {
|
return domainRua.String
|
||||||
return "v=DMARC1; p=none; rua=mailto:dmarc@" + domainName
|
}
|
||||||
|
return profileEmail
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmailDomain returns the lower-case domain part of addr, or "" when invalid.
|
||||||
|
func EmailDomain(addr string) string {
|
||||||
|
addr = strings.TrimSpace(addr)
|
||||||
|
at := strings.LastIndex(addr, "@")
|
||||||
|
if at < 0 || at == len(addr)-1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.ToLower(strings.TrimSpace(addr[at+1:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DMARCExample is the DMARC TXT record this server suggests for a sending
|
||||||
|
// domain. p=none is deliberate — it changes nothing about delivery. rua= is
|
||||||
|
// included only when reportEmail is set; SelfPost is send-only and most
|
||||||
|
// operators have no inbox on the sending domain itself.
|
||||||
|
func DMARCExample(reportEmail string) string {
|
||||||
|
base := "v=DMARC1; p=none"
|
||||||
|
if reportEmail == "" {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
return base + "; rua=mailto:" + reportEmail
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExternalReportAuth reports whether the hub domain must publish a
|
||||||
|
// _report._dmarc authorisation for aggregate reports sent to reportEmail from
|
||||||
|
// sendingDomain.
|
||||||
|
func ExternalReportAuth(sendingDomain, reportEmail string) (name, value string, ok bool) {
|
||||||
|
hub := EmailDomain(reportEmail)
|
||||||
|
if hub == "" || strings.EqualFold(hub, sendingDomain) {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return ReportAuthRecordName(hub), ReportAuthExample(), true
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ package dnscheck
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -18,8 +19,6 @@ func TestSPFExample(t *testing.T) {
|
|||||||
{"ipv4", "mail.example.com", []string{"203.0.113.10"}, "v=spf1 ip4:203.0.113.10 -all"},
|
{"ipv4", "mail.example.com", []string{"203.0.113.10"}, "v=spf1 ip4:203.0.113.10 -all"},
|
||||||
{"both families", "mail.example.com", []string{"203.0.113.10", "2001:db8::1"},
|
{"both families", "mail.example.com", []string{"203.0.113.10", "2001:db8::1"},
|
||||||
"v=spf1 ip4:203.0.113.10 ip6:2001:db8::1 -all"},
|
"v=spf1 ip4:203.0.113.10 ip6:2001:db8::1 -all"},
|
||||||
// The hostname does not resolve, so there is no address to name; an "a:"
|
|
||||||
// mechanism still gives the operator a publishable record.
|
|
||||||
{"no addresses", "mail.example.com", nil, "v=spf1 a:mail.example.com -all"},
|
{"no addresses", "mail.example.com", nil, "v=spf1 a:mail.example.com -all"},
|
||||||
{"unparsable addresses", "mail.example.com", []string{"not-an-ip"}, "v=spf1 a:mail.example.com -all"},
|
{"unparsable addresses", "mail.example.com", []string{"not-an-ip"}, "v=spf1 a:mail.example.com -all"},
|
||||||
}
|
}
|
||||||
@@ -32,6 +31,41 @@ func TestSPFExample(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDMARCExample(t *testing.T) {
|
||||||
|
if got := DMARCExample(""); got != "v=DMARC1; p=none" {
|
||||||
|
t.Errorf("empty = %q", got)
|
||||||
|
}
|
||||||
|
want := "v=DMARC1; p=none; rua=mailto:reports@hub.example"
|
||||||
|
if got := DMARCExample("reports@hub.example"); got != want {
|
||||||
|
t.Errorf("with rua = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDMARCRua(t *testing.T) {
|
||||||
|
inherit := sql.NullString{}
|
||||||
|
if got := ResolveDMARCRua(inherit, "a@b.com"); got != "a@b.com" {
|
||||||
|
t.Errorf("inherit profile = %q", got)
|
||||||
|
}
|
||||||
|
none := sql.NullString{Valid: true}
|
||||||
|
if got := ResolveDMARCRua(none, "a@b.com"); got != "" {
|
||||||
|
t.Errorf("explicit none = %q", got)
|
||||||
|
}
|
||||||
|
custom := sql.NullString{Valid: true, String: "x@y.com"}
|
||||||
|
if got := ResolveDMARCRua(custom, "a@b.com"); got != "x@y.com" {
|
||||||
|
t.Errorf("custom = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExternalReportAuth(t *testing.T) {
|
||||||
|
name, value, ok := ExternalReportAuth("shop.com", "reports@hub.com")
|
||||||
|
if !ok || name != "_report._dmarc.hub.com" || value != "v=DMARC1;" {
|
||||||
|
t.Fatalf("external = (%q, %q, %v)", name, value, ok)
|
||||||
|
}
|
||||||
|
if _, _, ok := ExternalReportAuth("shop.com", "dmarc@shop.com"); ok {
|
||||||
|
t.Fatal("same domain should not need external auth")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The record the panel shows and the one a failed check suggests must be the
|
// The record the panel shows and the one a failed check suggests must be the
|
||||||
// same string, or the operator is told two different things on one page.
|
// same string, or the operator is told two different things on one page.
|
||||||
func TestMissingRecordChecksSuggestTheShownExample(t *testing.T) {
|
func TestMissingRecordChecksSuggestTheShownExample(t *testing.T) {
|
||||||
@@ -50,11 +84,11 @@ func TestMissingRecordChecksSuggestTheShownExample(t *testing.T) {
|
|||||||
t.Errorf("SPF advice %q does not suggest %q", spf.Detail, want)
|
t.Errorf("SPF advice %q does not suggest %q", spf.Detail, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
dmarc := c.checkDMARC(context.Background(), "example.com")
|
dmarc := c.checkDMARC(context.Background(), Query{Name: "example.com"})
|
||||||
if dmarc.Status != health.StatusWarn {
|
if dmarc.Status != health.StatusWarn {
|
||||||
t.Fatalf("DMARC status = %q, want warn (%s)", dmarc.Status, dmarc.Detail)
|
t.Fatalf("DMARC status = %q, want warn (%s)", dmarc.Status, dmarc.Detail)
|
||||||
}
|
}
|
||||||
if want := DMARCExample("example.com"); !strings.Contains(dmarc.Detail, want) {
|
if want := DMARCExample(""); !strings.Contains(dmarc.Detail, want) {
|
||||||
t.Errorf("DMARC advice %q does not suggest %q", dmarc.Detail, want)
|
t.Errorf("DMARC advice %q does not suggest %q", dmarc.Detail, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,13 +60,15 @@ func (c *Checker) checkDKIM(ctx context.Context, q Query) Result {
|
|||||||
|
|
||||||
// checkDMARC reports whether the domain publishes a DMARC policy. DMARC is not
|
// checkDMARC reports whether the domain publishes a DMARC policy. DMARC is not
|
||||||
// required for delivery, so its absence is advice (warn), not a fault.
|
// required for delivery, so its absence is advice (warn), not a fault.
|
||||||
func (c *Checker) checkDMARC(ctx context.Context, domainName string) Result {
|
func (c *Checker) checkDMARC(ctx context.Context, q Query) Result {
|
||||||
name := DMARCRecordName(domainName)
|
name := DMARCRecordName(q.Name)
|
||||||
txt, found, err := c.lookupTXT(ctx, name)
|
txt, found, err := c.lookupTXT(ctx, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return lookupFailed("the DMARC record", err)
|
return lookupFailed("the DMARC record", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
example := DMARCExample(q.DMARCReportEmail)
|
||||||
|
|
||||||
var records []string
|
var records []string
|
||||||
for _, rec := range txt {
|
for _, rec := range txt {
|
||||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(rec)), "v=dmarc1") {
|
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(rec)), "v=dmarc1") {
|
||||||
@@ -74,10 +76,11 @@ func (c *Checker) checkDMARC(ctx context.Context, domainName string) Result {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found || len(records) == 0 {
|
if !found || len(records) == 0 {
|
||||||
return Result{
|
detail := fmt.Sprintf("No DMARC record at %s. Delivery works without one, but publishing %q tells receivers what to do with mail that fails authentication.", name, example)
|
||||||
Status: health.StatusWarn,
|
if q.DMARCReportEmail == "" {
|
||||||
Detail: fmt.Sprintf("No DMARC record at %s. Delivery works without one, but publishing at least %q tells receivers what to do with mail that fails DKIM and gets you reports.", name, DMARCExample(domainName)),
|
detail += " Aggregate reports (rua=) are optional on a send-only relay — omit rua= unless a mailbox that receives inbound mail is configured."
|
||||||
}
|
}
|
||||||
|
return Result{Status: health.StatusWarn, Detail: detail}
|
||||||
}
|
}
|
||||||
if len(records) > 1 {
|
if len(records) > 1 {
|
||||||
return Result{
|
return Result{
|
||||||
@@ -102,6 +105,52 @@ func (c *Checker) checkDMARC(ctx context.Context, domainName string) Result {
|
|||||||
return Result{Status: health.StatusOK, Detail: detail, Records: records}
|
return Result{Status: health.StatusOK, Detail: detail, Records: records}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkReportAuth verifies the hub domain publishes _report._dmarc for external
|
||||||
|
// aggregate-report destinations. Missing authorisation does not affect outbound
|
||||||
|
// delivery, only whether reports reach the rua= mailbox.
|
||||||
|
func (c *Checker) checkReportAuth(ctx context.Context, hubDomain string) Result {
|
||||||
|
name := ReportAuthRecordName(hubDomain)
|
||||||
|
expected := ReportAuthExample()
|
||||||
|
txt, found, err := c.lookupTXT(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return lookupFailed("the DMARC report-authorisation record", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var records []string
|
||||||
|
for _, rec := range txt {
|
||||||
|
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(rec)), "v=dmarc1") {
|
||||||
|
records = append(records, rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found || len(records) == 0 {
|
||||||
|
return Result{
|
||||||
|
Status: health.StatusWarn,
|
||||||
|
Detail: fmt.Sprintf("No report-authorisation record at %s. Aggregate DMARC reports sent to a mailbox on %s will not be delivered until %q is published there.", name, hubDomain, expected),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(records) > 1 {
|
||||||
|
return Result{
|
||||||
|
Status: health.StatusError,
|
||||||
|
Detail: fmt.Sprintf("More than one DMARC report-authorisation record is published at %s. Keep exactly one.", name),
|
||||||
|
Records: records,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Result{
|
||||||
|
Status: health.StatusOK,
|
||||||
|
Detail: fmt.Sprintf("Published at %s — aggregate reports addressed to %s are authorised.", name, hubDomain),
|
||||||
|
Records: records,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReportAuth checks whether hubDomain authorises external DMARC aggregate
|
||||||
|
// reports. It is used on the settings page for the administrator profile.
|
||||||
|
func (c *Checker) ReportAuth(ctx context.Context, hubDomain string) Result {
|
||||||
|
if hubDomain == "" {
|
||||||
|
return Result{}
|
||||||
|
}
|
||||||
|
return c.checkReportAuth(ctx, hubDomain)
|
||||||
|
}
|
||||||
|
|
||||||
// publicKeyTag extracts the p= (public key) tag of a DKIM record, with all
|
// publicKeyTag extracts the p= (public key) tag of a DKIM record, with all
|
||||||
// whitespace removed: DNS providers and TXT chunking freely insert spaces and
|
// whitespace removed: DNS providers and TXT chunking freely insert spaces and
|
||||||
// line breaks into the base64, none of which are part of the key.
|
// line breaks into the base64, none of which are part of the key.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package domain
|
package domain
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/buildinfo"
|
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||||
@@ -23,6 +24,7 @@ type DomainExport struct {
|
|||||||
Domain string `json:"domain"`
|
Domain string `json:"domain"`
|
||||||
DKIMSelector string `json:"dkim_selector"`
|
DKIMSelector string `json:"dkim_selector"`
|
||||||
DKIMPrivateKey string `json:"dkim_private_key"` // PKCS#1 PEM
|
DKIMPrivateKey string `json:"dkim_private_key"` // PKCS#1 PEM
|
||||||
|
DMARCRua *string `json:"dmarc_rua,omitempty"` // nil = inherit profile; set = override ("" = none)
|
||||||
Applications []AppExport `json:"applications"`
|
Applications []AppExport `json:"applications"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +61,10 @@ func (s *Service) Export(id int64) (DomainExport, error) {
|
|||||||
DKIMPrivateKey: string(pem),
|
DKIMPrivateKey: string(pem),
|
||||||
Applications: make([]AppExport, 0, len(apps)),
|
Applications: make([]AppExport, 0, len(apps)),
|
||||||
}
|
}
|
||||||
|
if d.DMARCRua.Valid {
|
||||||
|
s := d.DMARCRua.String
|
||||||
|
exp.DMARCRua = &s
|
||||||
|
}
|
||||||
for _, a := range apps {
|
for _, a := range apps {
|
||||||
password, err := s.apps.Secret(a.Login)
|
password, err := s.apps.Secret(a.Login)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -110,6 +116,14 @@ func (s *Service) Import(exp DomainExport) (store.Domain, error) {
|
|||||||
return store.Domain{}, err
|
return store.Domain{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if exp.DMARCRua != nil {
|
||||||
|
if err := s.store.UpdateDomainDMARCRua(d.ID, sql.NullString{Valid: true, String: *exp.DMARCRua}); err != nil {
|
||||||
|
s.importRollback(d.ID)
|
||||||
|
return store.Domain{}, err
|
||||||
|
}
|
||||||
|
d.DMARCRua = sql.NullString{Valid: true, String: *exp.DMARCRua}
|
||||||
|
}
|
||||||
|
|
||||||
for _, a := range exp.Applications {
|
for _, a := range exp.Applications {
|
||||||
if err := s.apps.ImportApplication(d.ID, a.Login, a.AddressMode, a.Addresses, a.Password); err != nil {
|
if err := s.apps.ImportApplication(d.ID, a.Login, a.AddressMode, a.Addresses, a.Password); err != nil {
|
||||||
s.importRollback(d.ID)
|
s.importRollback(d.ID)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package domain
|
package domain
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -72,6 +73,9 @@ func TestExportImportRoundTrip(t *testing.T) {
|
|||||||
if _, err := src.store.AddApplication(d.ID, "alerts", store.AddressModeList, []string{"a@example.com"}); err != nil {
|
if _, err := src.store.AddApplication(d.ID, "alerts", store.AddressModeList, []string{"a@example.com"}); err != nil {
|
||||||
t.Fatalf("add alerts: %v", err)
|
t.Fatalf("add alerts: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := src.store.UpdateDomainDMARCRua(d.ID, sql.NullString{Valid: true, String: "reports@hub.example"}); err != nil {
|
||||||
|
t.Fatalf("set dmarc rua: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
exp, err := src.Export(d.ID)
|
exp, err := src.Export(d.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -107,6 +111,12 @@ func TestExportImportRoundTrip(t *testing.T) {
|
|||||||
if got.Name != "example.com" || got.DKIMSelector != "selfpost" {
|
if got.Name != "example.com" || got.DKIMSelector != "selfpost" {
|
||||||
t.Errorf("imported domain = %+v", got)
|
t.Errorf("imported domain = %+v", got)
|
||||||
}
|
}
|
||||||
|
if !got.DMARCRua.Valid || got.DMARCRua.String != "reports@hub.example" {
|
||||||
|
t.Errorf("imported dmarc rua = %+v", got.DMARCRua)
|
||||||
|
}
|
||||||
|
if exp.DMARCRua == nil || *exp.DMARCRua != "reports@hub.example" {
|
||||||
|
t.Errorf("exported dmarc rua = %v", exp.DMARCRua)
|
||||||
|
}
|
||||||
// The DKIM key was imported byte-for-byte, so the DNS record is unchanged.
|
// The DKIM key was imported byte-for-byte, so the DNS record is unchanged.
|
||||||
dstKey, err := dstOdk.ExportKey("example.com", "selfpost")
|
dstKey, err := dstOdk.ExportKey("example.com", "selfpost")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Package legal embeds SelfPost's AGPL-3.0 licence text so the panel can
|
||||||
|
// serve it at /license (Appropriate Legal Notices) without depending on
|
||||||
|
// GitHub or a file on disk at runtime.
|
||||||
|
//
|
||||||
|
// The copy in this directory must stay identical to the repository-root
|
||||||
|
// LICENSE; license_test.go enforces that.
|
||||||
|
package legal
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed LICENSE
|
||||||
|
var License []byte
|
||||||
|
|
||||||
|
// SourceURL is where Corresponding Source for the published upstream
|
||||||
|
// releases lives. Operators who ship a modified version must point their
|
||||||
|
// users at their own sources instead (NOTICE; AGPL-3.0 §13).
|
||||||
|
const SourceURL = "https://github.com/mixeme/selfpost"
|
||||||
|
|
||||||
|
// CopyrightLine is the short copyright notice shown in the panel footer.
|
||||||
|
const CopyrightLine = "Copyright © 2026 Mikhail Yenuchenko"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package legal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEmbeddedLicenseMatchesRoot(t *testing.T) {
|
||||||
|
root, err := os.ReadFile(filepath.Join("..", "..", "LICENSE"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read root LICENSE: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(root, License) {
|
||||||
|
t.Fatal("internal/legal/LICENSE differs from the repository-root LICENSE; copy the root file over")
|
||||||
|
}
|
||||||
|
if len(License) == 0 {
|
||||||
|
t.Fatal("embedded LICENSE is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrNoAdmin is returned by GetAdmin when primary setup has not happened yet.
|
|
||||||
var ErrNoAdmin = errors.New("no administrator account")
|
|
||||||
|
|
||||||
// Admin is the single panel administrator (security.md).
|
|
||||||
type Admin struct {
|
|
||||||
Username string
|
|
||||||
PasswordHash string
|
|
||||||
CreatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminExists reports whether the administrator account has been created. This
|
|
||||||
// doubles as the "primary setup complete" flag: once true, the /setup route is
|
|
||||||
// permanently gone (security.md).
|
|
||||||
func (s *Store) AdminExists() (bool, error) {
|
|
||||||
var n int
|
|
||||||
if err := s.db.QueryRow("SELECT COUNT(*) FROM admin").Scan(&n); err != nil {
|
|
||||||
return false, fmt.Errorf("count admin: %w", err)
|
|
||||||
}
|
|
||||||
return n > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateAdmin inserts the administrator row. It fails if one already exists,
|
|
||||||
// which — combined with the id=1 constraint — makes admin creation one-shot
|
|
||||||
// even under a race between two setup submissions.
|
|
||||||
func (s *Store) CreateAdmin(username, passwordHash string) error {
|
|
||||||
_, err := s.db.Exec(
|
|
||||||
"INSERT INTO admin (id, username, password_hash, created_at) VALUES (1, ?, ?, ?)",
|
|
||||||
username, passwordHash, time.Now().UTC().Format(time.RFC3339),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create admin: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateAdmin replaces the administrator's username and password hash. It
|
|
||||||
// touches only the admin row (id = 1): panel credentials are unrelated to the
|
|
||||||
// SASL logins applications authenticate with, which live in their own table.
|
|
||||||
// ErrNoAdmin is returned if setup has not happened yet, so a change can never
|
|
||||||
// silently create an account.
|
|
||||||
func (s *Store) UpdateAdmin(username, passwordHash string) error {
|
|
||||||
res, err := s.db.Exec(
|
|
||||||
"UPDATE admin SET username = ?, password_hash = ? WHERE id = 1",
|
|
||||||
username, passwordHash,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("update admin: %w", err)
|
|
||||||
}
|
|
||||||
n, err := res.RowsAffected()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("update admin: %w", err)
|
|
||||||
}
|
|
||||||
if n == 0 {
|
|
||||||
return ErrNoAdmin
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAdmin returns the administrator account, or ErrNoAdmin if setup is pending.
|
|
||||||
func (s *Store) GetAdmin() (Admin, error) {
|
|
||||||
var (
|
|
||||||
a Admin
|
|
||||||
createdAt string
|
|
||||||
)
|
|
||||||
err := s.db.QueryRow("SELECT username, password_hash, created_at FROM admin WHERE id = 1").
|
|
||||||
Scan(&a.Username, &a.PasswordHash, &createdAt)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return Admin{}, ErrNoAdmin
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return Admin{}, fmt.Errorf("get admin: %w", err)
|
|
||||||
}
|
|
||||||
a.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
|
||||||
return a, nil
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestUpdateAdmin(t *testing.T) {
|
|
||||||
st := openTestStore(t)
|
|
||||||
|
|
||||||
if err := st.CreateAdmin("admin", "hash-one"); err != nil {
|
|
||||||
t.Fatalf("CreateAdmin: %v", err)
|
|
||||||
}
|
|
||||||
if err := st.UpdateAdmin("operator", "hash-two"); err != nil {
|
|
||||||
t.Fatalf("UpdateAdmin: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
a, err := st.GetAdmin()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetAdmin: %v", err)
|
|
||||||
}
|
|
||||||
if a.Username != "operator" || a.PasswordHash != "hash-two" {
|
|
||||||
t.Fatalf("unexpected admin after update: %+v", a)
|
|
||||||
}
|
|
||||||
if a.CreatedAt.IsZero() {
|
|
||||||
t.Fatal("update dropped created_at")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// An update before setup must not create the account: only the one-time setup
|
|
||||||
// flow may do that (security.md).
|
|
||||||
func TestUpdateAdminWithoutAdmin(t *testing.T) {
|
|
||||||
st := openTestStore(t)
|
|
||||||
|
|
||||||
if err := st.UpdateAdmin("operator", "hash"); !errors.Is(err, ErrNoAdmin) {
|
|
||||||
t.Fatalf("UpdateAdmin without admin = %v, want ErrNoAdmin", err)
|
|
||||||
}
|
|
||||||
exists, err := st.AdminExists()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("AdminExists: %v", err)
|
|
||||||
}
|
|
||||||
if exists {
|
|
||||||
t.Fatal("UpdateAdmin created an administrator")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,6 +24,7 @@ type Domain struct {
|
|||||||
ID int64
|
ID int64
|
||||||
Name string
|
Name string
|
||||||
DKIMSelector string
|
DKIMSelector string
|
||||||
|
DMARCRua sql.NullString // NULL = inherit profile; Valid+empty = no reports
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
AppCount int
|
AppCount int
|
||||||
}
|
}
|
||||||
@@ -54,7 +55,7 @@ func (s *Store) AddDomain(name, selector string) (Domain, error) {
|
|||||||
// ordered by name.
|
// ordered by name.
|
||||||
func (s *Store) ListDomains() ([]Domain, error) {
|
func (s *Store) ListDomains() ([]Domain, error) {
|
||||||
rows, err := s.db.Query(`
|
rows, err := s.db.Query(`
|
||||||
SELECT d.id, d.name, d.dkim_selector, d.created_at,
|
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at,
|
||||||
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
|
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
|
||||||
FROM domains d
|
FROM domains d
|
||||||
ORDER BY d.name`)
|
ORDER BY d.name`)
|
||||||
@@ -78,7 +79,7 @@ func (s *Store) ListDomains() ([]Domain, error) {
|
|||||||
// ErrDomainNotFound.
|
// ErrDomainNotFound.
|
||||||
func (s *Store) GetDomain(id int64) (Domain, error) {
|
func (s *Store) GetDomain(id int64) (Domain, error) {
|
||||||
row := s.db.QueryRow(`
|
row := s.db.QueryRow(`
|
||||||
SELECT d.id, d.name, d.dkim_selector, d.created_at,
|
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at,
|
||||||
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
|
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
|
||||||
FROM domains d
|
FROM domains d
|
||||||
WHERE d.id = ?`, id)
|
WHERE d.id = ?`, id)
|
||||||
@@ -120,7 +121,7 @@ func scanDomain(r scanRow) (Domain, error) {
|
|||||||
d Domain
|
d Domain
|
||||||
createdAt string
|
createdAt string
|
||||||
)
|
)
|
||||||
if err := r.Scan(&d.ID, &d.Name, &d.DKIMSelector, &createdAt, &d.AppCount); err != nil {
|
if err := r.Scan(&d.ID, &d.Name, &d.DKIMSelector, &d.DMARCRua, &createdAt, &d.AppCount); err != nil {
|
||||||
return Domain{}, err
|
return Domain{}, err
|
||||||
}
|
}
|
||||||
d.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
d.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||||
@@ -137,3 +138,21 @@ func isUniqueViolation(err error) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateDomainDMARCRua sets how this domain resolves its DMARC rua= destination.
|
||||||
|
// NULL means inherit the administrator profile; Valid with an empty string means
|
||||||
|
// policy-only with no aggregate reports for this domain.
|
||||||
|
func (s *Store) UpdateDomainDMARCRua(id int64, rua sql.NullString) error {
|
||||||
|
res, err := s.db.Exec("UPDATE domains SET dmarc_rua = ? WHERE id = ?", rua, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update domain dmarc rua: %w", err)
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update domain dmarc rua rows: %w", err)
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return ErrDomainNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDomainDMARCRua(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
d, err := st.AddDomain("example.com", "sel")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddDomain: %v", err)
|
||||||
|
}
|
||||||
|
got, err := st.GetDomain(d.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDomain: %v", err)
|
||||||
|
}
|
||||||
|
if got.DMARCRua.Valid {
|
||||||
|
t.Fatal("new domain should inherit profile")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := st.UpdateDomainDMARCRua(d.ID, sql.NullString{Valid: true, String: "reports@hub.com"}); err != nil {
|
||||||
|
t.Fatalf("UpdateDomainDMARCRua custom: %v", err)
|
||||||
|
}
|
||||||
|
got, err = st.GetDomain(d.ID)
|
||||||
|
if err != nil || got.DMARCRua.String != "reports@hub.com" {
|
||||||
|
t.Fatalf("custom = %+v, err=%v", got.DMARCRua, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := st.UpdateDomainDMARCRua(d.ID, sql.NullString{Valid: true}); err != nil {
|
||||||
|
t.Fatalf("UpdateDomainDMARCRua none: %v", err)
|
||||||
|
}
|
||||||
|
got, _ = st.GetDomain(d.ID)
|
||||||
|
if !got.DMARCRua.Valid || got.DMARCRua.String != "" {
|
||||||
|
t.Fatalf("none = %+v", got.DMARCRua)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := st.UpdateDomainDMARCRua(d.ID, sql.NullString{}); err != nil {
|
||||||
|
t.Fatalf("UpdateDomainDMARCRua inherit: %v", err)
|
||||||
|
}
|
||||||
|
got, _ = st.GetDomain(d.ID)
|
||||||
|
if got.DMARCRua.Valid {
|
||||||
|
t.Fatalf("inherit = %+v", got.DMARCRua)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Optional DMARC aggregate-report destination (rua=) for the panel administrator
|
||||||
|
-- and per-domain overrides. NULL dmarc_rua on a domain means inherit the profile.
|
||||||
|
|
||||||
|
ALTER TABLE admin ADD COLUMN dmarc_report_email TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE domains ADD COLUMN dmarc_rua TEXT;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- Panel users and domain-admin assignments. Migrates the single admin row into
|
||||||
|
-- a global user; drops the admin table.
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('global', 'domain_admin')),
|
||||||
|
dmarc_report_email TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_domains (
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (user_id, domain_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO users (username, password_hash, role, dmarc_report_email, created_at)
|
||||||
|
SELECT username, password_hash, 'global', dmarc_report_email, created_at
|
||||||
|
FROM admin WHERE id = 1;
|
||||||
|
|
||||||
|
INSERT OR REPLACE INTO settings (key, value)
|
||||||
|
SELECT 'dmarc_report_email', dmarc_report_email FROM admin WHERE id = 1;
|
||||||
|
|
||||||
|
DROP TABLE admin;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetSetting returns a settings value or empty string when missing.
|
||||||
|
func (s *Store) GetSetting(key string) (string, error) {
|
||||||
|
var value string
|
||||||
|
err := s.db.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("get setting %s: %w", key, err)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSetting upserts a settings key.
|
||||||
|
func (s *Store) SetSetting(key, value string) error {
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
"INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
|
key, value,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set setting %s: %w", key, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNoUser is returned when primary setup has not happened yet.
|
||||||
|
var ErrNoUser = errors.New("no panel user")
|
||||||
|
|
||||||
|
// ErrUserNotFound is returned when a panel user id or username does not exist.
|
||||||
|
var ErrUserNotFound = errors.New("user not found")
|
||||||
|
|
||||||
|
// ErrUserExists is returned when a username is already taken.
|
||||||
|
var ErrUserExists = errors.New("username already taken")
|
||||||
|
|
||||||
|
// ErrLastGlobal is returned when deleting or demoting the last global user.
|
||||||
|
var ErrLastGlobal = errors.New("cannot remove last global administrator")
|
||||||
|
|
||||||
|
// Role identifies a panel user's access level.
|
||||||
|
type Role string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RoleGlobal Role = "global"
|
||||||
|
RoleDomainAdmin Role = "domain_admin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User is a panel login (not an application SASL account).
|
||||||
|
type User struct {
|
||||||
|
ID int64
|
||||||
|
Username string
|
||||||
|
PasswordHash string
|
||||||
|
Role Role
|
||||||
|
DMARCReportEmail string
|
||||||
|
CreatedAt time.Time
|
||||||
|
DomainIDs []int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserExists reports whether any panel user exists (setup complete).
|
||||||
|
func (s *Store) UserExists() (bool, error) {
|
||||||
|
var n int
|
||||||
|
if err := s.db.QueryRow("SELECT COUNT(*) FROM users").Scan(&n); err != nil {
|
||||||
|
return false, fmt.Errorf("count users: %w", err)
|
||||||
|
}
|
||||||
|
return n > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateGlobalUser inserts the first global user during setup.
|
||||||
|
func (s *Store) CreateGlobalUser(username, passwordHash string) error {
|
||||||
|
exists, err := s.UserExists()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return fmt.Errorf("create global user: users already exist")
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
_, err = s.db.Exec(
|
||||||
|
"INSERT INTO users (username, password_hash, role, dmarc_report_email, created_at) VALUES (?, ?, ?, '', ?)",
|
||||||
|
username, passwordHash, RoleGlobal, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create global user: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByUsername returns a user with domain assignments loaded.
|
||||||
|
func (s *Store) GetUserByUsername(username string) (User, error) {
|
||||||
|
var (
|
||||||
|
u User
|
||||||
|
createdAt string
|
||||||
|
)
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
"SELECT id, username, password_hash, role, dmarc_report_email, created_at FROM users WHERE username = ?",
|
||||||
|
username,
|
||||||
|
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.DMARCReportEmail, &createdAt)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return User{}, ErrUserNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return User{}, fmt.Errorf("get user by username: %w", err)
|
||||||
|
}
|
||||||
|
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||||
|
u.DomainIDs, err = s.listUserDomainIDs(u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUser returns a user by id with domain assignments.
|
||||||
|
func (s *Store) GetUser(id int64) (User, error) {
|
||||||
|
var (
|
||||||
|
u User
|
||||||
|
createdAt string
|
||||||
|
)
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
"SELECT id, username, password_hash, role, dmarc_report_email, created_at FROM users WHERE id = ?",
|
||||||
|
id,
|
||||||
|
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.DMARCReportEmail, &createdAt)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return User{}, ErrUserNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return User{}, fmt.Errorf("get user: %w", err)
|
||||||
|
}
|
||||||
|
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||||
|
u.DomainIDs, err = s.listUserDomainIDs(u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers returns every panel user without domain ids.
|
||||||
|
func (s *Store) ListUsers() ([]User, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
"SELECT id, username, password_hash, role, dmarc_report_email, created_at FROM users ORDER BY id",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list users: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var users []User
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
u User
|
||||||
|
createdAt string
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.DMARCReportEmail, &createdAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("list users scan: %w", err)
|
||||||
|
}
|
||||||
|
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||||
|
users = append(users, u)
|
||||||
|
}
|
||||||
|
return users, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserRow is a user plus assigned domain names for the management list.
|
||||||
|
type UserRow struct {
|
||||||
|
User User
|
||||||
|
DomainNames []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUserRows returns users with assigned domain names for the management UI.
|
||||||
|
func (s *Store) ListUserRows() ([]UserRow, error) {
|
||||||
|
users, err := s.ListUsers()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows := make([]UserRow, len(users))
|
||||||
|
for i, u := range users {
|
||||||
|
rows[i].User = u
|
||||||
|
if u.Role == RoleGlobal {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
names, err := s.listUserDomainNames(u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows[i].DomainNames = names
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountGlobalUsers returns how many global-role users exist.
|
||||||
|
func (s *Store) CountGlobalUsers() (int, error) {
|
||||||
|
var n int
|
||||||
|
if err := s.db.QueryRow("SELECT COUNT(*) FROM users WHERE role = ?", RoleGlobal).Scan(&n); err != nil {
|
||||||
|
return 0, fmt.Errorf("count global users: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser inserts a panel user and optional domain assignments.
|
||||||
|
func (s *Store) CreateUser(username, passwordHash string, role Role, domainIDs []int64) (int64, error) {
|
||||||
|
if role == RoleDomainAdmin && len(domainIDs) == 0 {
|
||||||
|
return 0, fmt.Errorf("create user: domain_admin requires domains")
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
res, err := s.db.Exec(
|
||||||
|
"INSERT INTO users (username, password_hash, role, dmarc_report_email, created_at) VALUES (?, ?, ?, '', ?)",
|
||||||
|
username, passwordHash, role, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
return 0, ErrUserExists
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("create user: %w", err)
|
||||||
|
}
|
||||||
|
id, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("create user id: %w", err)
|
||||||
|
}
|
||||||
|
if role == RoleDomainAdmin {
|
||||||
|
if err := s.setUserDomains(id, domainIDs); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUser replaces username, password hash, and DMARC email for a user.
|
||||||
|
func (s *Store) UpdateUser(id int64, username, passwordHash, dmarcReportEmail string) error {
|
||||||
|
u, err := s.GetUser(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res, err := s.db.Exec(
|
||||||
|
"UPDATE users SET username = ?, password_hash = ?, dmarc_report_email = ? WHERE id = ?",
|
||||||
|
username, passwordHash, dmarcReportEmail, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
return ErrUserExists
|
||||||
|
}
|
||||||
|
return fmt.Errorf("update user: %w", err)
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update user: %w", err)
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return ErrUserNotFound
|
||||||
|
}
|
||||||
|
if u.Role == RoleGlobal {
|
||||||
|
if err := s.SetSetting("dmarc_report_email", dmarcReportEmail); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserRole updates a user's role.
|
||||||
|
func (s *Store) SetUserRole(userID int64, role Role) error {
|
||||||
|
res, err := s.db.Exec("UPDATE users SET role = ? WHERE id = ?", role, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set user role: %w", err)
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set user role: %w", err)
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return ErrUserNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearUserDomains removes all domain assignments for a user.
|
||||||
|
func (s *Store) ClearUserDomains(userID int64) error {
|
||||||
|
_, err := s.db.Exec("DELETE FROM user_domains WHERE user_id = ?", userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("clear user domains: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserDomains replaces domain assignments for a domain_admin user.
|
||||||
|
func (s *Store) SetUserDomains(userID int64, domainIDs []int64) error {
|
||||||
|
u, err := s.GetUser(userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if u.Role != RoleDomainAdmin {
|
||||||
|
return fmt.Errorf("set user domains: user is not domain_admin")
|
||||||
|
}
|
||||||
|
if len(domainIDs) == 0 {
|
||||||
|
return fmt.Errorf("set user domains: at least one domain required")
|
||||||
|
}
|
||||||
|
return s.setUserDomains(userID, domainIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser removes a panel user. ErrLastGlobal when deleting the only global user.
|
||||||
|
func (s *Store) DeleteUser(id int64) error {
|
||||||
|
u, err := s.GetUser(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if u.Role == RoleGlobal {
|
||||||
|
n, err := s.CountGlobalUsers()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n <= 1 {
|
||||||
|
return ErrLastGlobal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res, err := s.db.Exec("DELETE FROM users WHERE id = ?", id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete user: %w", err)
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete user: %w", err)
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return ErrUserNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GlobalDMARCReportEmail returns the shared default rua= for domain inherit mode.
|
||||||
|
func (s *Store) GlobalDMARCReportEmail() (string, error) {
|
||||||
|
return s.GetSetting("dmarc_report_email")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) listUserDomainIDs(userID int64) ([]int64, error) {
|
||||||
|
rows, err := s.db.Query("SELECT domain_id FROM user_domains WHERE user_id = ? ORDER BY domain_id", userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list user domains: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var ids []int64
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, fmt.Errorf("list user domains scan: %w", err)
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
return ids, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) listUserDomainNames(userID int64) ([]string, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
"SELECT d.name FROM user_domains ud JOIN domains d ON d.id = ud.domain_id WHERE ud.user_id = ? ORDER BY d.name",
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list user domain names: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var names []string
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
if err := rows.Scan(&name); err != nil {
|
||||||
|
return nil, fmt.Errorf("list user domain names scan: %w", err)
|
||||||
|
}
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
return names, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) setUserDomains(userID int64, domainIDs []int64) error {
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set user domains begin: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec("DELETE FROM user_domains WHERE user_id = ?", userID); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fmt.Errorf("set user domains clear: %w", err)
|
||||||
|
}
|
||||||
|
for _, did := range domainIDs {
|
||||||
|
if _, err := tx.Exec("INSERT INTO user_domains (user_id, domain_id) VALUES (?, ?)", userID, did); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fmt.Errorf("set user domains insert: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpdateUser(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
|
||||||
|
if err := st.CreateGlobalUser("admin", "hash-one"); err != nil {
|
||||||
|
t.Fatalf("CreateGlobalUser: %v", err)
|
||||||
|
}
|
||||||
|
u, err := st.GetUserByUsername("admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByUsername: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.UpdateUser(u.ID, "operator", "hash-two", "reports@hub.example"); err != nil {
|
||||||
|
t.Fatalf("UpdateUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := st.GetUser(u.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUser: %v", err)
|
||||||
|
}
|
||||||
|
if got.DMARCReportEmail != "reports@hub.example" {
|
||||||
|
t.Fatalf("dmarc email = %q", got.DMARCReportEmail)
|
||||||
|
}
|
||||||
|
email, err := st.GlobalDMARCReportEmail()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GlobalDMARCReportEmail: %v", err)
|
||||||
|
}
|
||||||
|
if email != "reports@hub.example" {
|
||||||
|
t.Fatalf("settings dmarc = %q", email)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := st.UpdateUser(u.ID, "operator", "hash-three", ""); err != nil {
|
||||||
|
t.Fatalf("clear dmarc email: %v", err)
|
||||||
|
}
|
||||||
|
got, err = st.GetUser(u.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUser: %v", err)
|
||||||
|
}
|
||||||
|
if got.Username != "operator" || got.PasswordHash != "hash-three" {
|
||||||
|
t.Fatalf("unexpected user after update: %+v", got)
|
||||||
|
}
|
||||||
|
if got.CreatedAt.IsZero() {
|
||||||
|
t.Fatal("update dropped created_at")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateUserWithoutUser(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
|
||||||
|
if err := st.UpdateUser(1, "operator", "hash", ""); !errors.Is(err, ErrUserNotFound) {
|
||||||
|
t.Fatalf("UpdateUser without user = %v, want ErrUserNotFound", err)
|
||||||
|
}
|
||||||
|
exists, err := st.UserExists()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UserExists: %v", err)
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
t.Fatal("UpdateUser created a user")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateDomainAdminUser(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
if err := st.CreateGlobalUser("admin", "hash"); err != nil {
|
||||||
|
t.Fatalf("CreateGlobalUser: %v", err)
|
||||||
|
}
|
||||||
|
d, err := st.AddDomain("example.com", "s1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddDomain: %v", err)
|
||||||
|
}
|
||||||
|
id, err := st.CreateUser("domainop", "hash2", RoleDomainAdmin, []int64{d.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser: %v", err)
|
||||||
|
}
|
||||||
|
u, err := st.GetUser(id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUser: %v", err)
|
||||||
|
}
|
||||||
|
if len(u.DomainIDs) != 1 || u.DomainIDs[0] != d.ID {
|
||||||
|
t.Fatalf("domain ids = %v, want [%d]", u.DomainIDs, d.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteLastGlobalUser(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
if err := st.CreateGlobalUser("admin", "hash"); err != nil {
|
||||||
|
t.Fatalf("CreateGlobalUser: %v", err)
|
||||||
|
}
|
||||||
|
u, err := st.GetUserByUsername("admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByUsername: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.DeleteUser(u.ID); !errors.Is(err, ErrLastGlobal) {
|
||||||
|
t.Fatalf("DeleteUser = %v, want ErrLastGlobal", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// Package auth implements the panel's login sessions, one-time setup flow,
|
||||||
|
// and authentication middleware.
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/view"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds auth-specific panel configuration.
|
||||||
|
type Config struct {
|
||||||
|
CookieSecure bool
|
||||||
|
Hostname string
|
||||||
|
SessionIdleDays int
|
||||||
|
TrustedProxyCIDRs []*net.IPNet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module handles login, logout, setup, and session middleware.
|
||||||
|
type Module struct {
|
||||||
|
store *store.Store
|
||||||
|
cfg Config
|
||||||
|
view *view.Engine
|
||||||
|
sessions *sessionStore
|
||||||
|
setup *setupManager
|
||||||
|
|
||||||
|
loginLimiter *rateLimiter
|
||||||
|
setupLimiter *rateLimiter
|
||||||
|
|
||||||
|
trustedProxies []*net.IPNet
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds the auth module. setupTokenPath is where the current setup token
|
||||||
|
// is mirrored on disk (security.md).
|
||||||
|
func New(st *store.Store, cfg Config, v *view.Engine, setupTokenPath string) *Module {
|
||||||
|
idleDays := cfg.SessionIdleDays
|
||||||
|
if idleDays <= 0 {
|
||||||
|
idleDays = 7
|
||||||
|
}
|
||||||
|
m := &Module{
|
||||||
|
store: st,
|
||||||
|
cfg: cfg,
|
||||||
|
view: v,
|
||||||
|
sessions: newSessionStore(st, time.Duration(idleDays)*24*time.Hour),
|
||||||
|
setupLimiter: newRateLimiter(10, time.Minute),
|
||||||
|
loginLimiter: newRateLimiter(10, 15*time.Minute),
|
||||||
|
trustedProxies: cfg.TrustedProxyCIDRs,
|
||||||
|
}
|
||||||
|
m.setup = newSetupManager(st, cfg.Hostname, setupTokenPath)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bootstrap runs once at startup. If setup is not complete it mints and
|
||||||
|
// announces the first setup token (security.md).
|
||||||
|
func (m *Module) Bootstrap() error {
|
||||||
|
return m.setup.bootstrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowLoginAttempt reports whether a login or account-password change attempt
|
||||||
|
// from r is within the rate limit (security.md).
|
||||||
|
func (m *Module) AllowLoginAttempt(r *http.Request) bool {
|
||||||
|
return m.loginLimiter.Allow(clientIP(r, m.trustedProxies))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionToken returns the session token the request carries, if exactly one
|
||||||
|
// cookie of that name is present.
|
||||||
|
func (m *Module) SessionToken(r *http.Request) (string, bool) {
|
||||||
|
return m.sessionToken(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenameSession updates the username carried by a session.
|
||||||
|
func (m *Module) RenameSession(token, username string) {
|
||||||
|
m.sessions.Rename(token, username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DestroyOtherSessions invalidates every session except keep.
|
||||||
|
func (m *Module) DestroyOtherSessions(keep string) {
|
||||||
|
m.sessions.DestroyOthers(keep)
|
||||||
|
}
|
||||||
|
|
||||||
|
func logf(format string, args ...any) {
|
||||||
|
log.Printf(format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientIP extracts the peer IP for rate-limiting. By default it is the
|
||||||
|
// transport peer (RemoteAddr), which cannot be spoofed. If RemoteAddr matches
|
||||||
|
// one of trustedProxies, the last entry of X-Forwarded-For is used instead —
|
||||||
|
// that is the address the trusted proxy itself appended, so a client can't
|
||||||
|
// forge it by sending its own XFF header.
|
||||||
|
func clientIP(r *http.Request, trustedProxies []*net.IPNet) string {
|
||||||
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
host = r.RemoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(trustedProxies) > 0 {
|
||||||
|
if peer := net.ParseIP(host); peer != nil && ipInAny(peer, trustedProxies) {
|
||||||
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||||
|
parts := strings.Split(xff, ",")
|
||||||
|
if ip := net.ParseIP(strings.TrimSpace(parts[len(parts)-1])); ip != nil {
|
||||||
|
return ip.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
func ipInAny(ip net.IP, nets []*net.IPNet) bool {
|
||||||
|
for _, n := range nets {
|
||||||
|
if n.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -1,72 +1,95 @@
|
|||||||
package web
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/view"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The __Host- prefix is only valid on a Secure cookie: getting this condition
|
func newTestSessionStore(t *testing.T) *sessionStore {
|
||||||
// backwards would make the development instance fail to log in at all, and
|
t.Helper()
|
||||||
// silently — the browser discards the Set-Cookie and the panel just shows the
|
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||||
// login form again.
|
if err != nil {
|
||||||
|
t.Fatalf("open store: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { st.Close() })
|
||||||
|
return newSessionStore(st, 7*24*time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustView(t *testing.T) *view.Engine {
|
||||||
|
t.Helper()
|
||||||
|
v, err := view.New("test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("view: %v", err)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func testModule(t *testing.T, cookieSecure bool) *Module {
|
||||||
|
t.Helper()
|
||||||
|
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open store: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { st.Close() })
|
||||||
|
return New(st, Config{CookieSecure: cookieSecure}, mustView(t), "")
|
||||||
|
}
|
||||||
|
|
||||||
func TestSessionCookieNameFollowsCookieSecure(t *testing.T) {
|
func TestSessionCookieNameFollowsCookieSecure(t *testing.T) {
|
||||||
secure := &Server{cfg: Config{CookieSecure: true}}
|
secure := testModule(t, true)
|
||||||
if got := secure.sessionCookie(); got != "__Host-selfpost_session" {
|
if got := secure.sessionCookie(); got != "__Host-selfpost_session" {
|
||||||
t.Errorf("with TLS the cookie is named %q, want the __Host- prefixed name", got)
|
t.Errorf("with TLS the cookie is named %q, want the __Host- prefixed name", got)
|
||||||
}
|
}
|
||||||
plain := &Server{cfg: Config{CookieSecure: false}}
|
plain := testModule(t, false)
|
||||||
if got := plain.sessionCookie(); got != "selfpost_session" {
|
if got := plain.sessionCookie(); got != "selfpost_session" {
|
||||||
t.Errorf("without TLS the cookie is named %q, want the bare name", got)
|
t.Errorf("without TLS the cookie is named %q, want the bare name", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A neighbouring host on the same registrable domain can set a cookie by the
|
|
||||||
// same name; the browser then sends both, oldest first. Picking one at random
|
|
||||||
// would leave the administrator in a login loop with no explanation, so the
|
|
||||||
// request counts as signed out instead.
|
|
||||||
func TestSessionTokenRejectsDuplicates(t *testing.T) {
|
func TestSessionTokenRejectsDuplicates(t *testing.T) {
|
||||||
s := &Server{cfg: Config{CookieSecure: false}}
|
m := testModule(t, false)
|
||||||
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
|
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
|
||||||
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "planted-by-a-neighbour"})
|
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "planted-by-a-neighbour"})
|
||||||
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "the-real-session"})
|
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "the-real-session"})
|
||||||
|
|
||||||
if token, ok := s.sessionToken(r); ok {
|
if token, ok := m.sessionToken(r); ok {
|
||||||
t.Fatalf("duplicate cookies accepted, token = %q", token)
|
t.Fatalf("duplicate cookies accepted, token = %q", token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSessionTokenReadsOneCookie(t *testing.T) {
|
func TestSessionTokenReadsOneCookie(t *testing.T) {
|
||||||
s := &Server{cfg: Config{CookieSecure: true}}
|
m := testModule(t, true)
|
||||||
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
|
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
|
||||||
r.AddCookie(&http.Cookie{Name: "__Host-selfpost_session", Value: "the-real-session"})
|
r.AddCookie(&http.Cookie{Name: "__Host-selfpost_session", Value: "the-real-session"})
|
||||||
|
|
||||||
token, ok := s.sessionToken(r)
|
token, ok := m.sessionToken(r)
|
||||||
if !ok || token != "the-real-session" {
|
if !ok || token != "the-real-session" {
|
||||||
t.Fatalf("sessionToken = %q, %t; want the cookie's value", token, ok)
|
t.Fatalf("sessionToken = %q, %t; want the cookie's value", token, ok)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A cookie under the other deployment's name is not this deployment's session:
|
|
||||||
// after an upgrade the pre-14 cookie must not be honoured as if it were the
|
|
||||||
// prefixed one.
|
|
||||||
func TestSessionTokenIgnoresTheOtherName(t *testing.T) {
|
func TestSessionTokenIgnoresTheOtherName(t *testing.T) {
|
||||||
s := &Server{cfg: Config{CookieSecure: true}}
|
m := testModule(t, true)
|
||||||
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
|
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
|
||||||
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "left-over-from-an-older-build"})
|
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "left-over-from-an-older-build"})
|
||||||
|
|
||||||
if _, ok := s.sessionToken(r); ok {
|
if _, ok := m.sessionToken(r); ok {
|
||||||
t.Fatal("the unprefixed cookie was accepted on a TLS deployment")
|
t.Fatal("the unprefixed cookie was accepted on a TLS deployment")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRequireAuthRejectsDuplicateCookies(t *testing.T) {
|
func TestRequireAuthRejectsDuplicateCookies(t *testing.T) {
|
||||||
s := &Server{cfg: Config{CookieSecure: false}, sessions: newTestSessionStore(t)}
|
m := testModule(t, false)
|
||||||
token := s.sessions.Create("admin")
|
token := m.sessions.Create("admin")
|
||||||
|
|
||||||
reached := false
|
reached := false
|
||||||
h := s.requireAuth(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true }))
|
h := m.RequireAuth(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true }))
|
||||||
|
|
||||||
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
|
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
|
||||||
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "planted-by-a-neighbour"})
|
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "planted-by-a-neighbour"})
|
||||||
@@ -82,19 +105,17 @@ func TestRequireAuthRejectsDuplicateCookies(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signing out has to expire the cookie under both names, or the cookie left
|
|
||||||
// over from a pre-__Host- build stays in the browser for the rest of its life.
|
|
||||||
func TestLogoutClearsBothCookieNames(t *testing.T) {
|
func TestLogoutClearsBothCookieNames(t *testing.T) {
|
||||||
s := &Server{cfg: Config{CookieSecure: true}, sessions: newTestSessionStore(t)}
|
m := testModule(t, true)
|
||||||
token := s.sessions.Create("admin")
|
token := m.sessions.Create("admin")
|
||||||
|
|
||||||
r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/logout", nil)
|
r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/logout", nil)
|
||||||
r.Host = "panel.example.com"
|
r.Host = "panel.example.com"
|
||||||
r.AddCookie(&http.Cookie{Name: "__Host-selfpost_session", Value: token})
|
r.AddCookie(&http.Cookie{Name: "__Host-selfpost_session", Value: token})
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
s.handleLogout(rec, r)
|
m.HandleLogout(rec, r)
|
||||||
|
|
||||||
if _, ok := s.sessions.Lookup(token); ok {
|
if _, ok := m.sessions.Lookup(token); ok {
|
||||||
t.Error("the session survived sign-out")
|
t.Error("the session survived sign-out")
|
||||||
}
|
}
|
||||||
set := rec.Header().Values("Set-Cookie")
|
set := rec.Header().Values("Set-Cookie")
|
||||||
@@ -110,3 +131,59 @@ func TestLogoutClearsBothCookieNames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSessionRename(t *testing.T) {
|
||||||
|
s := newTestSessionStore(t)
|
||||||
|
token := s.Create("admin")
|
||||||
|
|
||||||
|
s.Rename(token, "operator")
|
||||||
|
|
||||||
|
name, ok := s.Lookup(token)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("session lost after rename")
|
||||||
|
}
|
||||||
|
if name != "operator" {
|
||||||
|
t.Fatalf("session username = %q, want %q", name, "operator")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionDestroyOthers(t *testing.T) {
|
||||||
|
s := newTestSessionStore(t)
|
||||||
|
keep := s.Create("admin")
|
||||||
|
other := s.Create("admin")
|
||||||
|
|
||||||
|
s.DestroyOthers(keep)
|
||||||
|
|
||||||
|
if _, ok := s.Lookup(keep); !ok {
|
||||||
|
t.Fatal("current session was destroyed")
|
||||||
|
}
|
||||||
|
if _, ok := s.Lookup(other); ok {
|
||||||
|
t.Fatal("other session survived")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionLookupRejectsExpired(t *testing.T) {
|
||||||
|
s := newTestSessionStore(t)
|
||||||
|
s.idle = -time.Minute
|
||||||
|
token := s.Create("admin")
|
||||||
|
|
||||||
|
if _, ok := s.Lookup(token); ok {
|
||||||
|
t.Fatal("expired session was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionTouchThrottled(t *testing.T) {
|
||||||
|
s := newTestSessionStore(t)
|
||||||
|
token := s.Create("admin")
|
||||||
|
|
||||||
|
if s.Touch(token) {
|
||||||
|
t.Fatal("touch renewed a session created moments ago")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.store.RenewSession(hashToken(token), time.Now().Add(-2*time.Hour).Add(s.idle)); err != nil {
|
||||||
|
t.Fatalf("renew session: %v", err)
|
||||||
|
}
|
||||||
|
if !s.Touch(token) {
|
||||||
|
t.Fatal("touch did not renew a session past the throttle window")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/validate"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sessionCookieBase = "selfpost_session"
|
||||||
|
sessionCookiePrefixed = "__Host-" + sessionCookieBase
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *Module) sessionCookie() string {
|
||||||
|
if m.cfg.CookieSecure {
|
||||||
|
return sessionCookiePrefixed
|
||||||
|
}
|
||||||
|
return sessionCookieBase
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) sessionToken(r *http.Request) (string, bool) {
|
||||||
|
name := m.sessionCookie()
|
||||||
|
var token string
|
||||||
|
var n int
|
||||||
|
for _, c := range r.Cookies() {
|
||||||
|
if c.Name == name {
|
||||||
|
n++
|
||||||
|
token = c.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch n {
|
||||||
|
case 0:
|
||||||
|
return "", false
|
||||||
|
case 1:
|
||||||
|
return token, true
|
||||||
|
default:
|
||||||
|
logf("panel: %s %s carries %d cookies named %q — treating the request as signed out; "+
|
||||||
|
"another host on this domain is overwriting the session cookie, clear the cookies for the parent domain",
|
||||||
|
r.Method, r.URL.Path, n, name)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) clearSessionCookies(w http.ResponseWriter) {
|
||||||
|
for _, name := range []string{sessionCookieBase, sessionCookiePrefixed} {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: name,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: m.cfg.CookieSecure || name == sessionCookiePrefixed,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleLogin serves the login form (GET) and authenticates (POST).
|
||||||
|
func (m *Module) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
exists, err := m.store.UserExists()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
m.view.Render(w, http.StatusOK, "login", map[string]any{
|
||||||
|
"Title": "SelfPost — Sign in",
|
||||||
|
"Active": "login",
|
||||||
|
"SetupHint": true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
m.renderLogin(w, http.StatusOK, "")
|
||||||
|
case http.MethodPost:
|
||||||
|
m.submitLogin(w, r)
|
||||||
|
default:
|
||||||
|
w.Header().Set("Allow", "GET, POST")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) renderLogin(w http.ResponseWriter, status int, formErr string) {
|
||||||
|
m.view.Render(w, status, "login", map[string]any{
|
||||||
|
"Title": "SelfPost — Sign in",
|
||||||
|
"Active": "login",
|
||||||
|
"Error": formErr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) submitLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !m.loginLimiter.Allow(clientIP(r, m.trustedProxies)) {
|
||||||
|
m.renderLogin(w, http.StatusTooManyRequests, "Too many attempts. Please wait and try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
m.renderLogin(w, http.StatusBadRequest, "Invalid form submission.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||||
|
password := r.PostFormValue("password")
|
||||||
|
|
||||||
|
user, err := m.store.GetUserByUsername(username)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, store.ErrUserNotFound) {
|
||||||
|
logf("panel: login: get user failed: %v", err)
|
||||||
|
}
|
||||||
|
m.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||||
|
m.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := m.sessions.Create(user.Username)
|
||||||
|
m.setSessionCookie(w, token)
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) setSessionCookie(w http.ResponseWriter, token string) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: m.sessionCookie(),
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: m.sessions.MaxAge(),
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: m.cfg.CookieSecure,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleLogout destroys the session and clears the cookie.
|
||||||
|
func (m *Module) HandleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.Header().Set("Allow", "POST")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := m.sessionCookie()
|
||||||
|
for _, c := range r.Cookies() {
|
||||||
|
if c.Name == name {
|
||||||
|
m.sessions.Destroy(c.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.clearSessionCookies(w)
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleSetup serves the one-time administrator creation flow at
|
||||||
|
// /setup/<token> (security.md).
|
||||||
|
func (m *Module) HandleSetup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !m.setupLimiter.Allow(clientIP(r, m.trustedProxies)) {
|
||||||
|
http.Error(w, "too many requests", http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := strings.TrimPrefix(r.URL.Path, "/setup/")
|
||||||
|
if token == "" || strings.Contains(token, "/") {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !m.setup.validate(token) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
m.renderSetupForm(w, http.StatusOK, token, "")
|
||||||
|
case http.MethodPost:
|
||||||
|
m.submitSetup(w, r, token)
|
||||||
|
default:
|
||||||
|
w.Header().Set("Allow", "GET, POST")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) renderSetupForm(w http.ResponseWriter, status int, token, formErr string) {
|
||||||
|
m.view.Render(w, status, "setup", map[string]any{
|
||||||
|
"Title": "SelfPost — Create administrator",
|
||||||
|
"Active": "setup",
|
||||||
|
"Token": token,
|
||||||
|
"Error": formErr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) submitSetup(w http.ResponseWriter, r *http.Request, token string) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
m.renderSetupForm(w, http.StatusBadRequest, token, "Invalid form submission.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||||
|
password := r.PostFormValue("password")
|
||||||
|
confirm := r.PostFormValue("password_confirm")
|
||||||
|
|
||||||
|
if err := validate.Username(username); err != nil {
|
||||||
|
m.renderSetupForm(w, http.StatusBadRequest, token, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if password != confirm {
|
||||||
|
m.renderSetupForm(w, http.StatusBadRequest, token, "Passwords do not match.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validate.AdminPassword(password); err != nil {
|
||||||
|
m.renderSetupForm(w, http.StatusBadRequest, token, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
logf("panel: setup: hashing password failed: %v", err)
|
||||||
|
m.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.store.CreateGlobalUser(username, string(hash)); err != nil {
|
||||||
|
if exists, _ := m.store.UserExists(); exists {
|
||||||
|
m.setup.complete()
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logf("panel: setup: create user failed: %v", err)
|
||||||
|
m.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.setup.complete()
|
||||||
|
logf("panel: administrator %q created; setup link is now disabled", username)
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequireAuth wraps a handler so only requests with a valid session cookie
|
||||||
|
// reach it; everyone else is redirected to the login page. The authenticated
|
||||||
|
// principal is stashed in the request context for downstream handlers.
|
||||||
|
func (m *Module) RequireAuth(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token, ok := m.sessionToken(r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username, ok := m.sessions.Lookup(token)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isSessionActivity(r) && m.sessions.Touch(token) {
|
||||||
|
m.setSessionCookie(w, token)
|
||||||
|
}
|
||||||
|
u, err := m.store.GetUserByUsername(username)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p := principalFromUser(u)
|
||||||
|
ctx := withPrincipal(r.Context(), p)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSessionActivity(r *http.Request) bool {
|
||||||
|
return !(r.Method == http.MethodGet && r.Header.Get("HX-Request") != "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentUser returns the authenticated username from the request context.
|
||||||
|
func CurrentUser(r *http.Request) string {
|
||||||
|
if v, ok := r.Context().Value(usernameKey).(string); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireGlobal wraps a handler that only global administrators may reach.
|
||||||
|
func RequireGlobal(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p, ok := CurrentPrincipal(r.Context())
|
||||||
|
if !ok || !p.IsGlobal() {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ctxKey int
|
||||||
|
|
||||||
|
const (
|
||||||
|
usernameKey ctxKey = 0
|
||||||
|
principalKey ctxKey = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// Role is a panel user's access level.
|
||||||
|
type Role = store.Role
|
||||||
|
|
||||||
|
const (
|
||||||
|
RoleGlobal = store.RoleGlobal
|
||||||
|
RoleDomainAdmin = store.RoleDomainAdmin
|
||||||
|
)
|
||||||
|
|
||||||
|
// Principal is the authenticated panel user attached to a request.
|
||||||
|
type Principal struct {
|
||||||
|
ID int64
|
||||||
|
Username string
|
||||||
|
Role Role
|
||||||
|
Domains []int64 // assigned domain IDs; empty for global (all domains)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsGlobal reports whether the principal has full panel access.
|
||||||
|
func (p Principal) IsGlobal() bool {
|
||||||
|
return p.Role == RoleGlobal
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanAccessDomain reports whether the principal may access a domain id.
|
||||||
|
func (p Principal) CanAccessDomain(domainID int64) bool {
|
||||||
|
if p.IsGlobal() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, id := range p.Domains {
|
||||||
|
if id == domainID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanAccessApp reports whether the principal may access an application.
|
||||||
|
func (p Principal) CanAccessApp(app store.Application) bool {
|
||||||
|
return p.CanAccessDomain(app.DomainID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func principalFromUser(u store.User) Principal {
|
||||||
|
return Principal{
|
||||||
|
ID: u.ID,
|
||||||
|
Username: u.Username,
|
||||||
|
Role: u.Role,
|
||||||
|
Domains: u.DomainIDs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withPrincipal(ctx context.Context, p Principal) context.Context {
|
||||||
|
ctx = context.WithValue(ctx, usernameKey, p.Username)
|
||||||
|
return context.WithValue(ctx, principalKey, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentPrincipal returns the authenticated principal from the request context.
|
||||||
|
func CurrentPrincipal(ctx context.Context) (Principal, bool) {
|
||||||
|
if v, ok := ctx.Value(principalKey).(Principal); ok {
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
return Principal{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrincipalFromRequest returns the authenticated principal from an HTTP request.
|
||||||
|
func PrincipalFromRequest(r *http.Request) (Principal, bool) {
|
||||||
|
return CurrentPrincipal(r.Context())
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestWithPrincipal attaches a principal for middleware-equivalent tests.
|
||||||
|
func RequestWithPrincipal(r *http.Request, p Principal) *http.Request {
|
||||||
|
return r.WithContext(withPrincipal(r.Context(), p))
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package web
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sync"
|
"sync"
|
||||||
@@ -6,9 +6,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// rateLimiter is a simple fixed-window per-key counter used to throttle the
|
// rateLimiter is a simple fixed-window per-key counter used to throttle the
|
||||||
// setup and login routes (security.md). Keys are client IPs. It is not a
|
// setup and login routes (security.md). Keys are client IPs.
|
||||||
// precise sliding window — a coarse backstop against brute-force and log noise
|
|
||||||
// is all these routes need.
|
|
||||||
type rateLimiter struct {
|
type rateLimiter struct {
|
||||||
max int
|
max int
|
||||||
window time.Duration
|
window time.Duration
|
||||||
@@ -30,8 +28,6 @@ func newRateLimiter(max int, window time.Duration) *rateLimiter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow records an attempt for key and reports whether it is within the limit.
|
|
||||||
// The current window is reset lazily once it elapses.
|
|
||||||
func (r *rateLimiter) Allow(key string) bool {
|
func (r *rateLimiter) Allow(key string) bool {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
@@ -50,9 +46,6 @@ func (r *rateLimiter) Allow(key string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// sweep drops expired buckets so the map cannot grow without bound. Called
|
|
||||||
// under the lock while a window is being reset, which is often enough given the
|
|
||||||
// low request volume of these routes.
|
|
||||||
func (r *rateLimiter) sweep(now time.Time) {
|
func (r *rateLimiter) sweep(now time.Time) {
|
||||||
for k, b := range r.buckets {
|
for k, b := range r.buckets {
|
||||||
if now.After(b.windowEnds) {
|
if now.After(b.windowEnds) {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package web
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
@@ -22,10 +22,7 @@ const renewThreshold = time.Hour
|
|||||||
// cookie.
|
// cookie.
|
||||||
type sessionStore struct {
|
type sessionStore struct {
|
||||||
store *store.Store
|
store *store.Store
|
||||||
// idle is the sliding inactivity window (PANEL_SESSION_IDLE_DAYS). There is
|
idle time.Duration
|
||||||
// no absolute cap: an administrator who keeps coming back stays signed in
|
|
||||||
// indefinitely, deliberately.
|
|
||||||
idle time.Duration
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSessionStore(st *store.Store, idle time.Duration) *sessionStore {
|
func newSessionStore(st *store.Store, idle time.Duration) *sessionStore {
|
||||||
@@ -51,10 +48,6 @@ func (s *sessionStore) Create(username string) string {
|
|||||||
if err := s.store.CreateSession(hashToken(token), username, now.Add(s.idle)); err != nil {
|
if err := s.store.CreateSession(hashToken(token), username, now.Add(s.idle)); err != nil {
|
||||||
logf("panel: session: create failed: %v", err)
|
logf("panel: session: create failed: %v", err)
|
||||||
}
|
}
|
||||||
// Opportunistic cleanup: a session nobody ever came back to otherwise sits
|
|
||||||
// in the table forever. Piggybacking on Create (the one write every login
|
|
||||||
// already pays for) avoids a dedicated background sweep for what is, on a
|
|
||||||
// single-admin panel, a handful of rows at most.
|
|
||||||
if _, err := s.store.DeleteExpiredSessions(now); err != nil {
|
if _, err := s.store.DeleteExpiredSessions(now); err != nil {
|
||||||
logf("panel: session: prune expired failed: %v", err)
|
logf("panel: session: prune expired failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -86,10 +79,7 @@ func (s *sessionStore) Lookup(token string) (string, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Touch extends a session's sliding expiry if it has been at least
|
// Touch extends a session's sliding expiry if it has been at least
|
||||||
// renewThreshold since the last extension, and reports whether it did so —
|
// renewThreshold since the last extension, and reports whether it did so.
|
||||||
// the caller uses that to decide whether the response needs a fresh
|
|
||||||
// Set-Cookie. It assumes the caller has just confirmed the session is valid
|
|
||||||
// (e.g. via Lookup); it does nothing for a token that no longer exists.
|
|
||||||
func (s *sessionStore) Touch(token string) bool {
|
func (s *sessionStore) Touch(token string) bool {
|
||||||
hash := hashToken(token)
|
hash := hashToken(token)
|
||||||
row, found, err := s.store.LookupSession(hash)
|
row, found, err := s.store.LookupSession(hash)
|
||||||
@@ -100,8 +90,6 @@ func (s *sessionStore) Touch(token string) bool {
|
|||||||
if !found {
|
if !found {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// expiresAt = lastRenewal + idle, so this recovers when the session was
|
|
||||||
// last extended without a separate column.
|
|
||||||
lastRenewal := row.ExpiresAt.Add(-s.idle)
|
lastRenewal := row.ExpiresAt.Add(-s.idle)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if now.Sub(lastRenewal) < renewThreshold {
|
if now.Sub(lastRenewal) < renewThreshold {
|
||||||
@@ -114,19 +102,14 @@ func (s *sessionStore) Touch(token string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename updates the username carried by a session, keeping its expiry. It is
|
// Rename updates the username carried by a session, keeping its expiry.
|
||||||
// used when the administrator renames their own account so the current
|
|
||||||
// session keeps working under the new name.
|
|
||||||
func (s *sessionStore) Rename(token, username string) {
|
func (s *sessionStore) Rename(token, username string) {
|
||||||
if err := s.store.RenameSession(hashToken(token), username); err != nil {
|
if err := s.store.RenameSession(hashToken(token), username); err != nil {
|
||||||
logf("panel: session: rename failed: %v", err)
|
logf("panel: session: rename failed: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DestroyOthers invalidates every session except keep. It is called when the
|
// DestroyOthers invalidates every session except keep.
|
||||||
// administrator changes their password: a stolen cookie issued under the old
|
|
||||||
// password must stop working, while the admin performing the change stays
|
|
||||||
// signed in.
|
|
||||||
func (s *sessionStore) DestroyOthers(keep string) {
|
func (s *sessionStore) DestroyOthers(keep string) {
|
||||||
if err := s.store.DeleteOtherSessions(hashToken(keep)); err != nil {
|
if err := s.store.DeleteOtherSessions(hashToken(keep)); err != nil {
|
||||||
logf("panel: session: destroy others failed: %v", err)
|
logf("panel: session: destroy others failed: %v", err)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package web
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
@@ -32,10 +32,8 @@ func newSetupManager(st *store.Store, hostname, tokenPath string) *setupManager
|
|||||||
return &setupManager{store: st, hostname: hostname, tokenPath: tokenPath}
|
return &setupManager{store: st, hostname: hostname, tokenPath: tokenPath}
|
||||||
}
|
}
|
||||||
|
|
||||||
// bootstrap runs once at startup. If setup is already complete it clears any
|
|
||||||
// stale token file; otherwise it mints and announces the first token.
|
|
||||||
func (m *setupManager) bootstrap() error {
|
func (m *setupManager) bootstrap() error {
|
||||||
done, err := m.store.AdminExists()
|
done, err := m.store.UserExists()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -49,13 +47,10 @@ func (m *setupManager) bootstrap() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// activeToken returns the current valid setup token, regenerating and
|
|
||||||
// re-announcing it if none exists or it has expired. It returns ("", false)
|
|
||||||
// once setup is complete — callers must treat that as "route gone" (404).
|
|
||||||
func (m *setupManager) activeToken() (string, bool) {
|
func (m *setupManager) activeToken() (string, bool) {
|
||||||
done, err := m.store.AdminExists()
|
done, err := m.store.UserExists()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logf("panel: setup: admin check failed: %v", err)
|
logf("panel: setup: user check failed: %v", err)
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
if done {
|
if done {
|
||||||
@@ -69,10 +64,6 @@ func (m *setupManager) activeToken() (string, bool) {
|
|||||||
return m.token, true
|
return m.token, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// validate reports whether provided matches the active token, using a
|
|
||||||
// constant-time comparison to avoid leaking a correct prefix via timing
|
|
||||||
// (security.md). A mismatch does NOT regenerate or invalidate the token: failed
|
|
||||||
// attempts must not let an attacker DoS a legitimate setup (security.md).
|
|
||||||
func (m *setupManager) validate(provided string) bool {
|
func (m *setupManager) validate(provided string) bool {
|
||||||
token, ok := m.activeToken()
|
token, ok := m.activeToken()
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -81,8 +72,6 @@ func (m *setupManager) validate(provided string) bool {
|
|||||||
return subtle.ConstantTimeCompare([]byte(provided), []byte(token)) == 1
|
return subtle.ConstantTimeCompare([]byte(provided), []byte(token)) == 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// complete marks setup as finished: the admin row now exists, so drop the
|
|
||||||
// in-memory token and remove the on-disk copy.
|
|
||||||
func (m *setupManager) complete() {
|
func (m *setupManager) complete() {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.token = ""
|
m.token = ""
|
||||||
@@ -91,16 +80,12 @@ func (m *setupManager) complete() {
|
|||||||
m.clearTokenFile()
|
m.clearTokenFile()
|
||||||
}
|
}
|
||||||
|
|
||||||
// regenerateLocked mints a fresh token, announces it and mirrors it to disk.
|
|
||||||
// Caller holds m.mu.
|
|
||||||
func (m *setupManager) regenerateLocked() {
|
func (m *setupManager) regenerateLocked() {
|
||||||
m.token = randomToken(16) // 128 bits of entropy (security.md)
|
m.token = randomToken(16)
|
||||||
m.expiresAt = time.Now().Add(setupTokenTTL)
|
m.expiresAt = time.Now().Add(setupTokenTTL)
|
||||||
m.announce(m.token)
|
m.announce(m.token)
|
||||||
}
|
}
|
||||||
|
|
||||||
// announce prints the setup link to the container log and writes it to the
|
|
||||||
// token file so it can be read either way (security.md).
|
|
||||||
func (m *setupManager) announce(token string) {
|
func (m *setupManager) announce(token string) {
|
||||||
url := m.setupURL(token)
|
url := m.setupURL(token)
|
||||||
logf("panel: ==================================================================")
|
logf("panel: ==================================================================")
|
||||||
@@ -112,7 +97,6 @@ func (m *setupManager) announce(token string) {
|
|||||||
if m.tokenPath == "" {
|
if m.tokenPath == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 0600: the token is a bearer secret for creating the admin.
|
|
||||||
if err := os.WriteFile(m.tokenPath, []byte(url+"\n"), 0o600); err != nil {
|
if err := os.WriteFile(m.tokenPath, []byte(url+"\n"), 0o600); err != nil {
|
||||||
logf("panel: setup: could not write token file %s: %v", m.tokenPath, err)
|
logf("panel: setup: could not write token file %s: %v", m.tokenPath, err)
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package web
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handlers) principal(r *http.Request) (auth.Principal, bool) {
|
||||||
|
return auth.PrincipalFromRequest(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) requireGlobal(w http.ResponseWriter, r *http.Request) (auth.Principal, bool) {
|
||||||
|
p, ok := h.principal(r)
|
||||||
|
if !ok || !p.IsGlobal() {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return auth.Principal{}, false
|
||||||
|
}
|
||||||
|
return p, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) pageBase(r *http.Request) map[string]any {
|
||||||
|
p, _ := h.principal(r)
|
||||||
|
return map[string]any{
|
||||||
|
"User": auth.CurrentUser(r),
|
||||||
|
"IsGlobal": p.IsGlobal(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) assignedDomains(p auth.Principal) ([]store.Domain, error) {
|
||||||
|
if p.IsGlobal() {
|
||||||
|
return h.store.ListDomains()
|
||||||
|
}
|
||||||
|
all, err := h.store.ListDomains()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []store.Domain
|
||||||
|
for _, d := range all {
|
||||||
|
if p.CanAccessDomain(d.ID) {
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func domainNameSet(domains []store.Domain) map[string]bool {
|
||||||
|
m := make(map[string]bool, len(domains))
|
||||||
|
for _, d := range domains {
|
||||||
|
m[d.Name] = true
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func domainIDSet(p auth.Principal) map[int64]bool {
|
||||||
|
if p.IsGlobal() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m := make(map[int64]bool, len(p.Domains))
|
||||||
|
for _, id := range p.Domains {
|
||||||
|
m[id] = true
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Package handlers implements the panel's authenticated HTTP handlers.
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/app"
|
||||||
|
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||||
|
"github.com/mixeme/selfpost/internal/domain"
|
||||||
|
"github.com/mixeme/selfpost/internal/health"
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/auth"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/view"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds handler-specific panel configuration.
|
||||||
|
type Config struct {
|
||||||
|
Hostname string
|
||||||
|
SubmissionEnabled bool
|
||||||
|
MailLogPath string
|
||||||
|
DataDir string
|
||||||
|
DBPath string
|
||||||
|
Version string
|
||||||
|
TLSCertFile string
|
||||||
|
OpenDKIMSocket string
|
||||||
|
JournalSocket string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handlers holds dependencies for authenticated panel routes.
|
||||||
|
type Handlers struct {
|
||||||
|
store *store.Store
|
||||||
|
domains *domain.Service
|
||||||
|
apps *app.Service
|
||||||
|
cfg Config
|
||||||
|
view *view.Engine
|
||||||
|
dns *dnscheck.Checker
|
||||||
|
machine *health.MachineSampler
|
||||||
|
auth *auth.Module
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds authenticated panel handlers.
|
||||||
|
func New(
|
||||||
|
st *store.Store,
|
||||||
|
domains *domain.Service,
|
||||||
|
apps *app.Service,
|
||||||
|
cfg Config,
|
||||||
|
v *view.Engine,
|
||||||
|
dns *dnscheck.Checker,
|
||||||
|
machine *health.MachineSampler,
|
||||||
|
a *auth.Module,
|
||||||
|
) *Handlers {
|
||||||
|
return &Handlers{
|
||||||
|
store: st,
|
||||||
|
domains: domains,
|
||||||
|
apps: apps,
|
||||||
|
cfg: cfg,
|
||||||
|
view: v,
|
||||||
|
dns: dns,
|
||||||
|
machine: machine,
|
||||||
|
auth: a,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func logf(format string, args ...any) {
|
||||||
|
log.Printf(format, args...)
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/auth"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/validate"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleAccount serves the signed-in user's account settings.
|
||||||
|
func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
p, ok := h.principal(r)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, err := h.store.GetUser(p.ID)
|
||||||
|
if err != nil {
|
||||||
|
logf("panel: account: get user failed: %v", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.renderAccount(w, r, http.StatusOK, "", u.Username, u.DMARCReportEmail, p.IsGlobal())
|
||||||
|
case http.MethodPost:
|
||||||
|
h.submitAccount(w, r)
|
||||||
|
default:
|
||||||
|
w.Header().Set("Allow", "GET, POST")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string, showDMARC bool) {
|
||||||
|
var reportAuth dnscheck.Result
|
||||||
|
if showDMARC && formDMARCEmail != "" {
|
||||||
|
if hub := dnscheck.EmailDomain(formDMARCEmail); hub != "" {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||||
|
reportAuth = h.dns.ReportAuth(ctx, hub)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data := h.pageBase(r)
|
||||||
|
data["Title"] = "SelfPost — settings"
|
||||||
|
data["Active"] = "account"
|
||||||
|
data["FormUsername"] = formUsername
|
||||||
|
data["FormDMARCEmail"] = formDMARCEmail
|
||||||
|
data["ShowDMARC"] = showDMARC
|
||||||
|
data["ReportAuthName"] = dnscheck.ReportAuthRecordName(dnscheck.EmailDomain(formDMARCEmail))
|
||||||
|
data["ReportAuthExample"] = dnscheck.ReportAuthExample()
|
||||||
|
data["ReportAuthDNS"] = reportAuth
|
||||||
|
data["ReportAuthHub"] = dnscheck.EmailDomain(formDMARCEmail)
|
||||||
|
data["Error"] = formErr
|
||||||
|
data["Flash"] = accountFlash(r)
|
||||||
|
h.view.Render(w, status, "account", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func accountFlash(r *http.Request) string {
|
||||||
|
switch r.URL.Query().Get("updated") {
|
||||||
|
case "username":
|
||||||
|
return "Username changed."
|
||||||
|
case "password":
|
||||||
|
return "Password changed. Any other signed-in sessions were signed out."
|
||||||
|
case "both":
|
||||||
|
return "Username and password changed. Any other signed-in sessions were signed out."
|
||||||
|
case "email":
|
||||||
|
return "DMARC report address updated."
|
||||||
|
case "username-email":
|
||||||
|
return "Username and DMARC report address updated."
|
||||||
|
case "password-email":
|
||||||
|
return "Password and DMARC report address updated. Any other signed-in sessions were signed out."
|
||||||
|
case "all":
|
||||||
|
return "Settings updated. Any other signed-in sessions were signed out."
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !h.auth.AllowLoginAttempt(r) {
|
||||||
|
p, _ := h.principal(r)
|
||||||
|
h.renderAccount(w, r, http.StatusTooManyRequests,
|
||||||
|
"Too many attempts. Please wait and try again.", auth.CurrentUser(r), "", p.IsGlobal())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
p, _ := h.principal(r)
|
||||||
|
h.renderAccount(w, r, http.StatusBadRequest, "Invalid form submission.", auth.CurrentUser(r), "", p.IsGlobal())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p, ok := h.principal(r)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := h.store.GetUser(p.ID)
|
||||||
|
if err != nil {
|
||||||
|
logf("panel: account: get user failed: %v", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||||
|
current := r.PostFormValue("current_password")
|
||||||
|
password := r.PostFormValue("new_password")
|
||||||
|
confirm := r.PostFormValue("new_password_confirm")
|
||||||
|
dmarcEmail := strings.TrimSpace(r.PostFormValue("dmarc_report_email"))
|
||||||
|
if !p.IsGlobal() {
|
||||||
|
dmarcEmail = user.DMARCReportEmail
|
||||||
|
}
|
||||||
|
if username == "" {
|
||||||
|
username = user.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(current)); err != nil {
|
||||||
|
h.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail, p.IsGlobal())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
renaming := username != user.Username
|
||||||
|
if renaming {
|
||||||
|
if err := validate.Username(username); err != nil {
|
||||||
|
h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, p.IsGlobal())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.IsGlobal() {
|
||||||
|
if err := validate.Email(dmarcEmail); err != nil {
|
||||||
|
h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emailChanging := p.IsGlobal() && dmarcEmail != user.DMARCReportEmail
|
||||||
|
|
||||||
|
repassword := password != "" || confirm != ""
|
||||||
|
if repassword {
|
||||||
|
if password != confirm {
|
||||||
|
h.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username, dmarcEmail, p.IsGlobal())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validate.AdminPassword(password); err != nil {
|
||||||
|
h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, p.IsGlobal())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !renaming && !repassword && !emailChanging {
|
||||||
|
h.renderAccount(w, r, http.StatusBadRequest,
|
||||||
|
"Nothing to change: enter a new username, password, or DMARC report address.", username, dmarcEmail, p.IsGlobal())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := user.PasswordHash
|
||||||
|
if repassword {
|
||||||
|
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
logf("panel: account: hashing password failed: %v", err)
|
||||||
|
h.renderAccount(w, r, http.StatusInternalServerError,
|
||||||
|
"Internal error. Please try again.", username, dmarcEmail, p.IsGlobal())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash = string(newHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.UpdateUser(user.ID, username, hash, dmarcEmail); err != nil {
|
||||||
|
logf("panel: account: update user failed: %v", err)
|
||||||
|
msg := "Could not save the changes. Please check the logs and try again."
|
||||||
|
if errors.Is(err, store.ErrUserNotFound) {
|
||||||
|
msg = "There is no user account to update."
|
||||||
|
}
|
||||||
|
if errors.Is(err, store.ErrUserExists) {
|
||||||
|
msg = "That username is already in use."
|
||||||
|
h.renderAccount(w, r, http.StatusConflict, msg, username, dmarcEmail, p.IsGlobal())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.renderAccount(w, r, http.StatusInternalServerError, msg, username, dmarcEmail, p.IsGlobal())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if token, ok := h.auth.SessionToken(r); ok {
|
||||||
|
if renaming {
|
||||||
|
h.auth.RenameSession(token, username)
|
||||||
|
}
|
||||||
|
if repassword {
|
||||||
|
h.auth.DestroyOtherSessions(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logf("panel: user %d account updated (username: %t, password: %t, dmarc email: %t)", user.ID, renaming, repassword, emailChanging)
|
||||||
|
http.Redirect(w, r, "/account?updated="+updatedFlag(renaming, repassword, emailChanging), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatedFlag(renamed, repassword, emailChanged bool) string {
|
||||||
|
switch {
|
||||||
|
case renamed && repassword && emailChanged:
|
||||||
|
return "all"
|
||||||
|
case renamed && emailChanged:
|
||||||
|
return "username-email"
|
||||||
|
case repassword && emailChanged:
|
||||||
|
return "password-email"
|
||||||
|
case renamed && repassword:
|
||||||
|
return "both"
|
||||||
|
case renamed:
|
||||||
|
return "username"
|
||||||
|
case repassword:
|
||||||
|
return "password"
|
||||||
|
default:
|
||||||
|
return "email"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package web
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||||
"github.com/mixeme/selfpost/internal/domain"
|
"github.com/mixeme/selfpost/internal/domain"
|
||||||
"github.com/mixeme/selfpost/internal/store"
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/validate"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newCred carries a freshly generated login/password to the template so it can
|
// newCred carries a freshly generated login/password to the template so it can
|
||||||
@@ -48,28 +49,28 @@ type appRateLimitView struct {
|
|||||||
WindowVal string // window seconds, defaulted when unset
|
WindowVal string // window seconds, defaulted when unset
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleDomainDetail shows a single domain: its DKIM DNS record (product.md)
|
// HandleDomainDetail shows a single domain: its DKIM DNS record (product.md)
|
||||||
// and its applications with the controls to add, edit, delete and re-issue
|
// and its applications with the controls to add, edit, delete and re-issue
|
||||||
// credentials (product.md).
|
// credentials (product.md).
|
||||||
func (s *Server) handleDomainDetail(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleDomainDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
d, ok := s.lookupDomain(w, r)
|
d, ok := h.lookupDomain(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.renderDomainDetail(w, r, http.StatusOK, d, detailView{FormMode: store.AddressModeWildcard})
|
h.renderDomainDetail(w, r, http.StatusOK, d, detailView{FormMode: store.AddressModeWildcard})
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderDomainDetail renders the domain page. view supplies request-specific
|
// renderDomainDetail renders the domain page. view supplies request-specific
|
||||||
// extras (form error/values, a one-time credential); everything else is loaded
|
// extras (form error/values, a one-time credential); everything else is loaded
|
||||||
// fresh from the stores so the page always reflects committed state.
|
// fresh from the stores so the page always reflects committed state.
|
||||||
func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, status int, d store.Domain, view detailView) {
|
func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, status int, d store.Domain, view detailView) {
|
||||||
record, err := s.domains.DKIMRecord(d)
|
record, err := h.domains.DKIMRecord(d)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logf("panel: domain %d: dkim record: %v", d.ID, err)
|
logf("panel: domain %d: dkim record: %v", d.ID, err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
apps, err := s.apps.List(d.ID)
|
apps, err := h.apps.List(d.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logf("panel: domain %d: list applications: %v", d.ID, err)
|
logf("panel: domain %d: list applications: %v", d.ID, err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
@@ -77,7 +78,7 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
|
|||||||
}
|
}
|
||||||
appViews := make([]appRateLimitView, 0, len(apps))
|
appViews := make([]appRateLimitView, 0, len(apps))
|
||||||
for _, a := range apps {
|
for _, a := range apps {
|
||||||
rl, ok, err := s.apps.RateLimit(a.ID)
|
rl, ok, err := h.apps.RateLimit(a.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logf("panel: application %d: rate limit: %v", a.ID, err)
|
logf("panel: application %d: rate limit: %v", a.ID, err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
@@ -92,7 +93,7 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
domainRL, domainRLok, err := s.domains.RateLimit(d.ID)
|
domainRL, domainRLok, err := h.domains.RateLimit(d.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logf("panel: domain %d: rate limit: %v", d.ID, err)
|
logf("panel: domain %d: rate limit: %v", d.ID, err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
@@ -102,45 +103,72 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
|
|||||||
// What DNS actually publishes for the domain today, checked against the key
|
// What DNS actually publishes for the domain today, checked against the key
|
||||||
// this server signs with. Cached by the checker, so re-rendering the page
|
// this server signs with. Cached by the checker, so re-rendering the page
|
||||||
// after a form post costs nothing.
|
// after a form post costs nothing.
|
||||||
dns, srv := s.domainDNS(d, record, false)
|
profileEmail, err := h.store.GlobalDMARCReportEmail()
|
||||||
|
if err != nil {
|
||||||
|
logf("panel: domain %d: global dmarc email: %v", d.ID, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reportEmail := dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail)
|
||||||
|
dns, srv := h.domainDNS(d, record, profileEmail, false)
|
||||||
|
reportAuthName, reportAuthValue, needsReportAuth := dnscheck.ExternalReportAuth(d.Name, reportEmail)
|
||||||
|
dmarcMode := "inherit"
|
||||||
|
dmarcCustom := ""
|
||||||
|
if d.DMARCRua.Valid {
|
||||||
|
if d.DMARCRua.String == "" {
|
||||||
|
dmarcMode = "none"
|
||||||
|
} else {
|
||||||
|
dmarcMode = "custom"
|
||||||
|
dmarcCustom = d.DMARCRua.String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dmarcSource := "policy"
|
||||||
|
switch {
|
||||||
|
case dmarcMode == "custom":
|
||||||
|
dmarcSource = "custom"
|
||||||
|
case dmarcMode == "none":
|
||||||
|
dmarcSource = "none"
|
||||||
|
case profileEmail != "":
|
||||||
|
dmarcSource = "settings"
|
||||||
|
}
|
||||||
|
|
||||||
s.render(w, status, "domain_detail", map[string]any{
|
data := h.pageBase(r)
|
||||||
"Title": "SelfPost — " + d.Name,
|
data["Title"] = "SelfPost — " + d.Name
|
||||||
"User": currentUser(r),
|
data["Active"] = "domains"
|
||||||
"Active": "domains",
|
data["Domain"] = d
|
||||||
"Domain": d,
|
data["Record"] = record
|
||||||
"Record": record,
|
data["DNS"] = dns
|
||||||
"DNS": dns,
|
data["SPFExample"] = dnscheck.SPFExample(h.cfg.Hostname, srv.IPs)
|
||||||
// SPF and DMARC are the operator's to write — SelfPost cannot generate
|
data["DMARCName"] = dnscheck.DMARCRecordName(d.Name)
|
||||||
// them the way it generates the DKIM record — so the page shows what
|
data["DMARCExample"] = dnscheck.DMARCExample(reportEmail)
|
||||||
// this server expects rather than leaving it to the documentation. The
|
data["DMARCSource"] = dmarcSource
|
||||||
// same builders phrase the suggestions in the check messages, so the
|
data["ProfileDMARCEmail"] = profileEmail
|
||||||
// page and the checks below it never recommend different records.
|
data["ResolvedDMARCEmail"] = reportEmail
|
||||||
"SPFExample": dnscheck.SPFExample(s.cfg.Hostname, srv.IPs),
|
data["DMARCRuaMode"] = dmarcMode
|
||||||
"DMARCName": dnscheck.DMARCRecordName(d.Name),
|
data["DMARCRuaCustom"] = dmarcCustom
|
||||||
"DMARCExample": dnscheck.DMARCExample(d.Name),
|
data["ReportAuthName"] = reportAuthName
|
||||||
// Client connection settings (the same for every domain on this
|
data["ReportAuthValue"] = reportAuthValue
|
||||||
// instance): the hostname clients connect to, and whether the optional
|
data["NeedsReportAuth"] = needsReportAuth
|
||||||
// submission listener is enabled in this deployment.
|
data["SameDomainRUA"] = reportEmail != "" && strings.EqualFold(dnscheck.EmailDomain(reportEmail), d.Name)
|
||||||
"Hostname": s.cfg.Hostname,
|
data["Hostname"] = h.cfg.Hostname
|
||||||
"SubmissionEnabled": s.cfg.SubmissionEnabled,
|
data["SubmissionEnabled"] = h.cfg.SubmissionEnabled
|
||||||
"Apps": appViews,
|
data["Apps"] = appViews
|
||||||
"Error": view.FormErr,
|
data["Error"] = view.FormErr
|
||||||
"FormLogin": view.FormLogin,
|
data["FormLogin"] = view.FormLogin
|
||||||
"FormMode": view.FormMode,
|
data["FormMode"] = view.FormMode
|
||||||
"FormAddrs": view.FormAddrs,
|
data["FormAddrs"] = view.FormAddrs
|
||||||
"NewCred": view.NewCred,
|
data["NewCred"] = view.NewCred
|
||||||
"Flash": detailFlash(r),
|
data["Flash"] = detailFlash(r)
|
||||||
"Wildcard": store.AddressModeWildcard,
|
data["Wildcard"] = store.AddressModeWildcard
|
||||||
"List": store.AddressModeList,
|
data["List"] = store.AddressModeList
|
||||||
"RateLimitErr": view.RateLimitErr,
|
data["RateLimitErr"] = view.RateLimitErr
|
||||||
"ExportErr": view.ExportErr,
|
data["ExportErr"] = view.ExportErr
|
||||||
"MinPwLen": minSecretFilePasswordLen,
|
data["MinPwLen"] = validate.MinSecretFilePasswordLen
|
||||||
"DomainHasRL": domainRLok && domainRL.Active(),
|
data["DomainHasRL"] = domainRLok && domainRL.Active()
|
||||||
"DomainRLIPs": strings.Join(domainRL.AllowedIPs, "\n"),
|
data["DomainRLIPs"] = strings.Join(domainRL.AllowedIPs, "\n")
|
||||||
"DomainRLMax": intOrBlank(domainRL.MaxMessages),
|
data["DomainRLMax"] = intOrBlank(domainRL.MaxMessages)
|
||||||
"DomainRLWin": windowOrDefault(domainRL.WindowSeconds),
|
data["DomainRLWin"] = windowOrDefault(domainRL.WindowSeconds)
|
||||||
})
|
h.view.Render(w, status, "domain_detail", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// domainDNS resolves what the world sees for a domain: its DKIM, SPF and DMARC
|
// domainDNS resolves what the world sees for a domain: its DKIM, SPF and DMARC
|
||||||
@@ -149,31 +177,38 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
|
|||||||
// and no extra environment variable is needed. That server result is returned
|
// and no extra environment variable is needed. That server result is returned
|
||||||
// alongside, because the page's suggested SPF record is built from the same
|
// alongside, because the page's suggested SPF record is built from the same
|
||||||
// addresses. force bypasses the cache, for the Re-check button.
|
// addresses. force bypasses the cache, for the Re-check button.
|
||||||
func (s *Server) domainDNS(d store.Domain, record domain.DKIMRecord, force bool) (dnscheck.Domain, dnscheck.Server) {
|
func (h *Handlers) domainDNS(d store.Domain, record domain.DKIMRecord, profileEmail string, force bool) (dnscheck.Domain, dnscheck.Server) {
|
||||||
srv := s.dns.Server(s.cfg.Hostname, false)
|
srv := h.dns.Server(h.cfg.Hostname, false)
|
||||||
return s.dns.Domain(dnscheck.Query{
|
return h.dns.Domain(dnscheck.Query{
|
||||||
Name: d.Name,
|
Name: d.Name,
|
||||||
Selector: d.DKIMSelector,
|
Selector: d.DKIMSelector,
|
||||||
ExpectedDKIM: record.Value,
|
ExpectedDKIM: record.Value,
|
||||||
Hostname: srv.Hostname,
|
Hostname: srv.Hostname,
|
||||||
ServerIPs: srv.IPs,
|
ServerIPs: srv.IPs,
|
||||||
|
DMARCReportEmail: dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail),
|
||||||
}, force), srv
|
}, force), srv
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleDomainDNSRecheck re-runs the domain's DNS checks ignoring the cache and
|
// HandleDomainDNSRecheck re-runs the domain's DNS checks ignoring the cache and
|
||||||
// returns to its page, which then renders the fresh result.
|
// returns to its page, which then renders the fresh result.
|
||||||
func (s *Server) handleDomainDNSRecheck(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleDomainDNSRecheck(w http.ResponseWriter, r *http.Request) {
|
||||||
d, ok := s.lookupDomain(w, r)
|
d, ok := h.lookupDomain(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
record, err := s.domains.DKIMRecord(d)
|
record, err := h.domains.DKIMRecord(d)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logf("panel: domain %d: dkim record: %v", d.ID, err)
|
logf("panel: domain %d: dkim record: %v", d.ID, err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.domainDNS(d, record, true)
|
profileEmail, err := h.store.GlobalDMARCReportEmail()
|
||||||
|
if err != nil {
|
||||||
|
logf("panel: domain %d: global dmarc email: %v", d.ID, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.domainDNS(d, record, profileEmail, true)
|
||||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?rechecked=1", d.ID), http.StatusSeeOther)
|
http.Redirect(w, r, fmt.Sprintf("/domains/%d?rechecked=1", d.ID), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +240,8 @@ func detailFlash(r *http.Request) string {
|
|||||||
return "Application address mode updated."
|
return "Application address mode updated."
|
||||||
case r.URL.Query().Get("ratelimit") != "":
|
case r.URL.Query().Get("ratelimit") != "":
|
||||||
return "Rate limit updated."
|
return "Rate limit updated."
|
||||||
|
case r.URL.Query().Get("dmarc") != "":
|
||||||
|
return "DMARC report settings updated."
|
||||||
case r.URL.Query().Get("imported") != "":
|
case r.URL.Query().Get("imported") != "":
|
||||||
return "Domain imported. Its DKIM DNS record is unchanged — no DNS update is needed."
|
return "Domain imported. Its DKIM DNS record is unchanged — no DNS update is needed."
|
||||||
case r.URL.Query().Get("rechecked") != "":
|
case r.URL.Query().Get("rechecked") != "":
|
||||||
@@ -214,17 +251,17 @@ func detailFlash(r *http.Request) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAddApplication creates an application on a domain and renders the page
|
// HandleAddApplication creates an application on a domain and renders the page
|
||||||
// back with the generated password shown once (product.md, security.md). Because the
|
// back with the generated password shown once (product.md, security.md). Because the
|
||||||
// password cannot be recovered later, this deliberately renders inline rather
|
// password cannot be recovered later, this deliberately renders inline rather
|
||||||
// than redirecting.
|
// than redirecting.
|
||||||
func (s *Server) handleAddApplication(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleAddApplication(w http.ResponseWriter, r *http.Request) {
|
||||||
d, ok := s.lookupDomain(w, r)
|
d, ok := h.lookupDomain(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
s.renderDomainDetail(w, r, http.StatusBadRequest, d,
|
h.renderDomainDetail(w, r, http.StatusBadRequest, d,
|
||||||
detailView{FormErr: "Invalid form submission.", FormMode: store.AddressModeWildcard})
|
detailView{FormErr: "Invalid form submission.", FormMode: store.AddressModeWildcard})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -238,25 +275,25 @@ func (s *Server) handleAddApplication(w http.ResponseWriter, r *http.Request) {
|
|||||||
FormAddrs: r.PostFormValue("addresses"),
|
FormAddrs: r.PostFormValue("addresses"),
|
||||||
}
|
}
|
||||||
|
|
||||||
a, password, err := s.apps.Create(d.ID, login, mode, addrs)
|
a, password, err := h.apps.Create(d.ID, login, mode, addrs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
repopulate.FormErr = applicationErrorMessage(err)
|
repopulate.FormErr = applicationErrorMessage(err)
|
||||||
status := http.StatusBadRequest
|
status := http.StatusBadRequest
|
||||||
if errors.Is(err, store.ErrLoginExists) {
|
if errors.Is(err, store.ErrLoginExists) {
|
||||||
status = http.StatusConflict
|
status = http.StatusConflict
|
||||||
}
|
}
|
||||||
s.renderDomainDetail(w, r, status, d, repopulate)
|
h.renderDomainDetail(w, r, status, d, repopulate)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.renderDomainDetail(w, r, http.StatusCreated, d, detailView{
|
h.renderDomainDetail(w, r, http.StatusCreated, d, detailView{
|
||||||
FormMode: store.AddressModeWildcard,
|
FormMode: store.AddressModeWildcard,
|
||||||
NewCred: &newCred{Login: a.Login, Password: password},
|
NewCred: &newCred{Login: a.Login, Password: password},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleUpdateAppMode switches an application's address mode / list (product.md).
|
// HandleUpdateAppMode switches an application's address mode / list (product.md).
|
||||||
func (s *Server) handleUpdateAppMode(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleUpdateAppMode(w http.ResponseWriter, r *http.Request) {
|
||||||
a, ok := s.lookupApplication(w, r)
|
a, ok := h.lookupApplication(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -267,13 +304,13 @@ func (s *Server) handleUpdateAppMode(w http.ResponseWriter, r *http.Request) {
|
|||||||
mode := r.PostFormValue("mode")
|
mode := r.PostFormValue("mode")
|
||||||
addrs := splitAddresses(r.PostFormValue("addresses"))
|
addrs := splitAddresses(r.PostFormValue("addresses"))
|
||||||
|
|
||||||
if err := s.apps.UpdateMode(a.ID, mode, addrs); err != nil {
|
if err := h.apps.UpdateMode(a.ID, mode, addrs); err != nil {
|
||||||
d, derr := s.domains.Get(a.DomainID)
|
d, derr := h.domains.Get(a.DomainID)
|
||||||
if derr != nil {
|
if derr != nil {
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||||||
FormErr: fmt.Sprintf("Could not update %s: %s", a.Login, applicationErrorMessage(err)),
|
FormErr: fmt.Sprintf("Could not update %s: %s", a.Login, applicationErrorMessage(err)),
|
||||||
FormMode: store.AddressModeWildcard,
|
FormMode: store.AddressModeWildcard,
|
||||||
})
|
})
|
||||||
@@ -282,38 +319,38 @@ func (s *Server) handleUpdateAppMode(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?modeupdated=1", a.DomainID), http.StatusSeeOther)
|
http.Redirect(w, r, fmt.Sprintf("/domains/%d?modeupdated=1", a.DomainID), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleRegenPassword issues a new password for an application and shows it once
|
// HandleRegenPassword issues a new password for an application and shows it once
|
||||||
// (product.md, security.md). Rendered inline, like creation, so the password is visible.
|
// (product.md, security.md). Rendered inline, like creation, so the password is visible.
|
||||||
func (s *Server) handleRegenPassword(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleRegenPassword(w http.ResponseWriter, r *http.Request) {
|
||||||
a, ok := s.lookupApplication(w, r)
|
a, ok := h.lookupApplication(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
d, err := s.domains.Get(a.DomainID)
|
d, err := h.domains.Get(a.DomainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
password, err := s.apps.RegeneratePassword(a.ID)
|
password, err := h.apps.RegeneratePassword(a.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logf("panel: regenerate password for application %d: %v", a.ID, err)
|
logf("panel: regenerate password for application %d: %v", a.ID, err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.renderDomainDetail(w, r, http.StatusOK, d, detailView{
|
h.renderDomainDetail(w, r, http.StatusOK, d, detailView{
|
||||||
FormMode: store.AddressModeWildcard,
|
FormMode: store.AddressModeWildcard,
|
||||||
NewCred: &newCred{Login: a.Login, Password: password},
|
NewCred: &newCred{Login: a.Login, Password: password},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleDeleteApplication removes an application and returns to its domain page
|
// HandleDeleteApplication removes an application and returns to its domain page
|
||||||
// (product.md).
|
// (product.md).
|
||||||
func (s *Server) handleDeleteApplication(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleDeleteApplication(w http.ResponseWriter, r *http.Request) {
|
||||||
a, ok := s.lookupApplication(w, r)
|
a, ok := h.lookupApplication(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.apps.Delete(a.ID); err != nil {
|
if err := h.apps.Delete(a.ID); err != nil {
|
||||||
logf("panel: delete application %d: %v", a.ID, err)
|
logf("panel: delete application %d: %v", a.ID, err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -323,13 +360,13 @@ func (s *Server) handleDeleteApplication(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
// lookupApplication resolves the {aid} path value to an application, writing a
|
// lookupApplication resolves the {aid} path value to an application, writing a
|
||||||
// 404 for a bad id or missing application.
|
// 404 for a bad id or missing application.
|
||||||
func (s *Server) lookupApplication(w http.ResponseWriter, r *http.Request) (store.Application, bool) {
|
func (h *Handlers) lookupApplication(w http.ResponseWriter, r *http.Request) (store.Application, bool) {
|
||||||
id, err := strconv.ParseInt(r.PathValue("aid"), 10, 64)
|
id, err := strconv.ParseInt(r.PathValue("aid"), 10, 64)
|
||||||
if err != nil || id <= 0 {
|
if err != nil || id <= 0 {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return store.Application{}, false
|
return store.Application{}, false
|
||||||
}
|
}
|
||||||
a, err := s.apps.Get(id)
|
a, err := h.apps.Get(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, store.ErrApplicationNotFound) {
|
if errors.Is(err, store.ErrApplicationNotFound) {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
@@ -339,6 +376,11 @@ func (s *Server) lookupApplication(w http.ResponseWriter, r *http.Request) (stor
|
|||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return store.Application{}, false
|
return store.Application{}, false
|
||||||
}
|
}
|
||||||
|
p, ok := h.principal(r)
|
||||||
|
if !ok || !p.CanAccessApp(a) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return store.Application{}, false
|
||||||
|
}
|
||||||
return a, true
|
return a, true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package web
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/mixeme/selfpost/internal/domain"
|
"github.com/mixeme/selfpost/internal/domain"
|
||||||
"github.com/mixeme/selfpost/internal/secretfile"
|
"github.com/mixeme/selfpost/internal/secretfile"
|
||||||
"github.com/mixeme/selfpost/internal/store"
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/validate"
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxImportBytes caps a domain-import upload. A domain export is a small JSON
|
// maxImportBytes caps a domain-import upload. A domain export is a small JSON
|
||||||
@@ -22,35 +23,37 @@ import (
|
|||||||
// covers both forms.
|
// covers both forms.
|
||||||
const maxImportBytes = 1 << 20 // 1 MiB
|
const maxImportBytes = 1 << 20 // 1 MiB
|
||||||
|
|
||||||
// handleBackupPage renders the backup/migration screen: the full-server backup
|
// HandleBackupPage renders the backup/migration screen: the full-server backup
|
||||||
// and the domain import are separate actions with different risk, so each gets
|
// and the domain import are separate actions with different risk, so each gets
|
||||||
// its own card here rather than sharing a block on the domain list.
|
// its own card here rather than sharing a block on the domain list.
|
||||||
func (s *Server) handleBackupPage(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleBackupPage(w http.ResponseWriter, r *http.Request) {
|
||||||
s.renderBackupPage(w, r, http.StatusOK, "")
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.renderBackupPage(w, r, http.StatusOK, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderBackupPage draws the page; importErr surfaces a failed domain import
|
// renderBackupPage draws the page; importErr surfaces a failed domain import
|
||||||
// (architecture.md § Persistence) next to the form that produced it.
|
// (architecture.md § Persistence) next to the form that produced it.
|
||||||
func (s *Server) renderBackupPage(w http.ResponseWriter, r *http.Request, status int, importErr string) {
|
func (h *Handlers) renderBackupPage(w http.ResponseWriter, r *http.Request, status int, importErr string) {
|
||||||
s.renderBackupPageWith(w, r, status, importErr, "")
|
h.renderBackupPageWith(w, r, status, importErr, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderBackupPageWith is renderBackupPage with the second of the page's two
|
// renderBackupPageWith is renderBackupPage with the second of the page's two
|
||||||
// error slots: backupErr belongs to the full-backup card (a rejected encryption
|
// error slots: backupErr belongs to the full-backup card (a rejected encryption
|
||||||
// password), importErr to the import card, so neither message appears under the
|
// password), importErr to the import card, so neither message appears under the
|
||||||
// wrong form.
|
// wrong form.
|
||||||
func (s *Server) renderBackupPageWith(w http.ResponseWriter, r *http.Request, status int, importErr, backupErr string) {
|
func (h *Handlers) renderBackupPageWith(w http.ResponseWriter, r *http.Request, status int, importErr, backupErr string) {
|
||||||
s.render(w, status, "backup", map[string]any{
|
data := h.pageBase(r)
|
||||||
"Title": "SelfPost — backup",
|
data["Title"] = "SelfPost — backup"
|
||||||
"User": currentUser(r),
|
data["Active"] = "backup"
|
||||||
"Active": "backup",
|
data["ImportErr"] = importErr
|
||||||
"ImportErr": importErr,
|
data["BackupErr"] = backupErr
|
||||||
"BackupErr": backupErr,
|
data["MinPwLen"] = validate.MinSecretFilePasswordLen
|
||||||
"MinPwLen": minSecretFilePasswordLen,
|
h.view.Render(w, status, "backup", data)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleBackup streams a full-server backup as a download (architecture.md §
|
// HandleBackup streams a full-server backup as a download (architecture.md §
|
||||||
// Persistence). It is an authenticated admin action (this handler sits behind
|
// Persistence). It is an authenticated admin action (this handler sits behind
|
||||||
// the auth middleware). The archive carries DKIM private keys, the admin
|
// the auth middleware). The archive carries DKIM private keys, the admin
|
||||||
// password hash and SASL credentials, so it is served with no-store and as an
|
// password hash and SASL credentials, so it is served with no-store and as an
|
||||||
@@ -58,10 +61,13 @@ func (s *Server) renderBackupPageWith(w http.ResponseWriter, r *http.Request, st
|
|||||||
// "encrypt with a password", the archive is wrapped in a .spbk envelope on the
|
// "encrypt with a password", the archive is wrapped in a .spbk envelope on the
|
||||||
// way out, so the file that lands on their disk — wherever it is copied
|
// way out, so the file that lands on their disk — wherever it is copied
|
||||||
// afterwards — is useless without the password.
|
// afterwards — is useless without the password.
|
||||||
func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleBackup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
password, pwErr := secretFilePassword(r)
|
password, pwErr := secretFilePassword(r)
|
||||||
if pwErr != "" {
|
if pwErr != "" {
|
||||||
s.renderBackupPageWith(w, r, http.StatusBadRequest, "", pwErr)
|
h.renderBackupPageWith(w, r, http.StatusBadRequest, "", pwErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,9 +105,9 @@ func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := backup.Create(sink, backup.Params{
|
if err := backup.Create(sink, backup.Params{
|
||||||
DataDir: s.cfg.DataDir,
|
DataDir: h.cfg.DataDir,
|
||||||
DBPath: s.cfg.DBPath,
|
DBPath: h.cfg.DBPath,
|
||||||
Version: s.cfg.Version,
|
Version: h.cfg.Version,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
logf("panel: full backup failed: %v", err)
|
logf("panel: full backup failed: %v", err)
|
||||||
return
|
return
|
||||||
@@ -113,26 +119,26 @@ func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleExportDomain streams a single-domain export as a secret download
|
// HandleExportDomain streams a single-domain export as a secret download
|
||||||
// (architecture.md § Persistence). Like the full backup it is POST-only (state
|
// (architecture.md § Persistence). Like the full backup it is POST-only (state
|
||||||
// is not changed, but the response contains the domain's DKIM private key and
|
// is not changed, but the response contains the domain's DKIM private key and
|
||||||
// application passwords, so it must not be prefetchable or cached). Like the
|
// application passwords, so it must not be prefetchable or cached). Like the
|
||||||
// full backup it can be encrypted with a password, in which case the download
|
// full backup it can be encrypted with a password, in which case the download
|
||||||
// is a .spde envelope instead of plain JSON.
|
// is a .spde envelope instead of plain JSON.
|
||||||
func (s *Server) handleExportDomain(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleExportDomain(w http.ResponseWriter, r *http.Request) {
|
||||||
d, ok := s.lookupDomain(w, r)
|
d, ok := h.lookupDomain(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
password, pwErr := secretFilePassword(r)
|
password, pwErr := secretFilePassword(r)
|
||||||
if pwErr != "" {
|
if pwErr != "" {
|
||||||
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||||||
FormMode: store.AddressModeWildcard,
|
FormMode: store.AddressModeWildcard,
|
||||||
ExportErr: pwErr,
|
ExportErr: pwErr,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
exp, err := s.domains.Export(d.ID)
|
exp, err := h.domains.Export(d.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logf("panel: export domain %d: %v", d.ID, err)
|
logf("panel: export domain %d: %v", d.ID, err)
|
||||||
http.Error(w, "export failed", http.StatusInternalServerError)
|
http.Error(w, "export failed", http.StatusInternalServerError)
|
||||||
@@ -174,22 +180,25 @@ func (s *Server) handleExportDomain(w http.ResponseWriter, r *http.Request) {
|
|||||||
_, _ = w.Write(body)
|
_, _ = w.Write(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleImportDomain accepts an uploaded domain-export file and re-creates the
|
// HandleImportDomain accepts an uploaded domain-export file and re-creates the
|
||||||
// domain on this instance (architecture.md § Persistence). The domain name is
|
// domain on this instance (architecture.md § Persistence). The domain name is
|
||||||
// normalised and validated here (security.md); the domain service validates
|
// normalised and validated here (security.md); the domain service validates
|
||||||
// the selector, each login and address, and the DKIM key before writing
|
// the selector, each login and address, and the DKIM key before writing
|
||||||
// anything. On success it redirects to the new domain's page; on failure it
|
// anything. On success it redirects to the new domain's page; on failure it
|
||||||
// re-renders the backup page, where the import form lives, with a friendly
|
// re-renders the backup page, where the import form lives, with a friendly
|
||||||
// message.
|
// message.
|
||||||
func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleImportDomain(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxImportBytes)
|
r.Body = http.MaxBytesReader(w, r.Body, maxImportBytes)
|
||||||
if err := r.ParseMultipartForm(maxImportBytes); err != nil {
|
if err := r.ParseMultipartForm(maxImportBytes); err != nil {
|
||||||
s.renderBackupPage(w, r, http.StatusBadRequest, "Could not read the uploaded file (too large or not a valid upload).")
|
h.renderBackupPage(w, r, http.StatusBadRequest, "Could not read the uploaded file (too large or not a valid upload).")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
file, _, err := r.FormFile("file")
|
file, _, err := r.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.renderBackupPage(w, r, http.StatusBadRequest, "Choose a domain export file to import.")
|
h.renderBackupPage(w, r, http.StatusBadRequest, "Choose a domain export file to import.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
@@ -201,7 +210,7 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
|
|||||||
head := make([]byte, secretfile.MagicLen)
|
head := make([]byte, secretfile.MagicLen)
|
||||||
n, err := io.ReadFull(file, head)
|
n, err := io.ReadFull(file, head)
|
||||||
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||||
s.renderBackupPage(w, r, http.StatusBadRequest, "Could not read the uploaded file.")
|
h.renderBackupPage(w, r, http.StatusBadRequest, "Could not read the uploaded file.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
source := io.MultiReader(bytes.NewReader(head[:n]), file)
|
source := io.MultiReader(bytes.NewReader(head[:n]), file)
|
||||||
@@ -209,16 +218,16 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
if secretfile.HasMagic(head[:n]) {
|
if secretfile.HasMagic(head[:n]) {
|
||||||
if password == "" {
|
if password == "" {
|
||||||
s.renderBackupPage(w, r, http.StatusBadRequest, "That file is encrypted — enter the password it was exported with.")
|
h.renderBackupPage(w, r, http.StatusBadRequest, "That file is encrypted — enter the password it was exported with.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
env, err := secretfile.NewReader(source, password)
|
env, err := secretfile.NewReader(source, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err))
|
h.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if env.Type() != secretfile.TypeDomainExport {
|
if env.Type() != secretfile.TypeDomainExport {
|
||||||
s.renderBackupPage(w, r, http.StatusBadRequest, "That file is an encrypted "+env.Type().String()+", not a domain export.")
|
h.renderBackupPage(w, r, http.StatusBadRequest, "That file is an encrypted "+env.Type().String()+", not a domain export.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Read the whole plaintext first: authentication of the last chunk is
|
// Read the whole plaintext first: authentication of the last chunk is
|
||||||
@@ -226,12 +235,12 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
|
|||||||
// accept a truncated document before ever reaching it.
|
// accept a truncated document before ever reaching it.
|
||||||
plain, err := io.ReadAll(env)
|
plain, err := io.ReadAll(env)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err))
|
h.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
source = bytes.NewReader(plain)
|
source = bytes.NewReader(plain)
|
||||||
} else if password != "" {
|
} else if password != "" {
|
||||||
s.renderBackupPage(w, r, http.StatusBadRequest, "That file is not encrypted — leave the password empty.")
|
h.renderBackupPage(w, r, http.StatusBadRequest, "That file is not encrypted — leave the password empty.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,23 +248,23 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
|
|||||||
dec := json.NewDecoder(source)
|
dec := json.NewDecoder(source)
|
||||||
dec.DisallowUnknownFields()
|
dec.DisallowUnknownFields()
|
||||||
if err := dec.Decode(&exp); err != nil {
|
if err := dec.Decode(&exp); err != nil {
|
||||||
s.renderBackupPage(w, r, http.StatusBadRequest, "That file is not a valid SelfPost domain export.")
|
h.renderBackupPage(w, r, http.StatusBadRequest, "That file is not a valid SelfPost domain export.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalise and validate the domain name before it reaches the service, the
|
// Normalise and validate the domain name before it reaches the service, the
|
||||||
// same gate the add-domain form uses (security.md).
|
// same gate the add-domain form uses (security.md).
|
||||||
exp.Domain = normalizeDomain(exp.Domain)
|
exp.Domain = validate.NormalizeDomain(exp.Domain)
|
||||||
if err := validateDomain(exp.Domain); err != nil {
|
if err := validate.Domain(exp.Domain); err != nil {
|
||||||
s.renderBackupPage(w, r, http.StatusBadRequest, "Invalid domain in export file: "+err.Error())
|
h.renderBackupPage(w, r, http.StatusBadRequest, "Invalid domain in export file: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
d, err := s.domains.Import(exp)
|
d, err := h.domains.Import(exp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logf("panel: import domain %q: %v", exp.Domain, err)
|
logf("panel: import domain %q: %v", exp.Domain, err)
|
||||||
status, msg := importErrorMessage(err)
|
status, msg := importErrorMessage(err)
|
||||||
s.renderBackupPage(w, r, status, msg)
|
h.renderBackupPage(w, r, status, msg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?imported=1", d.ID), http.StatusSeeOther)
|
http.Redirect(w, r, fmt.Sprintf("/domains/%d?imported=1", d.ID), http.StatusSeeOther)
|
||||||
@@ -276,8 +285,8 @@ func secretFilePassword(r *http.Request) (password, errMsg string) {
|
|||||||
return "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
password = r.PostFormValue("password")
|
password = r.PostFormValue("password")
|
||||||
if len([]rune(password)) < minSecretFilePasswordLen {
|
if len([]rune(password)) < validate.MinSecretFilePasswordLen {
|
||||||
return "", fmt.Sprintf("The encryption password must be at least %d characters.", minSecretFilePasswordLen)
|
return "", fmt.Sprintf("The encryption password must be at least %d characters.", validate.MinSecretFilePasswordLen)
|
||||||
}
|
}
|
||||||
if password != r.PostFormValue("password_confirm") {
|
if password != r.PostFormValue("password_confirm") {
|
||||||
return "", "The two passwords do not match."
|
return "", "The two passwords do not match."
|
||||||
+7
-10
@@ -1,4 +1,4 @@
|
|||||||
package web
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/secretfile"
|
"github.com/mixeme/selfpost/internal/secretfile"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/validate"
|
||||||
)
|
)
|
||||||
|
|
||||||
// postForm builds the kind of request the backup and export forms submit.
|
// postForm builds the kind of request the backup and export forms submit.
|
||||||
@@ -24,8 +25,8 @@ func postForm(values url.Values) *http.Request {
|
|||||||
// the archive is sealed — and leaving the box unticked has to keep producing
|
// the archive is sealed — and leaving the box unticked has to keep producing
|
||||||
// the plain archive earlier versions produced.
|
// the plain archive earlier versions produced.
|
||||||
func TestSecretFilePassword(t *testing.T) {
|
func TestSecretFilePassword(t *testing.T) {
|
||||||
long := strings.Repeat("x", minSecretFilePasswordLen)
|
long := strings.Repeat("x", validate.MinSecretFilePasswordLen)
|
||||||
short := strings.Repeat("x", minSecretFilePasswordLen-1)
|
short := strings.Repeat("x", validate.MinSecretFilePasswordLen-1)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -104,13 +105,9 @@ func TestDecryptErrorMessage(t *testing.T) {
|
|||||||
// forgets to include the partial (or the data it needs) loses the option
|
// forgets to include the partial (or the data it needs) loses the option
|
||||||
// silently, since the plain download still works.
|
// silently, since the plain download still works.
|
||||||
func TestBackupPageOffersEncryption(t *testing.T) {
|
func TestBackupPageOffersEncryption(t *testing.T) {
|
||||||
tmpl, err := loadTemplates()
|
h := &Handlers{view: mustView(t), cfg: Config{Version: "test"}}
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("loadTemplates: %v", err)
|
|
||||||
}
|
|
||||||
s := &Server{tmpl: tmpl, cfg: Config{Version: "test"}}
|
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
s.renderBackupPageWith(rec, httptest.NewRequest(http.MethodGet, "/backup", nil),
|
h.renderBackupPageWith(rec, httptest.NewRequest(http.MethodGet, "/backup", nil),
|
||||||
http.StatusOK, "", "The two passwords do not match.")
|
http.StatusOK, "", "The two passwords do not match.")
|
||||||
|
|
||||||
body := rec.Body.String()
|
body := rec.Body.String()
|
||||||
@@ -118,7 +115,7 @@ func TestBackupPageOffersEncryption(t *testing.T) {
|
|||||||
`name="encrypt"`, `name="password"`, `name="password_confirm"`,
|
`name="encrypt"`, `name="password"`, `name="password_confirm"`,
|
||||||
`name="import_password"`, "data-encrypt-toggle", "data-encrypt-fields",
|
`name="import_password"`, "data-encrypt-toggle", "data-encrypt-fields",
|
||||||
"data-import-password-fields",
|
"data-import-password-fields",
|
||||||
fmt.Sprintf("at least %d characters", minSecretFilePasswordLen),
|
fmt.Sprintf("at least %d characters", validate.MinSecretFilePasswordLen),
|
||||||
"The two passwords do not match.",
|
"The two passwords do not match.",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(body, want) {
|
if !strings.Contains(body, want) {
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/web/validate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleDomainDMARC saves per-domain DMARC rua= settings.
|
||||||
|
func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.Header().Set("Allow", "POST")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d, ok := h.lookupDomain(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Invalid form submission."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var rua sql.NullString
|
||||||
|
switch strings.TrimSpace(r.PostFormValue("dmarc_rua_mode")) {
|
||||||
|
case "inherit":
|
||||||
|
rua = sql.NullString{}
|
||||||
|
case "none":
|
||||||
|
rua = sql.NullString{Valid: true, String: ""}
|
||||||
|
case "custom":
|
||||||
|
email := strings.TrimSpace(r.PostFormValue("dmarc_rua_email"))
|
||||||
|
if err := validate.Email(email); err != nil {
|
||||||
|
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if email == "" {
|
||||||
|
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Enter a custom report address or choose another mode."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rua = sql.NullString{Valid: true, String: email}
|
||||||
|
default:
|
||||||
|
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Choose how aggregate reports are addressed for this domain."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.UpdateDomainDMARCRua(d.ID, rua); err != nil {
|
||||||
|
logf("panel: domain %d: save dmarc rua: %v", d.ID, err)
|
||||||
|
h.renderDomainDetail(w, r, http.StatusInternalServerError, d, detailView{FormErr: "Could not save DMARC settings. Please check the logs and try again."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.dns.Forget(d.Name)
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/domains/%d?dmarc=1", d.ID), http.StatusSeeOther)
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/health"
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/auth"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/validate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// domainRow is one line of the domain list: the stored domain plus the rolled-up
|
||||||
|
// verdict of its published DNS records, so the operator sees which domains still
|
||||||
|
// need a record published without opening each one.
|
||||||
|
type domainRow struct {
|
||||||
|
store.Domain
|
||||||
|
DNS health.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDashboard is the authenticated landing page: the list of sending
|
||||||
|
// domains with their DKIM/selector and application counts, plus the add-domain
|
||||||
|
// form (product.md).
|
||||||
|
func (h *Handlers) HandleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderDashboard(w, r, http.StatusOK, "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) renderDashboard(w http.ResponseWriter, r *http.Request, status int, formErr, formName string) {
|
||||||
|
p, ok := h.principal(r)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
domains, err := h.assignedDomains(p)
|
||||||
|
if err != nil {
|
||||||
|
logf("panel: dashboard: list domains: %v", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := h.pageBase(r)
|
||||||
|
data["Title"] = "SelfPost"
|
||||||
|
data["Active"] = "domains"
|
||||||
|
data["Domains"] = h.domainRows(domains)
|
||||||
|
data["Error"] = formErr
|
||||||
|
data["FormName"] = formName
|
||||||
|
data["Flash"] = dashboardFlash(r)
|
||||||
|
h.view.Render(w, status, "dashboard", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) domainRows(domains []store.Domain) []domainRow {
|
||||||
|
profileEmail := ""
|
||||||
|
if email, err := h.store.GlobalDMARCReportEmail(); err == nil {
|
||||||
|
profileEmail = email
|
||||||
|
}
|
||||||
|
rows := make([]domainRow, len(domains))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, d := range domains {
|
||||||
|
rows[i] = domainRow{Domain: d, DNS: health.StatusUnknown}
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
record, err := h.domains.DKIMRecord(d)
|
||||||
|
if err != nil {
|
||||||
|
logf("panel: dashboard: domain %d: dkim record: %v", d.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dns, _ := h.domainDNS(d, record, profileEmail, false)
|
||||||
|
rows[i].DNS = dns.Overall
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func dashboardFlash(r *http.Request) string {
|
||||||
|
if r.URL.Query().Get("deleted") != "" {
|
||||||
|
return "Domain deleted."
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleAddDomain validates the submitted name, creates the domain (DKIM key +
|
||||||
|
// OpenDKIM reload), and redirects to the domain's page so the DNS record to
|
||||||
|
// publish is shown (product.md).
|
||||||
|
func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
h.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw := r.PostFormValue("name")
|
||||||
|
name := validate.NormalizeDomain(raw)
|
||||||
|
if err := validate.Domain(name); err != nil {
|
||||||
|
h.renderDashboard(w, r, http.StatusBadRequest, err.Error(), raw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
d, err := h.domains.Add(name)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrDomainExists) {
|
||||||
|
h.renderDashboard(w, r, http.StatusConflict, "That domain is already configured.", raw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logf("panel: add domain %q: %v", name, err)
|
||||||
|
h.renderDashboard(w, r, http.StatusInternalServerError,
|
||||||
|
"Could not add the domain. Please check the logs and try again.", raw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDeleteConfirm shows the cascade warning before a domain is removed.
|
||||||
|
func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d, ok := h.lookupDomain(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.view.Render(w, http.StatusOK, "domain_delete", map[string]any{
|
||||||
|
"Title": "SelfPost — delete " + d.Name,
|
||||||
|
"User": auth.CurrentUser(r),
|
||||||
|
"Active": "domains",
|
||||||
|
"Domain": d,
|
||||||
|
"IsGlobal": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDeleteDomain performs the deletion and returns to the domain list.
|
||||||
|
func (h *Handlers) HandleDeleteDomain(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := parseDomainID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if d, err := h.domains.Get(id); err == nil {
|
||||||
|
defer h.dns.Forget(d.Name)
|
||||||
|
}
|
||||||
|
if err := h.domains.Delete(id); err != nil {
|
||||||
|
if errors.Is(err, store.ErrDomainNotFound) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logf("panel: delete domain %d: %v", id, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/domains?deleted=1", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleReload re-applies both the OpenDKIM configuration and the Postfix
|
||||||
|
// sender map on demand (architecture.md § Panel HTTP surface).
|
||||||
|
func (h *Handlers) HandleReload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.domains.Resync(); err != nil {
|
||||||
|
logf("panel: manual reload (opendkim): %v", err)
|
||||||
|
http.Error(w, "reload failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.apps.Resync(); err != nil {
|
||||||
|
logf("panel: manual reload (postfix): %v", err)
|
||||||
|
http.Error(w, "reload failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/status?reloaded=1", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) lookupDomain(w http.ResponseWriter, r *http.Request) (store.Domain, bool) {
|
||||||
|
id, ok := parseDomainID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return store.Domain{}, false
|
||||||
|
}
|
||||||
|
d, err := h.domains.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrDomainNotFound) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return store.Domain{}, false
|
||||||
|
}
|
||||||
|
logf("panel: get domain %d: %v", id, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return store.Domain{}, false
|
||||||
|
}
|
||||||
|
p, ok := h.principal(r)
|
||||||
|
if !ok || !p.CanAccessDomain(d.ID) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return store.Domain{}, false
|
||||||
|
}
|
||||||
|
return d, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDomainID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
package web
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
"github.com/mixeme/selfpost/internal/mailhdr"
|
"github.com/mixeme/selfpost/internal/mailhdr"
|
||||||
"github.com/mixeme/selfpost/internal/postfix"
|
"github.com/mixeme/selfpost/internal/postfix"
|
||||||
"github.com/mixeme/selfpost/internal/store"
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// sendLogPageSize bounds each send-log page (product.md's monitoring screens
|
// sendLogPageSize bounds each send-log page (product.md's monitoring screens
|
||||||
@@ -24,38 +26,40 @@ const (
|
|||||||
deliveryLogLines = 200
|
deliveryLogLines = 200
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleDeliveries renders the Deliveries page over the send log: server-side
|
// HandleDeliveries renders the Deliveries page over the send log: server-side
|
||||||
// filters by domain/application and pagination (architecture.md §
|
// filters by domain/application and pagination (architecture.md §
|
||||||
// Persistence). The row table itself is the "deliveries_rows" fragment, shared
|
// Persistence). The row table itself is the "deliveries_rows" fragment, shared
|
||||||
// verbatim with handleDeliveriesRows so the initial page and its HTMX-polled
|
// verbatim with HandleDeliveriesRows so the initial page and its HTMX-polled
|
||||||
// refreshes never diverge.
|
// refreshes never diverge.
|
||||||
func (s *Server) handleDeliveries(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleDeliveries(w http.ResponseWriter, r *http.Request) {
|
||||||
data, err := s.sendLogData(r)
|
data, err := h.sendLogData(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logf("panel: send log: %v", err)
|
logf("panel: send log: %v", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data["Title"] = "SelfPost — deliveries"
|
data["Title"] = "SelfPost — deliveries"
|
||||||
data["User"] = currentUser(r)
|
for k, v := range h.pageBase(r) {
|
||||||
|
data[k] = v
|
||||||
|
}
|
||||||
data["Active"] = "deliveries"
|
data["Active"] = "deliveries"
|
||||||
s.render(w, http.StatusOK, "deliveries", data)
|
h.view.Render(w, http.StatusOK, "deliveries", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleDeliveriesRows serves the HTMX polling fragment for the delivery table
|
// HandleDeliveriesRows serves the HTMX polling fragment for the delivery table
|
||||||
// (architecture.md § Panel HTTP surface: fragment endpoints return HTML, not
|
// (architecture.md § Panel HTTP surface: fragment endpoints return HTML, not
|
||||||
// JSON).
|
// JSON).
|
||||||
func (s *Server) handleDeliveriesRows(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleDeliveriesRows(w http.ResponseWriter, r *http.Request) {
|
||||||
data, err := s.sendLogData(r)
|
data, err := h.sendLogData(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logf("panel: send log rows: %v", err)
|
logf("panel: send log rows: %v", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.renderFragment(w, http.StatusOK, "deliveries_rows", data)
|
h.view.RenderFragment(w, http.StatusOK, "deliveries_rows", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleDelivery renders one send-log row in full. The log itself carries only
|
// HandleDelivery renders one send-log row in full. The log itself carries only
|
||||||
// what identifies a message at a glance — when, who to and from, what about,
|
// what identifies a message at a glance — when, who to and from, what about,
|
||||||
// how it ended — and every remaining field (domain, application, queue id, when
|
// how it ended — and every remaining field (domain, application, queue id, when
|
||||||
// the status was last reported) lives here, one page per row, so widening the
|
// the status was last reported) lives here, one page per row, so widening the
|
||||||
@@ -67,13 +71,13 @@ func (s *Server) handleDeliveriesRows(w http.ResponseWriter, r *http.Request) {
|
|||||||
// its history side by side, and the mail.log lines for its queue id under both.
|
// its history side by side, and the mail.log lines for its queue id under both.
|
||||||
// The queue id used to be printed here as something to go and search the system
|
// The queue id used to be printed here as something to go and search the system
|
||||||
// log for by hand; the search is done for the operator instead.
|
// log for by hand; the search is done for the operator instead.
|
||||||
func (s *Server) handleDelivery(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleDelivery(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
if err != nil || id <= 0 {
|
if err != nil || id <= 0 {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
row, err := s.store.GetSendLog(id)
|
row, err := h.store.GetSendLog(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// A row pruned on the retention window is gone, not broken.
|
// A row pruned on the retention window is gone, not broken.
|
||||||
if errors.Is(err, store.ErrSendLogNotFound) {
|
if errors.Is(err, store.ErrSendLogNotFound) {
|
||||||
@@ -84,12 +88,29 @@ func (s *Server) handleDelivery(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
p, ok := h.principal(r)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !p.IsGlobal() {
|
||||||
|
allowed, err := h.assignedDomains(p)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !domainNameSet(allowed)[row.Domain] {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
row.Subject = mailhdr.DecodeSubject(row.Subject)
|
row.Subject = mailhdr.DecodeSubject(row.Subject)
|
||||||
logRows, logNote := s.deliveryLog(row)
|
logRows, logNote := h.deliveryLog(row)
|
||||||
s.render(w, http.StatusOK, "delivery", map[string]any{
|
h.view.Render(w, http.StatusOK, "delivery", map[string]any{
|
||||||
"Title": "SelfPost — delivery",
|
"Title": "SelfPost — delivery",
|
||||||
"User": currentUser(r),
|
"User": auth.CurrentUser(r),
|
||||||
"Active": "deliveries",
|
"Active": "deliveries",
|
||||||
|
"IsGlobal": p.IsGlobal(),
|
||||||
"Row": row,
|
"Row": row,
|
||||||
// The status in the panel's own badge vocabulary, so the headline reads
|
// The status in the panel's own badge vocabulary, so the headline reads
|
||||||
// the same way as every other health signal in the panel.
|
// the same way as every other health signal in the panel.
|
||||||
@@ -223,11 +244,11 @@ type deliveryLogRow struct {
|
|||||||
// have aged out of the log — so none of them is an error on the page. Only a
|
// have aged out of the log — so none of them is an error on the page. Only a
|
||||||
// log that cannot be read at all is reported as a fault, and that one is
|
// log that cannot be read at all is reported as a fault, and that one is
|
||||||
// logged for the operator as well.
|
// logged for the operator as well.
|
||||||
func (s *Server) deliveryLog(row store.SendLogRow) ([]deliveryLogRow, string) {
|
func (h *Handlers) deliveryLog(row store.SendLogRow) ([]deliveryLogRow, string) {
|
||||||
if row.QueueID == "" {
|
if row.QueueID == "" {
|
||||||
return nil, "This message never reached the queue, so Postfix wrote no delivery lines for it."
|
return nil, "This message never reached the queue, so Postfix wrote no delivery lines for it."
|
||||||
}
|
}
|
||||||
lines, err := logtail.QueueLines(s.cfg.MailLogPath, row.QueueID, deliveryLogLines)
|
lines, err := logtail.QueueLines(h.cfg.MailLogPath, row.QueueID, deliveryLogLines)
|
||||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
logf("panel: delivery log %s: %v", row.QueueID, err)
|
logf("panel: delivery log %s: %v", row.QueueID, err)
|
||||||
return nil, "Could not read the mail log."
|
return nil, "Could not read the mail log."
|
||||||
@@ -267,39 +288,69 @@ func deliveriesBackURL(r *http.Request) string {
|
|||||||
// sendLogData reads the domain/app filters and page number off the query
|
// sendLogData reads the domain/app filters and page number off the query
|
||||||
// string, queries the store, and assembles everything the template needs
|
// string, queries the store, and assembles everything the template needs
|
||||||
// (filter dropdown options plus the current selection, rows, and pagination).
|
// (filter dropdown options plus the current selection, rows, and pagination).
|
||||||
func (s *Server) sendLogData(r *http.Request) (map[string]any, error) {
|
func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) {
|
||||||
|
p, ok := h.principal(r)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("no principal")
|
||||||
|
}
|
||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
filter := store.SendLogFilter{
|
filter := store.SendLogFilter{
|
||||||
Domain: q.Get("domain"),
|
Domain: q.Get("domain"),
|
||||||
AppLogin: q.Get("app"),
|
AppLogin: q.Get("app"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assigned, err := h.assignedDomains(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
allowedNames := domainNameSet(assigned)
|
||||||
|
|
||||||
|
if !p.IsGlobal() {
|
||||||
|
if filter.Domain != "" && !allowedNames[filter.Domain] {
|
||||||
|
filter.Domain = ""
|
||||||
|
}
|
||||||
|
if filter.Domain == "" && len(assigned) == 1 {
|
||||||
|
filter.Domain = assigned[0].Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
page := parsePage(q.Get("p"))
|
page := parsePage(q.Get("p"))
|
||||||
|
|
||||||
total, err := s.store.CountSendLog(filter)
|
total, err := h.store.CountSendLog(filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rows, err := s.store.QuerySendLog(filter, sendLogPageSize, (page-1)*sendLogPageSize)
|
rows, err := h.store.QuerySendLog(filter, sendLogPageSize, (page-1)*sendLogPageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// Decode on the way out as well as on the way in: rows the journal-milter
|
|
||||||
// wrote before it decoded subjects itself still hold the raw header, and
|
|
||||||
// they are the ones an operator is most likely to be looking at.
|
|
||||||
for i := range rows {
|
for i := range rows {
|
||||||
rows[i].Subject = mailhdr.DecodeSubject(rows[i].Subject)
|
rows[i].Subject = mailhdr.DecodeSubject(rows[i].Subject)
|
||||||
}
|
}
|
||||||
domains, err := s.store.ListDomains()
|
|
||||||
if err != nil {
|
domainNames := make([]string, 0, len(assigned))
|
||||||
return nil, err
|
for _, d := range assigned {
|
||||||
|
domainNames = append(domainNames, d.Name)
|
||||||
}
|
}
|
||||||
domainNames := make([]string, len(domains))
|
|
||||||
for i, d := range domains {
|
loginSet := make(map[string]bool)
|
||||||
domainNames[i] = d.Name
|
for _, d := range assigned {
|
||||||
|
apps, err := h.store.ListApplicationsByDomain(d.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, a := range apps {
|
||||||
|
loginSet[a.Login] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
logins, err := s.store.ListApplicationLogins()
|
logins := make([]string, 0, len(loginSet))
|
||||||
if err != nil {
|
for login := range loginSet {
|
||||||
return nil, err
|
logins = append(logins, login)
|
||||||
|
}
|
||||||
|
sort.Strings(logins)
|
||||||
|
|
||||||
|
if !p.IsGlobal() && filter.AppLogin != "" && !loginSet[filter.AppLogin] {
|
||||||
|
filter.AppLogin = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
lastPage := 1
|
lastPage := 1
|
||||||
@@ -331,23 +382,30 @@ func parsePage(v string) int {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleMailQueue renders the Mail queue page (architecture.md § Panel HTTP
|
// HandleMailQueue renders the Mail queue page (architecture.md § Panel HTTP
|
||||||
// surface).
|
// surface).
|
||||||
func (s *Server) handleMailQueue(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleMailQueue(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
out, errText := readQueue()
|
out, errText := readQueue()
|
||||||
s.render(w, http.StatusOK, "mail_queue", map[string]any{
|
h.view.Render(w, http.StatusOK, "mail_queue", map[string]any{
|
||||||
"Title": "SelfPost — mail queue",
|
"Title": "SelfPost — mail queue",
|
||||||
"User": currentUser(r),
|
"User": auth.CurrentUser(r),
|
||||||
"Active": "mail_queue",
|
"Active": "mail_queue",
|
||||||
"Output": out,
|
"IsGlobal": true,
|
||||||
"Error": errText,
|
"Output": out,
|
||||||
|
"Error": errText,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleMailQueueBody serves the HTMX polling fragment for the queue view.
|
// HandleMailQueueBody serves the HTMX polling fragment for the queue view.
|
||||||
func (s *Server) handleMailQueueBody(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleMailQueueBody(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
out, errText := readQueue()
|
out, errText := readQueue()
|
||||||
s.renderFragment(w, http.StatusOK, "mail_queue_body", map[string]any{
|
h.view.RenderFragment(w, http.StatusOK, "mail_queue_body", map[string]any{
|
||||||
"Output": out,
|
"Output": out,
|
||||||
"Error": errText,
|
"Error": errText,
|
||||||
})
|
})
|
||||||
@@ -365,30 +423,37 @@ func readQueue() (string, string) {
|
|||||||
return out, ""
|
return out, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleSystemLog renders the System log page over mail.log (architecture.md §
|
// HandleSystemLog renders the System log page over mail.log (architecture.md §
|
||||||
// Panel HTTP surface).
|
// Panel HTTP surface).
|
||||||
func (s *Server) handleSystemLog(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleSystemLog(w http.ResponseWriter, r *http.Request) {
|
||||||
lines, errText := s.readLogTail()
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
s.render(w, http.StatusOK, "system_log", map[string]any{
|
return
|
||||||
"Title": "SelfPost — system log",
|
}
|
||||||
"User": currentUser(r),
|
lines, errText := h.readLogTail()
|
||||||
"Active": "system_log",
|
h.view.Render(w, http.StatusOK, "system_log", map[string]any{
|
||||||
"Lines": lines,
|
"Title": "SelfPost — system log",
|
||||||
"Error": errText,
|
"User": auth.CurrentUser(r),
|
||||||
|
"Active": "system_log",
|
||||||
|
"IsGlobal": true,
|
||||||
|
"Lines": lines,
|
||||||
|
"Error": errText,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleSystemLogBody serves the HTMX polling fragment for the log-tail view.
|
// HandleSystemLogBody serves the HTMX polling fragment for the log-tail view.
|
||||||
func (s *Server) handleSystemLogBody(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleSystemLogBody(w http.ResponseWriter, r *http.Request) {
|
||||||
lines, errText := s.readLogTail()
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
s.renderFragment(w, http.StatusOK, "system_log_body", map[string]any{
|
return
|
||||||
|
}
|
||||||
|
lines, errText := h.readLogTail()
|
||||||
|
h.view.RenderFragment(w, http.StatusOK, "system_log_body", map[string]any{
|
||||||
"Lines": lines,
|
"Lines": lines,
|
||||||
"Error": errText,
|
"Error": errText,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) readLogTail() ([]string, string) {
|
func (h *Handlers) readLogTail() ([]string, string) {
|
||||||
lines, err := logtail.TailLines(s.cfg.MailLogPath, logTailLines)
|
lines, err := logtail.TailLines(h.cfg.MailLogPath, logTailLines)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
// Rotation renamed the file away; Postfix recreates it on reload
|
// Rotation renamed the file away; Postfix recreates it on reload
|
||||||
@@ -396,7 +461,7 @@ func (s *Server) readLogTail() ([]string, string) {
|
|||||||
// than a failure worth alarming the operator about.
|
// than a failure worth alarming the operator about.
|
||||||
return nil, ""
|
return nil, ""
|
||||||
}
|
}
|
||||||
logf("panel: tail %s: %v", s.cfg.MailLogPath, err)
|
logf("panel: tail %s: %v", h.cfg.MailLogPath, err)
|
||||||
return nil, "Could not read the mail log."
|
return nil, "Could not read the mail log."
|
||||||
}
|
}
|
||||||
return lines, ""
|
return lines, ""
|
||||||
+35
-33
@@ -1,4 +1,4 @@
|
|||||||
package web
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -10,15 +10,16 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/store"
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// After log rotation renames mail.log away, Postfix takes about a second to
|
// After log rotation renames mail.log away, Postfix takes about a second to
|
||||||
// recreate it on reload (spec B.2); a missing file in that window is a normal,
|
// recreate it on reload (spec B.2); a missing file in that window is a normal,
|
||||||
// transient gap, not an operator-facing failure.
|
// transient gap, not an operator-facing failure.
|
||||||
func TestReadLogTailMissingFileIsNotAnError(t *testing.T) {
|
func TestReadLogTailMissingFileIsNotAnError(t *testing.T) {
|
||||||
s := &Server{cfg: Config{MailLogPath: filepath.Join(t.TempDir(), "mail.log")}}
|
h := &Handlers{cfg: Config{MailLogPath: filepath.Join(t.TempDir(), "mail.log")}}
|
||||||
|
|
||||||
lines, errText := s.readLogTail()
|
lines, errText := h.readLogTail()
|
||||||
if lines != nil {
|
if lines != nil {
|
||||||
t.Errorf("lines = %v, want nil", lines)
|
t.Errorf("lines = %v, want nil", lines)
|
||||||
}
|
}
|
||||||
@@ -31,9 +32,9 @@ func TestReadLogTailMissingFileIsNotAnError(t *testing.T) {
|
|||||||
// when, from, to, subject and status, and links each row to the page carrying
|
// when, from, to, subject and status, and links each row to the page carrying
|
||||||
// the rest. A column added back here is one the table has no width for.
|
// the rest. A column added back here is one the table has no width for.
|
||||||
func TestDeliveryLogShowsOnlyTheIdentifyingColumns(t *testing.T) {
|
func TestDeliveryLogShowsOnlyTheIdentifyingColumns(t *testing.T) {
|
||||||
s, row := serverWithDelivery(t)
|
h, row := serverWithDelivery(t)
|
||||||
|
|
||||||
out := getBody(t, s.handleDeliveries, "/deliveries")
|
out := getBody(t, h.HandleDeliveries, "/deliveries")
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
row.CreatedAt.Format("2006-01-02 15:04:05"),
|
row.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
"noreply@bs.example.ru", "public@example.ru",
|
"noreply@bs.example.ru", "public@example.ru",
|
||||||
@@ -56,11 +57,11 @@ func TestDeliveryLogShowsOnlyTheIdentifyingColumns(t *testing.T) {
|
|||||||
// decoded them, and those rows are still in the send log. Decoding on the way
|
// decoded them, and those rows are still in the send log. Decoding on the way
|
||||||
// out is what keeps them readable, so the encoding must not survive to the page.
|
// out is what keeps them readable, so the encoding must not survive to the page.
|
||||||
func TestDeliveryLogDecodesStoredEncodedSubjects(t *testing.T) {
|
func TestDeliveryLogDecodesStoredEncodedSubjects(t *testing.T) {
|
||||||
s, _ := serverWithDelivery(t)
|
h, _ := serverWithDelivery(t)
|
||||||
|
|
||||||
for name, out := range map[string]string{
|
for name, out := range map[string]string{
|
||||||
"log": getBody(t, s.handleDeliveries, "/deliveries"),
|
"log": getBody(t, h.HandleDeliveries, "/deliveries"),
|
||||||
"rows": getBody(t, s.handleDeliveriesRows, "/deliveries/rows"),
|
"rows": getBody(t, h.HandleDeliveriesRows, "/deliveries/rows"),
|
||||||
} {
|
} {
|
||||||
if strings.Contains(out, "=?utf-8?Q?") {
|
if strings.Contains(out, "=?utf-8?Q?") {
|
||||||
t.Errorf("%s shows the subject's MIME encoding instead of its text:\n%s", name, out)
|
t.Errorf("%s shows the subject's MIME encoding instead of its text:\n%s", name, out)
|
||||||
@@ -74,9 +75,9 @@ func TestDeliveryLogDecodesStoredEncodedSubjects(t *testing.T) {
|
|||||||
// Everything the log dropped has to be somewhere, and that somewhere is the
|
// Everything the log dropped has to be somewhere, and that somewhere is the
|
||||||
// per-row page — including for a row still holding an encoded subject.
|
// per-row page — including for a row still holding an encoded subject.
|
||||||
func TestDeliveryPageShowsWhatTheLogOmits(t *testing.T) {
|
func TestDeliveryPageShowsWhatTheLogOmits(t *testing.T) {
|
||||||
s, row := serverWithDelivery(t)
|
h, row := serverWithDelivery(t)
|
||||||
|
|
||||||
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID)+"?domain=bs.example.ru&p=2")
|
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID)+"?domain=bs.example.ru&p=2")
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"bs.example.ru", "Queuer3C", "4A1B2C3D", "Проверка",
|
"bs.example.ru", "Queuer3C", "4A1B2C3D", "Проверка",
|
||||||
"noreply@bs.example.ru", "public@example.ru", "sent",
|
"noreply@bs.example.ru", "public@example.ru", "sent",
|
||||||
@@ -95,9 +96,9 @@ func TestDeliveryPageShowsWhatTheLogOmits(t *testing.T) {
|
|||||||
// journal holds, stated as the steps they stand for, so a row is readable as
|
// journal holds, stated as the steps they stand for, so a row is readable as
|
||||||
// what happened to the message rather than as a list of fields.
|
// what happened to the message rather than as a list of fields.
|
||||||
func TestDeliveryPageTellsTheMessagesHistory(t *testing.T) {
|
func TestDeliveryPageTellsTheMessagesHistory(t *testing.T) {
|
||||||
s, row := serverWithDelivery(t)
|
h, row := serverWithDelivery(t)
|
||||||
|
|
||||||
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID))
|
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"Accepted and queued", "Delivered",
|
"Accepted and queued", "Delivered",
|
||||||
row.CreatedAt.Format("2006-01-02 15:04:05"),
|
row.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
@@ -121,19 +122,19 @@ func TestDeliveryPageTellsTheMessagesHistory(t *testing.T) {
|
|||||||
// for is drawn as one that has not happened rather than dated with the moment
|
// for is drawn as one that has not happened rather than dated with the moment
|
||||||
// the row was written.
|
// the row was written.
|
||||||
func TestDeliveryPageMarksAQueuedMessageAsStillWaiting(t *testing.T) {
|
func TestDeliveryPageMarksAQueuedMessageAsStillWaiting(t *testing.T) {
|
||||||
s, _ := serverWithDelivery(t)
|
h, _ := serverWithDelivery(t)
|
||||||
if err := s.store.InsertQueued(store.SendLogEntry{
|
if err := h.store.InsertQueued(store.SendLogEntry{
|
||||||
QueueID: "7F7F7F7F", Domain: "bs.example.ru", AppLogin: "Queuer3C",
|
QueueID: "7F7F7F7F", Domain: "bs.example.ru", AppLogin: "Queuer3C",
|
||||||
From: "noreply@bs.example.ru", To: "waiting@example.ru", Subject: "Still going",
|
From: "noreply@bs.example.ru", To: "waiting@example.ru", Subject: "Still going",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("insert: %v", err)
|
t.Fatalf("insert: %v", err)
|
||||||
}
|
}
|
||||||
rows, err := s.store.QuerySendLog(store.SendLogFilter{}, 1, 0)
|
rows, err := h.store.QuerySendLog(store.SendLogFilter{}, 1, 0)
|
||||||
if err != nil || len(rows) != 1 {
|
if err != nil || len(rows) != 1 {
|
||||||
t.Fatalf("query: %v (%d rows)", err, len(rows))
|
t.Fatalf("query: %v (%d rows)", err, len(rows))
|
||||||
}
|
}
|
||||||
|
|
||||||
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(rows[0].ID))
|
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(rows[0].ID))
|
||||||
for _, want := range []string{"Waiting for a delivery report", "pending", "not yet"} {
|
for _, want := range []string{"Waiting for a delivery report", "pending", "not yet"} {
|
||||||
if !strings.Contains(out, want) {
|
if !strings.Contains(out, want) {
|
||||||
t.Errorf("delivery page does not mark the message as still waiting (%q):\n%s", want, out)
|
t.Errorf("delivery page does not mark the message as still waiting (%q):\n%s", want, out)
|
||||||
@@ -146,14 +147,14 @@ func TestDeliveryPageMarksAQueuedMessageAsStillWaiting(t *testing.T) {
|
|||||||
// lines — as a table of when and what, so the seconds between the connection
|
// lines — as a table of when and what, so the seconds between the connection
|
||||||
// and the reply line up down one edge.
|
// and the reply line up down one edge.
|
||||||
func TestDeliveryPageShowsThisMessagesLogLines(t *testing.T) {
|
func TestDeliveryPageShowsThisMessagesLogLines(t *testing.T) {
|
||||||
s, row := serverWithDelivery(t)
|
h, row := serverWithDelivery(t)
|
||||||
s.cfg.MailLogPath = writeMailLog(t,
|
h.cfg.MailLogPath = writeMailLog(t,
|
||||||
"2026-08-03T05:15:52.219218+00:00 host postfix/smtpd[20]: 4A1B2C3D: client=mail.example.com[203.0.113.4]",
|
"2026-08-03T05:15:52.219218+00:00 host postfix/smtpd[20]: 4A1B2C3D: client=mail.example.com[203.0.113.4]",
|
||||||
"2026-08-03T05:15:52.300000+00:00 host postfix/qmgr[10]: 99999999: from=<other@example.ru>, size=500, nrcpt=1 (queue active)",
|
"2026-08-03T05:15:52.300000+00:00 host postfix/qmgr[10]: 99999999: from=<other@example.ru>, size=500, nrcpt=1 (queue active)",
|
||||||
"2026-08-03T05:16:03.884210+00:00 host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, dsn=2.0.0, status=sent (250 OK)",
|
"2026-08-03T05:16:03.884210+00:00 host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, dsn=2.0.0, status=sent (250 OK)",
|
||||||
)
|
)
|
||||||
|
|
||||||
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID))
|
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"<th>Time</th>", "<th>Message</th>",
|
"<th>Time</th>", "<th>Message</th>",
|
||||||
// The stamp is split off into its own cell, without the microseconds
|
// The stamp is split off into its own cell, without the microseconds
|
||||||
@@ -174,10 +175,10 @@ func TestDeliveryPageShowsThisMessagesLogLines(t *testing.T) {
|
|||||||
// A line whose head is not a timestamp still has to show in full; the format is
|
// A line whose head is not a timestamp still has to show in full; the format is
|
||||||
// the log's, not ours, and a line we cannot split is a line we must not drop.
|
// the log's, not ours, and a line we cannot split is a line we must not drop.
|
||||||
func TestDeliveryPageKeepsAnUnstampedLogLineWhole(t *testing.T) {
|
func TestDeliveryPageKeepsAnUnstampedLogLineWhole(t *testing.T) {
|
||||||
s, row := serverWithDelivery(t)
|
h, row := serverWithDelivery(t)
|
||||||
s.cfg.MailLogPath = writeMailLog(t, "host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, status=sent (250 OK)")
|
h.cfg.MailLogPath = writeMailLog(t, "host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, status=sent (250 OK)")
|
||||||
|
|
||||||
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID))
|
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
|
||||||
if !strings.Contains(out, "host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, status=sent (250 OK)") {
|
if !strings.Contains(out, "host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, status=sent (250 OK)") {
|
||||||
t.Errorf("an unstamped log line did not survive the split into columns:\n%s", out)
|
t.Errorf("an unstamped log line did not survive the split into columns:\n%s", out)
|
||||||
}
|
}
|
||||||
@@ -186,10 +187,10 @@ func TestDeliveryPageKeepsAnUnstampedLogLineWhole(t *testing.T) {
|
|||||||
// Rows outlive mail.log, and a message the milter refused never reached the
|
// Rows outlive mail.log, and a message the milter refused never reached the
|
||||||
// queue at all. Neither is a fault, so neither may render as an error.
|
// queue at all. Neither is a fault, so neither may render as an error.
|
||||||
func TestDeliveryPageExplainsAnEmptyDeliveryLog(t *testing.T) {
|
func TestDeliveryPageExplainsAnEmptyDeliveryLog(t *testing.T) {
|
||||||
s, row := serverWithDelivery(t)
|
h, row := serverWithDelivery(t)
|
||||||
s.cfg.MailLogPath = filepath.Join(t.TempDir(), "mail.log") // never created
|
h.cfg.MailLogPath = filepath.Join(t.TempDir(), "mail.log") // never created
|
||||||
|
|
||||||
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID))
|
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
|
||||||
if !strings.Contains(out, "rotated away") {
|
if !strings.Contains(out, "rotated away") {
|
||||||
t.Errorf("delivery page does not explain the empty delivery log:\n%s", out)
|
t.Errorf("delivery page does not explain the empty delivery log:\n%s", out)
|
||||||
}
|
}
|
||||||
@@ -201,13 +202,13 @@ func TestDeliveryPageExplainsAnEmptyDeliveryLog(t *testing.T) {
|
|||||||
// Send-log rows are pruned on the retention window, so a bookmarked delivery
|
// Send-log rows are pruned on the retention window, so a bookmarked delivery
|
||||||
// that no longer exists is a 404, not a 500.
|
// that no longer exists is a 404, not a 500.
|
||||||
func TestDeliveryPageNotFound(t *testing.T) {
|
func TestDeliveryPageNotFound(t *testing.T) {
|
||||||
s, _ := serverWithDelivery(t)
|
h, _ := serverWithDelivery(t)
|
||||||
|
|
||||||
for _, path := range []string{"/deliveries/999999", "/deliveries/abc", "/deliveries/0"} {
|
for _, path := range []string{"/deliveries/999999", "/deliveries/abc", "/deliveries/0"} {
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
req.SetPathValue("id", strings.TrimPrefix(path, "/deliveries/"))
|
req.SetPathValue("id", strings.TrimPrefix(path, "/deliveries/"))
|
||||||
s.handleDelivery(rec, req)
|
h.HandleDelivery(rec, req)
|
||||||
if rec.Code != http.StatusNotFound {
|
if rec.Code != http.StatusNotFound {
|
||||||
t.Errorf("GET %s = %d, want 404", path, rec.Code)
|
t.Errorf("GET %s = %d, want 404", path, rec.Code)
|
||||||
}
|
}
|
||||||
@@ -216,7 +217,7 @@ func TestDeliveryPageNotFound(t *testing.T) {
|
|||||||
|
|
||||||
// serverWithDelivery builds a panel over a store holding one delivery, written
|
// serverWithDelivery builds a panel over a store holding one delivery, written
|
||||||
// the way the journal-milter wrote them before it decoded subjects itself.
|
// the way the journal-milter wrote them before it decoded subjects itself.
|
||||||
func serverWithDelivery(t *testing.T) (*Server, store.SendLogRow) {
|
func serverWithDelivery(t *testing.T) (*Handlers, store.SendLogRow) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -242,11 +243,7 @@ func serverWithDelivery(t *testing.T) (*Server, store.SendLogRow) {
|
|||||||
t.Fatalf("query: %v (%d rows)", err, len(rows))
|
t.Fatalf("query: %v (%d rows)", err, len(rows))
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpl, err := loadTemplates()
|
return &Handlers{store: st, view: mustView(t), cfg: Config{Version: "test"}}, rows[0]
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("loadTemplates: %v", err)
|
|
||||||
}
|
|
||||||
return &Server{store: st, tmpl: tmpl, cfg: Config{Version: "test"}}, rows[0]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getBody runs one handler over a GET and returns the page it wrote, failing
|
// getBody runs one handler over a GET and returns the page it wrote, failing
|
||||||
@@ -256,6 +253,11 @@ func getBody(t *testing.T, h http.HandlerFunc, target string) string {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest(http.MethodGet, target, nil)
|
req := httptest.NewRequest(http.MethodGet, target, nil)
|
||||||
|
req = auth.RequestWithPrincipal(req, auth.Principal{
|
||||||
|
ID: 1,
|
||||||
|
Username: "admin",
|
||||||
|
Role: auth.RoleGlobal,
|
||||||
|
})
|
||||||
if rest, ok := strings.CutPrefix(req.URL.Path, "/deliveries/"); ok && rest != "rows" {
|
if rest, ok := strings.CutPrefix(req.URL.Path, "/deliveries/"); ok && rest != "rows" {
|
||||||
req.SetPathValue("id", rest)
|
req.SetPathValue("id", rest)
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package web
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -10,15 +10,8 @@ import (
|
|||||||
"github.com/mixeme/selfpost/internal/store"
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultRateLimitWindowSeconds is the sliding-window length used when an
|
|
||||||
// admin sets a message ceiling but leaves the window blank (guide § Rate
|
|
||||||
// limiting, matching the level-1 default hour; guide § Environment variables:
|
|
||||||
// RATE_LIMIT_WINDOW_SECONDS).
|
|
||||||
const defaultRateLimitWindowSeconds = 3600
|
const defaultRateLimitWindowSeconds = 3600
|
||||||
|
|
||||||
// rateLimitInput is the validated result of a rate-limit form submission.
|
|
||||||
// clear means "remove the differentiated limit" (guide § Rate limiting: an
|
|
||||||
// empty IP binding leaves only level 1).
|
|
||||||
type rateLimitInput struct {
|
type rateLimitInput struct {
|
||||||
clear bool
|
clear bool
|
||||||
ips []string
|
ips []string
|
||||||
@@ -26,10 +19,6 @@ type rateLimitInput struct {
|
|||||||
windowSeconds int
|
windowSeconds int
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseRateLimitForm validates a rate-limit submission on the server
|
|
||||||
// (security.md). It returns clear=true when the admin removes the limit or
|
|
||||||
// leaves the IP binding empty; otherwise it requires a positive ceiling and
|
|
||||||
// window. The returned error's message is safe to show to the admin.
|
|
||||||
func parseRateLimitForm(r *http.Request) (rateLimitInput, error) {
|
func parseRateLimitForm(r *http.Request) (rateLimitInput, error) {
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
return rateLimitInput{}, fmt.Errorf("invalid form submission")
|
return rateLimitInput{}, fmt.Errorf("invalid form submission")
|
||||||
@@ -42,8 +31,6 @@ func parseRateLimitForm(r *http.Request) (rateLimitInput, error) {
|
|||||||
return rateLimitInput{}, err
|
return rateLimitInput{}, err
|
||||||
}
|
}
|
||||||
if len(ips) == 0 {
|
if len(ips) == 0 {
|
||||||
// No IP binding: the differentiated limit does not apply (guide § Rate
|
|
||||||
// limiting).
|
|
||||||
return rateLimitInput{clear: true}, nil
|
return rateLimitInput{clear: true}, nil
|
||||||
}
|
}
|
||||||
maxMessages, err := parsePositiveInt(r.PostFormValue("max_messages"), 0)
|
maxMessages, err := parsePositiveInt(r.PostFormValue("max_messages"), 0)
|
||||||
@@ -57,10 +44,6 @@ func parseRateLimitForm(r *http.Request) (rateLimitInput, error) {
|
|||||||
return rateLimitInput{ips: ips, maxMessages: maxMessages, windowSeconds: windowSeconds}, nil
|
return rateLimitInput{ips: ips, maxMessages: maxMessages, windowSeconds: windowSeconds}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseIPList parses the allowed-IP field (IPs separated by newlines, commas or
|
|
||||||
// whitespace) into a deduplicated list of canonical addresses, rejecting any
|
|
||||||
// token that is not a valid IP (security.md). The values are only ever stored as
|
|
||||||
// SQLite parameters and compared in the milter, never written to a config file.
|
|
||||||
func parseIPList(raw string) ([]string, error) {
|
func parseIPList(raw string) ([]string, error) {
|
||||||
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||||||
return r == '\n' || r == '\r' || r == ',' || r == ' ' || r == '\t' || r == ';'
|
return r == '\n' || r == '\r' || r == ',' || r == ' ' || r == '\t' || r == ';'
|
||||||
@@ -81,8 +64,6 @@ func parseIPList(raw string) ([]string, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parsePositiveInt parses a trimmed integer field, returning def when it is
|
|
||||||
// blank. A non-numeric value returns an error.
|
|
||||||
func parsePositiveInt(raw string, def int) (int, error) {
|
func parsePositiveInt(raw string, def int) (int, error) {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
@@ -91,23 +72,20 @@ func parsePositiveInt(raw string, def int) (int, error) {
|
|||||||
return strconv.Atoi(raw)
|
return strconv.Atoi(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleDomainRateLimit saves or clears a domain-level differentiated rate
|
func (h *Handlers) HandleDomainRateLimit(w http.ResponseWriter, r *http.Request) {
|
||||||
// limit (guide § Rate limiting). No reload is needed — the milter reads the
|
d, ok := h.lookupDomain(w, r)
|
||||||
// row live.
|
|
||||||
func (s *Server) handleDomainRateLimit(w http.ResponseWriter, r *http.Request) {
|
|
||||||
d, ok := s.lookupDomain(w, r)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
in, err := parseRateLimitForm(r)
|
in, err := parseRateLimitForm(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||||||
FormMode: store.AddressModeWildcard,
|
FormMode: store.AddressModeWildcard,
|
||||||
RateLimitErr: err.Error(),
|
RateLimitErr: err.Error(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.applyRateLimit(in, s.domains.SaveRateLimit, s.domains.ClearRateLimit, d.ID); err != nil {
|
if err := h.applyRateLimit(in, h.domains.SaveRateLimit, h.domains.ClearRateLimit, d.ID); err != nil {
|
||||||
logf("panel: domain %d: save rate limit: %v", d.ID, err)
|
logf("panel: domain %d: save rate limit: %v", d.ID, err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -115,27 +93,25 @@ func (s *Server) handleDomainRateLimit(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?ratelimit=1", d.ID), http.StatusSeeOther)
|
http.Redirect(w, r, fmt.Sprintf("/domains/%d?ratelimit=1", d.ID), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAppRateLimit saves or clears an application-level differentiated rate
|
func (h *Handlers) HandleAppRateLimit(w http.ResponseWriter, r *http.Request) {
|
||||||
// limit (guide § Rate limiting).
|
a, ok := h.lookupApplication(w, r)
|
||||||
func (s *Server) handleAppRateLimit(w http.ResponseWriter, r *http.Request) {
|
|
||||||
a, ok := s.lookupApplication(w, r)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
d, err := s.domains.Get(a.DomainID)
|
d, err := h.domains.Get(a.DomainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
in, err := parseRateLimitForm(r)
|
in, err := parseRateLimitForm(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||||||
FormMode: store.AddressModeWildcard,
|
FormMode: store.AddressModeWildcard,
|
||||||
RateLimitErr: fmt.Sprintf("%s: %s", a.Login, err.Error()),
|
RateLimitErr: fmt.Sprintf("%s: %s", a.Login, err.Error()),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.applyRateLimit(in, s.apps.SaveRateLimit, s.apps.ClearRateLimit, a.ID); err != nil {
|
if err := h.applyRateLimit(in, h.apps.SaveRateLimit, h.apps.ClearRateLimit, a.ID); err != nil {
|
||||||
logf("panel: application %d: save rate limit: %v", a.ID, err)
|
logf("panel: application %d: save rate limit: %v", a.ID, err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -143,9 +119,7 @@ func (s *Server) handleAppRateLimit(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?ratelimit=1", a.DomainID), http.StatusSeeOther)
|
http.Redirect(w, r, fmt.Sprintf("/domains/%d?ratelimit=1", a.DomainID), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyRateLimit dispatches a validated input to the save or clear method of the
|
func (h *Handlers) applyRateLimit(
|
||||||
// relevant service, keyed by the domain or application id.
|
|
||||||
func (s *Server) applyRateLimit(
|
|
||||||
in rateLimitInput,
|
in rateLimitInput,
|
||||||
save func(id int64, ips []string, maxMessages, windowSeconds int) error,
|
save func(id int64, ips []string, maxMessages, windowSeconds int) error,
|
||||||
clear func(id int64) error,
|
clear func(id int64) error,
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/health"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handlers) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := h.statusBody()
|
||||||
|
srv := h.dns.Server(h.cfg.Hostname, false)
|
||||||
|
|
||||||
|
data["Title"] = "SelfPost — status"
|
||||||
|
data["User"] = auth.CurrentUser(r)
|
||||||
|
data["Active"] = "status"
|
||||||
|
data["IsGlobal"] = true
|
||||||
|
data["Flash"] = statusFlash(r)
|
||||||
|
data["Hostname"] = h.cfg.Hostname
|
||||||
|
data["PTR"] = srv.PTR
|
||||||
|
h.view.Render(w, http.StatusOK, "status", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) HandleStatusFragment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.view.RenderFragment(w, http.StatusOK, "status_body", h.statusBody())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) HandleStatusRecheck(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.dns.Server(h.cfg.Hostname, true)
|
||||||
|
http.Redirect(w, r, "/status?rechecked=1", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) statusBody() map[string]any {
|
||||||
|
procs, procErr := health.Processes()
|
||||||
|
procStatus := health.StatusUnknown
|
||||||
|
if procErr != nil {
|
||||||
|
logf("panel: status: supervisorctl: %v", procErr)
|
||||||
|
} else {
|
||||||
|
for _, p := range procs {
|
||||||
|
procStatus = health.Worst(procStatus, p.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
queueText, queueErr := readQueue()
|
||||||
|
queueStatus := health.StatusOK
|
||||||
|
if queueErr != "" {
|
||||||
|
queueStatus = health.StatusWarn
|
||||||
|
}
|
||||||
|
|
||||||
|
cert := health.CheckCertificate(h.cfg.TLSCertFile)
|
||||||
|
sockets := []health.Socket{
|
||||||
|
health.CheckSocket("OpenDKIM", h.cfg.OpenDKIMSocket, true),
|
||||||
|
health.CheckSocket("send-log", h.cfg.JournalSocket, false),
|
||||||
|
}
|
||||||
|
socketStatus := health.StatusUnknown
|
||||||
|
for _, sock := range sockets {
|
||||||
|
socketStatus = health.Worst(socketStatus, sock.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
machine := h.machine.Sample()
|
||||||
|
|
||||||
|
overall := health.Worst(procStatus, queueStatus, cert.Status, socketStatus, machine.Status)
|
||||||
|
return map[string]any{
|
||||||
|
"Processes": procs,
|
||||||
|
"ProcessError": procErr != nil,
|
||||||
|
"ProcessStatus": procStatus,
|
||||||
|
"QueueSummary": queueSummary(queueText),
|
||||||
|
"QueueError": queueErr,
|
||||||
|
"QueueStatus": queueStatus,
|
||||||
|
"Machine": machine,
|
||||||
|
"Cert": cert,
|
||||||
|
"Sockets": sockets,
|
||||||
|
"SocketStatus": socketStatus,
|
||||||
|
"OverallStatus": overall,
|
||||||
|
"OverallHeading": overallHeading(overall),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func queueSummary(out string) string {
|
||||||
|
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||||
|
for i := len(lines) - 1; i >= 0; i-- {
|
||||||
|
if line := strings.TrimSpace(lines[i]); line != "" {
|
||||||
|
return strings.TrimSpace(strings.TrimPrefix(line, "--"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func overallHeading(worst health.Status) string {
|
||||||
|
switch worst {
|
||||||
|
case health.StatusError:
|
||||||
|
return "A component needs attention — see the details below."
|
||||||
|
case health.StatusWarn:
|
||||||
|
return "Running, with warnings below."
|
||||||
|
case health.StatusOK:
|
||||||
|
return "All components are running normally."
|
||||||
|
default:
|
||||||
|
return "Some checks could not be performed."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusFlash(r *http.Request) string {
|
||||||
|
switch {
|
||||||
|
case r.URL.Query().Get("reloaded") != "":
|
||||||
|
return "Configuration regenerated from the database; OpenDKIM and Postfix have re-read it."
|
||||||
|
case r.URL.Query().Get("rechecked") != "":
|
||||||
|
return "DNS re-checked."
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/validate"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type userFormView struct {
|
||||||
|
FormErr string
|
||||||
|
FormUsername string
|
||||||
|
FormRole string
|
||||||
|
FormDomains map[int64]bool
|
||||||
|
FormPassword string
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleUsers lists panel users (global only).
|
||||||
|
func (h *Handlers) HandleUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := h.store.ListUserRows()
|
||||||
|
if err != nil {
|
||||||
|
logf("panel: list users: %v", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := h.pageBase(r)
|
||||||
|
data["Title"] = "SelfPost — users"
|
||||||
|
data["Active"] = "users"
|
||||||
|
data["Users"] = rows
|
||||||
|
data["Flash"] = usersFlash(r)
|
||||||
|
h.view.Render(w, http.StatusOK, "users", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func usersFlash(r *http.Request) string {
|
||||||
|
switch r.URL.Query().Get("done") {
|
||||||
|
case "created":
|
||||||
|
return "User created."
|
||||||
|
case "updated":
|
||||||
|
return "User updated."
|
||||||
|
case "deleted":
|
||||||
|
return "User deleted."
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleUserNew creates a panel user (global only).
|
||||||
|
func (h *Handlers) HandleUserNew(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
h.renderUserForm(w, r, http.StatusOK, 0, userFormView{FormRole: string(store.RoleDomainAdmin)})
|
||||||
|
case http.MethodPost:
|
||||||
|
h.submitUserCreate(w, r)
|
||||||
|
default:
|
||||||
|
w.Header().Set("Allow", "GET, POST")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleUserEdit edits or deletes a panel user (global only).
|
||||||
|
func (h *Handlers) HandleUserEdit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid, ok := parseUserID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, err := h.store.GetUser(uid)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrUserNotFound) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logf("panel: get user %d: %v", uid, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
selected := make(map[int64]bool, len(u.DomainIDs))
|
||||||
|
for _, id := range u.DomainIDs {
|
||||||
|
selected[id] = true
|
||||||
|
}
|
||||||
|
h.renderUserForm(w, r, http.StatusOK, u.ID, userFormView{
|
||||||
|
FormUsername: u.Username,
|
||||||
|
FormRole: string(u.Role),
|
||||||
|
FormDomains: selected,
|
||||||
|
})
|
||||||
|
case http.MethodPost:
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Invalid form submission.", FormUsername: u.Username, FormRole: string(u.Role)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.PostFormValue("action") == "delete" {
|
||||||
|
h.submitUserDelete(w, r, u)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.submitUserUpdate(w, r, u)
|
||||||
|
default:
|
||||||
|
w.Header().Set("Allow", "GET, POST")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) renderUserForm(w http.ResponseWriter, r *http.Request, status int, userID int64, view userFormView) {
|
||||||
|
domains, err := h.store.ListDomains()
|
||||||
|
if err != nil {
|
||||||
|
logf("panel: user form: list domains: %v", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := h.pageBase(r)
|
||||||
|
data["Title"] = "SelfPost — user"
|
||||||
|
data["Active"] = "users"
|
||||||
|
data["UserID"] = userID
|
||||||
|
data["Domains"] = domains
|
||||||
|
data["Error"] = view.FormErr
|
||||||
|
data["FormUsername"] = view.FormUsername
|
||||||
|
data["FormRole"] = view.FormRole
|
||||||
|
data["FormDomains"] = view.FormDomains
|
||||||
|
data["FormPassword"] = view.FormPassword
|
||||||
|
data["IsEdit"] = userID != 0
|
||||||
|
h.view.Render(w, status, "user_form", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) submitUserCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Invalid form submission."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||||
|
password := r.PostFormValue("password")
|
||||||
|
role := store.Role(r.PostFormValue("role"))
|
||||||
|
domainIDs := parseDomainIDs(r)
|
||||||
|
|
||||||
|
if err := validate.Username(username); err != nil {
|
||||||
|
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validate.AdminPassword(password); err != nil {
|
||||||
|
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if role != store.RoleGlobal && role != store.RoleDomainAdmin {
|
||||||
|
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if role == store.RoleDomainAdmin && len(domainIDs) == 0 {
|
||||||
|
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Select at least one domain for a domain administrator.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
logf("panel: create user hash: %v", err)
|
||||||
|
h.renderUserForm(w, r, http.StatusInternalServerError, 0, userFormView{FormErr: "Internal error. Please try again."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := h.store.CreateUser(username, string(hash), role, domainIDs); err != nil {
|
||||||
|
if errors.Is(err, store.ErrUserExists) {
|
||||||
|
h.renderUserForm(w, r, http.StatusConflict, 0, userFormView{FormErr: "That username is already in use.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logf("panel: create user: %v", err)
|
||||||
|
h.renderUserForm(w, r, http.StatusInternalServerError, 0, userFormView{FormErr: "Could not create user. Please check the logs."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/users?done=created", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u store.User) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Invalid form submission.", FormUsername: u.Username, FormRole: string(u.Role)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, ok := h.principal(r)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||||
|
password := r.PostFormValue("password")
|
||||||
|
role := store.Role(r.PostFormValue("role"))
|
||||||
|
domainIDs := parseDomainIDs(r)
|
||||||
|
selected := domainIDSetFromForm(r)
|
||||||
|
|
||||||
|
if username == "" {
|
||||||
|
username = u.Username
|
||||||
|
}
|
||||||
|
if err := validate.Username(username); err != nil {
|
||||||
|
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if role != store.RoleGlobal && role != store.RoleDomainAdmin {
|
||||||
|
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if role == store.RoleDomainAdmin && len(domainIDs) == 0 {
|
||||||
|
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Select at least one domain for a domain administrator.", FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.Role == store.RoleGlobal && role == store.RoleDomainAdmin {
|
||||||
|
n, err := h.store.CountGlobalUsers()
|
||||||
|
if err != nil || n <= 1 {
|
||||||
|
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Cannot demote the last global administrator.", FormUsername: username, FormRole: string(u.Role), FormDomains: selected})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.ID == p.ID && u.Role == store.RoleGlobal && role == store.RoleDomainAdmin {
|
||||||
|
n, err := h.store.CountGlobalUsers()
|
||||||
|
if err != nil || n <= 1 {
|
||||||
|
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "You cannot demote yourself without another global administrator.", FormUsername: username, FormRole: string(u.Role), FormDomains: selected})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := u.PasswordHash
|
||||||
|
if password != "" {
|
||||||
|
if err := validate.AdminPassword(password); err != nil {
|
||||||
|
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
logf("panel: update user hash: %v", err)
|
||||||
|
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Internal error. Please try again.", FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash = string(newHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.UpdateUser(u.ID, username, hash, u.DMARCReportEmail); err != nil {
|
||||||
|
if errors.Is(err, store.ErrUserExists) {
|
||||||
|
h.renderUserForm(w, r, http.StatusConflict, u.ID, userFormView{FormErr: "That username is already in use.", FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logf("panel: update user: %v", err)
|
||||||
|
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not save user. Please check the logs.", FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if role != u.Role {
|
||||||
|
if err := h.store.SetUserRole(u.ID, role); err != nil {
|
||||||
|
logf("panel: set user role: %v", err)
|
||||||
|
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not update role.", FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if role == store.RoleGlobal {
|
||||||
|
if err := h.store.ClearUserDomains(u.ID); err != nil {
|
||||||
|
logf("panel: clear user domains: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if role == store.RoleDomainAdmin {
|
||||||
|
if err := h.store.SetUserDomains(u.ID, domainIDs); err != nil {
|
||||||
|
logf("panel: set user domains: %v", err)
|
||||||
|
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not save domain assignments.", FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/users?done=updated", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) submitUserDelete(w http.ResponseWriter, r *http.Request, u store.User) {
|
||||||
|
p, ok := h.principal(r)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if u.ID == p.ID {
|
||||||
|
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "You cannot delete your own account while signed in.", FormUsername: u.Username, FormRole: string(u.Role)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.store.DeleteUser(u.ID); err != nil {
|
||||||
|
if errors.Is(err, store.ErrLastGlobal) {
|
||||||
|
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Cannot delete the last global administrator.", FormUsername: u.Username, FormRole: string(u.Role)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logf("panel: delete user %d: %v", u.ID, err)
|
||||||
|
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not delete user.", FormUsername: u.Username, FormRole: string(u.Role)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/users?done=deleted", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUserID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("uid"), 10, 64)
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDomainIDs(r *http.Request) []int64 {
|
||||||
|
var ids []int64
|
||||||
|
for _, v := range r.PostForm["domain_ids"] {
|
||||||
|
id, err := strconv.ParseInt(v, 10, 64)
|
||||||
|
if err == nil && id > 0 {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func domainIDSetFromForm(r *http.Request) map[int64]bool {
|
||||||
|
m := make(map[int64]bool)
|
||||||
|
for _, id := range parseDomainIDs(r) {
|
||||||
|
m[id] = true
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/web/view"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mustView(t *testing.T) *view.Engine {
|
||||||
|
t.Helper()
|
||||||
|
v, err := view.New("test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("view: %v", err)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
package web
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/store"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// handleAccount serves the administrator's own account settings: the username
|
|
||||||
// and password chosen during setup are the only panel credentials
|
|
||||||
// (security.md), and until now they could be changed only by recreating the
|
|
||||||
// state. Changing them here never touches application SASL logins, which are a
|
|
||||||
// separate identity system (architecture.md § Mail path).
|
|
||||||
func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.Method {
|
|
||||||
case http.MethodGet:
|
|
||||||
s.renderAccount(w, r, http.StatusOK, "", currentUser(r))
|
|
||||||
case http.MethodPost:
|
|
||||||
s.submitAccount(w, r)
|
|
||||||
default:
|
|
||||||
w.Header().Set("Allow", "GET, POST")
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderAccount draws the settings form. formUsername repopulates the username
|
|
||||||
// field after a rejected submission; the password fields are never repopulated.
|
|
||||||
func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername string) {
|
|
||||||
s.render(w, status, "account", map[string]any{
|
|
||||||
"Title": "SelfPost — account",
|
|
||||||
"User": currentUser(r),
|
|
||||||
"Active": "account",
|
|
||||||
"FormUsername": formUsername,
|
|
||||||
"Error": formErr,
|
|
||||||
"Flash": accountFlash(r),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// accountFlash maps a fixed redirect flag to a fixed message, so status text
|
|
||||||
// after a redirect is never attacker-influenced.
|
|
||||||
func accountFlash(r *http.Request) string {
|
|
||||||
switch r.URL.Query().Get("updated") {
|
|
||||||
case "username":
|
|
||||||
return "Username changed."
|
|
||||||
case "password":
|
|
||||||
return "Password changed. Any other signed-in sessions were signed out."
|
|
||||||
case "both":
|
|
||||||
return "Username and password changed. Any other signed-in sessions were signed out."
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// submitAccount applies a username and/or password change. The current password
|
|
||||||
// is always required, so a stolen session alone cannot lock the administrator
|
|
||||||
// out of their own panel, and the attempt is throttled on the same limiter as
|
|
||||||
// the login form so this route cannot be used to brute-force the password past
|
|
||||||
// that limit (security.md).
|
|
||||||
func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !s.loginLimiter.Allow(clientIP(r, s.trustedProxies)) {
|
|
||||||
s.renderAccount(w, r, http.StatusTooManyRequests,
|
|
||||||
"Too many attempts. Please wait and try again.", currentUser(r))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
s.renderAccount(w, r, http.StatusBadRequest, "Invalid form submission.", currentUser(r))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
|
||||||
current := r.PostFormValue("current_password")
|
|
||||||
password := r.PostFormValue("new_password")
|
|
||||||
confirm := r.PostFormValue("new_password_confirm")
|
|
||||||
|
|
||||||
admin, err := s.store.GetAdmin()
|
|
||||||
if err != nil {
|
|
||||||
logf("panel: account: get admin failed: %v", err)
|
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if username == "" {
|
|
||||||
username = admin.Username
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(current)); err != nil {
|
|
||||||
s.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
renaming := username != admin.Username
|
|
||||||
if renaming {
|
|
||||||
if err := validateUsername(username); err != nil {
|
|
||||||
s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// An empty pair of new-password fields means "leave the password alone", so
|
|
||||||
// the username can be changed on its own.
|
|
||||||
repassword := password != "" || confirm != ""
|
|
||||||
if repassword {
|
|
||||||
if password != confirm {
|
|
||||||
s.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := validateAdminPassword(password); err != nil {
|
|
||||||
s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !renaming && !repassword {
|
|
||||||
s.renderAccount(w, r, http.StatusBadRequest,
|
|
||||||
"Nothing to change: enter a new username, a new password, or both.", username)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hash := admin.PasswordHash
|
|
||||||
if repassword {
|
|
||||||
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
logf("panel: account: hashing password failed: %v", err)
|
|
||||||
s.renderAccount(w, r, http.StatusInternalServerError,
|
|
||||||
"Internal error. Please try again.", username)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hash = string(newHash)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.store.UpdateAdmin(username, hash); err != nil {
|
|
||||||
logf("panel: account: update admin failed: %v", err)
|
|
||||||
msg := "Could not save the changes. Please check the logs and try again."
|
|
||||||
if errors.Is(err, store.ErrNoAdmin) {
|
|
||||||
msg = "There is no administrator account to update."
|
|
||||||
}
|
|
||||||
s.renderAccount(w, r, http.StatusInternalServerError, msg, username)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep this session usable under the new name, and — when the password
|
|
||||||
// changed — drop every other session so a cookie captured under the old
|
|
||||||
// password stops working.
|
|
||||||
if token, ok := s.sessionToken(r); ok {
|
|
||||||
if renaming {
|
|
||||||
s.sessions.Rename(token, username)
|
|
||||||
}
|
|
||||||
if repassword {
|
|
||||||
s.sessions.DestroyOthers(token)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logf("panel: administrator account updated (username changed: %t, password changed: %t)", renaming, repassword)
|
|
||||||
http.Redirect(w, r, "/account?updated="+updatedFlag(renaming, repassword), http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
// updatedFlag names what changed, for the fixed post-redirect flash message.
|
|
||||||
func updatedFlag(renamed, repassword bool) string {
|
|
||||||
switch {
|
|
||||||
case renamed && repassword:
|
|
||||||
return "both"
|
|
||||||
case renamed:
|
|
||||||
return "username"
|
|
||||||
default:
|
|
||||||
return "password"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
package web
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/store"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The panel session cookie's two possible names. In the production shape —
|
|
||||||
// TLS in front, so CookieSecure — it carries the __Host- prefix, which turns
|
|
||||||
// what the cookie's attributes merely promise into something the browser
|
|
||||||
// enforces: Secure, Path=/ and, the point of the exercise, no Domain
|
|
||||||
// attribute, so no other host may set a cookie by this name.
|
|
||||||
// The prefix is only valid on a Secure cookie, so a development instance on
|
|
||||||
// plain HTTP has to keep the bare name: with the prefix the browser would
|
|
||||||
// discard the Set-Cookie outright and logging in would silently never stick.
|
|
||||||
const (
|
|
||||||
sessionCookieBase = "selfpost_session"
|
|
||||||
sessionCookiePrefixed = "__Host-" + sessionCookieBase
|
|
||||||
)
|
|
||||||
|
|
||||||
// sessionCookie is the session cookie's name for this deployment.
|
|
||||||
func (s *Server) sessionCookie() string {
|
|
||||||
if s.cfg.CookieSecure {
|
|
||||||
return sessionCookiePrefixed
|
|
||||||
}
|
|
||||||
return sessionCookieBase
|
|
||||||
}
|
|
||||||
|
|
||||||
// sessionToken returns the session token the request carries, if exactly one
|
|
||||||
// cookie of that name is present.
|
|
||||||
//
|
|
||||||
// It walks r.Cookies() rather than calling r.Cookie, which silently returns
|
|
||||||
// the first match. Two cookies with the same name mean somebody other than
|
|
||||||
// this panel set one of them — a host on the same registrable domain can,
|
|
||||||
// with Domain=example.com, and the browser will then send both — and RFC 6265
|
|
||||||
// makes the older one come first, so "the first match" is precisely the
|
|
||||||
// attacker's. The value cannot be forged into a valid session, so the effect
|
|
||||||
// is denial of service, not compromise; refusing the request and saying so in
|
|
||||||
// the log is what makes it diagnosable instead of an endless login loop. The
|
|
||||||
// __Host- prefix prevents this outright, but only where it applies — this
|
|
||||||
// check also covers the plain-HTTP development shape.
|
|
||||||
func (s *Server) sessionToken(r *http.Request) (string, bool) {
|
|
||||||
name := s.sessionCookie()
|
|
||||||
var token string
|
|
||||||
var n int
|
|
||||||
for _, c := range r.Cookies() {
|
|
||||||
if c.Name == name {
|
|
||||||
n++
|
|
||||||
token = c.Value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch n {
|
|
||||||
case 0:
|
|
||||||
return "", false
|
|
||||||
case 1:
|
|
||||||
return token, true
|
|
||||||
default:
|
|
||||||
logf("panel: %s %s carries %d cookies named %q — treating the request as signed out; "+
|
|
||||||
"another host on this domain is overwriting the session cookie, clear the cookies for the parent domain",
|
|
||||||
r.Method, r.URL.Path, n, name)
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// clearSessionCookies expires the session cookie under both names, so an
|
|
||||||
// upgrade that switches to the __Host- prefix does not leave the old cookie
|
|
||||||
// sitting in the browser until it is closed.
|
|
||||||
func (s *Server) clearSessionCookies(w http.ResponseWriter) {
|
|
||||||
for _, name := range []string{sessionCookieBase, sessionCookiePrefixed} {
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: name,
|
|
||||||
Value: "",
|
|
||||||
Path: "/",
|
|
||||||
MaxAge: -1,
|
|
||||||
HttpOnly: true,
|
|
||||||
// The prefixed name is only accepted at all when Secure is set,
|
|
||||||
// including on the expiring copy.
|
|
||||||
Secure: s.cfg.CookieSecure || name == sessionCookiePrefixed,
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleLogin serves the login form (GET) and authenticates (POST). Until an
|
|
||||||
// administrator exists there is nobody to log in, so it points at setup.
|
|
||||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
||||||
exists, err := s.store.AdminExists()
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !exists {
|
|
||||||
// No admin yet: login is meaningless. Send a clear message rather than
|
|
||||||
// a failing form.
|
|
||||||
s.render(w, http.StatusOK, "login", map[string]any{
|
|
||||||
"Title": "SelfPost — Sign in",
|
|
||||||
"Active": "login",
|
|
||||||
"SetupHint": true,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch r.Method {
|
|
||||||
case http.MethodGet:
|
|
||||||
s.renderLogin(w, http.StatusOK, "")
|
|
||||||
case http.MethodPost:
|
|
||||||
s.submitLogin(w, r)
|
|
||||||
default:
|
|
||||||
w.Header().Set("Allow", "GET, POST")
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) renderLogin(w http.ResponseWriter, status int, formErr string) {
|
|
||||||
// Active names the page for the layout even though no navigation is drawn
|
|
||||||
// here: it is what puts page-login on <main>, which the stylesheet uses to
|
|
||||||
// give the signed-out pages a column the width of their own card.
|
|
||||||
s.render(w, status, "login", map[string]any{
|
|
||||||
"Title": "SelfPost — Sign in",
|
|
||||||
"Active": "login",
|
|
||||||
"Error": formErr,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) submitLogin(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Brute-force throttle by client IP (security.md).
|
|
||||||
if !s.loginLimiter.Allow(clientIP(r, s.trustedProxies)) {
|
|
||||||
s.renderLogin(w, http.StatusTooManyRequests, "Too many attempts. Please wait and try again.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
s.renderLogin(w, http.StatusBadRequest, "Invalid form submission.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
|
||||||
password := r.PostFormValue("password")
|
|
||||||
|
|
||||||
admin, err := s.store.GetAdmin()
|
|
||||||
if err != nil {
|
|
||||||
if !errors.Is(err, store.ErrNoAdmin) {
|
|
||||||
logf("panel: login: get admin failed: %v", err)
|
|
||||||
}
|
|
||||||
s.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always run bcrypt so timing does not distinguish "wrong user" from
|
|
||||||
// "wrong password", and compare the username too.
|
|
||||||
pwErr := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password))
|
|
||||||
if username != admin.Username || pwErr != nil {
|
|
||||||
s.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token := s.sessions.Create(admin.Username)
|
|
||||||
s.setSessionCookie(w, token)
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
// setSessionCookie (re)issues the session cookie with a fresh Max-Age equal
|
|
||||||
// to the sliding idle window (plan B.1), so the browser-side expiry tracks
|
|
||||||
// whatever the database row was just set to — at login, and again whenever
|
|
||||||
// requireAuth extends an active session.
|
|
||||||
func (s *Server) setSessionCookie(w http.ResponseWriter, token string) {
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: s.sessionCookie(),
|
|
||||||
Value: token,
|
|
||||||
Path: "/",
|
|
||||||
MaxAge: s.sessions.MaxAge(),
|
|
||||||
HttpOnly: true,
|
|
||||||
Secure: s.cfg.CookieSecure,
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleLogout destroys the session and clears the cookie.
|
|
||||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
w.Header().Set("Allow", "POST")
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Destroy every token presented under the session cookie's name: if a
|
|
||||||
// shadowing duplicate is present (see sessionToken) one of them is the
|
|
||||||
// real session, and a value that names no session is simply not found.
|
|
||||||
name := s.sessionCookie()
|
|
||||||
for _, c := range r.Cookies() {
|
|
||||||
if c.Name == name {
|
|
||||||
s.sessions.Destroy(c.Value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.clearSessionCookies(w)
|
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
package web
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/health"
|
|
||||||
"github.com/mixeme/selfpost/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// domainRow is one line of the domain list: the stored domain plus the rolled-up
|
|
||||||
// verdict of its published DNS records, so the operator sees which domains still
|
|
||||||
// need a record published without opening each one.
|
|
||||||
type domainRow struct {
|
|
||||||
store.Domain
|
|
||||||
DNS health.Status
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleDashboard is the authenticated landing page: the list of sending
|
|
||||||
// domains with their DKIM/selector and application counts, plus the add-domain
|
|
||||||
// form (product.md).
|
|
||||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|
||||||
s.renderDashboard(w, r, http.StatusOK, "", "")
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderDashboard renders the domain list. formErr and formName repopulate the
|
|
||||||
// add-domain form after a rejected submission; flash surfaces a one-shot status
|
|
||||||
// message keyed by a redirect query flag (never reflected user input).
|
|
||||||
func (s *Server) renderDashboard(w http.ResponseWriter, r *http.Request, status int, formErr, formName string) {
|
|
||||||
domains, err := s.domains.List()
|
|
||||||
if err != nil {
|
|
||||||
logf("panel: dashboard: list domains: %v", err)
|
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.render(w, status, "dashboard", map[string]any{
|
|
||||||
"Title": "SelfPost",
|
|
||||||
"User": currentUser(r),
|
|
||||||
"Active": "domains",
|
|
||||||
"Domains": s.domainRows(domains),
|
|
||||||
"Error": formErr,
|
|
||||||
"FormName": formName,
|
|
||||||
"Flash": dashboardFlash(r),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// domainRows attaches each domain's DNS verdict to its row. The checks run
|
|
||||||
// concurrently rather than one after another: each carries its own timeout, so
|
|
||||||
// in series a dead resolver would multiply that wait by the number of domains
|
|
||||||
// and the list would look hung. The checker caches results for a few minutes,
|
|
||||||
// so a repeat view of the list costs no lookups at all, and it is the same
|
|
||||||
// cache the domain page fills — opening a domain after the list is free.
|
|
||||||
func (s *Server) domainRows(domains []store.Domain) []domainRow {
|
|
||||||
rows := make([]domainRow, len(domains))
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
for i, d := range domains {
|
|
||||||
rows[i] = domainRow{Domain: d, DNS: health.StatusUnknown}
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
record, err := s.domains.DKIMRecord(d)
|
|
||||||
if err != nil {
|
|
||||||
// Without the expected key there is nothing to compare DNS
|
|
||||||
// against; leave the row unknown rather than accusing the
|
|
||||||
// domain of a misconfiguration this server caused.
|
|
||||||
logf("panel: dashboard: domain %d: dkim record: %v", d.ID, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dns, _ := s.domainDNS(d, record, false)
|
|
||||||
rows[i].DNS = dns.Overall
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
// dashboardFlash maps a fixed redirect flag to a fixed message, so status text
|
|
||||||
// after a redirect is never attacker-influenced.
|
|
||||||
func dashboardFlash(r *http.Request) string {
|
|
||||||
if r.URL.Query().Get("deleted") != "" {
|
|
||||||
return "Domain deleted."
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleAddDomain validates the submitted name, creates the domain (DKIM key +
|
|
||||||
// OpenDKIM reload), and redirects to the domain's page so the DNS record to
|
|
||||||
// publish is shown (product.md).
|
|
||||||
func (s *Server) handleAddDomain(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
s.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
raw := r.PostFormValue("name")
|
|
||||||
name := normalizeDomain(raw)
|
|
||||||
if err := validateDomain(name); err != nil {
|
|
||||||
s.renderDashboard(w, r, http.StatusBadRequest, err.Error(), raw)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
d, err := s.domains.Add(name)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrDomainExists) {
|
|
||||||
s.renderDashboard(w, r, http.StatusConflict, "That domain is already configured.", raw)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logf("panel: add domain %q: %v", name, err)
|
|
||||||
s.renderDashboard(w, r, http.StatusInternalServerError,
|
|
||||||
"Could not add the domain. Please check the logs and try again.", raw)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleDeleteConfirm shows the cascade warning before a domain is removed: the
|
|
||||||
// panel must explicitly state that all bound applications go with it (product.md).
|
|
||||||
func (s *Server) handleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
|
|
||||||
d, ok := s.lookupDomain(w, r)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.render(w, http.StatusOK, "domain_delete", map[string]any{
|
|
||||||
"Title": "SelfPost — delete " + d.Name,
|
|
||||||
"User": currentUser(r),
|
|
||||||
"Active": "domains",
|
|
||||||
"Domain": d,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleDeleteDomain performs the deletion (cascade + DKIM key + OpenDKIM reload)
|
|
||||||
// and returns to the domain list.
|
|
||||||
func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, ok := parseDomainID(w, r)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Drop any cached DNS verdict for the name while it is still resolvable, so
|
|
||||||
// re-adding the domain later starts from a fresh check instead of a stale
|
|
||||||
// one from before it was removed.
|
|
||||||
if d, err := s.domains.Get(id); err == nil {
|
|
||||||
defer s.dns.Forget(d.Name)
|
|
||||||
}
|
|
||||||
if err := s.domains.Delete(id); err != nil {
|
|
||||||
if errors.Is(err, store.ErrDomainNotFound) {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logf("panel: delete domain %d: %v", id, err)
|
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, "/domains?deleted=1", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleReload re-applies both the OpenDKIM configuration and the Postfix
|
|
||||||
// sender map on demand (architecture.md § Panel HTTP surface). Each Resync
|
|
||||||
// regenerates its files from the database and reloads its daemon, so the
|
|
||||||
// button doubles as a drift-recovery. The button lives on the status page: it
|
|
||||||
// is a "put the daemons back in the state the database describes" action,
|
|
||||||
// which belongs with the rest of the server-health screen rather than in the
|
|
||||||
// domain list's top bar.
|
|
||||||
func (s *Server) handleReload(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := s.domains.Resync(); err != nil {
|
|
||||||
logf("panel: manual reload (opendkim): %v", err)
|
|
||||||
http.Error(w, "reload failed", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := s.apps.Resync(); err != nil {
|
|
||||||
logf("panel: manual reload (postfix): %v", err)
|
|
||||||
http.Error(w, "reload failed", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, "/status?reloaded=1", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
// lookupDomain resolves the {id} path value to a domain, writing a 404 for a
|
|
||||||
// bad id or a missing domain and reporting ok=false in that case.
|
|
||||||
func (s *Server) lookupDomain(w http.ResponseWriter, r *http.Request) (store.Domain, bool) {
|
|
||||||
id, ok := parseDomainID(w, r)
|
|
||||||
if !ok {
|
|
||||||
return store.Domain{}, false
|
|
||||||
}
|
|
||||||
d, err := s.domains.Get(id)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrDomainNotFound) {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return store.Domain{}, false
|
|
||||||
}
|
|
||||||
logf("panel: get domain %d: %v", id, err)
|
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
||||||
return store.Domain{}, false
|
|
||||||
}
|
|
||||||
return d, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseDomainID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
|
||||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
||||||
if err != nil || id <= 0 {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return id, true
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/legal"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLicenseHandlerServesAGPL(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/license", nil)
|
||||||
|
handleLicense(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
ct := rec.Header().Get("Content-Type")
|
||||||
|
if !strings.HasPrefix(ct, "text/plain") {
|
||||||
|
t.Errorf("Content-Type = %q, want text/plain", ct)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, "GNU AFFERO GENERAL PUBLIC LICENSE") {
|
||||||
|
t.Error("response is missing the AGPL title")
|
||||||
|
}
|
||||||
|
if body != string(legal.License) {
|
||||||
|
t.Error("response body does not match legal.License")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLicenseHandlerRejectsNonGET(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/license", nil)
|
||||||
|
handleLicense(rec, req)
|
||||||
|
if rec.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Fatalf("status = %d, want 405", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
package web
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// handleSetup serves the one-time administrator creation flow at
|
|
||||||
// /setup/<token> (security.md). Once an administrator exists the whole route
|
|
||||||
// returns 404; an invalid or expired token is indistinguishable from a missing
|
|
||||||
// page, also 404.
|
|
||||||
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Route-specific rate limit, separate from login (security.md).
|
|
||||||
if !s.setupLimiter.Allow(clientIP(r, s.trustedProxies)) {
|
|
||||||
http.Error(w, "too many requests", http.StatusTooManyRequests)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token := strings.TrimPrefix(r.URL.Path, "/setup/")
|
|
||||||
// Reject nested/garbage paths outright.
|
|
||||||
if token == "" || strings.Contains(token, "/") {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !s.setup.validate(token) {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch r.Method {
|
|
||||||
case http.MethodGet:
|
|
||||||
s.renderSetupForm(w, http.StatusOK, token, "")
|
|
||||||
case http.MethodPost:
|
|
||||||
s.submitSetup(w, r, token)
|
|
||||||
default:
|
|
||||||
w.Header().Set("Allow", "GET, POST")
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) renderSetupForm(w http.ResponseWriter, status int, token, formErr string) {
|
|
||||||
s.render(w, status, "setup", map[string]any{
|
|
||||||
"Title": "SelfPost — Create administrator",
|
|
||||||
"Active": "setup",
|
|
||||||
"Token": token,
|
|
||||||
"Error": formErr,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) submitSetup(w http.ResponseWriter, r *http.Request, token string) {
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
s.renderSetupForm(w, http.StatusBadRequest, token, "Invalid form submission.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
|
||||||
password := r.PostFormValue("password")
|
|
||||||
confirm := r.PostFormValue("password_confirm")
|
|
||||||
|
|
||||||
if err := validateUsername(username); err != nil {
|
|
||||||
s.renderSetupForm(w, http.StatusBadRequest, token, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if password != confirm {
|
|
||||||
s.renderSetupForm(w, http.StatusBadRequest, token, "Passwords do not match.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := validateAdminPassword(password); err != nil {
|
|
||||||
s.renderSetupForm(w, http.StatusBadRequest, token, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
logf("panel: setup: hashing password failed: %v", err)
|
|
||||||
s.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.store.CreateAdmin(username, string(hash)); err != nil {
|
|
||||||
// A concurrent submission may have already created the admin; the
|
|
||||||
// id=1 / non-empty-table guard makes this the second writer. Treat it
|
|
||||||
// as "setup already done" rather than an error.
|
|
||||||
if exists, _ := s.store.AdminExists(); exists {
|
|
||||||
s.setup.complete()
|
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logf("panel: setup: create admin failed: %v", err)
|
|
||||||
s.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup is now permanently complete: burn the token (security.md).
|
|
||||||
s.setup.complete()
|
|
||||||
logf("panel: administrator %q created; setup link is now disabled", username)
|
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
package web
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/health"
|
|
||||||
)
|
|
||||||
|
|
||||||
// handleStatus renders the server status page: the panel's landing page and the
|
|
||||||
// one screen that answers "is the service healthy and will mail be accepted".
|
|
||||||
// The cheap local checks live in the polled "status_body"
|
|
||||||
// fragment; the hostname/PTR lookup and the configuration reload sit outside it,
|
|
||||||
// because neither belongs on a five-second timer.
|
|
||||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|
||||||
data := s.statusBody()
|
|
||||||
srv := s.dns.Server(s.cfg.Hostname, false)
|
|
||||||
|
|
||||||
data["Title"] = "SelfPost — status"
|
|
||||||
data["User"] = currentUser(r)
|
|
||||||
data["Active"] = "status"
|
|
||||||
data["Flash"] = statusFlash(r)
|
|
||||||
data["Hostname"] = s.cfg.Hostname
|
|
||||||
data["PTR"] = srv.PTR
|
|
||||||
s.render(w, http.StatusOK, "status", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleStatusFragment serves the HTMX polling fragment for the local checks
|
|
||||||
// (architecture.md § Panel HTTP surface: fragment endpoints return HTML, not
|
|
||||||
// JSON).
|
|
||||||
func (s *Server) handleStatusFragment(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
s.renderFragment(w, http.StatusOK, "status_body", s.statusBody())
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleStatusRecheck forces a fresh hostname/PTR lookup, bypassing the cache,
|
|
||||||
// and returns to the page. DNS is the one part of this screen that talks to the
|
|
||||||
// network, so it refreshes on demand rather than with the poll.
|
|
||||||
func (s *Server) handleStatusRecheck(w http.ResponseWriter, r *http.Request) {
|
|
||||||
s.dns.Server(s.cfg.Hostname, true)
|
|
||||||
http.Redirect(w, r, "/status?rechecked=1", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
// statusBody collects the local checks the fragment renders. Each one
|
|
||||||
// reports its own problem rather than failing the page, so a broken component
|
|
||||||
// costs one line and not the whole screen.
|
|
||||||
func (s *Server) statusBody() map[string]any {
|
|
||||||
procs, procErr := health.Processes()
|
|
||||||
procStatus := health.StatusUnknown
|
|
||||||
if procErr != nil {
|
|
||||||
// Outside the container (or if the control socket is gone) there is
|
|
||||||
// nothing to report — "unknown", not "everything is broken".
|
|
||||||
logf("panel: status: supervisorctl: %v", procErr)
|
|
||||||
} else {
|
|
||||||
for _, p := range procs {
|
|
||||||
procStatus = health.Worst(procStatus, p.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
queueText, queueErr := readQueue()
|
|
||||||
queueStatus := health.StatusOK
|
|
||||||
if queueErr != "" {
|
|
||||||
queueStatus = health.StatusWarn
|
|
||||||
}
|
|
||||||
|
|
||||||
cert := health.CheckCertificate(s.cfg.TLSCertFile)
|
|
||||||
sockets := []health.Socket{
|
|
||||||
// OpenDKIM signs every outgoing message and Postfix is configured to
|
|
||||||
// tempfail without it: a missing socket stops mail.
|
|
||||||
health.CheckSocket("OpenDKIM", s.cfg.OpenDKIMSocket, true),
|
|
||||||
// The journal-milter only records the send log and fails open.
|
|
||||||
health.CheckSocket("send-log", s.cfg.JournalSocket, false),
|
|
||||||
}
|
|
||||||
socketStatus := health.StatusUnknown
|
|
||||||
for _, sock := range sockets {
|
|
||||||
socketStatus = health.Worst(socketStatus, sock.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resource usage of the machine underneath. It is graded like the rest —
|
|
||||||
// a processor that is fully busy or a machine out of memory delays or
|
|
||||||
// kills the mail path — so it counts towards the headline verdict, and
|
|
||||||
// its rates are measured against the previous poll (internal/health).
|
|
||||||
machine := s.machine.Sample()
|
|
||||||
|
|
||||||
overall := health.Worst(procStatus, queueStatus, cert.Status, socketStatus, machine.Status)
|
|
||||||
return map[string]any{
|
|
||||||
"Processes": procs,
|
|
||||||
"ProcessError": procErr != nil,
|
|
||||||
"ProcessStatus": procStatus,
|
|
||||||
"QueueSummary": queueSummary(queueText),
|
|
||||||
"QueueError": queueErr,
|
|
||||||
"QueueStatus": queueStatus,
|
|
||||||
"Machine": machine,
|
|
||||||
"Cert": cert,
|
|
||||||
"Sockets": sockets,
|
|
||||||
"SocketStatus": socketStatus,
|
|
||||||
"OverallStatus": overall,
|
|
||||||
"OverallHeading": overallHeading(overall),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// queueSummary reduces postqueue's listing to the one line worth showing on
|
|
||||||
// the status page; the full listing has its own screen (architecture.md §
|
|
||||||
// Panel HTTP surface). postqueue prints either "Mail queue is empty" or a
|
|
||||||
// trailing "-- N Kbytes in M Requests."
|
|
||||||
func queueSummary(out string) string {
|
|
||||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
|
||||||
for i := len(lines) - 1; i >= 0; i-- {
|
|
||||||
if line := strings.TrimSpace(lines[i]); line != "" {
|
|
||||||
return strings.TrimSpace(strings.TrimPrefix(line, "--"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// overallHeading turns the worst check into the page's one-line verdict.
|
|
||||||
func overallHeading(worst health.Status) string {
|
|
||||||
switch worst {
|
|
||||||
case health.StatusError:
|
|
||||||
return "A component needs attention — see the details below."
|
|
||||||
case health.StatusWarn:
|
|
||||||
return "Running, with warnings below."
|
|
||||||
case health.StatusOK:
|
|
||||||
return "All components are running normally."
|
|
||||||
default:
|
|
||||||
return "Some checks could not be performed."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// statusFlash maps a fixed redirect flag to a fixed message, so status text
|
|
||||||
// after a redirect is never attacker-influenced.
|
|
||||||
func statusFlash(r *http.Request) string {
|
|
||||||
switch {
|
|
||||||
case r.URL.Query().Get("reloaded") != "":
|
|
||||||
return "Configuration regenerated from the database; OpenDKIM and Postfix have re-read it."
|
|
||||||
case r.URL.Query().Get("rechecked") != "":
|
|
||||||
return "DNS re-checked."
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package web
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ctxKey int
|
|
||||||
|
|
||||||
const usernameKey ctxKey = 0
|
|
||||||
|
|
||||||
// requireAuth wraps a handler so only requests with a valid session cookie
|
|
||||||
// reach it; everyone else is redirected to the login page. The authenticated
|
|
||||||
// username is stashed in the request context for downstream handlers.
|
|
||||||
//
|
|
||||||
// It also extends the sliding session (plan B.1) on activity, defined as
|
|
||||||
// everything except a GET request carrying HX-Request: the four monitoring
|
|
||||||
// fragments (/status/fragment, /mail-queue/body, /system-log/body,
|
|
||||||
// /deliveries/rows)
|
|
||||||
// poll on a timer regardless of whether anyone is looking at the tab, so
|
|
||||||
// counting those as activity would make "N days idle" mean "N days since a
|
|
||||||
// browser tab was last open" instead.
|
|
||||||
func (s *Server) requireAuth(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
token, ok := s.sessionToken(r)
|
|
||||||
if !ok {
|
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
username, ok := s.sessions.Lookup(token)
|
|
||||||
if !ok {
|
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if isSessionActivity(r) && s.sessions.Touch(token) {
|
|
||||||
s.setSessionCookie(w, token)
|
|
||||||
}
|
|
||||||
ctx := context.WithValue(r.Context(), usernameKey, username)
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// isSessionActivity reports whether a request counts as administrator
|
|
||||||
// activity for the sliding session timeout, per requireAuth's doc comment.
|
|
||||||
func isSessionActivity(r *http.Request) bool {
|
|
||||||
return !(r.Method == http.MethodGet && r.Header.Get("HX-Request") != "")
|
|
||||||
}
|
|
||||||
|
|
||||||
// currentUser returns the authenticated username from the request context.
|
|
||||||
func currentUser(r *http.Request) string {
|
|
||||||
if v, ok := r.Context().Value(usernameKey).(string); ok {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
package web
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newTestSessionStore(t *testing.T) *sessionStore {
|
|
||||||
t.Helper()
|
|
||||||
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open store: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { st.Close() })
|
|
||||||
return newSessionStore(st, 7*24*time.Hour)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSessionRename(t *testing.T) {
|
|
||||||
s := newTestSessionStore(t)
|
|
||||||
token := s.Create("admin")
|
|
||||||
|
|
||||||
s.Rename(token, "operator")
|
|
||||||
|
|
||||||
name, ok := s.Lookup(token)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("session lost after rename")
|
|
||||||
}
|
|
||||||
if name != "operator" {
|
|
||||||
t.Fatalf("session username = %q, want %q", name, "operator")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A password change must invalidate every other session (so a cookie captured
|
|
||||||
// under the old password stops working) while keeping the one performing the
|
|
||||||
// change signed in.
|
|
||||||
func TestSessionDestroyOthers(t *testing.T) {
|
|
||||||
s := newTestSessionStore(t)
|
|
||||||
keep := s.Create("admin")
|
|
||||||
other := s.Create("admin")
|
|
||||||
|
|
||||||
s.DestroyOthers(keep)
|
|
||||||
|
|
||||||
if _, ok := s.Lookup(keep); !ok {
|
|
||||||
t.Fatal("current session was destroyed")
|
|
||||||
}
|
|
||||||
if _, ok := s.Lookup(other); ok {
|
|
||||||
t.Fatal("other session survived")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A session past its sliding idle expiry must not be honoured.
|
|
||||||
func TestSessionLookupRejectsExpired(t *testing.T) {
|
|
||||||
s := newTestSessionStore(t)
|
|
||||||
s.idle = -time.Minute // already expired the instant it's created
|
|
||||||
token := s.Create("admin")
|
|
||||||
|
|
||||||
if _, ok := s.Lookup(token); ok {
|
|
||||||
t.Fatal("expired session was accepted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Touch must not rewrite the expiry (or report a renewal) inside the
|
|
||||||
// once-an-hour throttle window, so an active tab's polling doesn't turn into
|
|
||||||
// a database write per request.
|
|
||||||
func TestSessionTouchThrottled(t *testing.T) {
|
|
||||||
s := newTestSessionStore(t)
|
|
||||||
token := s.Create("admin")
|
|
||||||
|
|
||||||
if s.Touch(token) {
|
|
||||||
t.Fatal("touch renewed a session created moments ago")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Back-date the session's last renewal by rewriting its expiry, as if it
|
|
||||||
// had been created (or last renewed) 2 hours ago rather than moments ago.
|
|
||||||
if err := s.store.RenewSession(hashToken(token), time.Now().Add(-2*time.Hour).Add(s.idle)); err != nil {
|
|
||||||
t.Fatalf("renew session: %v", err)
|
|
||||||
}
|
|
||||||
if !s.Touch(token) {
|
|
||||||
t.Fatal("touch did not renew a session past the throttle window")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{{define "content"}}
|
|
||||||
<h1>Account</h1>
|
|
||||||
|
|
||||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
|
||||||
|
|
||||||
<div class="card narrow">
|
|
||||||
<h2>Panel credentials</h2>
|
|
||||||
<p class="muted">These are the credentials for this control panel only.
|
|
||||||
Applications keep their own logins and passwords, which are not affected.</p>
|
|
||||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
|
||||||
<form method="post" action="/account">
|
|
||||||
<label for="username">Username</label>
|
|
||||||
<input id="username" name="username" autocomplete="username"
|
|
||||||
autocapitalize="none" spellcheck="false" value="{{.FormUsername}}" required>
|
|
||||||
|
|
||||||
<label for="current_password">Current password</label>
|
|
||||||
<input id="current_password" name="current_password" type="password"
|
|
||||||
autocomplete="current-password" required>
|
|
||||||
|
|
||||||
<label for="new_password">New password</label>
|
|
||||||
<input id="new_password" name="new_password" type="password" autocomplete="new-password">
|
|
||||||
|
|
||||||
<label for="new_password_confirm">Confirm new password</label>
|
|
||||||
<input id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password">
|
|
||||||
|
|
||||||
<button type="submit">Save changes</button>
|
|
||||||
</form>
|
|
||||||
<p class="muted">Leave both new-password fields empty to change the username
|
|
||||||
only. Changing the password signs out every other session; this one stays
|
|
||||||
signed in.</p>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
package web
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"unicode"
|
|
||||||
)
|
|
||||||
|
|
||||||
// minAdminPasswordLen is the floor for the administrator password. The panel is
|
|
||||||
// public (security.md), so this is deliberately not tiny.
|
|
||||||
const minAdminPasswordLen = 12
|
|
||||||
|
|
||||||
const (
|
|
||||||
minUsernameLen = 3
|
|
||||||
maxUsernameLen = 64
|
|
||||||
)
|
|
||||||
|
|
||||||
// minSecretFilePasswordLen is the floor for the password protecting an
|
|
||||||
// encrypted backup or domain export. Such a file is offline and can be attacked
|
|
||||||
// at leisure, so the floor matches the administrator password's rather than the
|
|
||||||
// weaker "any password is better than none".
|
|
||||||
const minSecretFilePasswordLen = minAdminPasswordLen
|
|
||||||
|
|
||||||
// validateUsername enforces a strict server-side whitelist (security.md):
|
|
||||||
// letters, digits, dot, dash, underscore. Client validation is never trusted.
|
|
||||||
func validateUsername(u string) error {
|
|
||||||
if len(u) < minUsernameLen || len(u) > maxUsernameLen {
|
|
||||||
return fmt.Errorf("username must be %d-%d characters", minUsernameLen, maxUsernameLen)
|
|
||||||
}
|
|
||||||
for _, r := range u {
|
|
||||||
if r > unicode.MaxASCII || (!isASCIILetterOrDigit(r) && r != '.' && r != '-' && r != '_') {
|
|
||||||
return fmt.Errorf("username may contain only letters, digits, '.', '-' and '_'")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateAdminPassword enforces a minimum length. Composition rules beyond
|
|
||||||
// length tend to reduce entropy in practice, so length is the sole gate.
|
|
||||||
func validateAdminPassword(p string) error {
|
|
||||||
if len(p) < minAdminPasswordLen {
|
|
||||||
return fmt.Errorf("password must be at least %d characters", minAdminPasswordLen)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isASCIILetterOrDigit(r rune) bool {
|
|
||||||
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxDomainLen = 253 // RFC 1035 limit on a fully-qualified name
|
|
||||||
|
|
||||||
// normalizeDomain lower-cases and trims a domain name. Domain names are
|
|
||||||
// case-insensitive, and the generated OpenDKIM tables/keys use the canonical
|
|
||||||
// lower-case form, so we normalise before both validation and storage.
|
|
||||||
func normalizeDomain(name string) string {
|
|
||||||
return strings.ToLower(strings.TrimSpace(name))
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateDomain enforces a strict server-side whitelist for sending-domain
|
|
||||||
// names (security.md). The result is safe to write verbatim into the OpenDKIM
|
|
||||||
// KeyTable/SigningTable and to use as a filesystem path segment: only
|
|
||||||
// lower-case letters, digits, '.' and '-' are allowed, in valid DNS label
|
|
||||||
// shape. Input must already be normalised with normalizeDomain.
|
|
||||||
//
|
|
||||||
// This is deliberately stricter than "any string DNS might accept" — no
|
|
||||||
// leading/trailing dots or hyphens, no empty or over-long labels, and at least
|
|
||||||
// two labels so single-word hostnames cannot be registered as sending domains.
|
|
||||||
func validateDomain(name string) error {
|
|
||||||
if name == "" {
|
|
||||||
return fmt.Errorf("domain is required")
|
|
||||||
}
|
|
||||||
if len(name) > maxDomainLen {
|
|
||||||
return fmt.Errorf("domain must be at most %d characters", maxDomainLen)
|
|
||||||
}
|
|
||||||
labels := strings.Split(name, ".")
|
|
||||||
if len(labels) < 2 {
|
|
||||||
return fmt.Errorf("domain must include at least one dot (e.g. example.com)")
|
|
||||||
}
|
|
||||||
for _, label := range labels {
|
|
||||||
if err := validateDomainLabel(label); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateDomainLabel(label string) error {
|
|
||||||
if len(label) == 0 {
|
|
||||||
return fmt.Errorf("domain must not contain an empty label")
|
|
||||||
}
|
|
||||||
if len(label) > 63 {
|
|
||||||
return fmt.Errorf("each domain label must be at most 63 characters")
|
|
||||||
}
|
|
||||||
if label[0] == '-' || label[len(label)-1] == '-' {
|
|
||||||
return fmt.Errorf("domain labels must not start or end with '-'")
|
|
||||||
}
|
|
||||||
for i := 0; i < len(label); i++ {
|
|
||||||
c := label[i]
|
|
||||||
lower := c >= 'a' && c <= 'z'
|
|
||||||
digit := c >= '0' && c <= '9'
|
|
||||||
if !lower && !digit && c != '-' {
|
|
||||||
return fmt.Errorf("domain may contain only lower-case letters, digits, '.' and '-'")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// Package validate holds shared server-side form validation for the panel.
|
||||||
|
package validate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MinAdminPasswordLen is the floor for the administrator password. The panel is
|
||||||
|
// public (security.md), so this is deliberately not tiny.
|
||||||
|
const MinAdminPasswordLen = 12
|
||||||
|
|
||||||
|
const (
|
||||||
|
minUsernameLen = 3
|
||||||
|
maxUsernameLen = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
// MinSecretFilePasswordLen is the floor for the password protecting an
|
||||||
|
// encrypted backup or domain export. Such a file is offline and can be attacked
|
||||||
|
// at leisure, so the floor matches the administrator password's rather than the
|
||||||
|
// weaker "any password is better than none".
|
||||||
|
const MinSecretFilePasswordLen = MinAdminPasswordLen
|
||||||
|
|
||||||
|
// Username enforces a strict server-side whitelist (security.md): letters,
|
||||||
|
// digits, dot, dash, underscore. Client validation is never trusted.
|
||||||
|
func Username(u string) error {
|
||||||
|
if len(u) < minUsernameLen || len(u) > maxUsernameLen {
|
||||||
|
return fmt.Errorf("username must be %d-%d characters", minUsernameLen, maxUsernameLen)
|
||||||
|
}
|
||||||
|
for _, r := range u {
|
||||||
|
if r > unicode.MaxASCII || (!isASCIILetterOrDigit(r) && r != '.' && r != '-' && r != '_') {
|
||||||
|
return fmt.Errorf("username may contain only letters, digits, '.', '-' and '_'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminPassword enforces a minimum length. Composition rules beyond length tend
|
||||||
|
// to reduce entropy in practice, so length is the sole gate.
|
||||||
|
func AdminPassword(p string) error {
|
||||||
|
if len(p) < MinAdminPasswordLen {
|
||||||
|
return fmt.Errorf("password must be at least %d characters", MinAdminPasswordLen)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isASCIILetterOrDigit(r rune) bool {
|
||||||
|
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxDomainLen = 253 // RFC 1035 limit on a fully-qualified name
|
||||||
|
|
||||||
|
// NormalizeDomain lower-cases and trims a domain name. Domain names are
|
||||||
|
// case-insensitive, and the generated OpenDKIM tables/keys use the canonical
|
||||||
|
// lower-case form, so we normalise before both validation and storage.
|
||||||
|
func NormalizeDomain(name string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain enforces a strict server-side whitelist for sending-domain names
|
||||||
|
// (security.md). The result is safe to write verbatim into the OpenDKIM
|
||||||
|
// KeyTable/SigningTable and to use as a filesystem path segment: only
|
||||||
|
// lower-case letters, digits, '.' and '-' are allowed, in valid DNS label
|
||||||
|
// shape. Input must already be normalised with NormalizeDomain.
|
||||||
|
func Domain(name string) error {
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("domain is required")
|
||||||
|
}
|
||||||
|
if len(name) > maxDomainLen {
|
||||||
|
return fmt.Errorf("domain must be at most %d characters", maxDomainLen)
|
||||||
|
}
|
||||||
|
labels := strings.Split(name, ".")
|
||||||
|
if len(labels) < 2 {
|
||||||
|
return fmt.Errorf("domain must include at least one dot (e.g. example.com)")
|
||||||
|
}
|
||||||
|
for _, label := range labels {
|
||||||
|
if err := domainLabel(label); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func domainLabel(label string) error {
|
||||||
|
if len(label) == 0 {
|
||||||
|
return fmt.Errorf("domain must not contain an empty label")
|
||||||
|
}
|
||||||
|
if len(label) > 63 {
|
||||||
|
return fmt.Errorf("each domain label must be at most 63 characters")
|
||||||
|
}
|
||||||
|
if label[0] == '-' || label[len(label)-1] == '-' {
|
||||||
|
return fmt.Errorf("domain labels must not start or end with '-'")
|
||||||
|
}
|
||||||
|
for i := 0; i < len(label); i++ {
|
||||||
|
c := label[i]
|
||||||
|
lower := c >= 'a' && c <= 'z'
|
||||||
|
digit := c >= '0' && c <= '9'
|
||||||
|
if !lower && !digit && c != '-' {
|
||||||
|
return fmt.Errorf("domain may contain only lower-case letters, digits, '.' and '-'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// freemailDomains lists public mail hosts that cannot publish _report._dmarc
|
||||||
|
// authorisation for third-party sending domains.
|
||||||
|
var freemailDomains = map[string]struct{}{
|
||||||
|
"gmail.com": {},
|
||||||
|
"googlemail.com": {},
|
||||||
|
"outlook.com": {},
|
||||||
|
"hotmail.com": {},
|
||||||
|
"live.com": {},
|
||||||
|
"yahoo.com": {},
|
||||||
|
"icloud.com": {},
|
||||||
|
"me.com": {},
|
||||||
|
"proton.me": {},
|
||||||
|
"protonmail.com": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email checks a DMARC rua= mailbox. Empty is allowed (policy-only).
|
||||||
|
func Email(addr string) error {
|
||||||
|
addr = strings.TrimSpace(addr)
|
||||||
|
if addr == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
at := strings.LastIndex(addr, "@")
|
||||||
|
if at <= 0 || at >= len(addr)-1 {
|
||||||
|
return fmt.Errorf("enter a valid email address")
|
||||||
|
}
|
||||||
|
local := addr[:at]
|
||||||
|
domain := NormalizeDomain(addr[at+1:])
|
||||||
|
if err := Domain(domain); err != nil {
|
||||||
|
return fmt.Errorf("email domain is invalid: %w", err)
|
||||||
|
}
|
||||||
|
for _, r := range local {
|
||||||
|
if r > unicode.MaxASCII {
|
||||||
|
return fmt.Errorf("email address must be ASCII")
|
||||||
|
}
|
||||||
|
if !isASCIILetterOrDigit(r) && r != '.' && r != '-' && r != '_' && r != '+' {
|
||||||
|
return fmt.Errorf("email address contains invalid characters")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, blocked := freemailDomains[domain]; blocked {
|
||||||
|
return fmt.Errorf("use an address on a domain you control; public mail hosts cannot receive authorised DMARC reports")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package validate
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestValidateEmail(t *testing.T) {
|
||||||
|
if err := Email(""); err != nil {
|
||||||
|
t.Errorf("empty: %v", err)
|
||||||
|
}
|
||||||
|
if err := Email("reports@mail.example.com"); err != nil {
|
||||||
|
t.Errorf("valid: %v", err)
|
||||||
|
}
|
||||||
|
if err := Email("bad"); err == nil {
|
||||||
|
t.Error("bad address accepted")
|
||||||
|
}
|
||||||
|
if err := Email("x@gmail.com"); err == nil {
|
||||||
|
t.Error("gmail accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package validate
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNormalizeDomain(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
" Example.COM ": "example.com",
|
||||||
|
"MAIL.Example.Org": "mail.example.org",
|
||||||
|
"example.com": "example.com",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := NormalizeDomain(in); got != want {
|
||||||
|
t.Errorf("NormalizeDomain(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDomainValid(t *testing.T) {
|
||||||
|
valid := []string{
|
||||||
|
"example.com",
|
||||||
|
"mail.example.com",
|
||||||
|
"a.co",
|
||||||
|
"sub-domain.example.co.uk",
|
||||||
|
"x1.y2.z3",
|
||||||
|
"1example.com",
|
||||||
|
}
|
||||||
|
for _, d := range valid {
|
||||||
|
if err := Domain(d); err != nil {
|
||||||
|
t.Errorf("Domain(%q) unexpected error: %v", d, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDomainInvalid(t *testing.T) {
|
||||||
|
invalid := []string{
|
||||||
|
"",
|
||||||
|
"localhost",
|
||||||
|
"example",
|
||||||
|
".example.com",
|
||||||
|
"example.com.",
|
||||||
|
"exa mple.com",
|
||||||
|
"example..com",
|
||||||
|
"-example.com",
|
||||||
|
"example-.com",
|
||||||
|
"example.com\n",
|
||||||
|
"exa*mple.com",
|
||||||
|
"exa_mple.com",
|
||||||
|
"Example.com",
|
||||||
|
"пример.рф",
|
||||||
|
"example.c/m",
|
||||||
|
}
|
||||||
|
for _, d := range invalid {
|
||||||
|
if err := Domain(d); err == nil {
|
||||||
|
t.Errorf("Domain(%q) = nil, want error", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDomainLongLabelRejected(t *testing.T) {
|
||||||
|
label := make([]byte, 64)
|
||||||
|
for i := range label {
|
||||||
|
label[i] = 'a'
|
||||||
|
}
|
||||||
|
if err := Domain(string(label) + ".com"); err == nil {
|
||||||
|
t.Error("expected error for over-long label")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package web
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestNormalizeDomain(t *testing.T) {
|
|
||||||
cases := map[string]string{
|
|
||||||
" Example.COM ": "example.com",
|
|
||||||
"MAIL.Example.Org": "mail.example.org",
|
|
||||||
"example.com": "example.com",
|
|
||||||
}
|
|
||||||
for in, want := range cases {
|
|
||||||
if got := normalizeDomain(in); got != want {
|
|
||||||
t.Errorf("normalizeDomain(%q) = %q, want %q", in, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateDomainValid(t *testing.T) {
|
|
||||||
valid := []string{
|
|
||||||
"example.com",
|
|
||||||
"mail.example.com",
|
|
||||||
"a.co",
|
|
||||||
"sub-domain.example.co.uk",
|
|
||||||
"x1.y2.z3",
|
|
||||||
"1example.com",
|
|
||||||
}
|
|
||||||
for _, d := range valid {
|
|
||||||
if err := validateDomain(d); err != nil {
|
|
||||||
t.Errorf("validateDomain(%q) unexpected error: %v", d, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateDomainInvalid(t *testing.T) {
|
|
||||||
invalid := []string{
|
|
||||||
"", // empty
|
|
||||||
"localhost", // single label
|
|
||||||
"example", // single label
|
|
||||||
".example.com", // leading dot -> empty label
|
|
||||||
"example.com.", // trailing dot -> empty label
|
|
||||||
"exa mple.com", // space
|
|
||||||
"example..com", // empty label
|
|
||||||
"-example.com", // label starts with '-'
|
|
||||||
"example-.com", // label ends with '-'
|
|
||||||
"example.com\n", // newline (config injection attempt)
|
|
||||||
"exa*mple.com", // disallowed char
|
|
||||||
"exa_mple.com", // underscore not allowed in domains
|
|
||||||
"Example.com", // upper-case (must be normalised first)
|
|
||||||
"пример.рф", // non-ASCII
|
|
||||||
"example.c/m", // slash (path-traversal attempt)
|
|
||||||
}
|
|
||||||
for _, d := range invalid {
|
|
||||||
if err := validateDomain(d); err == nil {
|
|
||||||
t.Errorf("validateDomain(%q) = nil, want error", d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateDomainLongLabelRejected(t *testing.T) {
|
|
||||||
label := make([]byte, 64)
|
|
||||||
for i := range label {
|
|
||||||
label[i] = 'a'
|
|
||||||
}
|
|
||||||
if err := validateDomain(string(label) + ".com"); err == nil {
|
|
||||||
t.Error("expected error for over-long label")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package view
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
)
|
||||||
|
|
||||||
|
// staticETags maps each /static/ URL path to a strong ETag over the asset's
|
||||||
|
// bytes, computed once at startup from the embedded FS.
|
||||||
|
//
|
||||||
|
// The assets are baked into the binary, so their FS modification times are the
|
||||||
|
// zero value and http.FileServer sends no Last-Modified. Without an ETag either,
|
||||||
|
// a response carries no validator at all and the browser is free to guess how
|
||||||
|
// long to keep it — which is how a replaced favicon keeps showing the old mark
|
||||||
|
// long after a deploy. Hashing the content gives every asset a validator that
|
||||||
|
// changes exactly when the asset does.
|
||||||
|
var staticETags = buildStaticETags()
|
||||||
|
|
||||||
|
func buildStaticETags() map[string]string {
|
||||||
|
etags := make(map[string]string)
|
||||||
|
// An error here would mean the embed directive and this walk disagree, which
|
||||||
|
// is a build-time defect rather than a runtime condition; the assets still
|
||||||
|
// serve correctly without a validator, so skip what can't be read.
|
||||||
|
_ = fs.WalkDir(assetsFS, "static", func(name string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b, err := fs.ReadFile(assetsFS, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(b)
|
||||||
|
etags["/"+name] = `"` + hex.EncodeToString(sum[:16]) + `"`
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return etags
|
||||||
|
}
|
||||||
|
|
||||||
|
// StaticHandler serves the embedded assets under /static/ with a content ETag.
|
||||||
|
//
|
||||||
|
// Cache-Control is no-cache rather than a max-age: it lets the browser keep the
|
||||||
|
// copy but requires it to revalidate, so an asset that changed is picked up on
|
||||||
|
// the next page load while an unchanged one costs a 304 with no body. For a
|
||||||
|
// handful of small files on a single-operator panel that trade is the right way
|
||||||
|
// round — correctness after a deploy matters more than saving the round trip.
|
||||||
|
// http.ServeContent answers the conditional request from the ETag we set here.
|
||||||
|
func StaticHandler() http.Handler {
|
||||||
|
files := http.FileServer(http.FS(assetsFS))
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if etag, ok := staticETags[path.Clean(r.URL.Path)]; ok {
|
||||||
|
w.Header().Set("ETag", etag)
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
}
|
||||||
|
files.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -176,7 +176,12 @@ h2 { font-size: 1.05rem; margin: 0 0 0.4rem; }
|
|||||||
.back { display: block; margin-bottom: 1rem; }
|
.back { display: block; margin-bottom: 1rem; }
|
||||||
/* Build version, closing every authenticated page. Quiet on purpose: it is
|
/* Build version, closing every authenticated page. Quiet on purpose: it is
|
||||||
reference material, not something to read on the way past. */
|
reference material, not something to read on the way past. */
|
||||||
.version { margin-top: 1.6rem; text-align: right; font-size: 0.8rem; color: #6b7280; }
|
.version { margin-top: 1.6rem; text-align: right; font-size: 0.8rem; color: #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 {
|
select, textarea {
|
||||||
width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem;
|
width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem;
|
||||||
border: 1px solid var(--control-border); border-radius: 6px; background: var(--input-bg); color: inherit;
|
border: 1px solid var(--control-border); border-radius: 6px; background: var(--input-bg); color: inherit;
|
||||||
File renamed without changes.
@@ -0,0 +1,74 @@
|
|||||||
|
package view
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serveStatic runs one GET against the static handler.
|
||||||
|
func serveStatic(path string, headers map[string]string) *httptest.ResponseRecorder {
|
||||||
|
r := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
for k, v := range headers {
|
||||||
|
r.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
StaticHandler().ServeHTTP(rec, r)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every embedded asset must carry a validator. The favicon is the one that
|
||||||
|
// prompted this: a browser given no ETag and no Last-Modified caches it on a
|
||||||
|
// guess, and a rebranded panel keeps serving the old mark from the tab.
|
||||||
|
func TestStaticAssetsCarryETag(t *testing.T) {
|
||||||
|
for _, name := range []string{"favicon.png", "favicon.svg", "panel.css", "panel.js", "htmx.min.js"} {
|
||||||
|
rec := serveStatic("/static/"+name, nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("%s: got status %d, want 200", name, rec.Code)
|
||||||
|
}
|
||||||
|
if rec.Header().Get("ETag") == "" {
|
||||||
|
t.Errorf("%s: no ETag", name)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Cache-Control"); got != "no-cache" {
|
||||||
|
t.Errorf("%s: Cache-Control = %q, want %q", name, got, "no-cache")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The point of the ETag is the cheap second request: the browser sends back
|
||||||
|
// what it has and gets a bodyless 304 when nothing changed.
|
||||||
|
func TestStaticETagRevalidates(t *testing.T) {
|
||||||
|
first := serveStatic("/static/favicon.png", nil)
|
||||||
|
etag := first.Header().Get("ETag")
|
||||||
|
if etag == "" {
|
||||||
|
t.Fatal("no ETag on the first response")
|
||||||
|
}
|
||||||
|
|
||||||
|
same := serveStatic("/static/favicon.png", map[string]string{"If-None-Match": etag})
|
||||||
|
if same.Code != http.StatusNotModified {
|
||||||
|
t.Errorf("matching If-None-Match: got status %d, want 304", same.Code)
|
||||||
|
}
|
||||||
|
if same.Body.Len() != 0 {
|
||||||
|
t.Errorf("matching If-None-Match: got %d bytes of body, want none", same.Body.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stale validator — what a browser holds after the asset is replaced —
|
||||||
|
// has to produce the new bytes rather than another 304.
|
||||||
|
stale := serveStatic("/static/favicon.png", map[string]string{"If-None-Match": `"0000000000000000"`})
|
||||||
|
if stale.Code != http.StatusOK {
|
||||||
|
t.Errorf("stale If-None-Match: got status %d, want 200", stale.Code)
|
||||||
|
}
|
||||||
|
if stale.Body.Len() == 0 {
|
||||||
|
t.Error("stale If-None-Match: empty body, want the asset")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two different assets must not share a validator, or replacing one would be
|
||||||
|
// masked by the other's cached copy.
|
||||||
|
func TestStaticETagsAreContentDerived(t *testing.T) {
|
||||||
|
png := serveStatic("/static/favicon.png", nil).Header().Get("ETag")
|
||||||
|
svg := serveStatic("/static/favicon.svg", nil).Header().Get("ETag")
|
||||||
|
if png == svg {
|
||||||
|
t.Errorf("favicon.png and favicon.svg share the ETag %s", png)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h1>Settings</h1>
|
||||||
|
|
||||||
|
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||||
|
|
||||||
|
<div class="card narrow">
|
||||||
|
<h2>Panel credentials</h2>
|
||||||
|
<p class="muted">These are the credentials for this control panel only.
|
||||||
|
Applications keep their own logins and passwords, which are not affected.</p>
|
||||||
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||||
|
<form method="post" action="/account">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input id="username" name="username" autocomplete="username"
|
||||||
|
autocapitalize="none" spellcheck="false" value="{{.FormUsername}}" required>
|
||||||
|
|
||||||
|
<label for="current_password">Current password</label>
|
||||||
|
<input id="current_password" name="current_password" type="password"
|
||||||
|
autocomplete="current-password" required>
|
||||||
|
|
||||||
|
<label for="new_password">New password</label>
|
||||||
|
<input id="new_password" name="new_password" type="password" autocomplete="new-password">
|
||||||
|
|
||||||
|
<label for="new_password_confirm">Confirm new password</label>
|
||||||
|
<input id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password">
|
||||||
|
|
||||||
|
{{if .ShowDMARC}}
|
||||||
|
<h3>DMARC aggregate reports</h3>
|
||||||
|
<p class="muted">Optional default <code>rua=</code> address for every sending
|
||||||
|
domain (can be overridden per domain). Use a mailbox on a domain that
|
||||||
|
receives inbound mail. SelfPost is send-only today; a future release will
|
||||||
|
be able to receive reports in the panel itself.</p>
|
||||||
|
|
||||||
|
<label for="dmarc_report_email">Default report address</label>
|
||||||
|
<input id="dmarc_report_email" name="dmarc_report_email" type="email"
|
||||||
|
autocomplete="email" autocapitalize="none" spellcheck="false"
|
||||||
|
value="{{.FormDMARCEmail}}" placeholder="reports@your-mail-domain.com">
|
||||||
|
|
||||||
|
{{if .FormDMARCEmail}}
|
||||||
|
<p class="muted">When <code>rua=</code> points at another domain, that hub
|
||||||
|
domain must publish a report-authorisation record so receivers will deliver
|
||||||
|
the XML aggregates.</p>
|
||||||
|
|
||||||
|
<label>Report authorization — host / name</label>
|
||||||
|
<div class="code-row">
|
||||||
|
<span class="code">{{.ReportAuthName}}</span>
|
||||||
|
<button type="button" class="copy">Copy</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>Report authorization — value</label>
|
||||||
|
<div class="code-row">
|
||||||
|
<span class="code">{{.ReportAuthExample}}</span>
|
||||||
|
<button type="button" class="copy">Copy</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .ReportAuthDNS.Status}}
|
||||||
|
<label>Report authorization DNS <span class="st st-{{.ReportAuthDNS.Status}}">{{.ReportAuthDNS.Status}}</span></label>
|
||||||
|
<p class="{{if eq .ReportAuthDNS.Status "ok"}}muted{{else}}error{{end}}">{{.ReportAuthDNS.Detail}}</p>
|
||||||
|
{{if .ReportAuthDNS.Records}}<span class="code">{{range .ReportAuthDNS.Records}}{{.}}
|
||||||
|
{{end}}</span>{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<button type="submit">Save changes</button>
|
||||||
|
</form>
|
||||||
|
<p class="muted">Leave both new-password fields empty to change the username
|
||||||
|
{{if .ShowDMARC}}or DMARC address {{end}}only. Changing the password signs out every other session;
|
||||||
|
this one stays signed in.</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
File renamed without changes.
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||||
|
|
||||||
|
{{if .IsGlobal}}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Add a sending domain</h2>
|
<h2>Add a sending domain</h2>
|
||||||
<form method="post" action="/domains">
|
<form method="post" action="/domains">
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
<p class="muted">A DKIM key is generated for the domain; you then publish the
|
<p class="muted">A DKIM key is generated for the domain; you then publish the
|
||||||
shown DNS record. Adding a domain does not create an application.</p>
|
shown DNS record. Adding a domain does not create an application.</p>
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Domains</h2>
|
<h2>Domains</h2>
|
||||||
@@ -31,7 +33,7 @@
|
|||||||
<td><a class="st st-{{.DNS}}" href="/domains/{{.ID}}#dns-status">{{.DNS}}</a></td>
|
<td><a class="st st-{{.DNS}}" href="/domains/{{.ID}}#dns-status">{{.DNS}}</a></td>
|
||||||
<td class="muted">{{.DKIMSelector}}</td>
|
<td class="muted">{{.DKIMSelector}}</td>
|
||||||
<td>{{.AppCount}}</td>
|
<td>{{.AppCount}}</td>
|
||||||
<td class="actions"><a class="danger" href="/domains/{{.ID}}/delete">Delete</a></td>
|
{{if $.IsGlobal}}<td class="actions"><a class="danger" href="/domains/{{.ID}}/delete">Delete</a></td>{{else}}<td></td>{{end}}
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+53
-11
@@ -51,8 +51,10 @@
|
|||||||
<h2>SPF and DMARC records</h2>
|
<h2>SPF and DMARC records</h2>
|
||||||
<p class="muted">These two are not generated the way the DKIM record above is —
|
<p class="muted">These two are not generated the way the DKIM record above is —
|
||||||
they are policy, and the domain may already publish an SPF record for other
|
they are policy, and the domain may already publish an SPF record for other
|
||||||
senders. What follows is what this server expects to see and what the checks
|
senders. SelfPost is a send-only relay — most operators have no inbox on the
|
||||||
below look for. Publish both as TXT records.</p>
|
sending domain, so the suggested DMARC record omits <code>rua=</code> unless a
|
||||||
|
report address is configured below or in <a href="/account">Settings</a>.
|
||||||
|
Publish both as TXT records.</p>
|
||||||
|
|
||||||
<label>SPF — host / name</label>
|
<label>SPF — host / name</label>
|
||||||
<div class="code-row">
|
<div class="code-row">
|
||||||
@@ -68,9 +70,23 @@
|
|||||||
|
|
||||||
<p class="muted">A domain may have only one SPF record. If it already has one,
|
<p class="muted">A domain may have only one SPF record. If it already has one,
|
||||||
do not add a second — add this server's mechanism to the existing record
|
do not add a second — add this server's mechanism to the existing record
|
||||||
instead, before its <code>all</code> term. <code>-all</code> at the end means
|
instead, before its <code>all</code> term.</p>
|
||||||
nothing else may send as the domain; use <code>~all</code> while other senders
|
|
||||||
are still being sorted out.</p>
|
<form method="post" action="/domains/{{.Domain.ID}}/dmarc">
|
||||||
|
<label for="dmarc_rua_mode">DMARC aggregate reports (rua=)</label>
|
||||||
|
<select id="dmarc_rua_mode" name="dmarc_rua_mode">
|
||||||
|
<option value="inherit"{{if eq .DMARCRuaMode "inherit"}} selected{{end}}>Same as Settings{{if .ProfileDMARCEmail}} ({{.ProfileDMARCEmail}}){{end}}</option>
|
||||||
|
<option value="none"{{if eq .DMARCRuaMode "none"}} selected{{end}}>No aggregate reports</option>
|
||||||
|
<option value="custom"{{if eq .DMARCRuaMode "custom"}} selected{{end}}>Custom address</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="dmarc_rua_email">Custom report address</label>
|
||||||
|
<input id="dmarc_rua_email" name="dmarc_rua_email" type="email"
|
||||||
|
autocapitalize="none" spellcheck="false" value="{{.DMARCRuaCustom}}"
|
||||||
|
placeholder="reports@your-mail-domain.com">
|
||||||
|
|
||||||
|
<button type="submit">Save DMARC report settings</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
<label>DMARC — host / name</label>
|
<label>DMARC — host / name</label>
|
||||||
<div class="code-row">
|
<div class="code-row">
|
||||||
@@ -78,17 +94,36 @@
|
|||||||
<button type="button" class="copy">Copy</button>
|
<button type="button" class="copy">Copy</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label>DMARC — value</label>
|
<label>DMARC — value{{if eq .DMARCSource "settings"}} <span class="muted">(from Settings)</span>{{else if eq .DMARCSource "custom"}} <span class="muted">(custom)</span>{{else if eq .DMARCSource "none"}} <span class="muted">(no reports)</span>{{end}}</label>
|
||||||
<div class="code-row">
|
<div class="code-row">
|
||||||
<span class="code">{{.DMARCExample}}</span>
|
<span class="code">{{.DMARCExample}}</span>
|
||||||
<button type="button" class="copy">Copy</button>
|
<button type="button" class="copy">Copy</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="muted">Point <code>rua=</code> at a mailbox that is actually read —
|
{{if .SameDomainRUA}}
|
||||||
the aggregate reports are how you find out who else sends as the domain.
|
<p class="error">The report address is on this sending domain. SelfPost does
|
||||||
<code>p=none</code> changes nothing about delivery, so it is safe to publish
|
not receive inbound mail — use a mailbox elsewhere or wait for in-panel report
|
||||||
straight away; tighten it to <code>p=quarantine</code> and then
|
reception in a future release.</p>
|
||||||
<code>p=reject</code> once the reports come back clean.</p>
|
{{end}}
|
||||||
|
|
||||||
|
{{if .NeedsReportAuth}}
|
||||||
|
<label>Report authorization — host / name</label>
|
||||||
|
<div class="code-row">
|
||||||
|
<span class="code">{{.ReportAuthName}}</span>
|
||||||
|
<button type="button" class="copy">Copy</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>Report authorization — value</label>
|
||||||
|
<div class="code-row">
|
||||||
|
<span class="code">{{.ReportAuthValue}}</span>
|
||||||
|
<button type="button" class="copy">Copy</button>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<p class="muted"><code>p=none</code> changes nothing about delivery. Omit
|
||||||
|
<code>rua=</code> on a send-only domain, or point it at a mailbox that
|
||||||
|
receives inbound mail. Tighten to <code>p=quarantine</code> and then
|
||||||
|
<code>p=reject</code> once aggregate reports look clean.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" id="dns-status">
|
<div class="card" id="dns-status">
|
||||||
@@ -116,6 +151,13 @@
|
|||||||
{{if .DNS.DMARC.Records}}<span class="code">{{range .DNS.DMARC.Records}}{{.}}
|
{{if .DNS.DMARC.Records}}<span class="code">{{range .DNS.DMARC.Records}}{{.}}
|
||||||
{{end}}</span>{{end}}
|
{{end}}</span>{{end}}
|
||||||
|
|
||||||
|
{{if .DNS.DMARCReportAuth.Status}}
|
||||||
|
<label>Report authorization <span class="st st-{{.DNS.DMARCReportAuth.Status}}">{{.DNS.DMARCReportAuth.Status}}</span></label>
|
||||||
|
<p class="{{if eq .DNS.DMARCReportAuth.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARCReportAuth.Detail}}</p>
|
||||||
|
{{if .DNS.DMARCReportAuth.Records}}<span class="code">{{range .DNS.DMARCReportAuth.Records}}{{.}}
|
||||||
|
{{end}}</span>{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/dns-recheck">
|
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/dns-recheck">
|
||||||
<button type="submit">Re-check</button>
|
<button type="submit">Re-check</button>
|
||||||
</form>
|
</form>
|
||||||
File renamed without changes.
@@ -37,11 +37,17 @@
|
|||||||
tell the send log from a single delivery's page. */}}
|
tell the send log from a single delivery's page. */}}
|
||||||
<main class="page-{{.Active}} {{template "wide" .}}">
|
<main class="page-{{.Active}} {{template "wide" .}}">
|
||||||
{{template "content" .}}
|
{{template "content" .}}
|
||||||
{{/* The running version, on every authenticated page: it is what a backup
|
{{/* Appropriate Legal Notices (AGPL-3.0): copyright, how to read the licence,
|
||||||
manifest is checked against on restore and the first thing to establish
|
where the Corresponding Source is, and that there is no warranty. Shown
|
||||||
when something behaves unexpectedly. Only for signed-in administrators —
|
on every page, including login/setup — those are interactive UIs too.
|
||||||
the login and setup pages must not advertise it to the internet. */}}
|
The running version stays signed-in only: it is what a backup manifest is
|
||||||
{{if .User}}<footer class="version">SelfPost {{.Version}}</footer>{{end}}
|
checked against on restore, and must not be advertised to the internet. */}}
|
||||||
|
<footer class="version">
|
||||||
|
{{if .User}}SelfPost {{.Version}} · {{end}}{{.Copyright}} ·
|
||||||
|
<a href="/license">License (AGPL-3.0)</a> ·
|
||||||
|
<a href="{{.SourceURL}}">Source</a> ·
|
||||||
|
No warranty
|
||||||
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
@@ -58,19 +64,24 @@
|
|||||||
the column: the mark, the panel's pages, the current page's own sections,
|
the column: the mark, the panel's pages, the current page's own sections,
|
||||||
and the session last. The mark goes to /status, the page the panel opens
|
and the session last. The mark goes to /status, the page the panel opens
|
||||||
on — named outright rather than as "/", which is only a redirect to it. */}}
|
on — named outright rather than as "/", which is only a redirect to it. */}}
|
||||||
<a class="brand" href="/status"><img src="/static/logo-compact.svg" width="220" height="100" alt="SelfPost"></a>
|
<a class="brand" href="{{if .IsGlobal}}/status{{else}}/domains{{end}}"><img src="/static/logo-compact.svg" width="220" height="100" alt="SelfPost"></a>
|
||||||
<div class="links">
|
<div class="links">
|
||||||
|
{{if .IsGlobal}}
|
||||||
{{if eq .Active "status"}}<span aria-current="page">{{template "icon-status"}}Status</span>{{else}}<a href="/status">{{template "icon-status"}}Status</a>{{end}}
|
{{if eq .Active "status"}}<span aria-current="page">{{template "icon-status"}}Status</span>{{else}}<a href="/status">{{template "icon-status"}}Status</a>{{end}}
|
||||||
|
{{end}}
|
||||||
{{if eq .Active "domains"}}<span aria-current="page">{{template "icon-domains"}}Domains</span>{{else}}<a href="/domains">{{template "icon-domains"}}Domains</a>{{end}}
|
{{if eq .Active "domains"}}<span aria-current="page">{{template "icon-domains"}}Domains</span>{{else}}<a href="/domains">{{template "icon-domains"}}Domains</a>{{end}}
|
||||||
{{if eq .Active "deliveries"}}<span aria-current="page">{{template "icon-deliveries"}}Deliveries</span>{{else}}<a href="/deliveries">{{template "icon-deliveries"}}Deliveries</a>{{end}}
|
{{if eq .Active "deliveries"}}<span aria-current="page">{{template "icon-deliveries"}}Deliveries</span>{{else}}<a href="/deliveries">{{template "icon-deliveries"}}Deliveries</a>{{end}}
|
||||||
|
{{if .IsGlobal}}
|
||||||
{{if eq .Active "mail_queue"}}<span aria-current="page">{{template "icon-mail-queue"}}Mail queue</span>{{else}}<a href="/mail-queue">{{template "icon-mail-queue"}}Mail queue</a>{{end}}
|
{{if eq .Active "mail_queue"}}<span aria-current="page">{{template "icon-mail-queue"}}Mail queue</span>{{else}}<a href="/mail-queue">{{template "icon-mail-queue"}}Mail queue</a>{{end}}
|
||||||
{{if eq .Active "system_log"}}<span aria-current="page">{{template "icon-system-log"}}System log</span>{{else}}<a href="/system-log">{{template "icon-system-log"}}System log</a>{{end}}
|
{{if eq .Active "system_log"}}<span aria-current="page">{{template "icon-system-log"}}System log</span>{{else}}<a href="/system-log">{{template "icon-system-log"}}System log</a>{{end}}
|
||||||
{{if eq .Active "backup"}}<span aria-current="page">{{template "icon-backup"}}Backup</span>{{else}}<a href="/backup">{{template "icon-backup"}}Backup</a>{{end}}
|
{{if eq .Active "backup"}}<span aria-current="page">{{template "icon-backup"}}Backup</span>{{else}}<a href="/backup">{{template "icon-backup"}}Backup</a>{{end}}
|
||||||
|
{{if eq .Active "users"}}<span aria-current="page">{{template "icon-account"}}Users</span>{{else}}<a href="/users">{{template "icon-account"}}Users</a>{{end}}
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{template "sections" .}}
|
{{template "sections" .}}
|
||||||
<div class="session">
|
<div class="session">
|
||||||
<span class="muted">{{.User}}</span>
|
<span class="muted">User: {{.User}}</span>
|
||||||
{{if eq .Active "account"}}<span aria-current="page">{{template "icon-account"}}Account</span>{{else}}<a href="/account">{{template "icon-account"}}Account</a>{{end}}
|
{{if eq .Active "account"}}<span aria-current="page">{{template "icon-account"}}Settings</span>{{else}}<a href="/account">{{template "icon-account"}}Settings</a>{{end}}
|
||||||
<form class="inline" method="post" action="/logout">
|
<form class="inline" method="post" action="/logout">
|
||||||
<button type="submit" class="danger">{{template "icon-sign-out"}}Sign out</button>
|
<button type="submit" class="danger">{{template "icon-sign-out"}}Sign out</button>
|
||||||
</form>
|
</form>
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user