diff --git a/CHANGELOG.md b/CHANGELOG.md index a48d6a0..944c19f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,8 +29,28 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version mockups too, and its button takes the same hover, focus and press tint as Account and Help (owner, 2026-10-09). +- panel-redesign (stage 1): the 2.0 schema. `users.email` is the user's own + address; the default DMARC report address is a user's setting + (`dmarc_default_mode` / `dmarc_default_address`) and a domain either has an + address of its own or follows a named user's default + (`domains.dmarc_rua_user_id`) — a new domain follows whoever created it, + and deleting that user leaves it with no report address. + `user_inbound_domains` and the per-user `all_domains` / + `all_inbound_domains` flags are in the schema for the inbound delegation + that follows. Details: `docs/schema-migrations.md`. + ### Changed +- **Breaking — the database starts from zero.** The 1.x migration chain + (`0001`–`0010`) is replaced by one baseline, `0001_init.sql`. A 1.x + `/data/selfpost.db` is refused at start-up with an explicit error and left + untouched; nothing is migrated. A domain can be carried over with its + export file. +- The panel role `domain_admin` is stored as `domain`. +- Until the Account page replaces it, the old Settings form's "default report + address" field sets the account e-mail and switches the user's DMARC + default between that e-mail and none; a domain's "Same as Settings" makes + it follow the user who chose it. - The old panel is untouched on screen but steps aside in the tree: its stylesheet is `legacy.css` and its layout `layout_legacy.html` until the last old page is restyled. The 2.0 menu paths in the new shell do not @@ -38,6 +58,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version - panel-redesign is `agreed` (owner, 2026-10-09); work starts with stage 0, the component kit. +### Removed + +- The instance setting `dmarc_report_email` (a mirror of the global user's + profile field) and the unused `rate_limits.allowed_ips` column. + ## [1.9.5] - 2026-10-09 CI-only cut: the release workflow no longer breaks the multi-arch image it diff --git a/docs/plans/panel-redesign.md b/docs/plans/panel-redesign.md index c8d005d..ca209cd 100644 --- a/docs/plans/panel-redesign.md +++ b/docs/plans/panel-redesign.md @@ -505,7 +505,7 @@ only when both are done. Every stage-2 step ends with the evidence of **Stage 1 — data and routes** (no template work; parallel with stage 0) -- [ ] Schema from zero: single `0001_init.sql` baseline with `users.email`, the DMARC default fields, `user_inbound_domains`, the two `all_*` flags; store tests; `schema-migrations.md` rewritten — **Opus** +- [x] Schema from zero: single `0001_init.sql` baseline with `users.email`, the DMARC default fields, `user_inbound_domains`, the two `all_*` flags; store tests; `schema-migrations.md` rewritten — **Opus** - [ ] Inbound delegation: `requireInboundDomain`, lists and log filtered in the query, two-list user form data, per-route 404 tests for both roles including another tenant's id — **Opus** - [ ] Routes renamed per § Routes: new paths, one fragment name, old paths removed, subtree `requireGlobal()` for `/server/`; links in the old templates retargeted so the panel works on every commit — **Opus** - [ ] The application form as one POST; `out-app-created` as the response with `Cache-Control: no-store` — **Opus** diff --git a/docs/roadmap.md b/docs/roadmap.md index 050cfb9..0397dcc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -39,8 +39,8 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md). | panel-notifications | E-mail notifications about important events | candidate | — | — | | delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | candidate | — | — | | password-reset | Password reset by e-mail | candidate | — | — | -| panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 10/27 | [plans/panel-redesign.md](plans/panel-redesign.md) | -| schema-squash | Squash SQLite migrations into a 2.x baseline | **2.x** | — | — | +| panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 11/27 | [plans/panel-redesign.md](plans/panel-redesign.md) | +| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) | **Recommended order** (not binding; owner, 2026-10-09: feature order follows development convenience): the next stage is **panel-redesign** (`2.0.0`), @@ -469,6 +469,12 @@ zero and 2.0 starts from an empty data directory. The [plans/panel-redesign.md](plans/panel-redesign.md) § No compatibility; this item ships as a step of that plan, not on its own. +**Built 2026-10-09** (panel-redesign stage 1): `internal/store/migrations/` +holds one baseline, `0001_init.sql`. The gate that survives is a refusal, not +an upgrade: `store.Open` rejects a database that was not created by 2.x +(`ErrForeignSchema`, tested), so a 1.x file is never half-read — the "missed +gate" risk below. See [schema-migrations.md](schema-migrations.md). + **Dependencies / risks:** a decided 2.x cut (another breaking change, or an explicit major). Squashing the current short chain is not a reason to cut 2.x on its own. A missed gate leaves a mid-chain 1.x database silently stuck. diff --git a/docs/schema-migrations.md b/docs/schema-migrations.md index d96ad00..5abf614 100644 --- a/docs/schema-migrations.md +++ b/docs/schema-migrations.md @@ -1,16 +1,15 @@ # SQLite schema and migrations -**What this file is.** A living reference for the embedded SQLite migration chain, -historical legacy that still runs on fresh 1.x installs, and the planned 2.x -schema squash. When you add or review a migration, update this file in the same -change. +**What this file is.** A living reference for the embedded SQLite schema: the +2.0 baseline, the migrations added after it, and how a database is opened. +When you add or review a migration, update this file in the same change. **Source of truth in code:** `internal/store/migrations/*.sql`, applied by `internal/store/store.go` (`migrate()`). **Related:** [architecture.md](architecture.md) § Persistence; -[roadmap.md](roadmap.md) § schema-squash; [development.md](development.md) § -Documentation. +[plans/panel-redesign.md](plans/panel-redesign.md) § No compatibility; +[development.md](development.md) § Documentation. --- @@ -18,14 +17,35 @@ Documentation. | Field | Value | |---|---| -| Chain head | `user_version = 10` | -| Files | `0001_init.sql` … `0010_send_log_app_login_index.sql` (10 files) | +| Chain head | `user_version = 1` | +| Files | `0001_init.sql` (the 2.0 baseline) | +| File mark | `PRAGMA application_id = 0x53503230` ("SP20") | | Database file | `/data/selfpost.db` (bind mount) | -| Compatibility | 1.x MINOR releases must boot a `1.0.0` data directory | +| Compatibility | 2.x MINOR releases must boot a `2.0.0` data directory. **Nothing is carried over from 1.x.** | -Last updated in **[1.9.2]** (`0010_send_log_app_login_index`); **1.9.1** -had no schema change. The release column below records the cut a migration -shipped in. +--- + +## 2.0 starts from an empty data directory + +Decided by the owner on 2026-09-21: the redesign is a breaking release, the +1.x migration chain (`0001` … `0010`, with its `admin` table created in the +first file and dropped in the fifth) is deleted from the binary, and there is +no upgrade path. The only live 1.x installation is the owner's and is set up +again on 2.0. The 1.x chain stays in git history (last present at `1.9.5`). + +What that means in practice: + +- A fresh `/data` gets the baseline and nothing else. +- **A 1.x database is refused, not migrated.** `store.Open` returns + `ErrForeignSchema` for a file that has a schema version but not the 2.x + `application_id`. Without that check a 1.x file at `user_version = 10` + would read as "already migrated" and the panel would start on tables it + does not know. The file is not modified. The same applies to restoring a + 1.x full backup into a 2.0 container. +- A database whose version is higher than the build knows is refused too. +- Domains can still be carried by hand: a 1.x **domain export** (JSON, with + the DKIM key and applications) imports into 2.0 — the transfer format is + not the database. --- @@ -38,17 +58,12 @@ shipped in. 4. Each pending migration runs in its own transaction, then bumps `user_version`. 5. There is no down-migration; fixes ship as a new `00NN_*.sql` file. -```text -0001_init.sql → user_version 1 -0002_sessions.sql → user_version 2 -… -0009_application_auth_ips.sql → user_version 9 -0010_send_log_app_login_index.sql → user_version 10 -``` - -**Implication:** never delete, rename, or reorder migration files while 1.x must -stay compatible with `1.0.0`. Git history keeps old files; only a 2.x cut may -replace the embedded set (see [Planned 2.x squash](#planned-2x-squash)). +**Implication:** once `2.0.0` is released, `0001_init.sql` is never edited +again and no file is deleted, renamed or reordered — every 2.x database has +already run it. Until that release the baseline is still being written: +later steps of the redesign and the features queued behind it (the next one +is `0002_inbound_spam_log.sql` of inbound-antispam-panel, `2.1.0`) add files +after it. --- @@ -56,152 +71,72 @@ replace the embedded set (see [Planned 2.x squash](#planned-2x-squash)). | Ver | File | Shipped | Kind | Summary | |-----|------|---------|------|---------| -| 1 | `0001_init.sql` | ≤ 1.0.0 | DDL | Core schema: `admin`, `settings`, `domains`, `applications`, `application_addresses`, `send_log`, `rate_limits` | -| 2 | `0002_sessions.sql` | ≤ 1.0.0 | DDL | `sessions` (token hash, sliding idle expiry) | -| 3 | `0003_logtail_state.sql` | 0.5.0 | DDL | `logtail_state` (log-tailer read offset + fingerprint) | -| 4 | `0004_dmarc_report_email.sql` | 1.1.0 | DDL | `admin.dmarc_report_email`, `domains.dmarc_rua` | -| 5 | `0005_panel_users.sql` | 1.2.0 | DDL + data | `users`, `user_domains`; migrate single `admin` row; copy profile `dmarc_report_email` to `settings`; **DROP `admin`** | -| 6 | `0006_inbound_relay.sql` | 1.4.0 | DDL | `inbound_domains`, `inbound_transports`, `inbound_recipients` | -| 7 | `0007_rate_limit_auto.sql` | 1.6.0 | DDL | `rate_limits.mode`, `auto_multiplier`, `auto_updated_at` | -| 8 | `0008_dmarc_reports.sql` | 1.7.0 | DDL | `dmarc_reports`, `dmarc_report_records` | -| 9 | `0009_application_auth_ips.sql` | 1.9.0 | DDL + data | `applications.auth_ip_restrict`, `auth_allowed_ips`; move legacy app `rate_limits.allowed_ips` into auth columns; clear those IPs on rate limits | -| 10 | `0010_send_log_app_login_index.sql` | 1.9.2 | DDL | `idx_send_log_app_login_created_at` — app-scoped rate-limit counts and per-application send statistics were scanning the `created_at` range | +| 1 | `0001_init.sql` | 2.0.0 | DDL | The whole 2.0 schema, below | **Kind:** *DDL* — schema only; *data* — `INSERT`/`UPDATE` that must stay correct -for operators upgrading from older 1.x images. - -The feature plans that introduced schema work (`inbound-relay`, -`domain-stats-auto-ratelimit`, `dmarc-reports`) were deleted once they -shipped; what each migration was for is the table above, and the release it -came with is in [CHANGELOG.md](../CHANGELOG.md). +for operators upgrading from older 2.x images. --- -## Schema after head (v10) +## Schema at head (v1) -Tables present in a fully migrated database: +| Table | Role | +|-------|------| +| `settings` | Key/value settings of the instance — what is true whoever is signed in (send-log retention). Nothing that belongs to a user | +| `users` | Panel logins. `role` is `global` or `domain`; `email` is the user's own address; `dmarc_default_mode` / `dmarc_default_address` are their default DMARC report address; `all_domains` / `all_inbound_domains` widen a `domain` user to every domain of that direction | +| `sessions` | Panel login sessions (token hash, sliding idle expiry) | +| `domains` | Sending (outbound) domains. `dmarc_rua` / `dmarc_rua_user_id` say where aggregate reports go | +| `user_domains` | Outbound domains assigned to a `domain` user | +| `applications` | SASL applications per domain, with the client-IP restriction (`auth_ip_restrict`, `auth_allowed_ips`) | +| `application_addresses` | Explicit From addresses (`list` mode) | +| `rate_limits` | Level-2 limits per domain / application, manual or auto | +| `send_log` | Delivery journal | +| `logtail_state` | Log-tailer read position | +| `inbound_domains` | Inbound relay domains | +| `inbound_transports` | Upstream host / port / TLS per inbound domain | +| `inbound_recipients` | Allow-list when `recipient_mode = list` | +| `user_inbound_domains` | Inbound domains assigned to a `domain` user — granted separately from the outbound ones | +| `dmarc_reports` | Parsed aggregate report summaries | +| `dmarc_report_records` | Per-source rows inside a report | -| Table | Introduced | Role | -|-------|------------|------| -| `settings` | 0001 | Key/value panel settings (retention, profile flags) | -| `domains` | 0001 (+ `dmarc_rua` in 0004) | Sending domains | -| `applications` | 0001 (+ auth IP cols in 0009) | SASL applications per domain | -| `application_addresses` | 0001 | Explicit From addresses (`list` mode) | -| `send_log` | 0001 | Delivery journal | -| `rate_limits` | 0001 (+ auto cols in 0007) | Level-2 limits per domain/application | -| `sessions` | 0002 | Panel login sessions | -| `logtail_state` | 0003 | Log-tailer persistence | -| `users` | 0005 | Panel users (`global`, `domain_admin`) | -| `user_domains` | 0005 | Domain-admin assignments | -| `inbound_domains` | 0006 | Inbound relay domains | -| `inbound_transports` | 0006 | Upstream host/port/TLS per inbound domain | -| `inbound_recipients` | 0006 | Allow-list when `recipient_mode = list` | -| `dmarc_reports` | 0008 | Parsed aggregate report summaries | -| `dmarc_report_records` | 0008 | Per-source rows inside a report | +`TestBaselineSchema` (`internal/store/schema_test.go`) pins every table, column +and index of this list. -**Not present after v10:** `admin` (dropped in 0005). +### What changed against the last 1.x schema + +| 1.x | 2.0 | Why | +|---|---|---| +| `admin` created in 0001, dropped in 0005 | never exists | the reason for the squash | +| `users.role` = `global` / `domain_admin` | `global` / `domain` | the role is a reach, not a rank — everyone in the panel is an administrator | +| `users.dmarc_report_email` | `users.email` | a user's e-mail is theirs; it is what the panel writes to, and DMARC only *may* use it | +| instance setting `dmarc_report_email` (mirror of the global user's field) | `users.dmarc_default_mode`, `users.dmarc_default_address` | the default report address is a user's setting, not the server's | +| `domains.dmarc_rua` NULL = inherit the instance default, `''` = none | `domains.dmarc_rua_user_id` set = follows that user's default; otherwise `dmarc_rua` is the address, `''` = none | a domain can have several users, so "the default" has to be someone's | +| — | `user_inbound_domains` | inbound is delegated per user, separately from outbound | +| — | `users.all_domains`, `users.all_inbound_domains` | "All" includes domains added later | +| `rate_limits.allowed_ips` (unused since 1.9.0) | gone | client IPs belong to the application (`auth_allowed_ips`) | + +### Where a domain's DMARC reports go + +Resolved in one place, `Store.DomainDMARCRua`: + +1. `domains.dmarc_rua_user_id` is set → the domain follows that user's default + (`DMARCDefault.Resolve`): `hosted` → SelfPost's own mailbox for the domain + (no reports while ingest is off), `account` → `users.email`, `custom` → + `users.dmarc_default_address`, `none` → no reports. +2. Otherwise `domains.dmarc_rua` is the address itself; `''` means a + policy-only record. + +A new domain follows whoever created it. Deleting the followed user sets +`dmarc_rua_user_id` to NULL (`ON DELETE SET NULL`), which leaves the domain at +"no reports" — never at another user's default. --- -## Legacy and fresh-install artefacts +## Adding a migration -These are intentional in 1.x; they are the main motivation for -[schema-squash](roadmap.md#schema-squash) at 2.x. - -### `admin` table (0001 → 0005) - -On a **new** 1.x data directory the chain still: - -1. Creates `admin` (0001), -2. Adds `admin.dmarc_report_email` (0004), -3. Copies the row into `users` and drops `admin` (0005). - -Functionally harmless; confusing when reading migrations or inferring schema from -code. A 2.x baseline should define `users` directly and omit `admin`. - -### Application trusted IPs (0009) - -Before 1.9.0, client IPs for an application lived in `rate_limits.allowed_ips` -(`scope = application`). Migration 0009 copies non-empty values into -`applications.auth_allowed_ips`, sets `auth_ip_restrict = 1`, and clears -`rate_limits.allowed_ips` for application scope. Level-2 limits no longer carry -IP bindings; auth and rate limiting are separate concerns. - -Note that the two columns do not mean the same thing: the old list was -*permissive* (those IPs got the application ceiling, every other IP fell back -to the domain limit), the new one is *restrictive* (every IP not on the list is -refused for that application). The conversion branch only fires for a database -that carried application-scope `allowed_ips` from before 1.9.0. - -### Profile DMARC email (0004 → 0005) - -`0004` adds `admin.dmarc_report_email`. `0005` copies it into the migrated global -`users` row and into `settings` key `dmarc_report_email`. Per-domain overrides -remain on `domains.dmarc_rua`. - -### Documentation references to migration numbers - -Other docs cite migrations by number (e.g. architecture § sessions → `0002`). -After a 2.x squash, those references remain valid for **upgrade history** and -git; fresh 2.x installs only run the baseline plus post-2.x files. - ---- - -## Rules for 1.x changes - -1. **Add** the next `00NN_descriptive_name.sql`; do not edit shipped migrations. -2. **Bump this file:** snapshot table, chain row, schema table if needed. -3. **CHANGELOG** under the release that ships the migration. -4. **Operator impact:** if upgrade behaviour matters, note it in [guide.md](guide.md). -5. **Backup manifest** is separate: restore requires matching binary version - ([architecture.md](architecture.md) § Persistence); it does not replace - running pending SQLite migrations. - -### Checklist for a new migration - -- [ ] File name is next integer, zero-padded four digits. -- [ ] SQL is idempotent in spirit (runs once per DB; guard with schema state, not - “IF NOT EXISTS” everywhere unless needed). -- [ ] Data migrations handle empty/partial state (e.g. no `admin` row on re-run is - impossible; mid-upgrade failure is recovered by re-running the same file - only if the transaction failed before `user_version` bump). -- [ ] Row added to [Migration chain](#migration-chain) and [Current snapshot](#current-snapshot). -- [ ] `go test ./...` (store and dependents). - ---- - -## Planned 2.x squash - -Tracked as [schema-squash](roadmap.md#schema-squash). **Not** a reason to cut 2.x -on its own — only bundled with another breaking change or an explicit major. - -**Goal:** embed one baseline SQL file equal to the schema at whatever the 1.x -chain head is when the squash ships (`v10` today), instead of the full 1.x -chain. Fresh 2.x `/data` directories skip create-then-drop `admin`. - -**Upgrade gate (required when squash ships):** - -| `user_version` | 2.x behaviour | -|---|---| -| `0` (empty DB) | Apply baseline; set `user_version` to new chain head | -| `>= N` (fully migrated 1.x) | Skip; schema already matches baseline | -| `1`…`N-1` (mid-chain 1.x) | **Refuse to start** — run the last 1.x image once, then 2.x | - -`N` is the chain head at cut time (`10` today). - -**Done when:** baseline embedded; gate tested; [guide.md](guide.md) states that -2.x will not open an unfinished 1.x database. - ---- - -## Restore vs migrate - -| Mechanism | What it checks | -|-----------|----------------| -| SQLite `user_version` | Which embedded migrations have run on this `selfpost.db` | -| Backup `manifest.json` | Binary/image version after full restore | - -An operator can have a matching manifest after restore but still need migrations -if they restored an older DB snapshot with a newer binary — normal `migrate()` -applies pending files. The 2.x gate adds a **refusal** for half-upgraded 1.x DBs -when the old chain is no longer embedded. +1. Add `internal/store/migrations/00NN_short_name.sql` (next number). +2. Update the table in [Migration chain](#migration-chain), the snapshot at the + top, and the affected rows of [Schema at head](#schema-at-head-v1). +3. Update `TestBaselineSchema` if tables, columns or indexes change, and add a + test that opens a database at the previous version and migrates it. +4. A *data* migration states in a comment which 2.x versions it is written for. diff --git a/internal/app/service.go b/internal/app/service.go index 8ebb5ae..43227f7 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -205,7 +205,6 @@ func (s *Service) RateLimit(appID int64) (store.RateLimit, bool, error) { func (s *Service) SaveRateLimit(appID int64, rl store.RateLimit) error { rl.Scope = store.RateLimitScopeApp rl.RefID = appID - rl.AllowedIPs = nil return s.store.SetRateLimit(rl) } diff --git a/internal/dmarc/service.go b/internal/dmarc/service.go index 4af04ca..7b1baa0 100644 --- a/internal/dmarc/service.go +++ b/internal/dmarc/service.go @@ -54,10 +54,6 @@ func (s *Service) AllowedRecipients() ([]string, error) { if !s.enabled || s.hostname == "" { return nil, nil } - profile, err := s.store.GlobalDMARCReportEmail() - if err != nil { - return nil, err - } seen := make(map[string]bool) var out []string add := func(addr string) { @@ -68,8 +64,24 @@ func (s *Service) AllowedRecipients() ([]string, error) { seen[addr] = true out = append(out, addr) } - add(profile) - if profile == "" { + // A user's own default that points at this host is accepted even before a + // domain follows it, so the address can be published first. With no such + // default anywhere, the instance's generic mailbox stays open — the address + // the guide tells a fresh install to publish. + users, err := s.store.ListUsers() + if err != nil { + return nil, err + } + anyDefault := false + for _, u := range users { + if def := u.DMARCDefault(); def.Mode == store.DMARCDefaultAccount || def.Mode == store.DMARCDefaultCustom { + if addr := def.Resolve(""); addr != "" { + anyDefault = true + add(addr) + } + } + } + if !anyDefault { add(DefaultHostedReportAddress(s.hostname)) } domains, err := s.store.ListDomains() @@ -77,21 +89,13 @@ func (s *Service) AllowedRecipients() ([]string, error) { return nil, err } for _, d := range domains { - rua := dnscheck.ResolveDMARCRua(d.DMARCRua, profile) - if rua == "" { - continue - } - if IsHostedOnHostname(rua, s.hostname) { - add(rua) - continue - } - if d.DMARCRua.Valid && d.DMARCRua.String == "" { - continue - } - hosted := HostedReportAddress(s.hostname, d.Name) - if strings.EqualFold(rua, hosted) { - add(rua) + // add keeps only addresses on this host, so a domain reporting elsewhere + // (or nowhere) opens nothing here. + rua, err := s.store.DomainDMARCRua(d, HostedReportAddress(s.hostname, d.Name)) + if err != nil { + return nil, err } + add(rua) } sort.Strings(out) return out, nil diff --git a/internal/dnscheck/examples.go b/internal/dnscheck/examples.go index 7c3c9be..d19ce46 100644 --- a/internal/dnscheck/examples.go +++ b/internal/dnscheck/examples.go @@ -1,7 +1,6 @@ package dnscheck import ( - "database/sql" "net" "strings" ) @@ -48,15 +47,6 @@ func SPFExample(hostname string, serverIPs []string) string { return "v=spf1 " + strings.Join(mechanisms, " ") + " -all" } -// ResolveDMARCRua picks the rua= mailbox for a sending domain: per-domain -// override wins, then the administrator profile, then policy-only (empty). -func ResolveDMARCRua(domainRua sql.NullString, profileEmail string) string { - if domainRua.Valid { - return domainRua.String - } - return profileEmail -} - // EmailDomain returns the lower-case domain part of addr, or "" when invalid. func EmailDomain(addr string) string { addr = strings.TrimSpace(addr) diff --git a/internal/dnscheck/examples_test.go b/internal/dnscheck/examples_test.go index 8dae58b..11ebc56 100644 --- a/internal/dnscheck/examples_test.go +++ b/internal/dnscheck/examples_test.go @@ -2,7 +2,6 @@ package dnscheck import ( "context" - "database/sql" "strings" "testing" @@ -41,21 +40,6 @@ func TestDMARCExample(t *testing.T) { } } -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;" { diff --git a/internal/domain/service.go b/internal/domain/service.go index 59f731d..87e69be 100644 --- a/internal/domain/service.go +++ b/internal/domain/service.go @@ -146,11 +146,9 @@ func (s *Service) RateLimit(domainID int64) (store.RateLimit, bool, error) { // SaveRateLimit stores the domain-level rate limit. The caller has validated the // numbers (security.md); the milter reads the row live, so no reload is needed. -// Domain limits do not use an IP allowlist. func (s *Service) SaveRateLimit(domainID int64, rl store.RateLimit) error { rl.Scope = store.RateLimitScopeDomain rl.RefID = domainID - rl.AllowedIPs = nil return s.store.SetRateLimit(rl) } diff --git a/internal/domain/transfer.go b/internal/domain/transfer.go index a3b9d5f..07c9637 100644 --- a/internal/domain/transfer.go +++ b/internal/domain/transfer.go @@ -1,7 +1,6 @@ package domain import ( - "database/sql" "fmt" "github.com/mixeme/selfpost/internal/buildinfo" @@ -24,7 +23,7 @@ type DomainExport struct { Domain string `json:"domain"` DKIMSelector string `json:"dkim_selector"` DKIMPrivateKey string `json:"dkim_private_key"` // PKCS#1 PEM - DMARCRua *string `json:"dmarc_rua,omitempty"` // nil = inherit profile; set = override ("" = none) + DMARCRua *string `json:"dmarc_rua,omitempty"` // nil = follows a user's default; set = the domain's own address ("" = none) RateLimit *RateLimitExport `json:"rate_limit,omitempty"` Applications []AppExport `json:"applications"` } @@ -74,9 +73,11 @@ func (s *Service) Export(id int64) (DomainExport, error) { DKIMPrivateKey: string(pem), Applications: make([]AppExport, 0, len(apps)), } - if d.DMARCRua.Valid { - s := d.DMARCRua.String - exp.DMARCRua = &s + // A domain that follows a user's default exports no address: users do not + // travel with a domain, and whoever imports it becomes the one it follows. + if !d.DMARCRuaUserID.Valid { + rua := d.DMARCRua + exp.DMARCRua = &rua } rl, ok, err := s.store.GetRateLimit(store.RateLimitScopeDomain, id) if err != nil { @@ -147,11 +148,11 @@ func (s *Service) Import(exp DomainExport) (store.Domain, error) { } if exp.DMARCRua != nil { - if err := s.store.UpdateDomainDMARCRua(d.ID, sql.NullString{Valid: true, String: *exp.DMARCRua}); err != nil { + if err := s.store.SetDomainDMARCAddress(d.ID, *exp.DMARCRua); err != nil { s.importRollback(d.ID) return store.Domain{}, err } - d.DMARCRua = sql.NullString{Valid: true, String: *exp.DMARCRua} + d.DMARCRua = *exp.DMARCRua } if exp.RateLimit != nil { if err := s.importRateLimit(store.RateLimitScopeDomain, d.ID, *exp.RateLimit); err != nil { diff --git a/internal/domain/transfer_test.go b/internal/domain/transfer_test.go index ddf08cf..8cb5b67 100644 --- a/internal/domain/transfer_test.go +++ b/internal/domain/transfer_test.go @@ -1,7 +1,6 @@ package domain import ( - "database/sql" "errors" "fmt" "testing" @@ -73,7 +72,7 @@ func TestExportImportRoundTrip(t *testing.T) { if _, err := src.store.AddApplication(d.ID, "alerts", store.AddressModeList, []string{"a@example.com"}); err != nil { t.Fatalf("add alerts: %v", err) } - if err := src.store.UpdateDomainDMARCRua(d.ID, sql.NullString{Valid: true, String: "reports@hub.example"}); err != nil { + if err := src.store.SetDomainDMARCAddress(d.ID, "reports@hub.example"); err != nil { t.Fatalf("set dmarc rua: %v", err) } @@ -111,8 +110,8 @@ func TestExportImportRoundTrip(t *testing.T) { if got.Name != "example.com" || got.DKIMSelector != "selfpost" { t.Errorf("imported domain = %+v", got) } - if !got.DMARCRua.Valid || got.DMARCRua.String != "reports@hub.example" { - t.Errorf("imported dmarc rua = %+v", got.DMARCRua) + if got.DMARCRuaUserID.Valid || got.DMARCRua != "reports@hub.example" { + t.Errorf("imported dmarc rua = %q (follows user: %v)", got.DMARCRua, got.DMARCRuaUserID.Valid) } if exp.DMARCRua == nil || *exp.DMARCRua != "reports@hub.example" { t.Errorf("exported dmarc rua = %v", exp.DMARCRua) diff --git a/internal/milter/milter_test.go b/internal/milter/milter_test.go index 568e5b4..c38ea18 100644 --- a/internal/milter/milter_test.go +++ b/internal/milter/milter_test.go @@ -292,8 +292,7 @@ func TestRateLimitDomainAppliesToAnyIP(t *testing.T) { }, counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999}, } - // Domain ceilings apply to every client IP; leftover AllowedIPs on the row - // are ignored. + // Domain ceilings apply to every client IP. if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail { t.Fatalf("domain over limit from any IP = %v, want TempFail", resp) } @@ -362,7 +361,7 @@ func TestAuthIPRestrictOffAllowsAnyIP(t *testing.T) { func TestRateLimitInactiveWithoutCeiling(t *testing.T) { rec := &fakeRecorder{ limits: map[string]store.RateLimit{ - store.RateLimitScopeDomain + "|example.com": {AllowedIPs: []string{limitIP}}, // no max/window + store.RateLimitScopeDomain + "|example.com": {}, // no max/window }, counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999}, } diff --git a/internal/store/domains.go b/internal/store/domains.go index 6983f95..29c5b8d 100644 --- a/internal/store/domains.go +++ b/internal/store/domains.go @@ -25,9 +25,13 @@ type Domain struct { ID int64 Name string DKIMSelector string - DMARCRua sql.NullString // NULL = inherit profile; Valid+empty = no reports - CreatedAt time.Time - AppCount int + // Where DMARC aggregate reports go. With DMARCRuaUserID set the domain + // follows that user's default (DMARCDefault) and DMARCRua is ignored; + // otherwise DMARCRua is the address itself, "" meaning no reports. + DMARCRua string + DMARCRuaUserID sql.NullInt64 + CreatedAt time.Time + AppCount int } // AddDomain inserts a new sending domain. The caller is responsible for having @@ -56,7 +60,7 @@ func (s *Store) AddDomain(name, selector string) (Domain, error) { // ordered by name. func (s *Store) ListDomains() ([]Domain, error) { rows, err := s.db.Query(` - SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at, + SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.dmarc_rua_user_id, d.created_at, (SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id) FROM domains d ORDER BY d.name`) @@ -80,7 +84,7 @@ func (s *Store) ListDomains() ([]Domain, error) { // ordered by name. func (s *Store) ListDomainsForUser(userID int64) ([]Domain, error) { rows, err := s.db.Query(` - SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at, + SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.dmarc_rua_user_id, d.created_at, (SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id) FROM domains d INNER JOIN user_domains ud ON ud.domain_id = d.id @@ -106,7 +110,7 @@ func (s *Store) ListDomainsForUser(userID int64) ([]Domain, error) { // ErrDomainNotFound. func (s *Store) GetDomain(id int64) (Domain, error) { row := s.db.QueryRow(` - SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at, + SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.dmarc_rua_user_id, d.created_at, (SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id) FROM domains d WHERE d.id = ?`, id) @@ -148,7 +152,7 @@ func scanDomain(r scanRow) (Domain, error) { d Domain createdAt string ) - if err := r.Scan(&d.ID, &d.Name, &d.DKIMSelector, &d.DMARCRua, &createdAt, &d.AppCount); err != nil { + if err := r.Scan(&d.ID, &d.Name, &d.DKIMSelector, &d.DMARCRua, &d.DMARCRuaUserID, &createdAt, &d.AppCount); err != nil { return Domain{}, err } d.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) @@ -166,11 +170,21 @@ func isUniqueViolation(err error) bool { return false } -// UpdateDomainDMARCRua sets how this domain resolves its DMARC rua= destination. -// NULL means inherit the administrator profile; Valid with an empty string means -// policy-only with no aggregate reports for this domain. -func (s *Store) UpdateDomainDMARCRua(id int64, rua sql.NullString) error { - res, err := s.db.Exec("UPDATE domains SET dmarc_rua = ? WHERE id = ?", rua, id) +// SetDomainDMARCAddress gives the domain a report address of its own; "" means +// no aggregate reports. The domain stops following anyone's default. +func (s *Store) SetDomainDMARCAddress(id int64, rua string) error { + return s.updateDomainDMARC(id, rua, sql.NullInt64{}) +} + +// SetDomainDMARCUser makes the domain follow userID's default report address +// (DMARCDefault). If that user is later deleted the domain falls back to no +// reports rather than to someone else's default. +func (s *Store) SetDomainDMARCUser(id, userID int64) error { + return s.updateDomainDMARC(id, "", sql.NullInt64{Int64: userID, Valid: true}) +} + +func (s *Store) updateDomainDMARC(id int64, rua string, userID sql.NullInt64) error { + res, err := s.db.Exec("UPDATE domains SET dmarc_rua = ?, dmarc_rua_user_id = ? WHERE id = ?", rua, userID, id) if err != nil { return fmt.Errorf("update domain dmarc rua: %w", err) } @@ -184,6 +198,24 @@ func (s *Store) UpdateDomainDMARCRua(id int64, rua sql.NullString) error { return nil } +// DomainDMARCRua resolves where the domain's aggregate reports go: its own +// address, or the default of the user it follows. hosted is the SelfPost-hosted +// mailbox for this domain, "" when report ingest is off (the store does not +// know the server's hostname). The result is "" for a policy-only record. +func (s *Store) DomainDMARCRua(d Domain, hosted string) (string, error) { + if !d.DMARCRuaUserID.Valid { + return d.DMARCRua, nil + } + def, err := s.GetDMARCDefault(d.DMARCRuaUserID.Int64) + if errors.Is(err, ErrUserNotFound) { + return "", nil + } + if err != nil { + return "", err + } + return def.Resolve(hosted), nil +} + // DomainExists reports whether a sending domain with this name is configured. // Used by the DMARC ingest path to refuse reports for domains this relay does // not send for (security.md: report XML is attacker-supplied input). diff --git a/internal/store/domains_dmarc_test.go b/internal/store/domains_dmarc_test.go index e2798b1..5c173aa 100644 --- a/internal/store/domains_dmarc_test.go +++ b/internal/store/domains_dmarc_test.go @@ -1,11 +1,15 @@ package store import ( - "database/sql" + "errors" "testing" ) -func TestDomainDMARCRua(t *testing.T) { +const hostedForTest = "dmarc+example.com@mail.example.org" + +// A domain either has a report address of its own or follows a user's default; +// the two never mix, and a fresh domain has neither. +func TestDomainDMARCAddressAndUser(t *testing.T) { st := openTestStore(t) d, err := st.AddDomain("example.com", "sel") if err != nil { @@ -15,31 +19,123 @@ func TestDomainDMARCRua(t *testing.T) { if err != nil { t.Fatalf("GetDomain: %v", err) } - if got.DMARCRua.Valid { - t.Fatal("new domain should inherit profile") + if got.DMARCRua != "" || got.DMARCRuaUserID.Valid { + t.Fatalf("a new domain = %q / follows %v, want no reports", got.DMARCRua, got.DMARCRuaUserID) } - 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) + if err := st.SetDomainDMARCAddress(d.ID, "reports@hub.com"); err != nil { + t.Fatalf("SetDomainDMARCAddress: %v", err) } got, _ = st.GetDomain(d.ID) - if !got.DMARCRua.Valid || got.DMARCRua.String != "" { - t.Fatalf("none = %+v", got.DMARCRua) + if rua, err := st.DomainDMARCRua(got, hostedForTest); err != nil || rua != "reports@hub.com" { + t.Fatalf("own address resolves to %q, %v", rua, err) } - if err := st.UpdateDomainDMARCRua(d.ID, sql.NullString{}); err != nil { - t.Fatalf("UpdateDomainDMARCRua inherit: %v", err) + if err := st.CreateGlobalUser("admin", "hash"); err != nil { + t.Fatalf("CreateGlobalUser: %v", err) + } + u, _ := st.GetUserByUsername("admin") + if err := st.SetDomainDMARCUser(d.ID, u.ID); err != nil { + t.Fatalf("SetDomainDMARCUser: %v", err) } got, _ = st.GetDomain(d.ID) - if got.DMARCRua.Valid { - t.Fatalf("inherit = %+v", got.DMARCRua) + if got.DMARCRua != "" || !got.DMARCRuaUserID.Valid || got.DMARCRuaUserID.Int64 != u.ID { + t.Fatalf("following a user left %q / %v; the own address must be dropped", got.DMARCRua, got.DMARCRuaUserID) + } + + if err := st.SetDomainDMARCAddress(d.ID, ""); err != nil { + t.Fatalf("SetDomainDMARCAddress none: %v", err) + } + got, _ = st.GetDomain(d.ID) + if got.DMARCRua != "" || got.DMARCRuaUserID.Valid { + t.Fatalf("none = %q / %v", got.DMARCRua, got.DMARCRuaUserID) + } + + if err := st.SetDomainDMARCAddress(d.ID+100, "x@y.com"); !errors.Is(err, ErrDomainNotFound) { + t.Fatalf("unknown domain = %v, want ErrDomainNotFound", err) + } +} + +// A domain that follows a user resolves to whatever that user's default says +// today — every mode — and changes with it. +func TestDomainFollowsUserDefault(t *testing.T) { + st := openTestStore(t) + if err := st.CreateGlobalUser("admin", "hash"); err != nil { + t.Fatalf("CreateGlobalUser: %v", err) + } + u, _ := st.GetUserByUsername("admin") + if err := st.UpdateUser(u.ID, "admin", "hash", "mix@example.org"); err != nil { + t.Fatalf("UpdateUser: %v", err) + } + d, _ := st.AddDomain("example.com", "sel") + if err := st.SetDomainDMARCUser(d.ID, u.ID); err != nil { + t.Fatalf("SetDomainDMARCUser: %v", err) + } + d, _ = st.GetDomain(d.ID) + + for _, tc := range []struct { + mode, address string + hosted string + want string + }{ + {DMARCDefaultNone, "", hostedForTest, ""}, + {DMARCDefaultAccount, "", hostedForTest, "mix@example.org"}, + {DMARCDefaultCustom, "dmarc@hub.example", hostedForTest, "dmarc@hub.example"}, + {DMARCDefaultHosted, "", hostedForTest, hostedForTest}, + // Ingest switched off: a hosted default has no mailbox to point at. + {DMARCDefaultHosted, "", "", ""}, + // The custom address belongs to the custom mode only. + {DMARCDefaultAccount, "ignored@hub.example", hostedForTest, "mix@example.org"}, + } { + if err := st.SetDMARCDefault(u.ID, tc.mode, tc.address); err != nil { + t.Fatalf("SetDMARCDefault(%s): %v", tc.mode, err) + } + got, err := st.DomainDMARCRua(d, tc.hosted) + if err != nil || got != tc.want { + t.Errorf("mode %s, hosted %q: resolved %q, %v; want %q", tc.mode, tc.hosted, got, err, tc.want) + } + } + def, err := st.GetDMARCDefault(u.ID) + if err != nil || def.Address != "" || def.Username != "admin" { + t.Errorf("default after a non-custom mode = %+v, %v; the custom address must be cleared", def, err) + } + if err := st.SetDMARCDefault(u.ID, "profile", ""); err == nil { + t.Error("an unknown mode was stored") + } + if err := st.SetDMARCDefault(u.ID+100, DMARCDefaultNone, ""); !errors.Is(err, ErrUserNotFound) { + t.Errorf("unknown user = %v, want ErrUserNotFound", err) + } +} + +// Deleting the user a domain follows must leave the domain with no report +// address — not with another user's default, and not with a dangling id. +func TestDeletingTheFollowedUserLeavesNoReports(t *testing.T) { + st := openTestStore(t) + if err := st.CreateGlobalUser("admin", "hash"); err != nil { + t.Fatalf("CreateGlobalUser: %v", err) + } + d, _ := st.AddDomain("example.com", "sel") + id, err := st.CreateUser("second", "hash", RoleGlobal, nil) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + if err := st.SetDMARCDefault(id, DMARCDefaultCustom, "dmarc@hub.example"); err != nil { + t.Fatalf("SetDMARCDefault: %v", err) + } + if err := st.SetDomainDMARCUser(d.ID, id); err != nil { + t.Fatalf("SetDomainDMARCUser: %v", err) + } + if err := st.DeleteUser(id); err != nil { + t.Fatalf("DeleteUser: %v", err) + } + got, err := st.GetDomain(d.ID) + if err != nil { + t.Fatalf("GetDomain: %v", err) + } + if got.DMARCRuaUserID.Valid || got.DMARCRua != "" { + t.Fatalf("after deleting the followed user: %q / %v, want no reports", got.DMARCRua, got.DMARCRuaUserID) + } + if rua, err := st.DomainDMARCRua(got, hostedForTest); err != nil || rua != "" { + t.Fatalf("resolved %q, %v; want no reports", rua, err) } } diff --git a/internal/store/migrations/0001_init.sql b/internal/store/migrations/0001_init.sql index c6f31f6..fdbc1b6 100644 --- a/internal/store/migrations/0001_init.sql +++ b/internal/store/migrations/0001_init.sql @@ -1,39 +1,103 @@ --- Initial SelfPost schema. One SQLite file under /data holds the whole panel --- state so a single directory backup/restore is sufficient. +-- SelfPost 2.0 baseline schema. One SQLite file under /data holds the whole +-- panel state, so a single directory backup/restore is sufficient. +-- +-- This file IS the 2.0 schema: the 1.x chain (0001..0010, with its admin table +-- created and dropped again) was deleted, not squashed with an upgrade path — +-- 2.0 starts from an empty data directory (docs/schema-migrations.md). Later +-- changes ship as 0002_*.sql and onwards; this file is never edited again once +-- 2.0.0 is released. --- Single administrator account. Exactly one row is allowed; the presence of --- that row is what marks primary setup as complete, which is why the /setup --- route disappears once it exists. -CREATE TABLE admin ( - id INTEGER PRIMARY KEY CHECK (id = 1), - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - created_at TEXT NOT NULL -); +-- Marks the file as a 2.x SelfPost database ("SP20"). store.Open refuses a +-- database that has a schema version but not this mark, which is what a 1.x +-- file looks like: its user_version would otherwise read as "already migrated". +PRAGMA application_id = 1397764656; --- Free-form key/value panel settings (retention overrides, misc flags). +-- Free-form key/value settings of the instance: what is true whoever is signed +-- in (send-log retention). Nothing that belongs to a user lives here. CREATE TABLE settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); --- Sending domains managed through the panel. DKIM keys themselves live on disk --- under /data; this row records the selector and metadata. +-- Panel logins (not application SASL accounts). The presence of a row is what +-- marks primary setup as complete, which is why /setup disappears once one +-- exists. role is a reach, not a rank: 'global' sees the whole instance, +-- 'domain' only what is assigned below. +-- +-- email is the user's own address — what the panel writes to. The DMARC +-- default is a separate choice that may use it: dmarc_default_mode says where +-- aggregate reports go for a domain that follows this user +-- (domains.dmarc_rua_user_id): 'hosted' (SelfPost's own mailbox for that +-- domain), 'account' (email above), 'custom' (dmarc_default_address) or 'none'. +-- +-- all_domains / all_inbound_domains widen a 'domain' user's list to every +-- domain of that direction, including the ones added later; with a flag set +-- the rows of the matching assignment table are ignored. +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('global', 'domain')), + email TEXT NOT NULL DEFAULT '', + dmarc_default_mode TEXT NOT NULL DEFAULT 'none' + CHECK (dmarc_default_mode IN ('hosted', 'account', 'custom', 'none')), + dmarc_default_address TEXT NOT NULL DEFAULT '', + all_domains INTEGER NOT NULL DEFAULT 0 CHECK (all_domains IN (0, 1)), + all_inbound_domains INTEGER NOT NULL DEFAULT 0 CHECK (all_inbound_domains IN (0, 1)), + created_at TEXT NOT NULL +); + +-- Panel login sessions. Persisted so a login survives a container restart or +-- redeploy; only the SHA-256 of the session token is stored, never the token +-- itself, so a stolen database file cannot be used to sign in. expires_at +-- implements the sliding idle timeout: it is pushed forward on activity rather +-- than being fixed at creation time. +CREATE TABLE sessions ( + token_hash TEXT PRIMARY KEY, + username TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL +); +CREATE INDEX idx_sessions_expires_at ON sessions (expires_at); + +-- Sending (outbound) domains. DKIM keys themselves live on disk under /data; +-- this row records the selector and metadata. +-- +-- Where the domain's DMARC aggregate reports go (rua=): with dmarc_rua_user_id +-- set, the domain follows that user's default and dmarc_rua is ignored; +-- otherwise dmarc_rua is the address itself, '' meaning no reports. Deleting +-- the user leaves the domain at "none" rather than silently handing its +-- reports to someone else. CREATE TABLE domains ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - dkim_selector TEXT NOT NULL, - created_at TEXT NOT NULL + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + dkim_selector TEXT NOT NULL, + dmarc_rua TEXT NOT NULL DEFAULT '', + dmarc_rua_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL +); + +-- Outbound domains assigned to a 'domain' user. +CREATE TABLE user_domains ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, domain_id) ); -- Applications bound to a domain. address_mode is either the domain wildcard or -- an explicit address list; the SASL login is globally unique. +-- +-- auth_ip_restrict / auth_allowed_ips restrict which client IPs may submit as +-- the application at all (refused otherwise). That is independent of the +-- level-2 rate limits below. CREATE TABLE applications ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE, - login TEXT NOT NULL UNIQUE, - address_mode TEXT NOT NULL CHECK (address_mode IN ('wildcard', 'list')), - created_at TEXT NOT NULL + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE, + login TEXT NOT NULL UNIQUE, + address_mode TEXT NOT NULL CHECK (address_mode IN ('wildcard', 'list')), + auth_ip_restrict INTEGER NOT NULL DEFAULT 0, + auth_allowed_ips TEXT, + created_at TEXT NOT NULL ); -- Explicit sender addresses for applications in 'list' mode. Each address must @@ -45,6 +109,20 @@ CREATE TABLE application_addresses ( UNIQUE (application_id, address) ); +-- Level-2 rate limits per domain or application. In 'auto' mode max_messages +-- is derived from 30-day send statistics times auto_multiplier. +CREATE TABLE rate_limits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scope TEXT NOT NULL CHECK (scope IN ('domain', 'application')), + ref_id INTEGER NOT NULL, + max_messages INTEGER, + window_seconds INTEGER, + mode TEXT NOT NULL DEFAULT 'manual' CHECK (mode IN ('manual', 'auto')), + auto_multiplier REAL, + auto_updated_at TEXT, + UNIQUE (scope, ref_id) +); + -- Structured send log. One row per (queue-id, recipient); the log-tailer -- advances status from queued to a final state. CREATE TABLE send_log ( @@ -62,15 +140,91 @@ CREATE TABLE send_log ( CREATE INDEX idx_send_log_queue_id ON send_log (queue_id); CREATE INDEX idx_send_log_domain ON send_log (domain); CREATE INDEX idx_send_log_created_at ON send_log (created_at); +-- The journal milter counts messages for a level-2 limit on every MAIL FROM, +-- and the domain page computes 30-day statistics per application on every +-- render: equality on app_login, range on created_at. +CREATE INDEX idx_send_log_app_login_created_at ON send_log (app_login, created_at); --- Differentiated rate limits per domain/application. Both the IP binding and --- the message limit are optional. -CREATE TABLE rate_limits ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - scope TEXT NOT NULL CHECK (scope IN ('domain', 'application')), - ref_id INTEGER NOT NULL, - allowed_ips TEXT, - max_messages INTEGER, - window_seconds INTEGER, - UNIQUE (scope, ref_id) +-- Log-tailer read position. Without it the tailer starts at end-of-file on +-- every start, so delivery lines written while the panel was down are never +-- parsed and their send-log rows stay "queued" forever. One row per followed +-- path; fingerprint identifies the file the offset belongs to (the head bytes +-- of the log), so a rotated or recreated mail.log is detected across a restart, +-- where os.SameFile cannot help. +CREATE TABLE logtail_state ( + path TEXT PRIMARY KEY, + fingerprint TEXT NOT NULL, + read_offset INTEGER NOT NULL, + updated_at TEXT NOT NULL ); + +-- Optional inbound relay (backup-MX / forwarder). A separate entity from the +-- sending domains, even under the same name: these rows exist while +-- INBOUND_RELAY_ENABLE is false, but the listener, the Postfix maps and the +-- panel pages are generated only when that flag is on. +CREATE TABLE inbound_domains ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + recipient_mode TEXT NOT NULL CHECK (recipient_mode IN ('list', 'any')), + created_at TEXT NOT NULL +); + +-- One upstream per inbound domain (host:port + TLS policy for the hand-off). +CREATE TABLE inbound_transports ( + inbound_domain_id INTEGER PRIMARY KEY REFERENCES inbound_domains(id) ON DELETE CASCADE, + host TEXT NOT NULL, + port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535), + tls_mode TEXT NOT NULL CHECK (tls_mode IN ('may', 'encrypt', 'none')) +); + +-- Explicit recipients for recipient_mode = 'list'. Ignored when mode is 'any'. +CREATE TABLE inbound_recipients ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + inbound_domain_id INTEGER NOT NULL REFERENCES inbound_domains(id) ON DELETE CASCADE, + address TEXT NOT NULL, + UNIQUE (inbound_domain_id, address) +); + +-- Inbound domains assigned to a 'domain' user — granted separately from the +-- outbound ones above; the same name on both lists is two assignments. +CREATE TABLE user_inbound_domains ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + inbound_domain_id INTEGER NOT NULL REFERENCES inbound_domains(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, inbound_domain_id) +); + +-- DMARC aggregate report summaries. One row per parsed report; per-source rows +-- hang off it. Forensic (ruf=) payloads are not stored. +CREATE TABLE dmarc_reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain TEXT NOT NULL, + reporter TEXT NOT NULL, + report_id TEXT NOT NULL, + period_begin TEXT NOT NULL, + period_end TEXT NOT NULL, + received_at TEXT NOT NULL, + contact_email TEXT NOT NULL DEFAULT '', + policy_p TEXT NOT NULL DEFAULT '', + policy_sp TEXT NOT NULL DEFAULT '', + policy_pct INTEGER NOT NULL DEFAULT 100, + policy_adkim TEXT NOT NULL DEFAULT '', + policy_aspf TEXT NOT NULL DEFAULT '', + pass_count INTEGER NOT NULL DEFAULT 0, + fail_count INTEGER NOT NULL DEFAULT 0, + recipient TEXT NOT NULL DEFAULT '', + UNIQUE (reporter, report_id, domain) +); +CREATE INDEX idx_dmarc_reports_domain ON dmarc_reports (domain); +CREATE INDEX idx_dmarc_reports_received ON dmarc_reports (received_at); + +CREATE TABLE dmarc_report_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + report_row_id INTEGER NOT NULL REFERENCES dmarc_reports(id) ON DELETE CASCADE, + source_ip TEXT NOT NULL DEFAULT '', + count INTEGER NOT NULL, + disposition TEXT NOT NULL DEFAULT '', + spf_result TEXT NOT NULL DEFAULT '', + dkim_result TEXT NOT NULL DEFAULT '', + header_from TEXT NOT NULL DEFAULT '' +); +CREATE INDEX idx_dmarc_report_records_report ON dmarc_report_records (report_row_id); diff --git a/internal/store/migrations/0002_sessions.sql b/internal/store/migrations/0002_sessions.sql deleted file mode 100644 index 22387cf..0000000 --- a/internal/store/migrations/0002_sessions.sql +++ /dev/null @@ -1,12 +0,0 @@ --- Panel login sessions. Persisted so a login survives a container restart or --- redeploy; only the SHA-256 of the session token is stored, never the token --- itself, so a stolen database file cannot be used to sign in. expires_at --- implements the sliding idle timeout: it is pushed forward on activity rather --- than being fixed at creation time. -CREATE TABLE sessions ( - token_hash TEXT PRIMARY KEY, - username TEXT NOT NULL, - created_at TEXT NOT NULL, - expires_at TEXT NOT NULL -); -CREATE INDEX idx_sessions_expires_at ON sessions (expires_at); diff --git a/internal/store/migrations/0003_logtail_state.sql b/internal/store/migrations/0003_logtail_state.sql deleted file mode 100644 index c1ea93a..0000000 --- a/internal/store/migrations/0003_logtail_state.sql +++ /dev/null @@ -1,12 +0,0 @@ --- Log-tailer read position. Without it the tailer starts at end-of-file on --- every start, so delivery lines written while the panel was down are never --- parsed and their send-log rows stay "queued" forever. One row per followed --- path; fingerprint identifies the file the offset belongs to (the head bytes --- of the log), so a rotated or recreated mail.log is detected across a restart, --- where os.SameFile cannot help. -CREATE TABLE logtail_state ( - path TEXT PRIMARY KEY, - fingerprint TEXT NOT NULL, - read_offset INTEGER NOT NULL, - updated_at TEXT NOT NULL -); diff --git a/internal/store/migrations/0004_dmarc_report_email.sql b/internal/store/migrations/0004_dmarc_report_email.sql deleted file mode 100644 index f36408a..0000000 --- a/internal/store/migrations/0004_dmarc_report_email.sql +++ /dev/null @@ -1,5 +0,0 @@ --- Optional DMARC aggregate-report destination (rua=) for the panel administrator --- and per-domain overrides. NULL dmarc_rua on a domain means inherit the profile. - -ALTER TABLE admin ADD COLUMN dmarc_report_email TEXT NOT NULL DEFAULT ''; -ALTER TABLE domains ADD COLUMN dmarc_rua TEXT; diff --git a/internal/store/migrations/0005_panel_users.sql b/internal/store/migrations/0005_panel_users.sql deleted file mode 100644 index 5e205ec..0000000 --- a/internal/store/migrations/0005_panel_users.sql +++ /dev/null @@ -1,26 +0,0 @@ --- Panel users and domain-admin assignments. Migrates the single admin row into --- a global user; drops the admin table. - -CREATE TABLE users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - role TEXT NOT NULL CHECK (role IN ('global', 'domain_admin')), - dmarc_report_email TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL -); - -CREATE TABLE user_domains ( - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE, - PRIMARY KEY (user_id, domain_id) -); - -INSERT INTO users (username, password_hash, role, dmarc_report_email, created_at) -SELECT username, password_hash, 'global', dmarc_report_email, created_at -FROM admin WHERE id = 1; - -INSERT OR REPLACE INTO settings (key, value) -SELECT 'dmarc_report_email', dmarc_report_email FROM admin WHERE id = 1; - -DROP TABLE admin; diff --git a/internal/store/migrations/0006_inbound_relay.sql b/internal/store/migrations/0006_inbound_relay.sql deleted file mode 100644 index c80f88d..0000000 --- a/internal/store/migrations/0006_inbound_relay.sql +++ /dev/null @@ -1,26 +0,0 @@ --- Optional inbound relay (backup-MX / forwarder). Separate from sending --- domains: these rows exist even when INBOUND_RELAY_ENABLE is false, but the --- listener, Postfix maps and panel UI are generated only when that flag is on. - -CREATE TABLE inbound_domains ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - recipient_mode TEXT NOT NULL CHECK (recipient_mode IN ('list', 'any')), - created_at TEXT NOT NULL -); - --- One upstream per inbound domain (host:port + TLS policy for the hand-off). -CREATE TABLE inbound_transports ( - inbound_domain_id INTEGER PRIMARY KEY REFERENCES inbound_domains(id) ON DELETE CASCADE, - host TEXT NOT NULL, - port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535), - tls_mode TEXT NOT NULL CHECK (tls_mode IN ('may', 'encrypt', 'none')) -); - --- Explicit recipients for recipient_mode = 'list'. Ignored when mode is 'any'. -CREATE TABLE inbound_recipients ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - inbound_domain_id INTEGER NOT NULL REFERENCES inbound_domains(id) ON DELETE CASCADE, - address TEXT NOT NULL, - UNIQUE (inbound_domain_id, address) -); diff --git a/internal/store/migrations/0007_rate_limit_auto.sql b/internal/store/migrations/0007_rate_limit_auto.sql deleted file mode 100644 index 9bcc2da..0000000 --- a/internal/store/migrations/0007_rate_limit_auto.sql +++ /dev/null @@ -1,7 +0,0 @@ --- Level-2 rate limit auto mode: operator sets a multiplier; max_messages is --- derived from 30-day send stats (plan domain-stats-auto-ratelimit). - -ALTER TABLE rate_limits ADD COLUMN mode TEXT NOT NULL DEFAULT 'manual' - CHECK (mode IN ('manual', 'auto')); -ALTER TABLE rate_limits ADD COLUMN auto_multiplier REAL; -ALTER TABLE rate_limits ADD COLUMN auto_updated_at TEXT; diff --git a/internal/store/migrations/0008_dmarc_reports.sql b/internal/store/migrations/0008_dmarc_reports.sql deleted file mode 100644 index 5fa4da7..0000000 --- a/internal/store/migrations/0008_dmarc_reports.sql +++ /dev/null @@ -1,38 +0,0 @@ --- DMARC aggregate report summaries (plans/dmarc-reports.md). One row per parsed --- report; per-source rows hang off it. Forensic (ruf=) payloads are not stored. - -CREATE TABLE dmarc_reports ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - domain TEXT NOT NULL, - reporter TEXT NOT NULL, - report_id TEXT NOT NULL, - period_begin TEXT NOT NULL, - period_end TEXT NOT NULL, - received_at TEXT NOT NULL, - contact_email TEXT NOT NULL DEFAULT '', - policy_p TEXT NOT NULL DEFAULT '', - policy_sp TEXT NOT NULL DEFAULT '', - policy_pct INTEGER NOT NULL DEFAULT 100, - policy_adkim TEXT NOT NULL DEFAULT '', - policy_aspf TEXT NOT NULL DEFAULT '', - pass_count INTEGER NOT NULL DEFAULT 0, - fail_count INTEGER NOT NULL DEFAULT 0, - recipient TEXT NOT NULL DEFAULT '', - UNIQUE (reporter, report_id, domain) -); - -CREATE INDEX idx_dmarc_reports_domain ON dmarc_reports (domain); -CREATE INDEX idx_dmarc_reports_received ON dmarc_reports (received_at); - -CREATE TABLE dmarc_report_records ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - report_row_id INTEGER NOT NULL REFERENCES dmarc_reports(id) ON DELETE CASCADE, - source_ip TEXT NOT NULL DEFAULT '', - count INTEGER NOT NULL, - disposition TEXT NOT NULL DEFAULT '', - spf_result TEXT NOT NULL DEFAULT '', - dkim_result TEXT NOT NULL DEFAULT '', - header_from TEXT NOT NULL DEFAULT '' -); - -CREATE INDEX idx_dmarc_report_records_report ON dmarc_report_records (report_row_id); diff --git a/internal/store/migrations/0009_application_auth_ips.sql b/internal/store/migrations/0009_application_auth_ips.sql deleted file mode 100644 index c4bf48e..0000000 --- a/internal/store/migrations/0009_application_auth_ips.sql +++ /dev/null @@ -1,22 +0,0 @@ --- Client IP restriction for application SASL authorization (independent of --- level-2 rate limits). When enabled, only listed IPs may submit mail as the --- application; when disabled, any client IP is allowed. - -ALTER TABLE applications ADD COLUMN auth_ip_restrict INTEGER NOT NULL DEFAULT 0; -ALTER TABLE applications ADD COLUMN auth_allowed_ips TEXT; - --- Migrate trusted IPs from application rate_limits into auth_allowed_ips. -UPDATE applications -SET auth_ip_restrict = 1, - auth_allowed_ips = ( - SELECT allowed_ips FROM rate_limits - WHERE scope = 'application' AND ref_id = applications.id - ) -WHERE EXISTS ( - SELECT 1 FROM rate_limits - WHERE scope = 'application' AND ref_id = applications.id - AND allowed_ips IS NOT NULL AND TRIM(allowed_ips) != '' -); - -UPDATE rate_limits SET allowed_ips = NULL -WHERE scope = 'application' AND allowed_ips IS NOT NULL; diff --git a/internal/store/migrations/0010_send_log_app_login_index.sql b/internal/store/migrations/0010_send_log_app_login_index.sql deleted file mode 100644 index 35626ac..0000000 --- a/internal/store/migrations/0010_send_log_app_login_index.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Send-log lookups by application login run on two hot paths: the journal --- milter counts messages for a level-2 rate limit on every MAIL FROM, and the --- domain page computes 30-day statistics per application on every render. --- 0001 indexed queue_id, domain and created_at, but not app_login, so both --- fell back to scanning the created_at range. The composite matches the shape --- of those queries (equality on app_login, range on created_at). - -CREATE INDEX IF NOT EXISTS idx_send_log_app_login_created_at - ON send_log (app_login, created_at); diff --git a/internal/store/ratelimit_auto.go b/internal/store/ratelimit_auto.go index 7820e3a..6066a0a 100644 --- a/internal/store/ratelimit_auto.go +++ b/internal/store/ratelimit_auto.go @@ -43,7 +43,7 @@ func (s *Store) RecalcAutoRateLimit(scope string, refID int64, retentionDays, l1 func (s *Store) listAutoRateLimits() ([]RateLimit, error) { rows, err := s.db.Query( - `SELECT scope, ref_id, allowed_ips, max_messages, window_seconds, mode, auto_multiplier, auto_updated_at + `SELECT scope, ref_id, max_messages, window_seconds, mode, auto_multiplier, auto_updated_at FROM rate_limits WHERE mode = ?`, RateLimitModeAuto, ) if err != nil { @@ -55,18 +55,16 @@ func (s *Store) listAutoRateLimits() ([]RateLimit, error) { for rows.Next() { var scope string var refID int64 - var ips sql.NullString var maxMsgs, windowSecs sql.NullInt64 var mode sql.NullString var autoMult sql.NullFloat64 var autoUpdated sql.NullString - if err := rows.Scan(&scope, &refID, &ips, &maxMsgs, &windowSecs, &mode, &autoMult, &autoUpdated); err != nil { + if err := rows.Scan(&scope, &refID, &maxMsgs, &windowSecs, &mode, &autoMult, &autoUpdated); err != nil { return nil, fmt.Errorf("scan auto rate limit: %w", err) } rl := RateLimit{ Scope: scope, RefID: refID, - AllowedIPs: splitIPs(ips.String), MaxMessages: int(maxMsgs.Int64), WindowSeconds: int(windowSecs.Int64), Mode: mode.String, diff --git a/internal/store/ratelimits.go b/internal/store/ratelimits.go index f69bf28..db2a23d 100644 --- a/internal/store/ratelimits.go +++ b/internal/store/ratelimits.go @@ -41,7 +41,6 @@ const ( type RateLimit struct { Scope string RefID int64 - AllowedIPs []string // legacy column; unused for new rows MaxMessages int WindowSeconds int Mode string // manual | auto @@ -63,7 +62,7 @@ func (r RateLimit) Active() bool { // its id, for the panel's edit form. ok is false when none is configured. func (s *Store) GetRateLimit(scope string, refID int64) (RateLimit, bool, error) { row := s.db.QueryRow( - `SELECT allowed_ips, max_messages, window_seconds, mode, auto_multiplier, auto_updated_at + `SELECT max_messages, window_seconds, mode, auto_multiplier, auto_updated_at FROM rate_limits WHERE scope = ? AND ref_id = ?`, scope, refID) rl, err := scanRateLimit(row) if errors.Is(err, sql.ErrNoRows) { @@ -93,16 +92,15 @@ func (s *Store) SetRateLimit(rl RateLimit) error { autoUpdated = rl.AutoUpdatedAt.UTC().Format(time.RFC3339) } _, err := s.db.Exec( - `INSERT INTO rate_limits (scope, ref_id, allowed_ips, max_messages, window_seconds, mode, auto_multiplier, auto_updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `INSERT INTO rate_limits (scope, ref_id, max_messages, window_seconds, mode, auto_multiplier, auto_updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(scope, ref_id) DO UPDATE SET - allowed_ips = excluded.allowed_ips, max_messages = excluded.max_messages, window_seconds = excluded.window_seconds, mode = excluded.mode, auto_multiplier = excluded.auto_multiplier, auto_updated_at = excluded.auto_updated_at`, - rl.Scope, rl.RefID, strings.Join(rl.AllowedIPs, ","), rl.MaxMessages, rl.WindowSeconds, + rl.Scope, rl.RefID, rl.MaxMessages, rl.WindowSeconds, mode, autoMult, autoUpdated, ) if err != nil { @@ -144,11 +142,11 @@ func (s *Store) RateLimit(scope, ref string) (RateLimit, bool, error) { var query string switch scope { case RateLimitScopeDomain: - query = `SELECT rl.allowed_ips, rl.max_messages, rl.window_seconds, rl.mode, rl.auto_multiplier, rl.auto_updated_at + query = `SELECT rl.max_messages, rl.window_seconds, rl.mode, rl.auto_multiplier, rl.auto_updated_at FROM rate_limits rl JOIN domains d ON d.id = rl.ref_id WHERE rl.scope = 'domain' AND d.name = ?` case RateLimitScopeApp: - query = `SELECT rl.allowed_ips, rl.max_messages, rl.window_seconds, rl.mode, rl.auto_multiplier, rl.auto_updated_at + query = `SELECT rl.max_messages, rl.window_seconds, rl.mode, rl.auto_multiplier, rl.auto_updated_at FROM rate_limits rl JOIN applications a ON a.id = rl.ref_id WHERE rl.scope = 'application' AND a.login = ?` default: @@ -196,23 +194,21 @@ func (s *Store) CountMessages(scope, ref string, since time.Time) (int64, error) return n, nil } -// scanRateLimit reads the three stored columns, tolerating NULL numeric columns +// scanRateLimit reads the stored columns, tolerating NULL numeric columns // by leaving the corresponding field zero, which makes the limit inert via // Active() until max and window are both set. func scanRateLimit(r scanRow) (RateLimit, error) { var ( - ips sql.NullString maxMsgs sql.NullInt64 windowSecs sql.NullInt64 mode sql.NullString autoMult sql.NullFloat64 autoUpdated sql.NullString ) - if err := r.Scan(&ips, &maxMsgs, &windowSecs, &mode, &autoMult, &autoUpdated); err != nil { + if err := r.Scan(&maxMsgs, &windowSecs, &mode, &autoMult, &autoUpdated); err != nil { return RateLimit{}, err } rl := RateLimit{ - AllowedIPs: splitIPs(ips.String), MaxMessages: int(maxMsgs.Int64), WindowSeconds: int(windowSecs.Int64), Mode: mode.String, diff --git a/internal/store/ratelimits_test.go b/internal/store/ratelimits_test.go index cabecba..561de10 100644 --- a/internal/store/ratelimits_test.go +++ b/internal/store/ratelimits_test.go @@ -20,7 +20,6 @@ func TestRateLimitSetGetDelete(t *testing.T) { want := RateLimit{ Scope: RateLimitScopeDomain, RefID: d.ID, - AllowedIPs: []string{"203.0.113.1", "203.0.113.2"}, MaxMessages: 100, WindowSeconds: 3600, } @@ -32,19 +31,17 @@ func TestRateLimitSetGetDelete(t *testing.T) { if err != nil || !ok { t.Fatalf("GetRateLimit after set: ok=%v err=%v", ok, err) } - if got.MaxMessages != 100 || got.WindowSeconds != 3600 || len(got.AllowedIPs) != 2 || - got.AllowedIPs[0] != "203.0.113.1" || got.AllowedIPs[1] != "203.0.113.2" { + if got.MaxMessages != 100 || got.WindowSeconds != 3600 { t.Fatalf("roundtrip mismatch: %+v", got) } // Upsert replaces in place (UNIQUE(scope, ref_id)). want.MaxMessages = 5 - want.AllowedIPs = []string{"198.51.100.9"} if err := st.SetRateLimit(want); err != nil { t.Fatalf("SetRateLimit upsert: %v", err) } got, _, _ = st.GetRateLimit(RateLimitScopeDomain, d.ID) - if got.MaxMessages != 5 || len(got.AllowedIPs) != 1 || got.AllowedIPs[0] != "198.51.100.9" { + if got.MaxMessages != 5 || got.WindowSeconds != 3600 { t.Fatalf("upsert did not replace: %+v", got) } @@ -64,10 +61,10 @@ func TestRateLimitByNameAndLogin(t *testing.T) { t.Fatalf("AddApplication: %v", err) } - if err := st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: d.ID, AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 10, WindowSeconds: 60}); err != nil { + if err := st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: d.ID, MaxMessages: 10, WindowSeconds: 60}); err != nil { t.Fatalf("set domain limit: %v", err) } - if err := st.SetRateLimit(RateLimit{Scope: RateLimitScopeApp, RefID: a.ID, AllowedIPs: []string{"203.0.113.2"}, MaxMessages: 3, WindowSeconds: 60}); err != nil { + if err := st.SetRateLimit(RateLimit{Scope: RateLimitScopeApp, RefID: a.ID, MaxMessages: 3, WindowSeconds: 60}); err != nil { t.Fatalf("set app limit: %v", err) } @@ -131,9 +128,9 @@ func TestDeleteRateLimitsForDomain(t *testing.T) { a, _ := st.AddApplication(d.ID, "app1", AddressModeWildcard, nil) other, _ := st.AddDomain("other.example", "selfpost") - _ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: d.ID, AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 10, WindowSeconds: 60}) - _ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeApp, RefID: a.ID, AllowedIPs: []string{"203.0.113.2"}, MaxMessages: 3, WindowSeconds: 60}) - _ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: other.ID, AllowedIPs: []string{"203.0.113.9"}, MaxMessages: 1, WindowSeconds: 60}) + _ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: d.ID, MaxMessages: 10, WindowSeconds: 60}) + _ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeApp, RefID: a.ID, MaxMessages: 3, WindowSeconds: 60}) + _ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: other.ID, MaxMessages: 1, WindowSeconds: 60}) if err := st.DeleteRateLimitsForDomain(d.ID); err != nil { t.Fatalf("DeleteRateLimitsForDomain: %v", err) @@ -153,9 +150,9 @@ func TestDeleteRateLimitsForDomain(t *testing.T) { func TestRateLimitActive(t *testing.T) { inactive := []RateLimit{ {}, - {Scope: RateLimitScopeDomain, AllowedIPs: []string{"203.0.113.1"}}, // no ceiling - {Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window - {Scope: RateLimitScopeApp, MaxMessages: 5}, // no window + {Scope: RateLimitScopeDomain}, // no ceiling + {Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window + {Scope: RateLimitScopeApp, MaxMessages: 5}, // no window } for i, rl := range inactive { if rl.Active() { diff --git a/internal/store/schema_test.go b/internal/store/schema_test.go new file mode 100644 index 0000000..d3f8673 --- /dev/null +++ b/internal/store/schema_test.go @@ -0,0 +1,291 @@ +package store + +import ( + "database/sql" + "errors" + "io/fs" + "path/filepath" + "sort" + "strings" + "testing" +) + +// 2.0 starts from one baseline: the 1.x chain is gone from the binary, not +// carried along (docs/schema-migrations.md). +func TestBaselineIsTheOnlyMigration(t *testing.T) { + entries, err := fs.ReadDir(migrationsFS, "migrations") + if err != nil { + t.Fatalf("read migrations: %v", err) + } + var names []string + for _, e := range entries { + names = append(names, e.Name()) + } + if len(names) == 0 || names[0] != "0001_init.sql" { + t.Fatalf("migrations = %v, want the chain to start at 0001_init.sql", names) + } + baseline, _ := migrationsFS.ReadFile("migrations/0001_init.sql") + for _, gone := range []string{"CREATE TABLE admin", "DROP TABLE", "ALTER TABLE", "dmarc_report_email", "allowed_ips ", "domain_admin"} { + if strings.Contains(string(baseline), gone) { + t.Errorf("the baseline still contains %q — a leftover of the 1.x chain", gone) + } + } +} + +// The schema a fresh data directory gets, table by table and column by +// column. A change here is a schema change: it ships as a new migration file +// and a row in docs/schema-migrations.md, never as an edit of the baseline +// after 2.0.0. +func TestBaselineSchema(t *testing.T) { + st := openTestStore(t) + want := map[string]string{ + "settings": "key value", + "users": "id username password_hash role email dmarc_default_mode dmarc_default_address all_domains all_inbound_domains created_at", + "sessions": "token_hash username created_at expires_at", + "domains": "id name dkim_selector dmarc_rua dmarc_rua_user_id created_at", + "user_domains": "user_id domain_id", + "applications": "id domain_id login address_mode auth_ip_restrict auth_allowed_ips created_at", + "application_addresses": "id application_id address", + "rate_limits": "id scope ref_id max_messages window_seconds mode auto_multiplier auto_updated_at", + "send_log": "id queue_id domain app_login from_addr to_addr subject status created_at updated_at", + "logtail_state": "path fingerprint read_offset updated_at", + "inbound_domains": "id name recipient_mode created_at", + "inbound_transports": "inbound_domain_id host port tls_mode", + "inbound_recipients": "id inbound_domain_id address", + "user_inbound_domains": "user_id inbound_domain_id", + "dmarc_reports": "id domain reporter report_id period_begin period_end received_at contact_email policy_p policy_sp policy_pct policy_adkim policy_aspf pass_count fail_count recipient", + "dmarc_report_records": "id report_row_id source_ip count disposition spf_result dkim_result header_from", + } + rows, err := st.db.Query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name") + if err != nil { + t.Fatalf("list tables: %v", err) + } + var tables []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + t.Fatal(err) + } + tables = append(tables, name) + } + rows.Close() + for _, name := range tables { + cols, ok := want[name] + if !ok { + t.Errorf("unexpected table %q", name) + continue + } + if got := strings.Join(tableColumns(t, st, name), " "); got != cols { + t.Errorf("table %s has columns\n %s\nwant\n %s", name, got, cols) + } + delete(want, name) + } + for name := range want { + t.Errorf("table %q is missing", name) + } + + var indexes []string + rows, err = st.db.Query("SELECT name FROM sqlite_master WHERE type = 'index' AND name NOT LIKE 'sqlite_%' ORDER BY name") + if err != nil { + t.Fatalf("list indexes: %v", err) + } + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + t.Fatal(err) + } + indexes = append(indexes, name) + } + rows.Close() + wantIdx := []string{ + "idx_dmarc_report_records_report", "idx_dmarc_reports_domain", "idx_dmarc_reports_received", + "idx_send_log_app_login_created_at", "idx_send_log_created_at", "idx_send_log_domain", "idx_send_log_queue_id", + "idx_sessions_expires_at", + } + sort.Strings(wantIdx) + if strings.Join(indexes, " ") != strings.Join(wantIdx, " ") { + t.Errorf("indexes = %v\nwant %v", indexes, wantIdx) + } + + var version, appID int64 + if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 1 { + t.Errorf("user_version = %d, %v; want 1", version, err) + } + if err := st.db.QueryRow("PRAGMA application_id").Scan(&appID); err != nil || appID != applicationID { + t.Errorf("application_id = %#x, %v; want %#x", appID, err, applicationID) + } +} + +func tableColumns(t *testing.T, st *Store, table string) []string { + t.Helper() + rows, err := st.db.Query("SELECT name FROM pragma_table_info(?) ORDER BY cid", table) + if err != nil { + t.Fatalf("columns of %s: %v", table, err) + } + defer rows.Close() + var cols []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + t.Fatal(err) + } + cols = append(cols, name) + } + return cols +} + +// Reopening a 2.x database applies nothing twice and keeps its data. +func TestReopenKeepsTheDatabase(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.db") + st, err := Open(path) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := st.CreateGlobalUser("admin", "hash"); err != nil { + t.Fatalf("CreateGlobalUser: %v", err) + } + st.Close() + + st, err = Open(path) + if err != nil { + t.Fatalf("reopen: %v", err) + } + defer st.Close() + if ok, err := st.UserExists(); err != nil || !ok { + t.Fatalf("after reopen: user exists = %v, %v", ok, err) + } +} + +// A 1.x data directory must be refused, loudly. Its user_version (10 at the +// last 1.x release) would otherwise read as "the baseline is already applied", +// and the panel would start on tables it does not know. +func TestOpenRefusesA1xDatabase(t *testing.T) { + for _, version := range []string{"1", "10"} { + path := filepath.Join(t.TempDir(), "old.db") + db, err := sql.Open("sqlite", "file:"+path) + if err != nil { + t.Fatalf("create old db: %v", err) + } + for _, stmt := range []string{ + "CREATE TABLE admin (id INTEGER PRIMARY KEY CHECK (id = 1), username TEXT NOT NULL, password_hash TEXT NOT NULL, created_at TEXT NOT NULL)", + "INSERT INTO admin VALUES (1, 'admin', 'hash', '2026-01-01T00:00:00Z')", + "PRAGMA user_version = " + version, + } { + if _, err := db.Exec(stmt); err != nil { + t.Fatalf("%s: %v", stmt, err) + } + } + db.Close() + + st, err := Open(path) + if err == nil { + st.Close() + t.Fatalf("a 1.x database at version %s was opened", version) + } + if !errors.Is(err, ErrForeignSchema) { + t.Fatalf("version %s: error = %v, want ErrForeignSchema", version, err) + } + + // Refusing must not have touched the file: the operator still has + // their 1.x data to go back to. + db, _ = sql.Open("sqlite", "file:"+path) + var n int + if err := db.QueryRow("SELECT COUNT(*) FROM admin").Scan(&n); err != nil || n != 1 { + t.Errorf("version %s: the refused database was altered (admin rows = %d, %v)", version, n, err) + } + var tables int + _ = db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table'").Scan(&tables) + if tables != 1 { + t.Errorf("version %s: the refused database now has %d tables", version, tables) + } + db.Close() + } +} + +// A database written by a newer build is not guessed at either. +func TestOpenRefusesANewerSchema(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.db") + st, err := Open(path) + if err != nil { + t.Fatalf("open: %v", err) + } + if _, err := st.db.Exec("PRAGMA user_version = 99"); err != nil { + t.Fatal(err) + } + st.Close() + if st, err := Open(path); err == nil { + st.Close() + t.Fatal("a database at schema version 99 was opened") + } +} + +// The checks the baseline puts on a user row, and what the new assignment +// table does when either side goes away. +func TestBaselineConstraints(t *testing.T) { + st := openTestStore(t) + insert := func(role, mode string, all int) error { + _, err := st.db.Exec( + "INSERT INTO users (username, password_hash, role, dmarc_default_mode, all_domains, created_at) VALUES (?, 'h', ?, ?, ?, 'now')", + role+mode, role, mode, all) + return err + } + if err := insert("global", "none", 0); err != nil { + t.Fatalf("a plain global user: %v", err) + } + if err := insert("domain", "hosted", 1); err != nil { + t.Fatalf("a domain user with All: %v", err) + } + if err := insert("domain_admin", "none", 0); err == nil { + t.Error("the 1.x role name domain_admin was accepted") + } + if err := insert("global", "profile", 0); err == nil { + t.Error("an unknown dmarc_default_mode was accepted") + } + if err := insert("domain", "custom", 2); err == nil { + t.Error("all_domains = 2 was accepted") + } + + u, err := st.GetUserByUsername("domainhosted") + if err != nil { + t.Fatalf("GetUserByUsername: %v", err) + } + if !u.AllDomains || u.AllInboundDomains || u.DMARCDefaultMode != DMARCDefaultHosted { + t.Errorf("flags read back as %+v", u) + } + + in, err := st.AddInboundDomain("in.example.com") + if err != nil { + t.Fatalf("AddInboundDomain: %v", err) + } + other, err := st.AddInboundDomain("other.example.com") + if err != nil { + t.Fatalf("AddInboundDomain: %v", err) + } + for _, id := range []int64{in.ID, other.ID} { + if _, err := st.db.Exec("INSERT INTO user_inbound_domains (user_id, inbound_domain_id) VALUES (?, ?)", u.ID, id); err != nil { + t.Fatalf("assign inbound domain: %v", err) + } + } + if _, err := st.db.Exec("INSERT INTO user_inbound_domains (user_id, inbound_domain_id) VALUES (?, ?)", u.ID, in.ID+100); err == nil { + t.Error("an assignment to a missing inbound domain was accepted") + } + u, _ = st.GetUser(u.ID) + if len(u.InboundDomainIDs) != 2 || len(u.DomainIDs) != 0 { + t.Fatalf("assignments = inbound %v, outbound %v; the two lists are separate", u.InboundDomainIDs, u.DomainIDs) + } + + if err := st.DeleteInboundDomain(in.ID); err != nil { + t.Fatalf("DeleteInboundDomain: %v", err) + } + u, _ = st.GetUser(u.ID) + if len(u.InboundDomainIDs) != 1 || u.InboundDomainIDs[0] != other.ID { + t.Errorf("after deleting an inbound domain its assignment stayed: %v", u.InboundDomainIDs) + } + if err := st.DeleteUser(u.ID); err != nil { + t.Fatalf("DeleteUser: %v", err) + } + var n int + if err := st.db.QueryRow("SELECT COUNT(*) FROM user_inbound_domains").Scan(&n); err != nil || n != 0 { + t.Errorf("after deleting the user %d inbound assignment(s) stayed (%v)", n, err) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 5841ae5..85af0d1 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -8,6 +8,7 @@ package store import ( "database/sql" "embed" + "errors" "fmt" "io/fs" "sort" @@ -50,6 +51,14 @@ func (s *Store) Close() error { return s.db.Close() } +// applicationID marks a database file as a 2.x SelfPost one ("SP20"). The +// baseline migration sets it. +const applicationID = 0x53503230 + +// ErrForeignSchema is returned by Open for a database that has a schema but +// was not created by SelfPost 2.x — in practice a 1.x data directory. +var ErrForeignSchema = errors.New("database was not created by SelfPost 2.x; 2.0 starts from an empty data directory (docs/schema-migrations.md)") + // migrate applies embedded migrations in filename order, tracking progress via // SQLite's PRAGMA user_version so each migration runs at most once. func (s *Store) migrate() error { @@ -70,6 +79,23 @@ func (s *Store) migrate() error { return fmt.Errorf("read schema version: %w", err) } + // A 1.x file carries a user_version from the deleted 1.x chain. Counting it + // against today's files would skip the baseline and leave the panel running + // on a schema it does not know, so such a file is refused outright; nothing + // is migrated from 1.x (docs/schema-migrations.md). + if version > 0 { + var appID int64 + if err := s.db.QueryRow("PRAGMA application_id").Scan(&appID); err != nil { + return fmt.Errorf("read application id: %w", err) + } + if appID != applicationID { + return fmt.Errorf("schema version %d: %w", version, ErrForeignSchema) + } + } + if version > len(names) { + return fmt.Errorf("database schema version %d is newer than this build knows (%d)", version, len(names)) + } + for i, name := range names { target := i + 1 if target <= version { diff --git a/internal/store/users.go b/internal/store/users.go index 33776b1..7579c94 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -22,20 +22,95 @@ var ErrLastGlobal = errors.New("cannot remove last global administrator") // Role identifies a panel user's access level. type Role string +// The role is a reach, not a rank: everyone in the panel is an administrator, +// of the whole instance or of the domains assigned to them. const ( - RoleGlobal Role = "global" - RoleDomainAdmin Role = "domain_admin" + RoleGlobal Role = "global" + RoleDomain Role = "domain" ) +// How a user's default DMARC report address is chosen (users.dmarc_default_mode). +const ( + DMARCDefaultHosted = "hosted" // SelfPost's own mailbox for the domain + DMARCDefaultAccount = "account" // the user's account e-mail + DMARCDefaultCustom = "custom" // DMARCDefault.Address + DMARCDefaultNone = "none" // no aggregate reports +) + +// DMARCDefault is a user's default report address: the choice a domain takes +// over when it follows that user (Domain.DMARCRuaUserID). +type DMARCDefault struct { + UserID int64 + Username string + Mode string + Address string // for DMARCDefaultCustom + Email string // the account e-mail, for DMARCDefaultAccount +} + +// Resolve returns the address the default stands for on one domain. hosted is +// the SelfPost-hosted mailbox for that domain, "" when report ingest is off — +// a hosted default then resolves to no reports. +func (d DMARCDefault) Resolve(hosted string) string { + switch d.Mode { + case DMARCDefaultHosted: + return hosted + case DMARCDefaultAccount: + return d.Email + case DMARCDefaultCustom: + return d.Address + } + return "" +} + +// ValidDMARCDefaultMode reports whether mode is one of the stored choices. +func ValidDMARCDefaultMode(mode string) bool { + switch mode { + case DMARCDefaultHosted, DMARCDefaultAccount, DMARCDefaultCustom, DMARCDefaultNone: + return true + } + return false +} + // User is a panel login (not an application SASL account). type User struct { - ID int64 - Username string - PasswordHash string - Role Role - DMARCReportEmail string - CreatedAt time.Time - DomainIDs []int64 + ID int64 + Username string + PasswordHash string + Role Role + // Email is the user's own address — what the panel writes to. DMARC may + // use it (DMARCDefaultAccount) but it is not a DMARC field. + Email string + DMARCDefaultMode string + DMARCDefaultAddress string + // AllDomains / AllInboundDomains widen a domain user's list to every + // domain of that direction, including those added later; the id lists + // are then ignored. + AllDomains bool + AllInboundDomains bool + CreatedAt time.Time + DomainIDs []int64 + InboundDomainIDs []int64 +} + +// DMARCDefault returns the user's default report address choice. +func (u User) DMARCDefault() DMARCDefault { + return DMARCDefault{UserID: u.ID, Username: u.Username, Mode: u.DMARCDefaultMode, Address: u.DMARCDefaultAddress, Email: u.Email} +} + +const userColumns = "id, username, password_hash, role, email, dmarc_default_mode, dmarc_default_address, all_domains, all_inbound_domains, created_at" + +func scanUser(r scanRow) (User, error) { + var ( + u User + createdAt string + ) + err := r.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.Email, &u.DMARCDefaultMode, &u.DMARCDefaultAddress, + &u.AllDomains, &u.AllInboundDomains, &createdAt) + if err != nil { + return User{}, err + } + u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + return u, nil } // UserExists reports whether any panel user exists (setup complete). @@ -58,7 +133,7 @@ func (s *Store) CreateGlobalUser(username, passwordHash string) error { } now := time.Now().UTC().Format(time.RFC3339) _, err = s.db.Exec( - "INSERT INTO users (username, password_hash, role, dmarc_report_email, created_at) VALUES (?, ?, ?, '', ?)", + "INSERT INTO users (username, password_hash, role, created_at) VALUES (?, ?, ?, ?)", username, passwordHash, RoleGlobal, now, ) if err != nil { @@ -69,23 +144,17 @@ func (s *Store) CreateGlobalUser(username, passwordHash string) error { // GetUserByUsername returns a user with domain assignments loaded. func (s *Store) GetUserByUsername(username string) (User, error) { - var ( - u User - createdAt string - ) - err := s.db.QueryRow( - "SELECT id, username, password_hash, role, dmarc_report_email, created_at FROM users WHERE username = ?", - username, - ).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.DMARCReportEmail, &createdAt) + u, err := scanUser(s.db.QueryRow("SELECT "+userColumns+" FROM users WHERE username = ?", username)) if errors.Is(err, sql.ErrNoRows) { return User{}, ErrUserNotFound } if err != nil { return User{}, fmt.Errorf("get user by username: %w", err) } - u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) - u.DomainIDs, err = s.listUserDomainIDs(u.ID) - if err != nil { + if u.DomainIDs, err = s.listUserDomainIDs(u.ID); err != nil { + return User{}, err + } + if u.InboundDomainIDs, err = s.listUserInboundDomainIDs(u.ID); err != nil { return User{}, err } return u, nil @@ -93,47 +162,35 @@ func (s *Store) GetUserByUsername(username string) (User, error) { // GetUser returns a user by id with domain assignments. func (s *Store) GetUser(id int64) (User, error) { - var ( - u User - createdAt string - ) - err := s.db.QueryRow( - "SELECT id, username, password_hash, role, dmarc_report_email, created_at FROM users WHERE id = ?", - id, - ).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.DMARCReportEmail, &createdAt) + u, err := scanUser(s.db.QueryRow("SELECT "+userColumns+" FROM users WHERE id = ?", id)) if errors.Is(err, sql.ErrNoRows) { return User{}, ErrUserNotFound } if err != nil { return User{}, fmt.Errorf("get user: %w", err) } - u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) - u.DomainIDs, err = s.listUserDomainIDs(u.ID) - if err != nil { + if u.DomainIDs, err = s.listUserDomainIDs(u.ID); err != nil { + return User{}, err + } + if u.InboundDomainIDs, err = s.listUserInboundDomainIDs(u.ID); err != nil { return User{}, err } return u, nil } -// ListUsers returns every panel user without domain ids. +// ListUsers returns every panel user without the assigned domain ids. func (s *Store) ListUsers() ([]User, error) { - rows, err := s.db.Query( - "SELECT id, username, password_hash, role, dmarc_report_email, created_at FROM users ORDER BY id", - ) + rows, err := s.db.Query("SELECT " + userColumns + " FROM users ORDER BY id") if err != nil { return nil, fmt.Errorf("list users: %w", err) } defer rows.Close() var users []User for rows.Next() { - var ( - u User - createdAt string - ) - if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.DMARCReportEmail, &createdAt); err != nil { + u, err := scanUser(rows) + if err != nil { return nil, fmt.Errorf("list users scan: %w", err) } - u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) users = append(users, u) } return users, rows.Err() @@ -177,12 +234,12 @@ func (s *Store) CountGlobalUsers() (int, error) { // CreateUser inserts a panel user and optional domain assignments. func (s *Store) CreateUser(username, passwordHash string, role Role, domainIDs []int64) (int64, error) { - if role == RoleDomainAdmin && len(domainIDs) == 0 { - return 0, fmt.Errorf("create user: domain_admin requires domains") + if role == RoleDomain && len(domainIDs) == 0 { + return 0, fmt.Errorf("create user: the domain role requires domains") } now := time.Now().UTC().Format(time.RFC3339) res, err := s.db.Exec( - "INSERT INTO users (username, password_hash, role, dmarc_report_email, created_at) VALUES (?, ?, ?, '', ?)", + "INSERT INTO users (username, password_hash, role, created_at) VALUES (?, ?, ?, ?)", username, passwordHash, role, now, ) if err != nil { @@ -195,7 +252,7 @@ func (s *Store) CreateUser(username, passwordHash string, role Role, domainIDs [ if err != nil { return 0, fmt.Errorf("create user id: %w", err) } - if role == RoleDomainAdmin { + if role == RoleDomain { if err := s.setUserDomains(id, domainIDs); err != nil { return 0, err } @@ -203,15 +260,11 @@ func (s *Store) CreateUser(username, passwordHash string, role Role, domainIDs [ return id, nil } -// UpdateUser replaces username, password hash, and DMARC email for a user. -func (s *Store) UpdateUser(id int64, username, passwordHash, dmarcReportEmail string) error { - u, err := s.GetUser(id) - if err != nil { - return err - } +// UpdateUser replaces a user's username, password hash and account e-mail. +func (s *Store) UpdateUser(id int64, username, passwordHash, email string) error { res, err := s.db.Exec( - "UPDATE users SET username = ?, password_hash = ?, dmarc_report_email = ? WHERE id = ?", - username, passwordHash, dmarcReportEmail, id, + "UPDATE users SET username = ?, password_hash = ?, email = ? WHERE id = ?", + username, passwordHash, email, id, ) if err != nil { if isUniqueViolation(err) { @@ -226,14 +279,50 @@ func (s *Store) UpdateUser(id int64, username, passwordHash, dmarcReportEmail st if n == 0 { return ErrUserNotFound } - if u.Role == RoleGlobal { - if err := s.SetSetting("dmarc_report_email", dmarcReportEmail); err != nil { - return err - } + return nil +} + +// SetDMARCDefault stores the user's default report address choice. address is +// kept only for DMARCDefaultCustom. Every domain that follows this user +// resolves to the new choice from the next read on. +func (s *Store) SetDMARCDefault(userID int64, mode, address string) error { + if !ValidDMARCDefaultMode(mode) { + return fmt.Errorf("set dmarc default: unknown mode %q", mode) + } + if mode != DMARCDefaultCustom { + address = "" + } + res, err := s.db.Exec( + "UPDATE users SET dmarc_default_mode = ?, dmarc_default_address = ? WHERE id = ?", mode, address, userID) + if err != nil { + return fmt.Errorf("set dmarc default: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("set dmarc default: %w", err) + } + if n == 0 { + return ErrUserNotFound } return nil } +// GetDMARCDefault returns a user's default report address choice, or +// ErrUserNotFound. +func (s *Store) GetDMARCDefault(userID int64) (DMARCDefault, error) { + d := DMARCDefault{UserID: userID} + err := s.db.QueryRow( + "SELECT username, dmarc_default_mode, dmarc_default_address, email FROM users WHERE id = ?", userID, + ).Scan(&d.Username, &d.Mode, &d.Address, &d.Email) + if errors.Is(err, sql.ErrNoRows) { + return DMARCDefault{}, ErrUserNotFound + } + if err != nil { + return DMARCDefault{}, fmt.Errorf("get dmarc default: %w", err) + } + return d, nil +} + // SetUserRole updates a user's role. func (s *Store) SetUserRole(userID int64, role Role) error { res, err := s.db.Exec("UPDATE users SET role = ? WHERE id = ?", role, userID) @@ -259,14 +348,14 @@ func (s *Store) ClearUserDomains(userID int64) error { return nil } -// SetUserDomains replaces domain assignments for a domain_admin user. +// SetUserDomains replaces the outbound domain assignments of a domain user. func (s *Store) SetUserDomains(userID int64, domainIDs []int64) error { u, err := s.GetUser(userID) if err != nil { return err } - if u.Role != RoleDomainAdmin { - return fmt.Errorf("set user domains: user is not domain_admin") + if u.Role != RoleDomain { + return fmt.Errorf("set user domains: user does not have the domain role") } if len(domainIDs) == 0 { return fmt.Errorf("set user domains: at least one domain required") @@ -303,9 +392,22 @@ func (s *Store) DeleteUser(id int64) error { return nil } -// GlobalDMARCReportEmail returns the shared default rua= for domain inherit mode. -func (s *Store) GlobalDMARCReportEmail() (string, error) { - return s.GetSetting("dmarc_report_email") +func (s *Store) listUserInboundDomainIDs(userID int64) ([]int64, error) { + rows, err := s.db.Query( + "SELECT inbound_domain_id FROM user_inbound_domains WHERE user_id = ? ORDER BY inbound_domain_id", userID) + if err != nil { + return nil, fmt.Errorf("list user inbound domains: %w", err) + } + defer rows.Close() + var ids []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("list user inbound domains scan: %w", err) + } + ids = append(ids, id) + } + return ids, rows.Err() } func (s *Store) listUserDomainIDs(userID int64) ([]int64, error) { diff --git a/internal/store/users_test.go b/internal/store/users_test.go index 2d743fd..cbadcf8 100644 --- a/internal/store/users_test.go +++ b/internal/store/users_test.go @@ -23,19 +23,20 @@ func TestUpdateUser(t *testing.T) { if err != nil { t.Fatalf("GetUser: %v", err) } - if got.DMARCReportEmail != "reports@hub.example" { - t.Fatalf("dmarc email = %q", got.DMARCReportEmail) + if got.Email != "reports@hub.example" { + t.Fatalf("email = %q", got.Email) } - email, err := st.GlobalDMARCReportEmail() - if err != nil { - t.Fatalf("GlobalDMARCReportEmail: %v", err) + // The account e-mail is the user's own: saving it does not make it anyone's + // report address, and nothing is mirrored into the instance settings. + if got.DMARCDefaultMode != DMARCDefaultNone { + t.Fatalf("dmarc default mode = %q after setting the e-mail, want %q", got.DMARCDefaultMode, DMARCDefaultNone) } - if email != "reports@hub.example" { - t.Fatalf("settings dmarc = %q", email) + if v, err := st.GetSetting("dmarc_report_email"); err == nil && v != "" { + t.Fatalf("the e-mail leaked into the instance settings: %q", v) } if err := st.UpdateUser(u.ID, "operator", "hash-three", ""); err != nil { - t.Fatalf("clear dmarc email: %v", err) + t.Fatalf("clear e-mail: %v", err) } got, err = st.GetUser(u.ID) if err != nil { @@ -73,7 +74,7 @@ func TestCreateDomainAdminUser(t *testing.T) { if err != nil { t.Fatalf("AddDomain: %v", err) } - id, err := st.CreateUser("domainop", "hash2", RoleDomainAdmin, []int64{d.ID}) + id, err := st.CreateUser("domainop", "hash2", RoleDomain, []int64{d.ID}) if err != nil { t.Fatalf("CreateUser: %v", err) } diff --git a/internal/web/auth/principal.go b/internal/web/auth/principal.go index e0397ab..24989ab 100644 --- a/internal/web/auth/principal.go +++ b/internal/web/auth/principal.go @@ -18,8 +18,8 @@ const ( type Role = store.Role const ( - RoleGlobal = store.RoleGlobal - RoleDomainAdmin = store.RoleDomainAdmin + RoleGlobal = store.RoleGlobal + RoleDomain = store.RoleDomain ) // Principal is the authenticated panel user attached to a request. diff --git a/internal/web/handlers/authz.go b/internal/web/handlers/authz.go index 3c5eabd..6da1b92 100644 --- a/internal/web/handlers/authz.go +++ b/internal/web/handlers/authz.go @@ -3,6 +3,7 @@ package handlers import ( "net/http" + "github.com/mixeme/selfpost/internal/dmarc" "github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/web/auth" ) @@ -35,6 +36,22 @@ func (h *Handlers) assignedDomains(p auth.Principal) ([]store.Domain, error) { return h.store.ListDomainsForUser(p.ID) } +// hostedDMARCAddress is the SelfPost-hosted report mailbox for a sending +// domain, "" when report ingest is off on this server. +func (h *Handlers) hostedDMARCAddress(domainName string) string { + if h.dmarc == nil || !h.cfg.DMARCEnabled { + return "" + } + return dmarc.HostedReportAddress(h.cfg.Hostname, domainName) +} + +// domainReportAddress resolves where a domain's DMARC aggregate reports go: +// its own address, or the default of the user it follows. "" is a policy-only +// record. +func (h *Handlers) domainReportAddress(d store.Domain) (string, error) { + return h.store.DomainDMARCRua(d, h.hostedDMARCAddress(d.Name)) +} + func domainNameSet(domains []store.Domain) map[string]bool { m := make(map[string]bool, len(domains)) for _, d := range domains { diff --git a/internal/web/handlers/handlers_apps.go b/internal/web/handlers/handlers_apps.go index 6c4cd03..ee31772 100644 --- a/internal/web/handlers/handlers_apps.go +++ b/internal/web/handlers/handlers_apps.go @@ -153,29 +153,37 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st // What DNS actually publishes for the domain today, checked against the key // this server signs with. Cached by the checker, so re-rendering the page // after a form post costs nothing. - profileEmail, err := h.store.GlobalDMARCReportEmail() + reportEmail, err := h.domainReportAddress(d) if err != nil { - logf("panel: domain %d: global dmarc email: %v", d.ID, err) + logf("panel: domain %d: dmarc report address: %v", d.ID, err) http.Error(w, "internal error", http.StatusInternalServerError) return } - reportEmail := dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail) - dns, srv := h.domainDNS(d, record, profileEmail, false) + dns, srv := h.domainDNS(d, record, reportEmail, false) reportAuthName, reportAuthValue, needsReportAuth := dnscheck.ExternalReportAuth(d.Name, reportEmail) + hostedAddr := h.hostedDMARCAddress(d.Name) + // The form's "inherit" is a domain that follows a user's default. What it + // offers next to that choice is the address following would give: the + // followed user's when the domain already follows one, otherwise the + // signed-in user's own — choosing it makes the domain follow them. dmarcMode := "inherit" dmarcCustom := "" - hostedAddr := "" - if h.dmarc != nil && h.cfg.DMARCEnabled { - hostedAddr = dmarc.HostedReportAddress(h.cfg.Hostname, d.Name) - } - if d.DMARCRua.Valid { - if d.DMARCRua.String == "" { + profileEmail := reportEmail + if !d.DMARCRuaUserID.Valid { + profileEmail = "" + if p, ok := h.principal(r); ok { + if def, err := h.store.GetDMARCDefault(p.ID); err == nil { + profileEmail = def.Resolve(hostedAddr) + } + } + switch { + case d.DMARCRua == "": dmarcMode = "none" - } else if hostedAddr != "" && strings.EqualFold(d.DMARCRua.String, hostedAddr) { + case hostedAddr != "" && strings.EqualFold(d.DMARCRua, hostedAddr): dmarcMode = "hosted" - } else { + default: dmarcMode = "custom" - dmarcCustom = d.DMARCRua.String + dmarcCustom = d.DMARCRua } } dmarcSource := "policy" @@ -186,7 +194,7 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st dmarcSource = "custom" case dmarcMode == "none": dmarcSource = "none" - case profileEmail != "": + case reportEmail != "": dmarcSource = "settings" } @@ -250,7 +258,7 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st // and no extra environment variable is needed. That server result is returned // alongside, because the page's suggested SPF record is built from the same // addresses. force bypasses the cache, for the Re-check button. -func (h *Handlers) domainDNS(d store.Domain, record domain.DKIMRecord, profileEmail string, force bool) (dnscheck.Domain, dnscheck.Server) { +func (h *Handlers) domainDNS(d store.Domain, record domain.DKIMRecord, reportEmail string, force bool) (dnscheck.Domain, dnscheck.Server) { srv := h.dns.Server(h.cfg.Hostname, false) return h.dns.Domain(dnscheck.Query{ Name: d.Name, @@ -258,7 +266,7 @@ func (h *Handlers) domainDNS(d store.Domain, record domain.DKIMRecord, profileEm ExpectedDKIM: record.Value, Hostname: srv.Hostname, ServerIPs: srv.IPs, - DMARCReportEmail: dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail), + DMARCReportEmail: reportEmail, }, force), srv } @@ -275,13 +283,13 @@ func (h *Handlers) HandleDomainDNSRecheck(w http.ResponseWriter, r *http.Request http.Error(w, "internal error", http.StatusInternalServerError) return } - profileEmail, err := h.store.GlobalDMARCReportEmail() + reportEmail, err := h.domainReportAddress(d) if err != nil { - logf("panel: domain %d: global dmarc email: %v", d.ID, err) + logf("panel: domain %d: dmarc report address: %v", d.ID, err) http.Error(w, "internal error", http.StatusInternalServerError) return } - h.domainDNS(d, record, profileEmail, true) + h.domainDNS(d, record, reportEmail, true) http.Redirect(w, r, fmt.Sprintf("/domains/%d?rechecked=1", d.ID), http.StatusSeeOther) } diff --git a/internal/web/handlers/handlers_backup.go b/internal/web/handlers/handlers_backup.go index e9d0759..400e65e 100644 --- a/internal/web/handlers/handlers_backup.go +++ b/internal/web/handlers/handlers_backup.go @@ -277,6 +277,13 @@ func (h *Handlers) HandleImportDomain(w http.ResponseWriter, r *http.Request) { h.renderBackupPage(w, r, status, msg) return } + // An export without an address of its own followed someone's default on the + // server it came from; here it follows whoever imports it. + if exp.DMARCRua == nil { + if p, ok := h.principal(r); ok { + h.followCreator(d.ID, p.ID) + } + } http.Redirect(w, r, fmt.Sprintf("/domains/%d?imported=1", d.ID), http.StatusSeeOther) } diff --git a/internal/web/handlers/handlers_dmarc.go b/internal/web/handlers/handlers_dmarc.go index 6632d7f..412745b 100644 --- a/internal/web/handlers/handlers_dmarc.go +++ b/internal/web/handlers/handlers_dmarc.go @@ -1,7 +1,6 @@ package handlers import ( - "database/sql" "fmt" "net/http" "strings" @@ -26,12 +25,14 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) { return } - var rua sql.NullString + // "inherit" makes the domain follow the default of the user who chose it; + // every other mode gives the domain an address of its own ("" = none). + follow := false + rua := "" switch strings.TrimSpace(r.PostFormValue("dmarc_rua_mode")) { case "inherit": - rua = sql.NullString{} + follow = true case "none": - rua = sql.NullString{Valid: true, String: ""} case "custom": email := strings.TrimSpace(r.PostFormValue("dmarc_rua_email")) if err := validate.Email(email); err != nil { @@ -42,20 +43,25 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) { h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Enter a custom report address or choose another mode."}) return } - rua = sql.NullString{Valid: true, String: email} + rua = email case "hosted": if h.dmarc == nil || !h.dmarc.Enabled() { h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "SelfPost-hosted reports are not enabled on this server."}) return } - addr := dmarc.HostedReportAddress(h.cfg.Hostname, d.Name) - rua = sql.NullString{Valid: true, String: addr} + rua = dmarc.HostedReportAddress(h.cfg.Hostname, d.Name) default: h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Choose how aggregate reports are addressed for this domain."}) return } - if err := h.store.UpdateDomainDMARCRua(d.ID, rua); err != nil { + var err error + if p, ok := h.principal(r); follow && ok { + err = h.store.SetDomainDMARCUser(d.ID, p.ID) + } else { + err = h.store.SetDomainDMARCAddress(d.ID, rua) + } + if err != nil { logf("panel: domain %d: save dmarc rua: %v", d.ID, err) h.renderDomainDetail(w, r, http.StatusInternalServerError, d, detailView{FormErr: "Could not save DMARC settings. Please check the logs and try again."}) return diff --git a/internal/web/handlers/handlers_dmarc_test.go b/internal/web/handlers/handlers_dmarc_test.go new file mode 100644 index 0000000..16013ee --- /dev/null +++ b/internal/web/handlers/handlers_dmarc_test.go @@ -0,0 +1,135 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/mixeme/selfpost/internal/domain" + "github.com/mixeme/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/web/auth" +) + +// postFormAs sends a form to a handler as the given principal. +func postFormAs(h http.HandlerFunc, p auth.Principal, target string, pathValues map[string]string, values url.Values) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(values.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for k, v := range pathValues { + req.SetPathValue(k, v) + } + rec := httptest.NewRecorder() + h(rec, auth.RequestWithPrincipal(req, p)) + return rec +} + +// The domain form's "Same as Settings" is, on the 2.0 schema, a domain that +// follows the default of the user who chose it; every other choice gives the +// domain an address of its own and stops it following anyone. +func TestDomainDMARCFollowsTheUserWhoChoseInherit(t *testing.T) { + h, _ := settingsServer(t) + h.domains = domain.NewService(h.store, nil, nil, "selfpost") + d, err := h.store.AddDomain("example.com", "selfpost") + if err != nil { + t.Fatalf("AddDomain: %v", err) + } + admin, err := h.store.GetUserByUsername("admin") + if err != nil { + t.Fatalf("GetUserByUsername: %v", err) + } + p := auth.Principal{ID: admin.ID, Username: "admin", Role: auth.RoleGlobal} + if err := h.store.UpdateUser(admin.ID, "admin", admin.PasswordHash, "mix@example.org"); err != nil { + t.Fatalf("UpdateUser: %v", err) + } + if err := h.store.SetDMARCDefault(admin.ID, store.DMARCDefaultAccount, ""); err != nil { + t.Fatalf("SetDMARCDefault: %v", err) + } + save := func(values url.Values) store.Domain { + t.Helper() + rec := postFormAs(h.HandleDomainDMARC, p, "/domains/1/dmarc", map[string]string{"id": "1"}, values) + if rec.Code != http.StatusSeeOther { + t.Fatalf("POST %v = %d, want 303:\n%s", values, rec.Code, rec.Body.String()) + } + got, err := h.store.GetDomain(d.ID) + if err != nil { + t.Fatalf("GetDomain: %v", err) + } + return got + } + + got := save(url.Values{"dmarc_rua_mode": {"inherit"}}) + if !got.DMARCRuaUserID.Valid || got.DMARCRuaUserID.Int64 != admin.ID { + t.Fatalf("inherit: the domain follows %v, want user %d", got.DMARCRuaUserID, admin.ID) + } + if rua, err := h.domainReportAddress(got); err != nil || rua != "mix@example.org" { + t.Fatalf("inherit resolves to %q, %v; want the user's account e-mail", rua, err) + } + + got = save(url.Values{"dmarc_rua_mode": {"custom"}, "dmarc_rua_email": {"dmarc@hub.example"}}) + if got.DMARCRuaUserID.Valid || got.DMARCRua != "dmarc@hub.example" { + t.Fatalf("custom: %q / follows %v", got.DMARCRua, got.DMARCRuaUserID) + } + + got = save(url.Values{"dmarc_rua_mode": {"none"}}) + if got.DMARCRuaUserID.Valid || got.DMARCRua != "" { + t.Fatalf("none: %q / follows %v", got.DMARCRua, got.DMARCRuaUserID) + } + +} + +// Until the Account page replaces the old Settings form, its single address +// field is the account e-mail and switches the user's DMARC default between +// "my account e-mail" and "none". Nothing is written to the instance settings. +func TestSettingsFormMapsToAccountEmailAndDefault(t *testing.T) { + h, password := settingsServer(t) + submit := func(email string) store.User { + t.Helper() + rec := postFormAs(h.HandleSettings, globalPrincipal, "/settings", nil, url.Values{ + "username": {"admin"}, "current_password": {password}, "dmarc_report_email": {email}, + }) + if rec.Code != http.StatusSeeOther { + t.Fatalf("POST /settings (%q) = %d, want 303:\n%s", email, rec.Code, rec.Body.String()) + } + u, err := h.store.GetUser(globalPrincipal.ID) + if err != nil { + t.Fatalf("GetUser: %v", err) + } + return u + } + + u := submit("mix@example.org") + if u.Email != "mix@example.org" || u.DMARCDefaultMode != store.DMARCDefaultAccount { + t.Fatalf("after setting the address: e-mail %q, default %q", u.Email, u.DMARCDefaultMode) + } + if v, err := h.store.GetSetting("dmarc_report_email"); err == nil && v != "" { + t.Fatalf("the address was mirrored into the instance settings: %q", v) + } + + u = submit("") + if u.Email != "" || u.DMARCDefaultMode != store.DMARCDefaultNone { + t.Fatalf("after clearing the address: e-mail %q, default %q", u.Email, u.DMARCDefaultMode) + } +} + +// A new domain takes its report address from whoever created it. +func TestFollowCreator(t *testing.T) { + h, _ := settingsServer(t) + d, err := h.store.AddDomain("example.com", "selfpost") + if err != nil { + t.Fatalf("AddDomain: %v", err) + } + h.followCreator(d.ID, globalPrincipal.ID) + got, err := h.store.GetDomain(d.ID) + if err != nil { + t.Fatalf("GetDomain: %v", err) + } + if !got.DMARCRuaUserID.Valid || got.DMARCRuaUserID.Int64 != globalPrincipal.ID { + t.Fatalf("the domain follows %v, want its creator (user %d)", got.DMARCRuaUserID, globalPrincipal.ID) + } + // The creator's default is "none" until they choose one, so the new + // domain publishes a policy-only record — as a fresh 1.x install did. + if rua, err := h.domainReportAddress(got); err != nil || rua != "" { + t.Fatalf("resolved %q, %v; want no report address yet", rua, err) + } +} diff --git a/internal/web/handlers/handlers_domains.go b/internal/web/handlers/handlers_domains.go index 579cf45..c26e451 100644 --- a/internal/web/handlers/handlers_domains.go +++ b/internal/web/handlers/handlers_domains.go @@ -51,10 +51,6 @@ func (h *Handlers) renderDashboard(w http.ResponseWriter, r *http.Request, statu } func (h *Handlers) domainRows(domains []store.Domain) []domainRow { - profileEmail := "" - if email, err := h.store.GlobalDMARCReportEmail(); err == nil { - profileEmail = email - } rows := make([]domainRow, len(domains)) var wg sync.WaitGroup for i, d := range domains { @@ -67,7 +63,12 @@ func (h *Handlers) domainRows(domains []store.Domain) []domainRow { logf("panel: dashboard: domain %d: dkim record: %v", d.ID, err) return } - dns, _ := h.domainDNS(d, record, profileEmail, false) + reportEmail, err := h.domainReportAddress(d) + if err != nil { + logf("panel: dashboard: domain %d: dmarc report address: %v", d.ID, err) + return + } + dns, _ := h.domainDNS(d, record, reportEmail, false) rows[i].DNS = dns.Overall }() } @@ -86,7 +87,8 @@ func dashboardFlash(r *http.Request) string { // OpenDKIM reload), and redirects to the domain's page so the DNS record to // publish is shown (product.md). func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) { - if _, ok := h.requireGlobal(w, r); !ok { + p, ok := h.requireGlobal(w, r) + if !ok { return } if err := r.ParseForm(); err != nil { @@ -111,9 +113,26 @@ func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) { "Could not add the domain. Please check the logs and try again.", raw) return } + h.followCreator(d.ID, p.ID) http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther) } +// followCreator makes a new domain take its DMARC report address from the +// default of whoever created it (plan § Account e-mail). The domain exists +// either way: a failure here leaves it with no report address, which its page +// shows and its DMARC form can change. +func (h *Handlers) followCreator(domainID, userID int64) { + if err := h.store.SetDomainDMARCUser(domainID, userID); err != nil { + logf("panel: domain %d: follow user %d's dmarc default: %v", domainID, userID, err) + return + } + if h.dmarc != nil && h.dmarc.Enabled() { + if err := h.dmarc.Resync(); err != nil { + logf("panel: domain %d: dmarc resync: %v", domainID, err) + } + } +} + // HandleDeleteConfirm shows the cascade warning before a domain is removed. func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) { if _, ok := h.requireGlobal(w, r); !ok { diff --git a/internal/web/handlers/handlers_monitor_test.go b/internal/web/handlers/handlers_monitor_test.go index cd39ec9..6a25b8c 100644 --- a/internal/web/handlers/handlers_monitor_test.go +++ b/internal/web/handlers/handlers_monitor_test.go @@ -373,7 +373,7 @@ func domainAdmin(t *testing.T, st *store.Store, username string, domainIDs ...in if err != nil { t.Fatalf("add placeholder domain: %v", err) } - id, err := st.CreateUser(username, hash, store.RoleDomainAdmin, []int64{placeholder.ID}) + id, err := st.CreateUser(username, hash, store.RoleDomain, []int64{placeholder.ID}) if err != nil { t.Fatalf("create domain admin %s: %v", username, err) } @@ -387,7 +387,7 @@ func domainAdmin(t *testing.T, st *store.Store, username string, domainIDs ...in } return auth.Principal{ID: u.ID, Username: u.Username, Role: u.Role, Domains: u.DomainIDs} } - id, err := st.CreateUser(username, hash, store.RoleDomainAdmin, domainIDs) + id, err := st.CreateUser(username, hash, store.RoleDomain, domainIDs) if err != nil { t.Fatalf("create domain admin %s: %v", username, err) } diff --git a/internal/web/handlers/handlers_settings.go b/internal/web/handlers/handlers_settings.go index 433a14b..b8bb25c 100644 --- a/internal/web/handlers/handlers_settings.go +++ b/internal/web/handlers/handlers_settings.go @@ -30,7 +30,7 @@ func (h *Handlers) HandleSettings(w http.ResponseWriter, r *http.Request) { http.Error(w, "internal error", http.StatusInternalServerError) return } - h.renderSettings(w, r, http.StatusOK, "", u.Username, u.DMARCReportEmail, h.sendLogRetentionDays(), p.IsGlobal()) + h.renderSettings(w, r, http.StatusOK, "", u.Username, u.Email, h.sendLogRetentionDays(), p.IsGlobal()) case http.MethodPost: h.submitSettings(w, r) default: @@ -128,7 +128,7 @@ func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) { dmarcEmail := strings.TrimSpace(r.PostFormValue("dmarc_report_email")) formRetention := currentRetention if !p.IsGlobal() { - dmarcEmail = user.DMARCReportEmail + dmarcEmail = user.Email } else if raw := strings.TrimSpace(r.PostFormValue("send_log_retention_days")); raw != "" { parsed, err := strconv.Atoi(raw) if err != nil { @@ -161,7 +161,7 @@ func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) { } } - emailChanging := p.IsGlobal() && dmarcEmail != user.DMARCReportEmail + emailChanging := p.IsGlobal() && dmarcEmail != user.Email retentionChanging := false if p.IsGlobal() && formRetention != currentRetention { @@ -218,6 +218,23 @@ func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) { } } + // Until the Account page replaces this form (stage 2), its one "default + // report address" field stands for both halves of the 2.0 model: it is the + // account e-mail, and the user's DMARC default is "my account e-mail" while + // it is filled and "none" once it is cleared. + if emailChanging { + mode := store.DMARCDefaultNone + if dmarcEmail != "" { + mode = store.DMARCDefaultAccount + } + if err := h.store.SetDMARCDefault(user.ID, mode, ""); err != nil { + logf("panel: settings: set dmarc default failed: %v", err) + h.renderSettings(w, r, http.StatusInternalServerError, + "Could not save the changes. Please check the logs and try again.", username, dmarcEmail, formRetention, p.IsGlobal()) + return + } + } + if retentionChanging { if err := h.store.SetSendLogRetentionDays(formRetention); err != nil { logf("panel: settings: set send-log retention failed: %v", err) diff --git a/internal/web/handlers/handlers_users.go b/internal/web/handlers/handlers_users.go index 59c7bcb..6ee5de0 100644 --- a/internal/web/handlers/handlers_users.go +++ b/internal/web/handlers/handlers_users.go @@ -58,7 +58,7 @@ func (h *Handlers) HandleUserNew(w http.ResponseWriter, r *http.Request) { } switch r.Method { case http.MethodGet: - h.renderUserForm(w, r, http.StatusOK, 0, userFormView{FormRole: string(store.RoleDomainAdmin)}) + h.renderUserForm(w, r, http.StatusOK, 0, userFormView{FormRole: string(store.RoleDomain)}) case http.MethodPost: h.submitUserCreate(w, r) default: @@ -162,11 +162,11 @@ func (h *Handlers) submitUserCreate(w http.ResponseWriter, r *http.Request) { h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)}) return } - if role != store.RoleGlobal && role != store.RoleDomainAdmin { + if role != store.RoleGlobal && role != store.RoleDomain { h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)}) return } - if role == store.RoleDomainAdmin && len(domainIDs) == 0 { + if role == store.RoleDomain && len(domainIDs) == 0 { h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Select at least one domain for a domain administrator.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)}) return } @@ -213,16 +213,16 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: selected}) return } - if role != store.RoleGlobal && role != store.RoleDomainAdmin { + if role != store.RoleGlobal && role != store.RoleDomain { h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormRole: string(role), FormDomains: selected}) return } - if role == store.RoleDomainAdmin && len(domainIDs) == 0 { + if role == store.RoleDomain && len(domainIDs) == 0 { h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Select at least one domain for a domain administrator.", FormUsername: username, FormRole: string(role), FormDomains: selected}) return } - if u.Role == store.RoleGlobal && role == store.RoleDomainAdmin { + if u.Role == store.RoleGlobal && role == store.RoleDomain { n, err := h.store.CountGlobalUsers() if err != nil || n <= 1 { msg := "Cannot demote the last global administrator." @@ -249,7 +249,7 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st hash = string(newHash) } - if err := h.store.UpdateUser(u.ID, username, hash, u.DMARCReportEmail); err != nil { + if err := h.store.UpdateUser(u.ID, username, hash, u.Email); err != nil { if errors.Is(err, store.ErrUserExists) { h.renderUserForm(w, r, http.StatusConflict, u.ID, userFormView{FormErr: "That username is already in use.", FormUsername: username, FormRole: string(role), FormDomains: selected}) return @@ -272,7 +272,7 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st } } - if role == store.RoleDomainAdmin { + if role == store.RoleDomain { if err := h.store.SetUserDomains(u.ID, domainIDs); err != nil { logf("panel: set user domains: %v", err) h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not save domain assignments.", FormUsername: username, FormRole: string(role), FormDomains: selected}) diff --git a/internal/web/view/templates/user_form.html b/internal/web/view/templates/user_form.html index b1df42d..0043770 100644 --- a/internal/web/view/templates/user_form.html +++ b/internal/web/view/templates/user_form.html @@ -16,7 +16,7 @@ {{if .LastGlobalLocked}}{{end}} {{if .LastGlobalLocked}}
The only global administrator cannot be demoted.
{{end}}