Fix DMARC guidance for send-only relays with optional rua= settings.
test / test (push) Has been cancelled
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:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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") != "":
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user