Fix DMARC guidance for send-only relays with optional rua= settings.
test / test (push) Has been cancelled

The panel now suggests policy-only DMARC by default, lets operators configure a default and per-domain report address, and DNS-checks hub _report._dmarc records. Future in-panel report ingestion is tracked as dmarc-reports in the roadmap.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-10 22:47:34 +03:00
parent 2bdc0ea9a8
commit efaf016c5f
25 changed files with 784 additions and 116 deletions
+42
View File
@@ -7,6 +7,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
### Added ### 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 - AGPL packaging hygiene: [NOTICE](NOTICE) names the copyright holder and the
bundled third-party works (htmx 0BSD, IBM Plex OFL in outlined logos); 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 panel footer on every page — including login and setup — shows copyright, a
@@ -27,6 +33,12 @@ database, or the on-disk layout. Upgrading is a tag bump.
### Added ### 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.
- `SECURITY.md` — how to report a vulnerability privately (GitHub private - `SECURITY.md` — how to report a vulnerability privately (GitHub private
vulnerability reporting, `public@mixeme.ru` as fallback), which releases get 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 fixes, and what is in and out of scope for a relay. No response time is
@@ -93,6 +105,12 @@ database, or the on-disk layout. Upgrading is a tag bump.
### Added ### 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.
- A **Delivery log** on each delivery's page (`/deliveries/{id}`): the - A **Delivery log** on each delivery's page (`/deliveries/{id}`): the
`mail.log` lines Postfix wrote about that message, oldest first — the `mail.log` lines Postfix wrote about that message, oldest first — the
connection to the receiving server, its reply, and the status that reply was connection to the receiving server, its reply, and the status that reply was
@@ -214,6 +232,12 @@ database, or the on-disk layout. Upgrading is a tag bump.
### Added ### 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.
- A page per delivery (`/deliveries/{id}`), reached from the *Details* link on - A page per delivery (`/deliveries/{id}`), reached from the *Details* link on
every send-log row. It carries what the log itself no longer shows — the every send-log row. It carries what the log itself no longer shows — the
sending domain, the application the message was submitted under, the Postfix sending domain, the application the message was submitted under, the Postfix
@@ -429,6 +453,12 @@ database, or the on-disk layout. Upgrading is a tag bump.
### Added ### 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.
- Optional password encryption for the two secret-bearing downloads (plan - Optional password encryption for the two secret-bearing downloads (plan
phase 1.5, `docs/code-review.md`): an *Encrypt with a password* checkbox on phase 1.5, `docs/code-review.md`): an *Encrypt with a password* checkbox on
the full-backup and domain-export forms writes a `.spbk` / `.spde` envelope the full-backup and domain-export forms writes a `.spbk` / `.spde` envelope
@@ -530,6 +560,12 @@ database, or the on-disk layout. Upgrading is a tag bump.
### Added ### 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.
- The project's mark is now in use rather than only on file. The README opens - The project's mark is now in use rather than only on file. The README opens
with the full stamp; the panel carries the compact one at the left of its with the full stamp; the panel carries the compact one at the left of its
navigation bar, linking to the status page, and the full one above the card navigation bar, linking to the status page, and the full one above the card
@@ -762,6 +798,12 @@ of `docs/implementation-plan.md`).
### Added ### 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.
- Panel (Go, single static binary) with SQLite persistence, one-time - Panel (Go, single static binary) with SQLite persistence, one-time
crypto-random setup link, bcrypt admin auth, session cookies. crypto-random setup link, bcrypt admin auth, session cookies.
- Domain management with per-domain DKIM (RSA-2048, generated in pure Go) and - Domain management with per-domain DKIM (RSA-2048, generated in pure Go) and
+9 -3
View File
@@ -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
+49
View File
@@ -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).
+28
View File
@@ -31,6 +31,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| inbound-relay | Inbound relay (backup-MX / forwarding) | **agreed** | [plans/inbound-relay.md](plans/inbound-relay.md) | | inbound-relay | Inbound relay (backup-MX / forwarding) | **agreed** | [plans/inbound-relay.md](plans/inbound-relay.md) |
| contributing | `CONTRIBUTING.md` | candidate | — | | contributing | `CONTRIBUTING.md` | candidate | — |
| visual-style | Обновление визуального стиля | candidate | — | | visual-style | Обновление визуального стиля | candidate | — |
| dmarc-reports | DMARC aggregate report ingestion and panel UI | candidate | [plans/dmarc-reports.md](plans/dmarc-reports.md) |
**Recommended order** (not binding): **web-split → domain-admin → **Recommended order** (not binding): **web-split → domain-admin →
inbound-relay** — first the package split, then role-wide authorisation, then inbound-relay** — first the package split, then role-wide authorisation, then
@@ -142,3 +143,30 @@ dark schemes remain supported; readability and contrast are preserved.
regression across pages; low priority relative to functional work — take up regression across pages; low priority relative to functional work — take up
after explicit agreement, independently of the feature roadmap order. after explicit agreement, independently of the feature roadmap order.
**Version:** no bearing on semver. **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.
+23 -14
View File
@@ -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
} }
+19 -2
View File
@@ -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())
}
}
+48 -6
View File
@@ -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
} }
+38 -4
View File
@@ -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)
} }
} }
+54 -5
View File
@@ -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.
+14
View File
@@ -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)
+10
View File
@@ -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 {
+9 -8
View File
@@ -12,9 +12,10 @@ var ErrNoAdmin = errors.New("no administrator account")
// Admin is the single panel administrator (security.md). // Admin is the single panel administrator (security.md).
type Admin struct { type Admin struct {
Username string Username string
PasswordHash string PasswordHash string
CreatedAt time.Time DMARCReportEmail string // default rua= for all sending domains; empty = none
CreatedAt time.Time
} }
// AdminExists reports whether the administrator account has been created. This // AdminExists reports whether the administrator account has been created. This
@@ -47,10 +48,10 @@ func (s *Store) CreateAdmin(username, passwordHash string) error {
// SASL logins applications authenticate with, which live in their own table. // 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 // ErrNoAdmin is returned if setup has not happened yet, so a change can never
// silently create an account. // silently create an account.
func (s *Store) UpdateAdmin(username, passwordHash string) error { func (s *Store) UpdateAdmin(username, passwordHash, dmarcReportEmail string) error {
res, err := s.db.Exec( res, err := s.db.Exec(
"UPDATE admin SET username = ?, password_hash = ? WHERE id = 1", "UPDATE admin SET username = ?, password_hash = ?, dmarc_report_email = ? WHERE id = 1",
username, passwordHash, username, passwordHash, dmarcReportEmail,
) )
if err != nil { if err != nil {
return fmt.Errorf("update admin: %w", err) return fmt.Errorf("update admin: %w", err)
@@ -71,8 +72,8 @@ func (s *Store) GetAdmin() (Admin, error) {
a Admin a Admin
createdAt string createdAt string
) )
err := s.db.QueryRow("SELECT username, password_hash, created_at FROM admin WHERE id = 1"). err := s.db.QueryRow("SELECT username, password_hash, dmarc_report_email, created_at FROM admin WHERE id = 1").
Scan(&a.Username, &a.PasswordHash, &createdAt) Scan(&a.Username, &a.PasswordHash, &a.DMARCReportEmail, &createdAt)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return Admin{}, ErrNoAdmin return Admin{}, ErrNoAdmin
} }
+14 -3
View File
@@ -11,7 +11,7 @@ func TestUpdateAdmin(t *testing.T) {
if err := st.CreateAdmin("admin", "hash-one"); err != nil { if err := st.CreateAdmin("admin", "hash-one"); err != nil {
t.Fatalf("CreateAdmin: %v", err) t.Fatalf("CreateAdmin: %v", err)
} }
if err := st.UpdateAdmin("operator", "hash-two"); err != nil { if err := st.UpdateAdmin("operator", "hash-two", "reports@hub.example"); err != nil {
t.Fatalf("UpdateAdmin: %v", err) t.Fatalf("UpdateAdmin: %v", err)
} }
@@ -19,7 +19,18 @@ func TestUpdateAdmin(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("GetAdmin: %v", err) t.Fatalf("GetAdmin: %v", err)
} }
if a.Username != "operator" || a.PasswordHash != "hash-two" { if a.DMARCReportEmail != "reports@hub.example" {
t.Fatalf("dmarc email = %q", a.DMARCReportEmail)
}
if err := st.UpdateAdmin("operator", "hash-three", ""); err != nil {
t.Fatalf("clear dmarc email: %v", err)
}
a, err = st.GetAdmin()
if err != nil {
t.Fatalf("GetAdmin: %v", err)
}
if a.Username != "operator" || a.PasswordHash != "hash-three" {
t.Fatalf("unexpected admin after update: %+v", a) t.Fatalf("unexpected admin after update: %+v", a)
} }
if a.CreatedAt.IsZero() { if a.CreatedAt.IsZero() {
@@ -32,7 +43,7 @@ func TestUpdateAdmin(t *testing.T) {
func TestUpdateAdminWithoutAdmin(t *testing.T) { func TestUpdateAdminWithoutAdmin(t *testing.T) {
st := openTestStore(t) st := openTestStore(t)
if err := st.UpdateAdmin("operator", "hash"); !errors.Is(err, ErrNoAdmin) { if err := st.UpdateAdmin("operator", "hash", ""); !errors.Is(err, ErrNoAdmin) {
t.Fatalf("UpdateAdmin without admin = %v, want ErrNoAdmin", err) t.Fatalf("UpdateAdmin without admin = %v, want ErrNoAdmin", err)
} }
exists, err := st.AdminExists() exists, err := st.AdminExists()
+22 -3
View File
@@ -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
}
+45
View File
@@ -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;
+70 -31
View File
@@ -1,10 +1,13 @@
package web package web
import ( import (
"context"
"errors" "errors"
"net/http" "net/http"
"strings" "strings"
"time"
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/store"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -17,7 +20,13 @@ import (
func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
s.renderAccount(w, r, http.StatusOK, "", currentUser(r)) admin, err := s.store.GetAdmin()
if err != nil {
logf("panel: account: get admin failed: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.renderAccount(w, r, http.StatusOK, "", admin.Username, admin.DMARCReportEmail)
case http.MethodPost: case http.MethodPost:
s.submitAccount(w, r) s.submitAccount(w, r)
default: default:
@@ -26,16 +35,28 @@ func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) {
} }
} }
// renderAccount draws the settings form. formUsername repopulates the username // renderAccount draws the settings form. formUsername and formDMARCEmail
// field after a rejected submission; the password fields are never repopulated. // repopulate fields after a rejected submission; password fields are never
func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername string) { // repopulated.
func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string) {
var reportAuth dnscheck.Result
if hub := dnscheck.EmailDomain(formDMARCEmail); hub != "" {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
reportAuth = s.dns.ReportAuth(ctx, hub)
cancel()
}
s.render(w, status, "account", map[string]any{ s.render(w, status, "account", map[string]any{
"Title": "SelfPost — settings", "Title": "SelfPost — settings",
"User": currentUser(r), "User": currentUser(r),
"Active": "account", "Active": "account",
"FormUsername": formUsername, "FormUsername": formUsername,
"Error": formErr, "FormDMARCEmail": formDMARCEmail,
"Flash": accountFlash(r), "ReportAuthName": dnscheck.ReportAuthRecordName(dnscheck.EmailDomain(formDMARCEmail)),
"ReportAuthExample": dnscheck.ReportAuthExample(),
"ReportAuthDNS": reportAuth,
"ReportAuthHub": dnscheck.EmailDomain(formDMARCEmail),
"Error": formErr,
"Flash": accountFlash(r),
}) })
} }
@@ -49,6 +70,14 @@ func accountFlash(r *http.Request) string {
return "Password changed. Any other signed-in sessions were signed out." return "Password changed. Any other signed-in sessions were signed out."
case "both": case "both":
return "Username and password changed. Any other signed-in sessions were signed out." 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: default:
return "" return ""
} }
@@ -62,11 +91,11 @@ func accountFlash(r *http.Request) string {
func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) { func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) {
if !s.loginLimiter.Allow(clientIP(r, s.trustedProxies)) { if !s.loginLimiter.Allow(clientIP(r, s.trustedProxies)) {
s.renderAccount(w, r, http.StatusTooManyRequests, s.renderAccount(w, r, http.StatusTooManyRequests,
"Too many attempts. Please wait and try again.", currentUser(r)) "Too many attempts. Please wait and try again.", currentUser(r), "")
return return
} }
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
s.renderAccount(w, r, http.StatusBadRequest, "Invalid form submission.", currentUser(r)) s.renderAccount(w, r, http.StatusBadRequest, "Invalid form submission.", currentUser(r), "")
return return
} }
@@ -74,6 +103,7 @@ func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) {
current := r.PostFormValue("current_password") current := r.PostFormValue("current_password")
password := r.PostFormValue("new_password") password := r.PostFormValue("new_password")
confirm := r.PostFormValue("new_password_confirm") confirm := r.PostFormValue("new_password_confirm")
dmarcEmail := strings.TrimSpace(r.PostFormValue("dmarc_report_email"))
admin, err := s.store.GetAdmin() admin, err := s.store.GetAdmin()
if err != nil { if err != nil {
@@ -86,34 +116,39 @@ func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) {
} }
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(current)); err != nil { if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(current)); err != nil {
s.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username) s.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail)
return return
} }
renaming := username != admin.Username renaming := username != admin.Username
if renaming { if renaming {
if err := validateUsername(username); err != nil { if err := validateUsername(username); err != nil {
s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username) s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail)
return return
} }
} }
// An empty pair of new-password fields means "leave the password alone", so if err := validateEmail(dmarcEmail); err != nil {
// the username can be changed on its own. s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail)
return
}
emailChanging := dmarcEmail != admin.DMARCReportEmail
repassword := password != "" || confirm != "" repassword := password != "" || confirm != ""
if repassword { if repassword {
if password != confirm { if password != confirm {
s.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username) s.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username, dmarcEmail)
return return
} }
if err := validateAdminPassword(password); err != nil { if err := validateAdminPassword(password); err != nil {
s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username) s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail)
return return
} }
} }
if !renaming && !repassword { if !renaming && !repassword && !emailChanging {
s.renderAccount(w, r, http.StatusBadRequest, s.renderAccount(w, r, http.StatusBadRequest,
"Nothing to change: enter a new username, a new password, or both.", username) "Nothing to change: enter a new username, password, or DMARC report address.", username, dmarcEmail)
return return
} }
@@ -123,25 +158,22 @@ func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
logf("panel: account: hashing password failed: %v", err) logf("panel: account: hashing password failed: %v", err)
s.renderAccount(w, r, http.StatusInternalServerError, s.renderAccount(w, r, http.StatusInternalServerError,
"Internal error. Please try again.", username) "Internal error. Please try again.", username, dmarcEmail)
return return
} }
hash = string(newHash) hash = string(newHash)
} }
if err := s.store.UpdateAdmin(username, hash); err != nil { if err := s.store.UpdateAdmin(username, hash, dmarcEmail); err != nil {
logf("panel: account: update admin failed: %v", err) logf("panel: account: update admin failed: %v", err)
msg := "Could not save the changes. Please check the logs and try again." msg := "Could not save the changes. Please check the logs and try again."
if errors.Is(err, store.ErrNoAdmin) { if errors.Is(err, store.ErrNoAdmin) {
msg = "There is no administrator account to update." msg = "There is no administrator account to update."
} }
s.renderAccount(w, r, http.StatusInternalServerError, msg, username) s.renderAccount(w, r, http.StatusInternalServerError, msg, username, dmarcEmail)
return 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 token, ok := s.sessionToken(r); ok {
if renaming { if renaming {
s.sessions.Rename(token, username) s.sessions.Rename(token, username)
@@ -151,18 +183,25 @@ func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) {
} }
} }
logf("panel: administrator account updated (username changed: %t, password changed: %t)", renaming, repassword) logf("panel: administrator account updated (username: %t, password: %t, dmarc email: %t)", renaming, repassword, emailChanging)
http.Redirect(w, r, "/account?updated="+updatedFlag(renaming, repassword), http.StatusSeeOther) http.Redirect(w, r, "/account?updated="+updatedFlag(renaming, repassword, emailChanging), http.StatusSeeOther)
} }
// updatedFlag names what changed, for the fixed post-redirect flash message. func updatedFlag(renamed, repassword, emailChanged bool) string {
func updatedFlag(renamed, repassword bool) string {
switch { switch {
case renamed && repassword && emailChanged:
return "all"
case renamed && emailChanged:
return "username-email"
case repassword && emailChanged:
return "password-email"
case renamed && repassword: case renamed && repassword:
return "both" return "both"
case renamed: case renamed:
return "username" return "username"
default: case repassword:
return "password" return "password"
default:
return "email"
} }
} }
+55 -9
View File
@@ -102,7 +102,35 @@ 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) admin, err := s.store.GetAdmin()
if err != nil {
logf("panel: domain %d: get admin: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
profileEmail := admin.DMARCReportEmail
reportEmail := dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail)
dns, srv := s.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{ s.render(w, status, "domain_detail", map[string]any{
"Title": "SelfPost — " + d.Name, "Title": "SelfPost — " + d.Name,
@@ -118,7 +146,16 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
// page and the checks below it never recommend different records. // page and the checks below it never recommend different records.
"SPFExample": dnscheck.SPFExample(s.cfg.Hostname, srv.IPs), "SPFExample": dnscheck.SPFExample(s.cfg.Hostname, srv.IPs),
"DMARCName": dnscheck.DMARCRecordName(d.Name), "DMARCName": dnscheck.DMARCRecordName(d.Name),
"DMARCExample": dnscheck.DMARCExample(d.Name), "DMARCExample": dnscheck.DMARCExample(reportEmail),
"DMARCSource": dmarcSource,
"ProfileDMARCEmail": profileEmail,
"ResolvedDMARCEmail": reportEmail,
"DMARCRuaMode": dmarcMode,
"DMARCRuaCustom": dmarcCustom,
"ReportAuthName": reportAuthName,
"ReportAuthValue": reportAuthValue,
"NeedsReportAuth": needsReportAuth,
"SameDomainRUA": reportEmail != "" && strings.EqualFold(dnscheck.EmailDomain(reportEmail), d.Name),
// Client connection settings (the same for every domain on this // Client connection settings (the same for every domain on this
// instance): the hostname clients connect to, and whether the optional // instance): the hostname clients connect to, and whether the optional
// submission listener is enabled in this deployment. // submission listener is enabled in this deployment.
@@ -149,14 +186,15 @@ 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 (s *Server) domainDNS(d store.Domain, record domain.DKIMRecord, profileEmail string, force bool) (dnscheck.Domain, dnscheck.Server) {
srv := s.dns.Server(s.cfg.Hostname, false) srv := s.dns.Server(s.cfg.Hostname, false)
return s.dns.Domain(dnscheck.Query{ return s.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
} }
@@ -173,7 +211,13 @@ func (s *Server) handleDomainDNSRecheck(w http.ResponseWriter, r *http.Request)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
s.domainDNS(d, record, true) admin, err := s.store.GetAdmin()
if err != nil {
logf("panel: domain %d: get admin: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.domainDNS(d, record, admin.DMARCReportEmail, 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 +249,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") != "":
+56
View File
@@ -0,0 +1,56 @@
package web
import (
"database/sql"
"fmt"
"net/http"
"strings"
)
// handleDomainDMARC saves per-domain DMARC rua= settings.
func (s *Server) 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 := s.lookupDomain(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
s.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 := validateEmail(email); err != nil {
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: err.Error()})
return
}
if email == "" {
s.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:
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Choose how aggregate reports are addressed for this domain."})
return
}
if err := s.store.UpdateDomainDMARCRua(d.ID, rua); err != nil {
logf("panel: domain %d: save dmarc rua: %v", d.ID, err)
s.renderDomainDetail(w, r, http.StatusInternalServerError, d, detailView{FormErr: "Could not save DMARC settings. Please check the logs and try again."})
return
}
s.dns.Forget(d.Name)
http.Redirect(w, r, fmt.Sprintf("/domains/%d?dmarc=1", d.ID), http.StatusSeeOther)
}
+5 -1
View File
@@ -54,6 +54,10 @@ func (s *Server) renderDashboard(w http.ResponseWriter, r *http.Request, status
// so a repeat view of the list costs no lookups at all, and it is the same // 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. // cache the domain page fills — opening a domain after the list is free.
func (s *Server) domainRows(domains []store.Domain) []domainRow { func (s *Server) domainRows(domains []store.Domain) []domainRow {
profileEmail := ""
if admin, err := s.store.GetAdmin(); err == nil {
profileEmail = admin.DMARCReportEmail
}
rows := make([]domainRow, len(domains)) rows := make([]domainRow, len(domains))
var wg sync.WaitGroup var wg sync.WaitGroup
for i, d := range domains { for i, d := range domains {
@@ -69,7 +73,7 @@ func (s *Server) domainRows(domains []store.Domain) []domainRow {
logf("panel: dashboard: domain %d: dkim record: %v", d.ID, err) logf("panel: dashboard: domain %d: dkim record: %v", d.ID, err)
return return
} }
dns, _ := s.domainDNS(d, record, false) dns, _ := s.domainDNS(d, record, profileEmail, false)
rows[i].DNS = dns.Overall rows[i].DNS = dns.Overall
}() }()
} }
+38 -2
View File
@@ -23,10 +23,46 @@
<label for="new_password_confirm">Confirm new password</label> <label for="new_password_confirm">Confirm new password</label>
<input id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password"> <input id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password">
<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}}
<button type="submit">Save changes</button> <button type="submit">Save changes</button>
</form> </form>
<p class="muted">Leave both new-password fields empty to change the username <p class="muted">Leave both new-password fields empty to change the username
only. Changing the password signs out every other session; this one stays or DMARC address only. Changing the password signs out every other session;
signed in.</p> this one stays signed in.</p>
</div> </div>
{{end}} {{end}}
+53 -11
View File
@@ -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>
+44
View File
@@ -105,3 +105,47 @@ func validateDomainLabel(label string) error {
} }
return nil 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": {},
}
// validateEmail checks a DMARC rua= mailbox. Empty is allowed (policy-only).
func validateEmail(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 := validateDomain(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
}
+18
View File
@@ -0,0 +1,18 @@
package web
import "testing"
func TestValidateEmail(t *testing.T) {
if err := validateEmail(""); err != nil {
t.Errorf("empty: %v", err)
}
if err := validateEmail("reports@mail.example.com"); err != nil {
t.Errorf("valid: %v", err)
}
if err := validateEmail("bad"); err == nil {
t.Error("bad address accepted")
}
if err := validateEmail("x@gmail.com"); err == nil {
t.Error("gmail accepted")
}
}
+16 -14
View File
@@ -1,4 +1,4 @@
// Package web implements the SelfPost control panel's HTTP surface: the // Package web implements the SelfPost control panel's HTTP surface: the
// one-time administrator setup flow (security.md), login/session handling // one-time administrator setup flow (security.md), login/session handling
// (security.md) and the authenticated shell the later phases build on. // (security.md) and the authenticated shell the later phases build on.
package web package web
@@ -24,7 +24,7 @@ var assetsFS embed.FS
// Config holds the panel's HTTP-facing configuration. // Config holds the panel's HTTP-facing configuration.
type Config struct { type Config struct {
// Hostname is the server's external hostname, used to build the absolute // Hostname is the server's external hostname, used to build the absolute
// setup link shown in the logs (security.md; guide § Environment // setup link shown in the logs (security.md; guide В§ Environment
// variables for SELFPOST_HOSTNAME). // variables for SELFPOST_HOSTNAME).
Hostname string Hostname string
// CookieSecure sets the Secure attribute on the session cookie. It defaults // CookieSecure sets the Secure attribute on the session cookie. It defaults
@@ -33,16 +33,16 @@ type Config struct {
CookieSecure bool CookieSecure bool
// SubmissionEnabled mirrors SUBMISSION_ENABLE: whether this deployment also // SubmissionEnabled mirrors SUBMISSION_ENABLE: whether this deployment also
// runs the 587/STARTTLS submission listener next to the primary 465 one // runs the 587/STARTTLS submission listener next to the primary 465 one
// (architecture.md § Mail path). The panel only reports it on the domain // (architecture.md В§ Mail path). The panel only reports it on the domain
// page's connection settings; it is a deploy-time flag, not something the // page's connection settings; it is a deploy-time flag, not something the
// panel can verify. // panel can verify.
SubmissionEnabled bool SubmissionEnabled bool
// MailLogPath is where Postfix's delivery log lives, read by the mail.log // MailLogPath is where Postfix's delivery log lives, read by the mail.log
// monitoring view (architecture.md § Panel HTTP surface). It is the same path // monitoring view (architecture.md В§ Panel HTTP surface). It is the same path
// the log-tailer role follows in cmd/panel. // the log-tailer role follows in cmd/panel.
MailLogPath string MailLogPath string
// DataDir and DBPath locate the persistent state a full backup archives // DataDir and DBPath locate the persistent state a full backup archives
// (architecture.md § Persistence); Version is stamped into the backup // (architecture.md В§ Persistence); Version is stamped into the backup
// manifest. They mirror the panel's own configuration. // manifest. They mirror the panel's own configuration.
DataDir string DataDir string
DBPath string DBPath string
@@ -53,7 +53,7 @@ type Config struct {
// honoured, so the header can't be spoofed by anyone but a trusted proxy. // honoured, so the header can't be spoofed by anyone but a trusted proxy.
// Empty (the default) keeps rate-limiting keyed on RemoteAddr only. // Empty (the default) keeps rate-limiting keyed on RemoteAddr only.
TrustedProxyCIDRs []*net.IPNet TrustedProxyCIDRs []*net.IPNet
// TLSCertFile is the certificate Postfix serves on 465/587 (guide § // TLSCertFile is the certificate Postfix serves on 465/587 (guide В§
// Environment variables), read read-only by the status page to report how // Environment variables), read read-only by the status page to report how
// much validity is left. // much validity is left.
TLSCertFile string TLSCertFile string
@@ -69,8 +69,8 @@ type Config struct {
SessionIdleDays int SessionIdleDays int
// DNSResolvers are the recursive resolvers the deliverability checks query // DNSResolvers are the recursive resolvers the deliverability checks query
// (env SELFPOST_DNS_RESOLVERS). Empty uses dnscheck.DefaultResolvers. The // (env SELFPOST_DNS_RESOLVERS). Empty uses dnscheck.DefaultResolvers. The
// checks must not go through the system resolver see dnscheck's // checks must not go through the system resolver — see dnscheck's
// externalResolver so this is how a closed network points them at its own. // externalResolver — so this is how a closed network points them at its own.
DNSResolvers []string DNSResolvers []string
} }
@@ -98,9 +98,9 @@ type Server struct {
// New builds the panel server. setupTokenPath is where the current setup token // New builds the panel server. setupTokenPath is where the current setup token
// is mirrored on disk (security.md); domains is the sending-domain service // is mirrored on disk (security.md); domains is the sending-domain service
// that owns DKIM keys and the OpenDKIM tables (architecture.md § OpenDKIM); // that owns DKIM keys and the OpenDKIM tables (architecture.md В§ OpenDKIM);
// apps owns application SASL accounts and the Postfix sender map // apps owns application SASL accounts and the Postfix sender map
// (architecture.md § Mail path). // (architecture.md В§ Mail path).
func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config, setupTokenPath string) (*Server, error) { func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config, setupTokenPath string) (*Server, error) {
tmpl, err := loadTemplates() tmpl, err := loadTemplates()
if err != nil { if err != nil {
@@ -183,6 +183,7 @@ func (s *Server) Handler() http.Handler {
authed.HandleFunc("POST /domains/{id}/delete", s.handleDeleteDomain) authed.HandleFunc("POST /domains/{id}/delete", s.handleDeleteDomain)
authed.HandleFunc("POST /domains/{id}/applications", s.handleAddApplication) authed.HandleFunc("POST /domains/{id}/applications", s.handleAddApplication)
authed.HandleFunc("POST /domains/{id}/ratelimit", s.handleDomainRateLimit) authed.HandleFunc("POST /domains/{id}/ratelimit", s.handleDomainRateLimit)
authed.HandleFunc("POST /domains/{id}/dmarc", s.handleDomainDMARC)
authed.HandleFunc("POST /domains/{id}/export", s.handleExportDomain) authed.HandleFunc("POST /domains/{id}/export", s.handleExportDomain)
authed.HandleFunc("POST /applications/{aid}/mode", s.handleUpdateAppMode) authed.HandleFunc("POST /applications/{aid}/mode", s.handleUpdateAppMode)
authed.HandleFunc("POST /applications/{aid}/password", s.handleRegenPassword) authed.HandleFunc("POST /applications/{aid}/password", s.handleRegenPassword)
@@ -193,13 +194,13 @@ func (s *Server) Handler() http.Handler {
// Administrator's own panel credentials. // Administrator's own panel credentials.
authed.HandleFunc("/account", s.handleAccount) authed.HandleFunc("/account", s.handleAccount)
// Backup and migration: the page with both actions (architecture.md § // Backup and migration: the page with both actions (architecture.md В§
// Persistence-B), and the full-server backup download itself. // Persistence-B), and the full-server backup download itself.
authed.HandleFunc("GET /backup", s.handleBackupPage) authed.HandleFunc("GET /backup", s.handleBackupPage)
authed.HandleFunc("POST /backup", s.handleBackup) authed.HandleFunc("POST /backup", s.handleBackup)
// Monitoring screens (architecture.md § Panel HTTP surface): each page and // Monitoring screens (architecture.md В§ Panel HTTP surface): each page and
// its HTMX polling fragment (architecture.md § Panel HTTP surface the /rows // its HTMX polling fragment (architecture.md В§ Panel HTTP surface — the /rows
// and /body endpoints return HTML, not JSON). // and /body endpoints return HTML, not JSON).
authed.HandleFunc("GET /deliveries", s.handleDeliveries) authed.HandleFunc("GET /deliveries", s.handleDeliveries)
authed.HandleFunc("GET /deliveries/rows", s.handleDeliveriesRows) authed.HandleFunc("GET /deliveries/rows", s.handleDeliveriesRows)
@@ -234,7 +235,7 @@ func handleHealth(w http.ResponseWriter, _ *http.Request) {
// clientIP extracts the peer IP for rate-limiting. By default it is the // clientIP extracts the peer IP for rate-limiting. By default it is the
// transport peer (RemoteAddr), which cannot be spoofed. If RemoteAddr matches // transport peer (RemoteAddr), which cannot be spoofed. If RemoteAddr matches
// one of trustedProxies, the last entry of X-Forwarded-For is used instead // 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 // that is the address the trusted proxy itself appended, so a client can't
// forge it by sending its own XFF header. With no trusted // forge it by sending its own XFF header. With no trusted
// proxies configured, behind a reverse proxy this is the proxy's own address, // proxies configured, behind a reverse proxy this is the proxy's own address,
@@ -272,3 +273,4 @@ func ipInAny(ip net.IP, nets []*net.IPNet) bool {
func logf(format string, args ...any) { func logf(format string, args ...any) {
log.Printf(format, args...) log.Printf(format, args...)
} }