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
|
mockups too, and its button takes the same hover, focus and press tint as
|
||||||
Account and Help (owner, 2026-10-09).
|
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
|
### 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
|
- 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
|
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
|
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,
|
- panel-redesign is `agreed` (owner, 2026-10-09); work starts with stage 0,
|
||||||
the component kit.
|
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
|
## [1.9.5] - 2026-10-09
|
||||||
|
|
||||||
CI-only cut: the release workflow no longer breaks the multi-arch image it
|
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)
|
**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**
|
- [ ] 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**
|
- [ ] 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**
|
- [ ] 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 | — | — |
|
| 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 | — | — |
|
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | candidate | — | — |
|
||||||
| password-reset | Password reset by e-mail | 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) |
|
| 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 | **2.x** | — | — |
|
| 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
|
**Recommended order** (not binding; owner, 2026-10-09: feature order follows
|
||||||
development convenience): the next stage is **panel-redesign** (`2.0.0`),
|
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
|
[plans/panel-redesign.md](plans/panel-redesign.md) § No compatibility; this
|
||||||
item ships as a step of that plan, not on its own.
|
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
|
**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
|
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.
|
its own. A missed gate leaves a mid-chain 1.x database silently stuck.
|
||||||
|
|||||||
+95
-160
@@ -1,16 +1,15 @@
|
|||||||
# SQLite schema and migrations
|
# SQLite schema and migrations
|
||||||
|
|
||||||
**What this file is.** A living reference for the embedded SQLite migration chain,
|
**What this file is.** A living reference for the embedded SQLite schema: the
|
||||||
historical legacy that still runs on fresh 1.x installs, and the planned 2.x
|
2.0 baseline, the migrations added after it, and how a database is opened.
|
||||||
schema squash. When you add or review a migration, update this file in the same
|
When you add or review a migration, update this file in the same change.
|
||||||
change.
|
|
||||||
|
|
||||||
**Source of truth in code:** `internal/store/migrations/*.sql`, applied by
|
**Source of truth in code:** `internal/store/migrations/*.sql`, applied by
|
||||||
`internal/store/store.go` (`migrate()`).
|
`internal/store/store.go` (`migrate()`).
|
||||||
|
|
||||||
**Related:** [architecture.md](architecture.md) § Persistence;
|
**Related:** [architecture.md](architecture.md) § Persistence;
|
||||||
[roadmap.md](roadmap.md) § schema-squash; [development.md](development.md) §
|
[plans/panel-redesign.md](plans/panel-redesign.md) § No compatibility;
|
||||||
Documentation.
|
[development.md](development.md) § Documentation.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -18,14 +17,35 @@ Documentation.
|
|||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Chain head | `user_version = 10` |
|
| Chain head | `user_version = 1` |
|
||||||
| Files | `0001_init.sql` … `0010_send_log_app_login_index.sql` (10 files) |
|
| Files | `0001_init.sql` (the 2.0 baseline) |
|
||||||
|
| File mark | `PRAGMA application_id = 0x53503230` ("SP20") |
|
||||||
| Database file | `/data/selfpost.db` (bind mount) |
|
| 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`.
|
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.
|
5. There is no down-migration; fixes ship as a new `00NN_*.sql` file.
|
||||||
|
|
||||||
```text
|
**Implication:** once `2.0.0` is released, `0001_init.sql` is never edited
|
||||||
0001_init.sql → user_version 1
|
again and no file is deleted, renamed or reordered — every 2.x database has
|
||||||
0002_sessions.sql → user_version 2
|
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
|
||||||
0009_application_auth_ips.sql → user_version 9
|
is `0002_inbound_spam_log.sql` of inbound-antispam-panel, `2.1.0`) add files
|
||||||
0010_send_log_app_login_index.sql → user_version 10
|
after it.
|
||||||
```
|
|
||||||
|
|
||||||
**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)).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -56,152 +71,72 @@ replace the embedded set (see [Planned 2.x squash](#planned-2x-squash)).
|
|||||||
|
|
||||||
| Ver | File | Shipped | Kind | Summary |
|
| 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` |
|
| 1 | `0001_init.sql` | 2.0.0 | DDL | The whole 2.0 schema, below |
|
||||||
| 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 |
|
|
||||||
|
|
||||||
**Kind:** *DDL* — schema only; *data* — `INSERT`/`UPDATE` that must stay correct
|
**Kind:** *DDL* — schema only; *data* — `INSERT`/`UPDATE` that must stay correct
|
||||||
for operators upgrading from older 1.x images.
|
for operators upgrading from older 2.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).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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 |
|
`TestBaselineSchema` (`internal/store/schema_test.go`) pins every table, column
|
||||||
|-------|------------|------|
|
and index of this list.
|
||||||
| `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 |
|
|
||||||
|
|
||||||
**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
|
1. Add `internal/store/migrations/00NN_short_name.sql` (next number).
|
||||||
[schema-squash](roadmap.md#schema-squash) at 2.x.
|
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).
|
||||||
### `admin` table (0001 → 0005)
|
3. Update `TestBaselineSchema` if tables, columns or indexes change, and add a
|
||||||
|
test that opens a database at the previous version and migrates it.
|
||||||
On a **new** 1.x data directory the chain still:
|
4. A *data* migration states in a comment which 2.x versions it is written for.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
@@ -205,7 +205,6 @@ func (s *Service) RateLimit(appID int64) (store.RateLimit, bool, error) {
|
|||||||
func (s *Service) SaveRateLimit(appID int64, rl store.RateLimit) error {
|
func (s *Service) SaveRateLimit(appID int64, rl store.RateLimit) error {
|
||||||
rl.Scope = store.RateLimitScopeApp
|
rl.Scope = store.RateLimitScopeApp
|
||||||
rl.RefID = appID
|
rl.RefID = appID
|
||||||
rl.AllowedIPs = nil
|
|
||||||
return s.store.SetRateLimit(rl)
|
return s.store.SetRateLimit(rl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+24
-20
@@ -54,10 +54,6 @@ func (s *Service) AllowedRecipients() ([]string, error) {
|
|||||||
if !s.enabled || s.hostname == "" {
|
if !s.enabled || s.hostname == "" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
profile, err := s.store.GlobalDMARCReportEmail()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
var out []string
|
var out []string
|
||||||
add := func(addr string) {
|
add := func(addr string) {
|
||||||
@@ -68,8 +64,24 @@ func (s *Service) AllowedRecipients() ([]string, error) {
|
|||||||
seen[addr] = true
|
seen[addr] = true
|
||||||
out = append(out, addr)
|
out = append(out, addr)
|
||||||
}
|
}
|
||||||
add(profile)
|
// A user's own default that points at this host is accepted even before a
|
||||||
if profile == "" {
|
// 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))
|
add(DefaultHostedReportAddress(s.hostname))
|
||||||
}
|
}
|
||||||
domains, err := s.store.ListDomains()
|
domains, err := s.store.ListDomains()
|
||||||
@@ -77,21 +89,13 @@ func (s *Service) AllowedRecipients() ([]string, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, d := range domains {
|
for _, d := range domains {
|
||||||
rua := dnscheck.ResolveDMARCRua(d.DMARCRua, profile)
|
// add keeps only addresses on this host, so a domain reporting elsewhere
|
||||||
if rua == "" {
|
// (or nowhere) opens nothing here.
|
||||||
continue
|
rua, err := s.store.DomainDMARCRua(d, HostedReportAddress(s.hostname, d.Name))
|
||||||
}
|
if err != nil {
|
||||||
if IsHostedOnHostname(rua, s.hostname) {
|
return nil, err
|
||||||
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(rua)
|
||||||
}
|
}
|
||||||
sort.Strings(out)
|
sort.Strings(out)
|
||||||
return out, nil
|
return out, nil
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package dnscheck
|
package dnscheck
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -48,15 +47,6 @@ func SPFExample(hostname string, serverIPs []string) string {
|
|||||||
return "v=spf1 " + strings.Join(mechanisms, " ") + " -all"
|
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.
|
// EmailDomain returns the lower-case domain part of addr, or "" when invalid.
|
||||||
func EmailDomain(addr string) string {
|
func EmailDomain(addr string) string {
|
||||||
addr = strings.TrimSpace(addr)
|
addr = strings.TrimSpace(addr)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package dnscheck
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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) {
|
func TestExternalReportAuth(t *testing.T) {
|
||||||
name, value, ok := ExternalReportAuth("shop.com", "reports@hub.com")
|
name, value, ok := ExternalReportAuth("shop.com", "reports@hub.com")
|
||||||
if !ok || name != "_report._dmarc.hub.com" || value != "v=DMARC1;" {
|
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
|
// 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.
|
// 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 {
|
func (s *Service) SaveRateLimit(domainID int64, rl store.RateLimit) error {
|
||||||
rl.Scope = store.RateLimitScopeDomain
|
rl.Scope = store.RateLimitScopeDomain
|
||||||
rl.RefID = domainID
|
rl.RefID = domainID
|
||||||
rl.AllowedIPs = nil
|
|
||||||
return s.store.SetRateLimit(rl)
|
return s.store.SetRateLimit(rl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package domain
|
package domain
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/buildinfo"
|
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||||
@@ -24,7 +23,7 @@ type DomainExport struct {
|
|||||||
Domain string `json:"domain"`
|
Domain string `json:"domain"`
|
||||||
DKIMSelector string `json:"dkim_selector"`
|
DKIMSelector string `json:"dkim_selector"`
|
||||||
DKIMPrivateKey string `json:"dkim_private_key"` // PKCS#1 PEM
|
DKIMPrivateKey string `json:"dkim_private_key"` // PKCS#1 PEM
|
||||||
DMARCRua *string `json:"dmarc_rua,omitempty"` // nil = inherit profile; set = override ("" = none)
|
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"`
|
RateLimit *RateLimitExport `json:"rate_limit,omitempty"`
|
||||||
Applications []AppExport `json:"applications"`
|
Applications []AppExport `json:"applications"`
|
||||||
}
|
}
|
||||||
@@ -74,9 +73,11 @@ func (s *Service) Export(id int64) (DomainExport, error) {
|
|||||||
DKIMPrivateKey: string(pem),
|
DKIMPrivateKey: string(pem),
|
||||||
Applications: make([]AppExport, 0, len(apps)),
|
Applications: make([]AppExport, 0, len(apps)),
|
||||||
}
|
}
|
||||||
if d.DMARCRua.Valid {
|
// A domain that follows a user's default exports no address: users do not
|
||||||
s := d.DMARCRua.String
|
// travel with a domain, and whoever imports it becomes the one it follows.
|
||||||
exp.DMARCRua = &s
|
if !d.DMARCRuaUserID.Valid {
|
||||||
|
rua := d.DMARCRua
|
||||||
|
exp.DMARCRua = &rua
|
||||||
}
|
}
|
||||||
rl, ok, err := s.store.GetRateLimit(store.RateLimitScopeDomain, id)
|
rl, ok, err := s.store.GetRateLimit(store.RateLimitScopeDomain, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -147,11 +148,11 @@ func (s *Service) Import(exp DomainExport) (store.Domain, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if exp.DMARCRua != nil {
|
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)
|
s.importRollback(d.ID)
|
||||||
return store.Domain{}, err
|
return store.Domain{}, err
|
||||||
}
|
}
|
||||||
d.DMARCRua = sql.NullString{Valid: true, String: *exp.DMARCRua}
|
d.DMARCRua = *exp.DMARCRua
|
||||||
}
|
}
|
||||||
if exp.RateLimit != nil {
|
if exp.RateLimit != nil {
|
||||||
if err := s.importRateLimit(store.RateLimitScopeDomain, d.ID, *exp.RateLimit); err != nil {
|
if err := s.importRateLimit(store.RateLimitScopeDomain, d.ID, *exp.RateLimit); err != nil {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package domain
|
package domain
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"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 {
|
if _, err := src.store.AddApplication(d.ID, "alerts", store.AddressModeList, []string{"a@example.com"}); err != nil {
|
||||||
t.Fatalf("add alerts: %v", err)
|
t.Fatalf("add alerts: %v", err)
|
||||||
}
|
}
|
||||||
if err := src.store.UpdateDomainDMARCRua(d.ID, sql.NullString{Valid: true, String: "reports@hub.example"}); err != nil {
|
if err := src.store.SetDomainDMARCAddress(d.ID, "reports@hub.example"); err != nil {
|
||||||
t.Fatalf("set dmarc rua: %v", err)
|
t.Fatalf("set dmarc rua: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,8 +110,8 @@ func TestExportImportRoundTrip(t *testing.T) {
|
|||||||
if got.Name != "example.com" || got.DKIMSelector != "selfpost" {
|
if got.Name != "example.com" || got.DKIMSelector != "selfpost" {
|
||||||
t.Errorf("imported domain = %+v", got)
|
t.Errorf("imported domain = %+v", got)
|
||||||
}
|
}
|
||||||
if !got.DMARCRua.Valid || got.DMARCRua.String != "reports@hub.example" {
|
if got.DMARCRuaUserID.Valid || got.DMARCRua != "reports@hub.example" {
|
||||||
t.Errorf("imported dmarc rua = %+v", got.DMARCRua)
|
t.Errorf("imported dmarc rua = %q (follows user: %v)", got.DMARCRua, got.DMARCRuaUserID.Valid)
|
||||||
}
|
}
|
||||||
if exp.DMARCRua == nil || *exp.DMARCRua != "reports@hub.example" {
|
if exp.DMARCRua == nil || *exp.DMARCRua != "reports@hub.example" {
|
||||||
t.Errorf("exported dmarc rua = %v", exp.DMARCRua)
|
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},
|
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
||||||
}
|
}
|
||||||
// Domain ceilings apply to every client IP; leftover AllowedIPs on the row
|
// Domain ceilings apply to every client IP.
|
||||||
// are ignored.
|
|
||||||
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
|
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)
|
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) {
|
func TestRateLimitInactiveWithoutCeiling(t *testing.T) {
|
||||||
rec := &fakeRecorder{
|
rec := &fakeRecorder{
|
||||||
limits: map[string]store.RateLimit{
|
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},
|
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-12
@@ -25,9 +25,13 @@ type Domain struct {
|
|||||||
ID int64
|
ID int64
|
||||||
Name string
|
Name string
|
||||||
DKIMSelector string
|
DKIMSelector string
|
||||||
DMARCRua sql.NullString // NULL = inherit profile; Valid+empty = no reports
|
// Where DMARC aggregate reports go. With DMARCRuaUserID set the domain
|
||||||
CreatedAt time.Time
|
// follows that user's default (DMARCDefault) and DMARCRua is ignored;
|
||||||
AppCount int
|
// 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
|
// 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.
|
// ordered by name.
|
||||||
func (s *Store) ListDomains() ([]Domain, error) {
|
func (s *Store) ListDomains() ([]Domain, error) {
|
||||||
rows, err := s.db.Query(`
|
rows, err := s.db.Query(`
|
||||||
SELECT d.id, d.name, d.dkim_selector, d.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)
|
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
|
||||||
FROM domains d
|
FROM domains d
|
||||||
ORDER BY d.name`)
|
ORDER BY d.name`)
|
||||||
@@ -80,7 +84,7 @@ func (s *Store) ListDomains() ([]Domain, error) {
|
|||||||
// ordered by name.
|
// ordered by name.
|
||||||
func (s *Store) ListDomainsForUser(userID int64) ([]Domain, error) {
|
func (s *Store) ListDomainsForUser(userID int64) ([]Domain, error) {
|
||||||
rows, err := s.db.Query(`
|
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)
|
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
|
||||||
FROM domains d
|
FROM domains d
|
||||||
INNER JOIN user_domains ud ON ud.domain_id = d.id
|
INNER JOIN user_domains ud ON ud.domain_id = d.id
|
||||||
@@ -106,7 +110,7 @@ func (s *Store) ListDomainsForUser(userID int64) ([]Domain, error) {
|
|||||||
// ErrDomainNotFound.
|
// ErrDomainNotFound.
|
||||||
func (s *Store) GetDomain(id int64) (Domain, error) {
|
func (s *Store) GetDomain(id int64) (Domain, error) {
|
||||||
row := s.db.QueryRow(`
|
row := s.db.QueryRow(`
|
||||||
SELECT d.id, d.name, d.dkim_selector, d.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)
|
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
|
||||||
FROM domains d
|
FROM domains d
|
||||||
WHERE d.id = ?`, id)
|
WHERE d.id = ?`, id)
|
||||||
@@ -148,7 +152,7 @@ func scanDomain(r scanRow) (Domain, error) {
|
|||||||
d Domain
|
d Domain
|
||||||
createdAt string
|
createdAt string
|
||||||
)
|
)
|
||||||
if err := r.Scan(&d.ID, &d.Name, &d.DKIMSelector, &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
|
return Domain{}, err
|
||||||
}
|
}
|
||||||
d.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
d.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||||
@@ -166,11 +170,21 @@ func isUniqueViolation(err error) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateDomainDMARCRua sets how this domain resolves its DMARC rua= destination.
|
// SetDomainDMARCAddress gives the domain a report address of its own; "" means
|
||||||
// NULL means inherit the administrator profile; Valid with an empty string means
|
// no aggregate reports. The domain stops following anyone's default.
|
||||||
// policy-only with no aggregate reports for this domain.
|
func (s *Store) SetDomainDMARCAddress(id int64, rua string) error {
|
||||||
func (s *Store) UpdateDomainDMARCRua(id int64, rua sql.NullString) error {
|
return s.updateDomainDMARC(id, rua, sql.NullInt64{})
|
||||||
res, err := s.db.Exec("UPDATE domains SET dmarc_rua = ? WHERE id = ?", rua, id)
|
}
|
||||||
|
|
||||||
|
// 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 {
|
if err != nil {
|
||||||
return fmt.Errorf("update domain dmarc rua: %w", err)
|
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
|
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.
|
// 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
|
// 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).
|
// not send for (security.md: report XML is attacker-supplied input).
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"errors"
|
||||||
"testing"
|
"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)
|
st := openTestStore(t)
|
||||||
d, err := st.AddDomain("example.com", "sel")
|
d, err := st.AddDomain("example.com", "sel")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -15,31 +19,123 @@ func TestDomainDMARCRua(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetDomain: %v", err)
|
t.Fatalf("GetDomain: %v", err)
|
||||||
}
|
}
|
||||||
if got.DMARCRua.Valid {
|
if got.DMARCRua != "" || got.DMARCRuaUserID.Valid {
|
||||||
t.Fatal("new domain should inherit profile")
|
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 {
|
if err := st.SetDomainDMARCAddress(d.ID, "reports@hub.com"); err != nil {
|
||||||
t.Fatalf("UpdateDomainDMARCRua custom: %v", err)
|
t.Fatalf("SetDomainDMARCAddress: %v", err)
|
||||||
}
|
|
||||||
got, err = st.GetDomain(d.ID)
|
|
||||||
if err != nil || got.DMARCRua.String != "reports@hub.com" {
|
|
||||||
t.Fatalf("custom = %+v, err=%v", got.DMARCRua, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := st.UpdateDomainDMARCRua(d.ID, sql.NullString{Valid: true}); err != nil {
|
|
||||||
t.Fatalf("UpdateDomainDMARCRua none: %v", err)
|
|
||||||
}
|
}
|
||||||
got, _ = st.GetDomain(d.ID)
|
got, _ = st.GetDomain(d.ID)
|
||||||
if !got.DMARCRua.Valid || got.DMARCRua.String != "" {
|
if rua, err := st.DomainDMARCRua(got, hostedForTest); err != nil || rua != "reports@hub.com" {
|
||||||
t.Fatalf("none = %+v", got.DMARCRua)
|
t.Fatalf("own address resolves to %q, %v", rua, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := st.UpdateDomainDMARCRua(d.ID, sql.NullString{}); err != nil {
|
if err := st.CreateGlobalUser("admin", "hash"); err != nil {
|
||||||
t.Fatalf("UpdateDomainDMARCRua inherit: %v", err)
|
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)
|
got, _ = st.GetDomain(d.ID)
|
||||||
if got.DMARCRua.Valid {
|
if got.DMARCRua != "" || !got.DMARCRuaUserID.Valid || got.DMARCRuaUserID.Int64 != u.ID {
|
||||||
t.Fatalf("inherit = %+v", got.DMARCRua)
|
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
|
-- SelfPost 2.0 baseline schema. One SQLite file under /data holds the whole
|
||||||
-- state so a single directory backup/restore is sufficient.
|
-- 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
|
-- Marks the file as a 2.x SelfPost database ("SP20"). store.Open refuses a
|
||||||
-- that row is what marks primary setup as complete, which is why the /setup
|
-- database that has a schema version but not this mark, which is what a 1.x
|
||||||
-- route disappears once it exists.
|
-- file looks like: its user_version would otherwise read as "already migrated".
|
||||||
CREATE TABLE admin (
|
PRAGMA application_id = 1397764656;
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
||||||
username TEXT NOT NULL,
|
|
||||||
password_hash TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 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 (
|
CREATE TABLE settings (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Sending domains managed through the panel. DKIM keys themselves live on disk
|
-- Panel logins (not application SASL accounts). The presence of a row is what
|
||||||
-- under /data; this row records the selector and metadata.
|
-- 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 (
|
CREATE TABLE domains (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
dkim_selector TEXT NOT NULL,
|
dkim_selector TEXT NOT NULL,
|
||||||
created_at 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
|
-- Applications bound to a domain. address_mode is either the domain wildcard or
|
||||||
-- an explicit address list; the SASL login is globally unique.
|
-- 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 (
|
CREATE TABLE applications (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
||||||
login TEXT NOT NULL UNIQUE,
|
login TEXT NOT NULL UNIQUE,
|
||||||
address_mode TEXT NOT NULL CHECK (address_mode IN ('wildcard', 'list')),
|
address_mode TEXT NOT NULL CHECK (address_mode IN ('wildcard', 'list')),
|
||||||
created_at TEXT NOT NULL
|
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
|
-- Explicit sender addresses for applications in 'list' mode. Each address must
|
||||||
@@ -45,6 +109,20 @@ CREATE TABLE application_addresses (
|
|||||||
UNIQUE (application_id, address)
|
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
|
-- Structured send log. One row per (queue-id, recipient); the log-tailer
|
||||||
-- advances status from queued to a final state.
|
-- advances status from queued to a final state.
|
||||||
CREATE TABLE send_log (
|
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_queue_id ON send_log (queue_id);
|
||||||
CREATE INDEX idx_send_log_domain ON send_log (domain);
|
CREATE INDEX idx_send_log_domain ON send_log (domain);
|
||||||
CREATE INDEX idx_send_log_created_at ON send_log (created_at);
|
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
|
-- Log-tailer read position. Without it the tailer starts at end-of-file on
|
||||||
-- the message limit are optional.
|
-- every start, so delivery lines written while the panel was down are never
|
||||||
CREATE TABLE rate_limits (
|
-- parsed and their send-log rows stay "queued" forever. One row per followed
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
-- path; fingerprint identifies the file the offset belongs to (the head bytes
|
||||||
scope TEXT NOT NULL CHECK (scope IN ('domain', 'application')),
|
-- of the log), so a rotated or recreated mail.log is detected across a restart,
|
||||||
ref_id INTEGER NOT NULL,
|
-- where os.SameFile cannot help.
|
||||||
allowed_ips TEXT,
|
CREATE TABLE logtail_state (
|
||||||
max_messages INTEGER,
|
path TEXT PRIMARY KEY,
|
||||||
window_seconds INTEGER,
|
fingerprint TEXT NOT NULL,
|
||||||
UNIQUE (scope, ref_id)
|
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) {
|
func (s *Store) listAutoRateLimits() ([]RateLimit, error) {
|
||||||
rows, err := s.db.Query(
|
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,
|
FROM rate_limits WHERE mode = ?`, RateLimitModeAuto,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -55,18 +55,16 @@ func (s *Store) listAutoRateLimits() ([]RateLimit, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var scope string
|
var scope string
|
||||||
var refID int64
|
var refID int64
|
||||||
var ips sql.NullString
|
|
||||||
var maxMsgs, windowSecs sql.NullInt64
|
var maxMsgs, windowSecs sql.NullInt64
|
||||||
var mode sql.NullString
|
var mode sql.NullString
|
||||||
var autoMult sql.NullFloat64
|
var autoMult sql.NullFloat64
|
||||||
var autoUpdated sql.NullString
|
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)
|
return nil, fmt.Errorf("scan auto rate limit: %w", err)
|
||||||
}
|
}
|
||||||
rl := RateLimit{
|
rl := RateLimit{
|
||||||
Scope: scope,
|
Scope: scope,
|
||||||
RefID: refID,
|
RefID: refID,
|
||||||
AllowedIPs: splitIPs(ips.String),
|
|
||||||
MaxMessages: int(maxMsgs.Int64),
|
MaxMessages: int(maxMsgs.Int64),
|
||||||
WindowSeconds: int(windowSecs.Int64),
|
WindowSeconds: int(windowSecs.Int64),
|
||||||
Mode: mode.String,
|
Mode: mode.String,
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ const (
|
|||||||
type RateLimit struct {
|
type RateLimit struct {
|
||||||
Scope string
|
Scope string
|
||||||
RefID int64
|
RefID int64
|
||||||
AllowedIPs []string // legacy column; unused for new rows
|
|
||||||
MaxMessages int
|
MaxMessages int
|
||||||
WindowSeconds int
|
WindowSeconds int
|
||||||
Mode string // manual | auto
|
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.
|
// 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) {
|
func (s *Store) GetRateLimit(scope string, refID int64) (RateLimit, bool, error) {
|
||||||
row := s.db.QueryRow(
|
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)
|
FROM rate_limits WHERE scope = ? AND ref_id = ?`, scope, refID)
|
||||||
rl, err := scanRateLimit(row)
|
rl, err := scanRateLimit(row)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
@@ -93,16 +92,15 @@ func (s *Store) SetRateLimit(rl RateLimit) error {
|
|||||||
autoUpdated = rl.AutoUpdatedAt.UTC().Format(time.RFC3339)
|
autoUpdated = rl.AutoUpdatedAt.UTC().Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
`INSERT INTO rate_limits (scope, ref_id, allowed_ips, max_messages, window_seconds, mode, auto_multiplier, auto_updated_at)
|
`INSERT INTO rate_limits (scope, ref_id, max_messages, window_seconds, mode, auto_multiplier, auto_updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(scope, ref_id) DO UPDATE SET
|
ON CONFLICT(scope, ref_id) DO UPDATE SET
|
||||||
allowed_ips = excluded.allowed_ips,
|
|
||||||
max_messages = excluded.max_messages,
|
max_messages = excluded.max_messages,
|
||||||
window_seconds = excluded.window_seconds,
|
window_seconds = excluded.window_seconds,
|
||||||
mode = excluded.mode,
|
mode = excluded.mode,
|
||||||
auto_multiplier = excluded.auto_multiplier,
|
auto_multiplier = excluded.auto_multiplier,
|
||||||
auto_updated_at = excluded.auto_updated_at`,
|
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,
|
mode, autoMult, autoUpdated,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -144,11 +142,11 @@ func (s *Store) RateLimit(scope, ref string) (RateLimit, bool, error) {
|
|||||||
var query string
|
var query string
|
||||||
switch scope {
|
switch scope {
|
||||||
case RateLimitScopeDomain:
|
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
|
FROM rate_limits rl JOIN domains d ON d.id = rl.ref_id
|
||||||
WHERE rl.scope = 'domain' AND d.name = ?`
|
WHERE rl.scope = 'domain' AND d.name = ?`
|
||||||
case RateLimitScopeApp:
|
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
|
FROM rate_limits rl JOIN applications a ON a.id = rl.ref_id
|
||||||
WHERE rl.scope = 'application' AND a.login = ?`
|
WHERE rl.scope = 'application' AND a.login = ?`
|
||||||
default:
|
default:
|
||||||
@@ -196,23 +194,21 @@ func (s *Store) CountMessages(scope, ref string, since time.Time) (int64, error)
|
|||||||
return n, nil
|
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
|
// by leaving the corresponding field zero, which makes the limit inert via
|
||||||
// Active() until max and window are both set.
|
// Active() until max and window are both set.
|
||||||
func scanRateLimit(r scanRow) (RateLimit, error) {
|
func scanRateLimit(r scanRow) (RateLimit, error) {
|
||||||
var (
|
var (
|
||||||
ips sql.NullString
|
|
||||||
maxMsgs sql.NullInt64
|
maxMsgs sql.NullInt64
|
||||||
windowSecs sql.NullInt64
|
windowSecs sql.NullInt64
|
||||||
mode sql.NullString
|
mode sql.NullString
|
||||||
autoMult sql.NullFloat64
|
autoMult sql.NullFloat64
|
||||||
autoUpdated sql.NullString
|
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
|
return RateLimit{}, err
|
||||||
}
|
}
|
||||||
rl := RateLimit{
|
rl := RateLimit{
|
||||||
AllowedIPs: splitIPs(ips.String),
|
|
||||||
MaxMessages: int(maxMsgs.Int64),
|
MaxMessages: int(maxMsgs.Int64),
|
||||||
WindowSeconds: int(windowSecs.Int64),
|
WindowSeconds: int(windowSecs.Int64),
|
||||||
Mode: mode.String,
|
Mode: mode.String,
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ func TestRateLimitSetGetDelete(t *testing.T) {
|
|||||||
want := RateLimit{
|
want := RateLimit{
|
||||||
Scope: RateLimitScopeDomain,
|
Scope: RateLimitScopeDomain,
|
||||||
RefID: d.ID,
|
RefID: d.ID,
|
||||||
AllowedIPs: []string{"203.0.113.1", "203.0.113.2"},
|
|
||||||
MaxMessages: 100,
|
MaxMessages: 100,
|
||||||
WindowSeconds: 3600,
|
WindowSeconds: 3600,
|
||||||
}
|
}
|
||||||
@@ -32,19 +31,17 @@ func TestRateLimitSetGetDelete(t *testing.T) {
|
|||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
t.Fatalf("GetRateLimit after set: ok=%v err=%v", ok, err)
|
t.Fatalf("GetRateLimit after set: ok=%v err=%v", ok, err)
|
||||||
}
|
}
|
||||||
if got.MaxMessages != 100 || got.WindowSeconds != 3600 || len(got.AllowedIPs) != 2 ||
|
if got.MaxMessages != 100 || got.WindowSeconds != 3600 {
|
||||||
got.AllowedIPs[0] != "203.0.113.1" || got.AllowedIPs[1] != "203.0.113.2" {
|
|
||||||
t.Fatalf("roundtrip mismatch: %+v", got)
|
t.Fatalf("roundtrip mismatch: %+v", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert replaces in place (UNIQUE(scope, ref_id)).
|
// Upsert replaces in place (UNIQUE(scope, ref_id)).
|
||||||
want.MaxMessages = 5
|
want.MaxMessages = 5
|
||||||
want.AllowedIPs = []string{"198.51.100.9"}
|
|
||||||
if err := st.SetRateLimit(want); err != nil {
|
if err := st.SetRateLimit(want); err != nil {
|
||||||
t.Fatalf("SetRateLimit upsert: %v", err)
|
t.Fatalf("SetRateLimit upsert: %v", err)
|
||||||
}
|
}
|
||||||
got, _, _ = st.GetRateLimit(RateLimitScopeDomain, d.ID)
|
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)
|
t.Fatalf("upsert did not replace: %+v", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,10 +61,10 @@ func TestRateLimitByNameAndLogin(t *testing.T) {
|
|||||||
t.Fatalf("AddApplication: %v", err)
|
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)
|
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)
|
t.Fatalf("set app limit: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,9 +128,9 @@ func TestDeleteRateLimitsForDomain(t *testing.T) {
|
|||||||
a, _ := st.AddApplication(d.ID, "app1", AddressModeWildcard, nil)
|
a, _ := st.AddApplication(d.ID, "app1", AddressModeWildcard, nil)
|
||||||
other, _ := st.AddDomain("other.example", "selfpost")
|
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: RateLimitScopeDomain, RefID: d.ID, 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: RateLimitScopeApp, RefID: a.ID, 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: other.ID, MaxMessages: 1, WindowSeconds: 60})
|
||||||
|
|
||||||
if err := st.DeleteRateLimitsForDomain(d.ID); err != nil {
|
if err := st.DeleteRateLimitsForDomain(d.ID); err != nil {
|
||||||
t.Fatalf("DeleteRateLimitsForDomain: %v", err)
|
t.Fatalf("DeleteRateLimitsForDomain: %v", err)
|
||||||
@@ -153,9 +150,9 @@ func TestDeleteRateLimitsForDomain(t *testing.T) {
|
|||||||
func TestRateLimitActive(t *testing.T) {
|
func TestRateLimitActive(t *testing.T) {
|
||||||
inactive := []RateLimit{
|
inactive := []RateLimit{
|
||||||
{},
|
{},
|
||||||
{Scope: RateLimitScopeDomain, AllowedIPs: []string{"203.0.113.1"}}, // no ceiling
|
{Scope: RateLimitScopeDomain}, // no ceiling
|
||||||
{Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window
|
{Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window
|
||||||
{Scope: RateLimitScopeApp, MaxMessages: 5}, // no window
|
{Scope: RateLimitScopeApp, MaxMessages: 5}, // no window
|
||||||
}
|
}
|
||||||
for i, rl := range inactive {
|
for i, rl := range inactive {
|
||||||
if rl.Active() {
|
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 (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"embed"
|
"embed"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -50,6 +51,14 @@ func (s *Store) Close() error {
|
|||||||
return s.db.Close()
|
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
|
// migrate applies embedded migrations in filename order, tracking progress via
|
||||||
// SQLite's PRAGMA user_version so each migration runs at most once.
|
// SQLite's PRAGMA user_version so each migration runs at most once.
|
||||||
func (s *Store) migrate() error {
|
func (s *Store) migrate() error {
|
||||||
@@ -70,6 +79,23 @@ func (s *Store) migrate() error {
|
|||||||
return fmt.Errorf("read schema version: %w", err)
|
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 {
|
for i, name := range names {
|
||||||
target := i + 1
|
target := i + 1
|
||||||
if target <= version {
|
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.
|
// Role identifies a panel user's access level.
|
||||||
type Role string
|
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 (
|
const (
|
||||||
RoleGlobal Role = "global"
|
RoleGlobal Role = "global"
|
||||||
RoleDomainAdmin Role = "domain_admin"
|
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).
|
// User is a panel login (not an application SASL account).
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int64
|
ID int64
|
||||||
Username string
|
Username string
|
||||||
PasswordHash string
|
PasswordHash string
|
||||||
Role Role
|
Role Role
|
||||||
DMARCReportEmail string
|
// Email is the user's own address — what the panel writes to. DMARC may
|
||||||
CreatedAt time.Time
|
// use it (DMARCDefaultAccount) but it is not a DMARC field.
|
||||||
DomainIDs []int64
|
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).
|
// 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)
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
_, err = s.db.Exec(
|
_, 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,
|
username, passwordHash, RoleGlobal, now,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -69,23 +144,17 @@ func (s *Store) CreateGlobalUser(username, passwordHash string) error {
|
|||||||
|
|
||||||
// GetUserByUsername returns a user with domain assignments loaded.
|
// GetUserByUsername returns a user with domain assignments loaded.
|
||||||
func (s *Store) GetUserByUsername(username string) (User, error) {
|
func (s *Store) GetUserByUsername(username string) (User, error) {
|
||||||
var (
|
u, err := scanUser(s.db.QueryRow("SELECT "+userColumns+" FROM users WHERE username = ?", username))
|
||||||
u User
|
|
||||||
createdAt string
|
|
||||||
)
|
|
||||||
err := s.db.QueryRow(
|
|
||||||
"SELECT id, username, password_hash, role, dmarc_report_email, created_at FROM users WHERE username = ?",
|
|
||||||
username,
|
|
||||||
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.DMARCReportEmail, &createdAt)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return User{}, ErrUserNotFound
|
return User{}, ErrUserNotFound
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return User{}, fmt.Errorf("get user by username: %w", err)
|
return User{}, fmt.Errorf("get user by username: %w", err)
|
||||||
}
|
}
|
||||||
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
if u.DomainIDs, err = s.listUserDomainIDs(u.ID); err != nil {
|
||||||
u.DomainIDs, err = s.listUserDomainIDs(u.ID)
|
return User{}, err
|
||||||
if err != nil {
|
}
|
||||||
|
if u.InboundDomainIDs, err = s.listUserInboundDomainIDs(u.ID); err != nil {
|
||||||
return User{}, err
|
return User{}, err
|
||||||
}
|
}
|
||||||
return u, nil
|
return u, nil
|
||||||
@@ -93,47 +162,35 @@ func (s *Store) GetUserByUsername(username string) (User, error) {
|
|||||||
|
|
||||||
// GetUser returns a user by id with domain assignments.
|
// GetUser returns a user by id with domain assignments.
|
||||||
func (s *Store) GetUser(id int64) (User, error) {
|
func (s *Store) GetUser(id int64) (User, error) {
|
||||||
var (
|
u, err := scanUser(s.db.QueryRow("SELECT "+userColumns+" FROM users WHERE id = ?", id))
|
||||||
u User
|
|
||||||
createdAt string
|
|
||||||
)
|
|
||||||
err := s.db.QueryRow(
|
|
||||||
"SELECT id, username, password_hash, role, dmarc_report_email, created_at FROM users WHERE id = ?",
|
|
||||||
id,
|
|
||||||
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.DMARCReportEmail, &createdAt)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return User{}, ErrUserNotFound
|
return User{}, ErrUserNotFound
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return User{}, fmt.Errorf("get user: %w", err)
|
return User{}, fmt.Errorf("get user: %w", err)
|
||||||
}
|
}
|
||||||
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
if u.DomainIDs, err = s.listUserDomainIDs(u.ID); err != nil {
|
||||||
u.DomainIDs, err = s.listUserDomainIDs(u.ID)
|
return User{}, err
|
||||||
if err != nil {
|
}
|
||||||
|
if u.InboundDomainIDs, err = s.listUserInboundDomainIDs(u.ID); err != nil {
|
||||||
return User{}, err
|
return User{}, err
|
||||||
}
|
}
|
||||||
return u, nil
|
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) {
|
func (s *Store) ListUsers() ([]User, error) {
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query("SELECT " + userColumns + " FROM users ORDER BY id")
|
||||||
"SELECT id, username, password_hash, role, dmarc_report_email, created_at FROM users ORDER BY id",
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list users: %w", err)
|
return nil, fmt.Errorf("list users: %w", err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var users []User
|
var users []User
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var (
|
u, err := scanUser(rows)
|
||||||
u User
|
if err != nil {
|
||||||
createdAt string
|
|
||||||
)
|
|
||||||
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.DMARCReportEmail, &createdAt); err != nil {
|
|
||||||
return nil, fmt.Errorf("list users scan: %w", err)
|
return nil, fmt.Errorf("list users scan: %w", err)
|
||||||
}
|
}
|
||||||
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
|
||||||
users = append(users, u)
|
users = append(users, u)
|
||||||
}
|
}
|
||||||
return users, rows.Err()
|
return users, rows.Err()
|
||||||
@@ -177,12 +234,12 @@ func (s *Store) CountGlobalUsers() (int, error) {
|
|||||||
|
|
||||||
// CreateUser inserts a panel user and optional domain assignments.
|
// CreateUser inserts a panel user and optional domain assignments.
|
||||||
func (s *Store) CreateUser(username, passwordHash string, role Role, domainIDs []int64) (int64, error) {
|
func (s *Store) CreateUser(username, passwordHash string, role Role, domainIDs []int64) (int64, error) {
|
||||||
if role == RoleDomainAdmin && len(domainIDs) == 0 {
|
if role == RoleDomain && len(domainIDs) == 0 {
|
||||||
return 0, fmt.Errorf("create user: domain_admin requires domains")
|
return 0, fmt.Errorf("create user: the domain role requires domains")
|
||||||
}
|
}
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
res, err := s.db.Exec(
|
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,
|
username, passwordHash, role, now,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -195,7 +252,7 @@ func (s *Store) CreateUser(username, passwordHash string, role Role, domainIDs [
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("create user id: %w", err)
|
return 0, fmt.Errorf("create user id: %w", err)
|
||||||
}
|
}
|
||||||
if role == RoleDomainAdmin {
|
if role == RoleDomain {
|
||||||
if err := s.setUserDomains(id, domainIDs); err != nil {
|
if err := s.setUserDomains(id, domainIDs); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -203,15 +260,11 @@ func (s *Store) CreateUser(username, passwordHash string, role Role, domainIDs [
|
|||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateUser replaces username, password hash, and DMARC email for a user.
|
// UpdateUser replaces a user's username, password hash and account e-mail.
|
||||||
func (s *Store) UpdateUser(id int64, username, passwordHash, dmarcReportEmail string) error {
|
func (s *Store) UpdateUser(id int64, username, passwordHash, email string) error {
|
||||||
u, err := s.GetUser(id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
res, err := s.db.Exec(
|
res, err := s.db.Exec(
|
||||||
"UPDATE users SET username = ?, password_hash = ?, dmarc_report_email = ? WHERE id = ?",
|
"UPDATE users SET username = ?, password_hash = ?, email = ? WHERE id = ?",
|
||||||
username, passwordHash, dmarcReportEmail, id,
|
username, passwordHash, email, id,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isUniqueViolation(err) {
|
if isUniqueViolation(err) {
|
||||||
@@ -226,14 +279,50 @@ func (s *Store) UpdateUser(id int64, username, passwordHash, dmarcReportEmail st
|
|||||||
if n == 0 {
|
if n == 0 {
|
||||||
return ErrUserNotFound
|
return ErrUserNotFound
|
||||||
}
|
}
|
||||||
if u.Role == RoleGlobal {
|
return nil
|
||||||
if err := s.SetSetting("dmarc_report_email", dmarcReportEmail); err != nil {
|
}
|
||||||
return err
|
|
||||||
}
|
// 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
|
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.
|
// SetUserRole updates a user's role.
|
||||||
func (s *Store) SetUserRole(userID int64, role Role) error {
|
func (s *Store) SetUserRole(userID int64, role Role) error {
|
||||||
res, err := s.db.Exec("UPDATE users SET role = ? WHERE id = ?", role, userID)
|
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
|
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 {
|
func (s *Store) SetUserDomains(userID int64, domainIDs []int64) error {
|
||||||
u, err := s.GetUser(userID)
|
u, err := s.GetUser(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if u.Role != RoleDomainAdmin {
|
if u.Role != RoleDomain {
|
||||||
return fmt.Errorf("set user domains: user is not domain_admin")
|
return fmt.Errorf("set user domains: user does not have the domain role")
|
||||||
}
|
}
|
||||||
if len(domainIDs) == 0 {
|
if len(domainIDs) == 0 {
|
||||||
return fmt.Errorf("set user domains: at least one domain required")
|
return fmt.Errorf("set user domains: at least one domain required")
|
||||||
@@ -303,9 +392,22 @@ func (s *Store) DeleteUser(id int64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GlobalDMARCReportEmail returns the shared default rua= for domain inherit mode.
|
func (s *Store) listUserInboundDomainIDs(userID int64) ([]int64, error) {
|
||||||
func (s *Store) GlobalDMARCReportEmail() (string, error) {
|
rows, err := s.db.Query(
|
||||||
return s.GetSetting("dmarc_report_email")
|
"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) {
|
func (s *Store) listUserDomainIDs(userID int64) ([]int64, error) {
|
||||||
|
|||||||
@@ -23,19 +23,20 @@ func TestUpdateUser(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetUser: %v", err)
|
t.Fatalf("GetUser: %v", err)
|
||||||
}
|
}
|
||||||
if got.DMARCReportEmail != "reports@hub.example" {
|
if got.Email != "reports@hub.example" {
|
||||||
t.Fatalf("dmarc email = %q", got.DMARCReportEmail)
|
t.Fatalf("email = %q", got.Email)
|
||||||
}
|
}
|
||||||
email, err := st.GlobalDMARCReportEmail()
|
// The account e-mail is the user's own: saving it does not make it anyone's
|
||||||
if err != nil {
|
// report address, and nothing is mirrored into the instance settings.
|
||||||
t.Fatalf("GlobalDMARCReportEmail: %v", err)
|
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" {
|
if v, err := st.GetSetting("dmarc_report_email"); err == nil && v != "" {
|
||||||
t.Fatalf("settings dmarc = %q", email)
|
t.Fatalf("the e-mail leaked into the instance settings: %q", v)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := st.UpdateUser(u.ID, "operator", "hash-three", ""); err != nil {
|
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)
|
got, err = st.GetUser(u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -73,7 +74,7 @@ func TestCreateDomainAdminUser(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("AddDomain: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("CreateUser: %v", err)
|
t.Fatalf("CreateUser: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ const (
|
|||||||
type Role = store.Role
|
type Role = store.Role
|
||||||
|
|
||||||
const (
|
const (
|
||||||
RoleGlobal = store.RoleGlobal
|
RoleGlobal = store.RoleGlobal
|
||||||
RoleDomainAdmin = store.RoleDomainAdmin
|
RoleDomain = store.RoleDomain
|
||||||
)
|
)
|
||||||
|
|
||||||
// Principal is the authenticated panel user attached to a request.
|
// Principal is the authenticated panel user attached to a request.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/dmarc"
|
||||||
"github.com/mixeme/selfpost/internal/store"
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
"github.com/mixeme/selfpost/internal/web/auth"
|
"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)
|
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 {
|
func domainNameSet(domains []store.Domain) map[string]bool {
|
||||||
m := make(map[string]bool, len(domains))
|
m := make(map[string]bool, len(domains))
|
||||||
for _, d := range 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
|
// What DNS actually publishes for the domain today, checked against the key
|
||||||
// this server signs with. Cached by the checker, so re-rendering the page
|
// this server signs with. Cached by the checker, so re-rendering the page
|
||||||
// after a form post costs nothing.
|
// after a form post costs nothing.
|
||||||
profileEmail, err := h.store.GlobalDMARCReportEmail()
|
reportEmail, err := h.domainReportAddress(d)
|
||||||
if err != nil {
|
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)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
reportEmail := dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail)
|
dns, srv := h.domainDNS(d, record, reportEmail, false)
|
||||||
dns, srv := h.domainDNS(d, record, profileEmail, false)
|
|
||||||
reportAuthName, reportAuthValue, needsReportAuth := dnscheck.ExternalReportAuth(d.Name, reportEmail)
|
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"
|
dmarcMode := "inherit"
|
||||||
dmarcCustom := ""
|
dmarcCustom := ""
|
||||||
hostedAddr := ""
|
profileEmail := reportEmail
|
||||||
if h.dmarc != nil && h.cfg.DMARCEnabled {
|
if !d.DMARCRuaUserID.Valid {
|
||||||
hostedAddr = dmarc.HostedReportAddress(h.cfg.Hostname, d.Name)
|
profileEmail = ""
|
||||||
}
|
if p, ok := h.principal(r); ok {
|
||||||
if d.DMARCRua.Valid {
|
if def, err := h.store.GetDMARCDefault(p.ID); err == nil {
|
||||||
if d.DMARCRua.String == "" {
|
profileEmail = def.Resolve(hostedAddr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case d.DMARCRua == "":
|
||||||
dmarcMode = "none"
|
dmarcMode = "none"
|
||||||
} else if hostedAddr != "" && strings.EqualFold(d.DMARCRua.String, hostedAddr) {
|
case hostedAddr != "" && strings.EqualFold(d.DMARCRua, hostedAddr):
|
||||||
dmarcMode = "hosted"
|
dmarcMode = "hosted"
|
||||||
} else {
|
default:
|
||||||
dmarcMode = "custom"
|
dmarcMode = "custom"
|
||||||
dmarcCustom = d.DMARCRua.String
|
dmarcCustom = d.DMARCRua
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dmarcSource := "policy"
|
dmarcSource := "policy"
|
||||||
@@ -186,7 +194,7 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
|
|||||||
dmarcSource = "custom"
|
dmarcSource = "custom"
|
||||||
case dmarcMode == "none":
|
case dmarcMode == "none":
|
||||||
dmarcSource = "none"
|
dmarcSource = "none"
|
||||||
case profileEmail != "":
|
case reportEmail != "":
|
||||||
dmarcSource = "settings"
|
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
|
// and no extra environment variable is needed. That server result is returned
|
||||||
// alongside, because the page's suggested SPF record is built from the same
|
// alongside, because the page's suggested SPF record is built from the same
|
||||||
// addresses. force bypasses the cache, for the Re-check button.
|
// addresses. force bypasses the cache, for the Re-check button.
|
||||||
func (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)
|
srv := h.dns.Server(h.cfg.Hostname, false)
|
||||||
return h.dns.Domain(dnscheck.Query{
|
return h.dns.Domain(dnscheck.Query{
|
||||||
Name: d.Name,
|
Name: d.Name,
|
||||||
@@ -258,7 +266,7 @@ func (h *Handlers) domainDNS(d store.Domain, record domain.DKIMRecord, profileEm
|
|||||||
ExpectedDKIM: record.Value,
|
ExpectedDKIM: record.Value,
|
||||||
Hostname: srv.Hostname,
|
Hostname: srv.Hostname,
|
||||||
ServerIPs: srv.IPs,
|
ServerIPs: srv.IPs,
|
||||||
DMARCReportEmail: dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail),
|
DMARCReportEmail: reportEmail,
|
||||||
}, force), srv
|
}, force), srv
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,13 +283,13 @@ func (h *Handlers) HandleDomainDNSRecheck(w http.ResponseWriter, r *http.Request
|
|||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
profileEmail, err := h.store.GlobalDMARCReportEmail()
|
reportEmail, err := h.domainReportAddress(d)
|
||||||
if err != nil {
|
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)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
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)
|
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)
|
h.renderBackupPage(w, r, status, msg)
|
||||||
return
|
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)
|
http.Redirect(w, r, fmt.Sprintf("/domains/%d?imported=1", d.ID), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -26,12 +25,14 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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")) {
|
switch strings.TrimSpace(r.PostFormValue("dmarc_rua_mode")) {
|
||||||
case "inherit":
|
case "inherit":
|
||||||
rua = sql.NullString{}
|
follow = true
|
||||||
case "none":
|
case "none":
|
||||||
rua = sql.NullString{Valid: true, String: ""}
|
|
||||||
case "custom":
|
case "custom":
|
||||||
email := strings.TrimSpace(r.PostFormValue("dmarc_rua_email"))
|
email := strings.TrimSpace(r.PostFormValue("dmarc_rua_email"))
|
||||||
if err := validate.Email(email); err != nil {
|
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."})
|
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Enter a custom report address or choose another mode."})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rua = sql.NullString{Valid: true, String: email}
|
rua = email
|
||||||
case "hosted":
|
case "hosted":
|
||||||
if h.dmarc == nil || !h.dmarc.Enabled() {
|
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."})
|
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "SelfPost-hosted reports are not enabled on this server."})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
addr := dmarc.HostedReportAddress(h.cfg.Hostname, d.Name)
|
rua = dmarc.HostedReportAddress(h.cfg.Hostname, d.Name)
|
||||||
rua = sql.NullString{Valid: true, String: addr}
|
|
||||||
default:
|
default:
|
||||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Choose how aggregate reports are addressed for this domain."})
|
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Choose how aggregate reports are addressed for this domain."})
|
||||||
return
|
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)
|
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."})
|
h.renderDomainDetail(w, r, http.StatusInternalServerError, d, detailView{FormErr: "Could not save DMARC settings. Please check the logs and try again."})
|
||||||
return
|
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 {
|
func (h *Handlers) domainRows(domains []store.Domain) []domainRow {
|
||||||
profileEmail := ""
|
|
||||||
if email, err := h.store.GlobalDMARCReportEmail(); err == nil {
|
|
||||||
profileEmail = email
|
|
||||||
}
|
|
||||||
rows := make([]domainRow, len(domains))
|
rows := make([]domainRow, len(domains))
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for i, d := range domains {
|
for i, d := range domains {
|
||||||
@@ -67,7 +63,12 @@ func (h *Handlers) domainRows(domains []store.Domain) []domainRow {
|
|||||||
logf("panel: dashboard: domain %d: dkim record: %v", d.ID, err)
|
logf("panel: dashboard: domain %d: dkim record: %v", d.ID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dns, _ := 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
|
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
|
// OpenDKIM reload), and redirects to the domain's page so the DNS record to
|
||||||
// publish is shown (product.md).
|
// publish is shown (product.md).
|
||||||
func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
|
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
|
return
|
||||||
}
|
}
|
||||||
if err := r.ParseForm(); err != nil {
|
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)
|
"Could not add the domain. Please check the logs and try again.", raw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.followCreator(d.ID, p.ID)
|
||||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther)
|
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.
|
// HandleDeleteConfirm shows the cascade warning before a domain is removed.
|
||||||
func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
|
||||||
if _, ok := h.requireGlobal(w, r); !ok {
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("add placeholder domain: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("create domain admin %s: %v", username, err)
|
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}
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("create domain admin %s: %v", username, err)
|
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)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
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:
|
case http.MethodPost:
|
||||||
h.submitSettings(w, r)
|
h.submitSettings(w, r)
|
||||||
default:
|
default:
|
||||||
@@ -128,7 +128,7 @@ func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
dmarcEmail := strings.TrimSpace(r.PostFormValue("dmarc_report_email"))
|
dmarcEmail := strings.TrimSpace(r.PostFormValue("dmarc_report_email"))
|
||||||
formRetention := currentRetention
|
formRetention := currentRetention
|
||||||
if !p.IsGlobal() {
|
if !p.IsGlobal() {
|
||||||
dmarcEmail = user.DMARCReportEmail
|
dmarcEmail = user.Email
|
||||||
} else if raw := strings.TrimSpace(r.PostFormValue("send_log_retention_days")); raw != "" {
|
} else if raw := strings.TrimSpace(r.PostFormValue("send_log_retention_days")); raw != "" {
|
||||||
parsed, err := strconv.Atoi(raw)
|
parsed, err := strconv.Atoi(raw)
|
||||||
if err != nil {
|
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
|
retentionChanging := false
|
||||||
if p.IsGlobal() && formRetention != currentRetention {
|
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 retentionChanging {
|
||||||
if err := h.store.SetSendLogRetentionDays(formRetention); err != nil {
|
if err := h.store.SetSendLogRetentionDays(formRetention); err != nil {
|
||||||
logf("panel: settings: set send-log retention failed: %v", err)
|
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 {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
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:
|
case http.MethodPost:
|
||||||
h.submitUserCreate(w, r)
|
h.submitUserCreate(w, r)
|
||||||
default:
|
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)})
|
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
|
||||||
return
|
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)})
|
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
|
||||||
return
|
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)})
|
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
|
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})
|
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||||
return
|
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})
|
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||||
return
|
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})
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.Role == store.RoleGlobal && role == store.RoleDomainAdmin {
|
if u.Role == store.RoleGlobal && role == store.RoleDomain {
|
||||||
n, err := h.store.CountGlobalUsers()
|
n, err := h.store.CountGlobalUsers()
|
||||||
if err != nil || n <= 1 {
|
if err != nil || n <= 1 {
|
||||||
msg := "Cannot demote the last global administrator."
|
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)
|
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) {
|
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})
|
h.renderUserForm(w, r, http.StatusConflict, u.ID, userFormView{FormErr: "That username is already in use.", FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||||
return
|
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 {
|
if err := h.store.SetUserDomains(u.ID, domainIDs); err != nil {
|
||||||
logf("panel: set user domains: %v", err)
|
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})
|
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>
|
<label for="role">Role</label>
|
||||||
{{if .LastGlobalLocked}}<input type="hidden" name="role" value="{{.FormRole}}">{{end}}
|
{{if .LastGlobalLocked}}<input type="hidden" name="role" value="{{.FormRole}}">{{end}}
|
||||||
<select id="role" name="role" data-global-role="{{.GlobalRole}}"{{if .LastGlobalLocked}} disabled{{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>
|
<option value="global" {{if eq .FormRole "global"}}selected{{end}}>Global administrator</option>
|
||||||
</select>
|
</select>
|
||||||
{{if .LastGlobalLocked}}<p class="muted">The only global administrator cannot be demoted.</p>{{end}}
|
{{if .LastGlobalLocked}}<p class="muted">The only global administrator cannot be demoted.</p>{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user