panel-redesign: the 2.0 schema from zero - one baseline, no 1.x chain
Stage 1, step 1. BREAKING: a 1.x database is not migrated.
Schema
- internal/store/migrations/ holds one file, 0001_init.sql, equal to the
2.0 schema; 0002..0010 are deleted (they stay in history at 1.9.5). No
admin table created and dropped, no ALTERs.
- users.email replaces users.dmarc_report_email: the address is the
user's own. The default DMARC report address is a user's setting
(dmarc_default_mode hosted/account/custom/none, dmarc_default_address);
the instance setting that mirrored the global user's field is gone.
- domains.dmarc_rua_user_id: a domain follows a named user's default, or
has an address of its own in dmarc_rua ('' = none). ON DELETE SET NULL
leaves it at "no reports", never at someone else's default.
- user_inbound_domains, users.all_domains, users.all_inbound_domains: in
the schema for the inbound delegation of the next step.
- users.role is 'global' / 'domain' (was 'domain_admin');
rate_limits.allowed_ips, unused since 1.9.0, is dropped.
Opening a database
- The baseline sets PRAGMA application_id ("SP20"). store.Open refuses a
file that has a schema version without it (ErrForeignSchema) and one
newer than the build. A 1.x file at user_version 10 would otherwise read
as already migrated. The refused file is left untouched.
Code
- Store.DomainDMARCRua is the one place a domain's report address is
resolved; dnscheck.ResolveDMARCRua and Store.GlobalDMARCReportEmail are
gone. dmarc.AllowedRecipients, the domain page, the dashboard and the
DNS re-check go through it.
- The old templates keep working on the new model until their pages are
restyled: the Settings form's address field is the account e-mail and
switches the user's default between it and none; a domain's "Same as
Settings" makes it follow the user who chose it; a new or imported
domain follows its creator.
- Domain export/import: a domain that follows a user exports no address.
Tests: the baseline pinned table by table (TestBaselineSchema), refusal of a
1.x and of a newer database, constraints and cascades of the new columns and
table, resolution in every default mode, the deleted-user case, and the three
bridged handlers.
docs/schema-migrations.md is rewritten for the baseline; roadmap 11/27,
schema-squash marked built.
go test ./... (Windows)
ok for every package except two that fail the same way before this commit:
internal/domain TestWriteLoadPrivateKeyRoundtrip (file mode on NTFS)
internal/logtail TestFollowTailsAndRotates (rename of an open file)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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**
|
||||
|
||||
+8
-2
@@ -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.
|
||||
|
||||
+95
-160
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+24
-20
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
+44
-12
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+166
-64
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<label for="role">Role</label>
|
||||
{{if .LastGlobalLocked}}<input type="hidden" name="role" value="{{.FormRole}}">{{end}}
|
||||
<select id="role" name="role" data-global-role="{{.GlobalRole}}"{{if .LastGlobalLocked}} disabled{{end}}>
|
||||
<option value="domain_admin" {{if eq .FormRole "domain_admin"}}selected{{end}}>Domain administrator</option>
|
||||
<option value="domain" {{if eq .FormRole "domain"}}selected{{end}}>Domain administrator</option>
|
||||
<option value="global" {{if eq .FormRole "global"}}selected{{end}}>Global administrator</option>
|
||||
</select>
|
||||
{{if .LastGlobalLocked}}<p class="muted">The only global administrator cannot be demoted.</p>{{end}}
|
||||
|
||||
Reference in New Issue
Block a user