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:
+44
-12
@@ -25,9 +25,13 @@ type Domain struct {
|
||||
ID int64
|
||||
Name string
|
||||
DKIMSelector string
|
||||
DMARCRua sql.NullString // NULL = inherit profile; Valid+empty = no reports
|
||||
CreatedAt time.Time
|
||||
AppCount int
|
||||
// Where DMARC aggregate reports go. With DMARCRuaUserID set the domain
|
||||
// follows that user's default (DMARCDefault) and DMARCRua is ignored;
|
||||
// otherwise DMARCRua is the address itself, "" meaning no reports.
|
||||
DMARCRua string
|
||||
DMARCRuaUserID sql.NullInt64
|
||||
CreatedAt time.Time
|
||||
AppCount int
|
||||
}
|
||||
|
||||
// AddDomain inserts a new sending domain. The caller is responsible for having
|
||||
@@ -56,7 +60,7 @@ func (s *Store) AddDomain(name, selector string) (Domain, error) {
|
||||
// ordered by name.
|
||||
func (s *Store) ListDomains() ([]Domain, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at,
|
||||
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.dmarc_rua_user_id, d.created_at,
|
||||
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
|
||||
FROM domains d
|
||||
ORDER BY d.name`)
|
||||
@@ -80,7 +84,7 @@ func (s *Store) ListDomains() ([]Domain, error) {
|
||||
// ordered by name.
|
||||
func (s *Store) ListDomainsForUser(userID int64) ([]Domain, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at,
|
||||
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.dmarc_rua_user_id, d.created_at,
|
||||
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
|
||||
FROM domains d
|
||||
INNER JOIN user_domains ud ON ud.domain_id = d.id
|
||||
@@ -106,7 +110,7 @@ func (s *Store) ListDomainsForUser(userID int64) ([]Domain, error) {
|
||||
// ErrDomainNotFound.
|
||||
func (s *Store) GetDomain(id int64) (Domain, error) {
|
||||
row := s.db.QueryRow(`
|
||||
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at,
|
||||
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.dmarc_rua_user_id, d.created_at,
|
||||
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
|
||||
FROM domains d
|
||||
WHERE d.id = ?`, id)
|
||||
@@ -148,7 +152,7 @@ func scanDomain(r scanRow) (Domain, error) {
|
||||
d Domain
|
||||
createdAt string
|
||||
)
|
||||
if err := r.Scan(&d.ID, &d.Name, &d.DKIMSelector, &d.DMARCRua, &createdAt, &d.AppCount); err != nil {
|
||||
if err := r.Scan(&d.ID, &d.Name, &d.DKIMSelector, &d.DMARCRua, &d.DMARCRuaUserID, &createdAt, &d.AppCount); err != nil {
|
||||
return Domain{}, err
|
||||
}
|
||||
d.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
@@ -166,11 +170,21 @@ func isUniqueViolation(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// UpdateDomainDMARCRua sets how this domain resolves its DMARC rua= destination.
|
||||
// NULL means inherit the administrator profile; Valid with an empty string means
|
||||
// policy-only with no aggregate reports for this domain.
|
||||
func (s *Store) UpdateDomainDMARCRua(id int64, rua sql.NullString) error {
|
||||
res, err := s.db.Exec("UPDATE domains SET dmarc_rua = ? WHERE id = ?", rua, id)
|
||||
// SetDomainDMARCAddress gives the domain a report address of its own; "" means
|
||||
// no aggregate reports. The domain stops following anyone's default.
|
||||
func (s *Store) SetDomainDMARCAddress(id int64, rua string) error {
|
||||
return s.updateDomainDMARC(id, rua, sql.NullInt64{})
|
||||
}
|
||||
|
||||
// SetDomainDMARCUser makes the domain follow userID's default report address
|
||||
// (DMARCDefault). If that user is later deleted the domain falls back to no
|
||||
// reports rather than to someone else's default.
|
||||
func (s *Store) SetDomainDMARCUser(id, userID int64) error {
|
||||
return s.updateDomainDMARC(id, "", sql.NullInt64{Int64: userID, Valid: true})
|
||||
}
|
||||
|
||||
func (s *Store) updateDomainDMARC(id int64, rua string, userID sql.NullInt64) error {
|
||||
res, err := s.db.Exec("UPDATE domains SET dmarc_rua = ?, dmarc_rua_user_id = ? WHERE id = ?", rua, userID, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update domain dmarc rua: %w", err)
|
||||
}
|
||||
@@ -184,6 +198,24 @@ func (s *Store) UpdateDomainDMARCRua(id int64, rua sql.NullString) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DomainDMARCRua resolves where the domain's aggregate reports go: its own
|
||||
// address, or the default of the user it follows. hosted is the SelfPost-hosted
|
||||
// mailbox for this domain, "" when report ingest is off (the store does not
|
||||
// know the server's hostname). The result is "" for a policy-only record.
|
||||
func (s *Store) DomainDMARCRua(d Domain, hosted string) (string, error) {
|
||||
if !d.DMARCRuaUserID.Valid {
|
||||
return d.DMARCRua, nil
|
||||
}
|
||||
def, err := s.GetDMARCDefault(d.DMARCRuaUserID.Int64)
|
||||
if errors.Is(err, ErrUserNotFound) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return def.Resolve(hosted), nil
|
||||
}
|
||||
|
||||
// DomainExists reports whether a sending domain with this name is configured.
|
||||
// Used by the DMARC ingest path to refuse reports for domains this relay does
|
||||
// not send for (security.md: report XML is attacker-supplied input).
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDomainDMARCRua(t *testing.T) {
|
||||
const hostedForTest = "dmarc+example.com@mail.example.org"
|
||||
|
||||
// A domain either has a report address of its own or follows a user's default;
|
||||
// the two never mix, and a fresh domain has neither.
|
||||
func TestDomainDMARCAddressAndUser(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
d, err := st.AddDomain("example.com", "sel")
|
||||
if err != nil {
|
||||
@@ -15,31 +19,123 @@ func TestDomainDMARCRua(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("GetDomain: %v", err)
|
||||
}
|
||||
if got.DMARCRua.Valid {
|
||||
t.Fatal("new domain should inherit profile")
|
||||
if got.DMARCRua != "" || got.DMARCRuaUserID.Valid {
|
||||
t.Fatalf("a new domain = %q / follows %v, want no reports", got.DMARCRua, got.DMARCRuaUserID)
|
||||
}
|
||||
|
||||
if err := st.UpdateDomainDMARCRua(d.ID, sql.NullString{Valid: true, String: "reports@hub.com"}); err != nil {
|
||||
t.Fatalf("UpdateDomainDMARCRua custom: %v", err)
|
||||
}
|
||||
got, err = st.GetDomain(d.ID)
|
||||
if err != nil || got.DMARCRua.String != "reports@hub.com" {
|
||||
t.Fatalf("custom = %+v, err=%v", got.DMARCRua, err)
|
||||
}
|
||||
|
||||
if err := st.UpdateDomainDMARCRua(d.ID, sql.NullString{Valid: true}); err != nil {
|
||||
t.Fatalf("UpdateDomainDMARCRua none: %v", err)
|
||||
if err := st.SetDomainDMARCAddress(d.ID, "reports@hub.com"); err != nil {
|
||||
t.Fatalf("SetDomainDMARCAddress: %v", err)
|
||||
}
|
||||
got, _ = st.GetDomain(d.ID)
|
||||
if !got.DMARCRua.Valid || got.DMARCRua.String != "" {
|
||||
t.Fatalf("none = %+v", got.DMARCRua)
|
||||
if rua, err := st.DomainDMARCRua(got, hostedForTest); err != nil || rua != "reports@hub.com" {
|
||||
t.Fatalf("own address resolves to %q, %v", rua, err)
|
||||
}
|
||||
|
||||
if err := st.UpdateDomainDMARCRua(d.ID, sql.NullString{}); err != nil {
|
||||
t.Fatalf("UpdateDomainDMARCRua inherit: %v", err)
|
||||
if err := st.CreateGlobalUser("admin", "hash"); err != nil {
|
||||
t.Fatalf("CreateGlobalUser: %v", err)
|
||||
}
|
||||
u, _ := st.GetUserByUsername("admin")
|
||||
if err := st.SetDomainDMARCUser(d.ID, u.ID); err != nil {
|
||||
t.Fatalf("SetDomainDMARCUser: %v", err)
|
||||
}
|
||||
got, _ = st.GetDomain(d.ID)
|
||||
if got.DMARCRua.Valid {
|
||||
t.Fatalf("inherit = %+v", got.DMARCRua)
|
||||
if got.DMARCRua != "" || !got.DMARCRuaUserID.Valid || got.DMARCRuaUserID.Int64 != u.ID {
|
||||
t.Fatalf("following a user left %q / %v; the own address must be dropped", got.DMARCRua, got.DMARCRuaUserID)
|
||||
}
|
||||
|
||||
if err := st.SetDomainDMARCAddress(d.ID, ""); err != nil {
|
||||
t.Fatalf("SetDomainDMARCAddress none: %v", err)
|
||||
}
|
||||
got, _ = st.GetDomain(d.ID)
|
||||
if got.DMARCRua != "" || got.DMARCRuaUserID.Valid {
|
||||
t.Fatalf("none = %q / %v", got.DMARCRua, got.DMARCRuaUserID)
|
||||
}
|
||||
|
||||
if err := st.SetDomainDMARCAddress(d.ID+100, "x@y.com"); !errors.Is(err, ErrDomainNotFound) {
|
||||
t.Fatalf("unknown domain = %v, want ErrDomainNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A domain that follows a user resolves to whatever that user's default says
|
||||
// today — every mode — and changes with it.
|
||||
func TestDomainFollowsUserDefault(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if err := st.CreateGlobalUser("admin", "hash"); err != nil {
|
||||
t.Fatalf("CreateGlobalUser: %v", err)
|
||||
}
|
||||
u, _ := st.GetUserByUsername("admin")
|
||||
if err := st.UpdateUser(u.ID, "admin", "hash", "mix@example.org"); err != nil {
|
||||
t.Fatalf("UpdateUser: %v", err)
|
||||
}
|
||||
d, _ := st.AddDomain("example.com", "sel")
|
||||
if err := st.SetDomainDMARCUser(d.ID, u.ID); err != nil {
|
||||
t.Fatalf("SetDomainDMARCUser: %v", err)
|
||||
}
|
||||
d, _ = st.GetDomain(d.ID)
|
||||
|
||||
for _, tc := range []struct {
|
||||
mode, address string
|
||||
hosted string
|
||||
want string
|
||||
}{
|
||||
{DMARCDefaultNone, "", hostedForTest, ""},
|
||||
{DMARCDefaultAccount, "", hostedForTest, "mix@example.org"},
|
||||
{DMARCDefaultCustom, "dmarc@hub.example", hostedForTest, "dmarc@hub.example"},
|
||||
{DMARCDefaultHosted, "", hostedForTest, hostedForTest},
|
||||
// Ingest switched off: a hosted default has no mailbox to point at.
|
||||
{DMARCDefaultHosted, "", "", ""},
|
||||
// The custom address belongs to the custom mode only.
|
||||
{DMARCDefaultAccount, "ignored@hub.example", hostedForTest, "mix@example.org"},
|
||||
} {
|
||||
if err := st.SetDMARCDefault(u.ID, tc.mode, tc.address); err != nil {
|
||||
t.Fatalf("SetDMARCDefault(%s): %v", tc.mode, err)
|
||||
}
|
||||
got, err := st.DomainDMARCRua(d, tc.hosted)
|
||||
if err != nil || got != tc.want {
|
||||
t.Errorf("mode %s, hosted %q: resolved %q, %v; want %q", tc.mode, tc.hosted, got, err, tc.want)
|
||||
}
|
||||
}
|
||||
def, err := st.GetDMARCDefault(u.ID)
|
||||
if err != nil || def.Address != "" || def.Username != "admin" {
|
||||
t.Errorf("default after a non-custom mode = %+v, %v; the custom address must be cleared", def, err)
|
||||
}
|
||||
if err := st.SetDMARCDefault(u.ID, "profile", ""); err == nil {
|
||||
t.Error("an unknown mode was stored")
|
||||
}
|
||||
if err := st.SetDMARCDefault(u.ID+100, DMARCDefaultNone, ""); !errors.Is(err, ErrUserNotFound) {
|
||||
t.Errorf("unknown user = %v, want ErrUserNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting the user a domain follows must leave the domain with no report
|
||||
// address — not with another user's default, and not with a dangling id.
|
||||
func TestDeletingTheFollowedUserLeavesNoReports(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if err := st.CreateGlobalUser("admin", "hash"); err != nil {
|
||||
t.Fatalf("CreateGlobalUser: %v", err)
|
||||
}
|
||||
d, _ := st.AddDomain("example.com", "sel")
|
||||
id, err := st.CreateUser("second", "hash", RoleGlobal, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
if err := st.SetDMARCDefault(id, DMARCDefaultCustom, "dmarc@hub.example"); err != nil {
|
||||
t.Fatalf("SetDMARCDefault: %v", err)
|
||||
}
|
||||
if err := st.SetDomainDMARCUser(d.ID, id); err != nil {
|
||||
t.Fatalf("SetDomainDMARCUser: %v", err)
|
||||
}
|
||||
if err := st.DeleteUser(id); err != nil {
|
||||
t.Fatalf("DeleteUser: %v", err)
|
||||
}
|
||||
got, err := st.GetDomain(d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDomain: %v", err)
|
||||
}
|
||||
if got.DMARCRuaUserID.Valid || got.DMARCRua != "" {
|
||||
t.Fatalf("after deleting the followed user: %q / %v, want no reports", got.DMARCRua, got.DMARCRuaUserID)
|
||||
}
|
||||
if rua, err := st.DomainDMARCRua(got, hostedForTest); err != nil || rua != "" {
|
||||
t.Fatalf("resolved %q, %v; want no reports", rua, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,103 @@
|
||||
-- Initial SelfPost schema. One SQLite file under /data holds the whole panel
|
||||
-- state so a single directory backup/restore is sufficient.
|
||||
-- SelfPost 2.0 baseline schema. One SQLite file under /data holds the whole
|
||||
-- panel state, so a single directory backup/restore is sufficient.
|
||||
--
|
||||
-- This file IS the 2.0 schema: the 1.x chain (0001..0010, with its admin table
|
||||
-- created and dropped again) was deleted, not squashed with an upgrade path —
|
||||
-- 2.0 starts from an empty data directory (docs/schema-migrations.md). Later
|
||||
-- changes ship as 0002_*.sql and onwards; this file is never edited again once
|
||||
-- 2.0.0 is released.
|
||||
|
||||
-- Single administrator account. Exactly one row is allowed; the presence of
|
||||
-- that row is what marks primary setup as complete, which is why the /setup
|
||||
-- route disappears once it exists.
|
||||
CREATE TABLE admin (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
-- Marks the file as a 2.x SelfPost database ("SP20"). store.Open refuses a
|
||||
-- database that has a schema version but not this mark, which is what a 1.x
|
||||
-- file looks like: its user_version would otherwise read as "already migrated".
|
||||
PRAGMA application_id = 1397764656;
|
||||
|
||||
-- Free-form key/value panel settings (retention overrides, misc flags).
|
||||
-- Free-form key/value settings of the instance: what is true whoever is signed
|
||||
-- in (send-log retention). Nothing that belongs to a user lives here.
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Sending domains managed through the panel. DKIM keys themselves live on disk
|
||||
-- under /data; this row records the selector and metadata.
|
||||
-- Panel logins (not application SASL accounts). The presence of a row is what
|
||||
-- marks primary setup as complete, which is why /setup disappears once one
|
||||
-- exists. role is a reach, not a rank: 'global' sees the whole instance,
|
||||
-- 'domain' only what is assigned below.
|
||||
--
|
||||
-- email is the user's own address — what the panel writes to. The DMARC
|
||||
-- default is a separate choice that may use it: dmarc_default_mode says where
|
||||
-- aggregate reports go for a domain that follows this user
|
||||
-- (domains.dmarc_rua_user_id): 'hosted' (SelfPost's own mailbox for that
|
||||
-- domain), 'account' (email above), 'custom' (dmarc_default_address) or 'none'.
|
||||
--
|
||||
-- all_domains / all_inbound_domains widen a 'domain' user's list to every
|
||||
-- domain of that direction, including the ones added later; with a flag set
|
||||
-- the rows of the matching assignment table are ignored.
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('global', 'domain')),
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
dmarc_default_mode TEXT NOT NULL DEFAULT 'none'
|
||||
CHECK (dmarc_default_mode IN ('hosted', 'account', 'custom', 'none')),
|
||||
dmarc_default_address TEXT NOT NULL DEFAULT '',
|
||||
all_domains INTEGER NOT NULL DEFAULT 0 CHECK (all_domains IN (0, 1)),
|
||||
all_inbound_domains INTEGER NOT NULL DEFAULT 0 CHECK (all_inbound_domains IN (0, 1)),
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Panel login sessions. Persisted so a login survives a container restart or
|
||||
-- redeploy; only the SHA-256 of the session token is stored, never the token
|
||||
-- itself, so a stolen database file cannot be used to sign in. expires_at
|
||||
-- implements the sliding idle timeout: it is pushed forward on activity rather
|
||||
-- than being fixed at creation time.
|
||||
CREATE TABLE sessions (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_sessions_expires_at ON sessions (expires_at);
|
||||
|
||||
-- Sending (outbound) domains. DKIM keys themselves live on disk under /data;
|
||||
-- this row records the selector and metadata.
|
||||
--
|
||||
-- Where the domain's DMARC aggregate reports go (rua=): with dmarc_rua_user_id
|
||||
-- set, the domain follows that user's default and dmarc_rua is ignored;
|
||||
-- otherwise dmarc_rua is the address itself, '' meaning no reports. Deleting
|
||||
-- the user leaves the domain at "none" rather than silently handing its
|
||||
-- reports to someone else.
|
||||
CREATE TABLE domains (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
dkim_selector TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
dkim_selector TEXT NOT NULL,
|
||||
dmarc_rua TEXT NOT NULL DEFAULT '',
|
||||
dmarc_rua_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Outbound domains assigned to a 'domain' user.
|
||||
CREATE TABLE user_domains (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, domain_id)
|
||||
);
|
||||
|
||||
-- Applications bound to a domain. address_mode is either the domain wildcard or
|
||||
-- an explicit address list; the SASL login is globally unique.
|
||||
--
|
||||
-- auth_ip_restrict / auth_allowed_ips restrict which client IPs may submit as
|
||||
-- the application at all (refused otherwise). That is independent of the
|
||||
-- level-2 rate limits below.
|
||||
CREATE TABLE applications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
||||
login TEXT NOT NULL UNIQUE,
|
||||
address_mode TEXT NOT NULL CHECK (address_mode IN ('wildcard', 'list')),
|
||||
created_at TEXT NOT NULL
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
||||
login TEXT NOT NULL UNIQUE,
|
||||
address_mode TEXT NOT NULL CHECK (address_mode IN ('wildcard', 'list')),
|
||||
auth_ip_restrict INTEGER NOT NULL DEFAULT 0,
|
||||
auth_allowed_ips TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Explicit sender addresses for applications in 'list' mode. Each address must
|
||||
@@ -45,6 +109,20 @@ CREATE TABLE application_addresses (
|
||||
UNIQUE (application_id, address)
|
||||
);
|
||||
|
||||
-- Level-2 rate limits per domain or application. In 'auto' mode max_messages
|
||||
-- is derived from 30-day send statistics times auto_multiplier.
|
||||
CREATE TABLE rate_limits (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('domain', 'application')),
|
||||
ref_id INTEGER NOT NULL,
|
||||
max_messages INTEGER,
|
||||
window_seconds INTEGER,
|
||||
mode TEXT NOT NULL DEFAULT 'manual' CHECK (mode IN ('manual', 'auto')),
|
||||
auto_multiplier REAL,
|
||||
auto_updated_at TEXT,
|
||||
UNIQUE (scope, ref_id)
|
||||
);
|
||||
|
||||
-- Structured send log. One row per (queue-id, recipient); the log-tailer
|
||||
-- advances status from queued to a final state.
|
||||
CREATE TABLE send_log (
|
||||
@@ -62,15 +140,91 @@ CREATE TABLE send_log (
|
||||
CREATE INDEX idx_send_log_queue_id ON send_log (queue_id);
|
||||
CREATE INDEX idx_send_log_domain ON send_log (domain);
|
||||
CREATE INDEX idx_send_log_created_at ON send_log (created_at);
|
||||
-- The journal milter counts messages for a level-2 limit on every MAIL FROM,
|
||||
-- and the domain page computes 30-day statistics per application on every
|
||||
-- render: equality on app_login, range on created_at.
|
||||
CREATE INDEX idx_send_log_app_login_created_at ON send_log (app_login, created_at);
|
||||
|
||||
-- Differentiated rate limits per domain/application. Both the IP binding and
|
||||
-- the message limit are optional.
|
||||
CREATE TABLE rate_limits (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('domain', 'application')),
|
||||
ref_id INTEGER NOT NULL,
|
||||
allowed_ips TEXT,
|
||||
max_messages INTEGER,
|
||||
window_seconds INTEGER,
|
||||
UNIQUE (scope, ref_id)
|
||||
-- Log-tailer read position. Without it the tailer starts at end-of-file on
|
||||
-- every start, so delivery lines written while the panel was down are never
|
||||
-- parsed and their send-log rows stay "queued" forever. One row per followed
|
||||
-- path; fingerprint identifies the file the offset belongs to (the head bytes
|
||||
-- of the log), so a rotated or recreated mail.log is detected across a restart,
|
||||
-- where os.SameFile cannot help.
|
||||
CREATE TABLE logtail_state (
|
||||
path TEXT PRIMARY KEY,
|
||||
fingerprint TEXT NOT NULL,
|
||||
read_offset INTEGER NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Optional inbound relay (backup-MX / forwarder). A separate entity from the
|
||||
-- sending domains, even under the same name: these rows exist while
|
||||
-- INBOUND_RELAY_ENABLE is false, but the listener, the Postfix maps and the
|
||||
-- panel pages are generated only when that flag is on.
|
||||
CREATE TABLE inbound_domains (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
recipient_mode TEXT NOT NULL CHECK (recipient_mode IN ('list', 'any')),
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- One upstream per inbound domain (host:port + TLS policy for the hand-off).
|
||||
CREATE TABLE inbound_transports (
|
||||
inbound_domain_id INTEGER PRIMARY KEY REFERENCES inbound_domains(id) ON DELETE CASCADE,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535),
|
||||
tls_mode TEXT NOT NULL CHECK (tls_mode IN ('may', 'encrypt', 'none'))
|
||||
);
|
||||
|
||||
-- Explicit recipients for recipient_mode = 'list'. Ignored when mode is 'any'.
|
||||
CREATE TABLE inbound_recipients (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
inbound_domain_id INTEGER NOT NULL REFERENCES inbound_domains(id) ON DELETE CASCADE,
|
||||
address TEXT NOT NULL,
|
||||
UNIQUE (inbound_domain_id, address)
|
||||
);
|
||||
|
||||
-- Inbound domains assigned to a 'domain' user — granted separately from the
|
||||
-- outbound ones above; the same name on both lists is two assignments.
|
||||
CREATE TABLE user_inbound_domains (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
inbound_domain_id INTEGER NOT NULL REFERENCES inbound_domains(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, inbound_domain_id)
|
||||
);
|
||||
|
||||
-- DMARC aggregate report summaries. One row per parsed report; per-source rows
|
||||
-- hang off it. Forensic (ruf=) payloads are not stored.
|
||||
CREATE TABLE dmarc_reports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain TEXT NOT NULL,
|
||||
reporter TEXT NOT NULL,
|
||||
report_id TEXT NOT NULL,
|
||||
period_begin TEXT NOT NULL,
|
||||
period_end TEXT NOT NULL,
|
||||
received_at TEXT NOT NULL,
|
||||
contact_email TEXT NOT NULL DEFAULT '',
|
||||
policy_p TEXT NOT NULL DEFAULT '',
|
||||
policy_sp TEXT NOT NULL DEFAULT '',
|
||||
policy_pct INTEGER NOT NULL DEFAULT 100,
|
||||
policy_adkim TEXT NOT NULL DEFAULT '',
|
||||
policy_aspf TEXT NOT NULL DEFAULT '',
|
||||
pass_count INTEGER NOT NULL DEFAULT 0,
|
||||
fail_count INTEGER NOT NULL DEFAULT 0,
|
||||
recipient TEXT NOT NULL DEFAULT '',
|
||||
UNIQUE (reporter, report_id, domain)
|
||||
);
|
||||
CREATE INDEX idx_dmarc_reports_domain ON dmarc_reports (domain);
|
||||
CREATE INDEX idx_dmarc_reports_received ON dmarc_reports (received_at);
|
||||
|
||||
CREATE TABLE dmarc_report_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
report_row_id INTEGER NOT NULL REFERENCES dmarc_reports(id) ON DELETE CASCADE,
|
||||
source_ip TEXT NOT NULL DEFAULT '',
|
||||
count INTEGER NOT NULL,
|
||||
disposition TEXT NOT NULL DEFAULT '',
|
||||
spf_result TEXT NOT NULL DEFAULT '',
|
||||
dkim_result TEXT NOT NULL DEFAULT '',
|
||||
header_from TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX idx_dmarc_report_records_report ON dmarc_report_records (report_row_id);
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
-- Panel login sessions. Persisted so a login survives a container restart or
|
||||
-- redeploy; only the SHA-256 of the session token is stored, never the token
|
||||
-- itself, so a stolen database file cannot be used to sign in. expires_at
|
||||
-- implements the sliding idle timeout: it is pushed forward on activity rather
|
||||
-- than being fixed at creation time.
|
||||
CREATE TABLE sessions (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_sessions_expires_at ON sessions (expires_at);
|
||||
@@ -1,12 +0,0 @@
|
||||
-- Log-tailer read position. Without it the tailer starts at end-of-file on
|
||||
-- every start, so delivery lines written while the panel was down are never
|
||||
-- parsed and their send-log rows stay "queued" forever. One row per followed
|
||||
-- path; fingerprint identifies the file the offset belongs to (the head bytes
|
||||
-- of the log), so a rotated or recreated mail.log is detected across a restart,
|
||||
-- where os.SameFile cannot help.
|
||||
CREATE TABLE logtail_state (
|
||||
path TEXT PRIMARY KEY,
|
||||
fingerprint TEXT NOT NULL,
|
||||
read_offset INTEGER NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -1,5 +0,0 @@
|
||||
-- Optional DMARC aggregate-report destination (rua=) for the panel administrator
|
||||
-- and per-domain overrides. NULL dmarc_rua on a domain means inherit the profile.
|
||||
|
||||
ALTER TABLE admin ADD COLUMN dmarc_report_email TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE domains ADD COLUMN dmarc_rua TEXT;
|
||||
@@ -1,26 +0,0 @@
|
||||
-- Panel users and domain-admin assignments. Migrates the single admin row into
|
||||
-- a global user; drops the admin table.
|
||||
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('global', 'domain_admin')),
|
||||
dmarc_report_email TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE user_domains (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, domain_id)
|
||||
);
|
||||
|
||||
INSERT INTO users (username, password_hash, role, dmarc_report_email, created_at)
|
||||
SELECT username, password_hash, 'global', dmarc_report_email, created_at
|
||||
FROM admin WHERE id = 1;
|
||||
|
||||
INSERT OR REPLACE INTO settings (key, value)
|
||||
SELECT 'dmarc_report_email', dmarc_report_email FROM admin WHERE id = 1;
|
||||
|
||||
DROP TABLE admin;
|
||||
@@ -1,26 +0,0 @@
|
||||
-- Optional inbound relay (backup-MX / forwarder). Separate from sending
|
||||
-- domains: these rows exist even when INBOUND_RELAY_ENABLE is false, but the
|
||||
-- listener, Postfix maps and panel UI are generated only when that flag is on.
|
||||
|
||||
CREATE TABLE inbound_domains (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
recipient_mode TEXT NOT NULL CHECK (recipient_mode IN ('list', 'any')),
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- One upstream per inbound domain (host:port + TLS policy for the hand-off).
|
||||
CREATE TABLE inbound_transports (
|
||||
inbound_domain_id INTEGER PRIMARY KEY REFERENCES inbound_domains(id) ON DELETE CASCADE,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535),
|
||||
tls_mode TEXT NOT NULL CHECK (tls_mode IN ('may', 'encrypt', 'none'))
|
||||
);
|
||||
|
||||
-- Explicit recipients for recipient_mode = 'list'. Ignored when mode is 'any'.
|
||||
CREATE TABLE inbound_recipients (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
inbound_domain_id INTEGER NOT NULL REFERENCES inbound_domains(id) ON DELETE CASCADE,
|
||||
address TEXT NOT NULL,
|
||||
UNIQUE (inbound_domain_id, address)
|
||||
);
|
||||
@@ -1,7 +0,0 @@
|
||||
-- Level-2 rate limit auto mode: operator sets a multiplier; max_messages is
|
||||
-- derived from 30-day send stats (plan domain-stats-auto-ratelimit).
|
||||
|
||||
ALTER TABLE rate_limits ADD COLUMN mode TEXT NOT NULL DEFAULT 'manual'
|
||||
CHECK (mode IN ('manual', 'auto'));
|
||||
ALTER TABLE rate_limits ADD COLUMN auto_multiplier REAL;
|
||||
ALTER TABLE rate_limits ADD COLUMN auto_updated_at TEXT;
|
||||
@@ -1,38 +0,0 @@
|
||||
-- DMARC aggregate report summaries (plans/dmarc-reports.md). One row per parsed
|
||||
-- report; per-source rows hang off it. Forensic (ruf=) payloads are not stored.
|
||||
|
||||
CREATE TABLE dmarc_reports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain TEXT NOT NULL,
|
||||
reporter TEXT NOT NULL,
|
||||
report_id TEXT NOT NULL,
|
||||
period_begin TEXT NOT NULL,
|
||||
period_end TEXT NOT NULL,
|
||||
received_at TEXT NOT NULL,
|
||||
contact_email TEXT NOT NULL DEFAULT '',
|
||||
policy_p TEXT NOT NULL DEFAULT '',
|
||||
policy_sp TEXT NOT NULL DEFAULT '',
|
||||
policy_pct INTEGER NOT NULL DEFAULT 100,
|
||||
policy_adkim TEXT NOT NULL DEFAULT '',
|
||||
policy_aspf TEXT NOT NULL DEFAULT '',
|
||||
pass_count INTEGER NOT NULL DEFAULT 0,
|
||||
fail_count INTEGER NOT NULL DEFAULT 0,
|
||||
recipient TEXT NOT NULL DEFAULT '',
|
||||
UNIQUE (reporter, report_id, domain)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_dmarc_reports_domain ON dmarc_reports (domain);
|
||||
CREATE INDEX idx_dmarc_reports_received ON dmarc_reports (received_at);
|
||||
|
||||
CREATE TABLE dmarc_report_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
report_row_id INTEGER NOT NULL REFERENCES dmarc_reports(id) ON DELETE CASCADE,
|
||||
source_ip TEXT NOT NULL DEFAULT '',
|
||||
count INTEGER NOT NULL,
|
||||
disposition TEXT NOT NULL DEFAULT '',
|
||||
spf_result TEXT NOT NULL DEFAULT '',
|
||||
dkim_result TEXT NOT NULL DEFAULT '',
|
||||
header_from TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX idx_dmarc_report_records_report ON dmarc_report_records (report_row_id);
|
||||
@@ -1,22 +0,0 @@
|
||||
-- Client IP restriction for application SASL authorization (independent of
|
||||
-- level-2 rate limits). When enabled, only listed IPs may submit mail as the
|
||||
-- application; when disabled, any client IP is allowed.
|
||||
|
||||
ALTER TABLE applications ADD COLUMN auth_ip_restrict INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE applications ADD COLUMN auth_allowed_ips TEXT;
|
||||
|
||||
-- Migrate trusted IPs from application rate_limits into auth_allowed_ips.
|
||||
UPDATE applications
|
||||
SET auth_ip_restrict = 1,
|
||||
auth_allowed_ips = (
|
||||
SELECT allowed_ips FROM rate_limits
|
||||
WHERE scope = 'application' AND ref_id = applications.id
|
||||
)
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM rate_limits
|
||||
WHERE scope = 'application' AND ref_id = applications.id
|
||||
AND allowed_ips IS NOT NULL AND TRIM(allowed_ips) != ''
|
||||
);
|
||||
|
||||
UPDATE rate_limits SET allowed_ips = NULL
|
||||
WHERE scope = 'application' AND allowed_ips IS NOT NULL;
|
||||
@@ -1,9 +0,0 @@
|
||||
-- Send-log lookups by application login run on two hot paths: the journal
|
||||
-- milter counts messages for a level-2 rate limit on every MAIL FROM, and the
|
||||
-- domain page computes 30-day statistics per application on every render.
|
||||
-- 0001 indexed queue_id, domain and created_at, but not app_login, so both
|
||||
-- fell back to scanning the created_at range. The composite matches the shape
|
||||
-- of those queries (equality on app_login, range on created_at).
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_send_log_app_login_created_at
|
||||
ON send_log (app_login, created_at);
|
||||
@@ -43,7 +43,7 @@ func (s *Store) RecalcAutoRateLimit(scope string, refID int64, retentionDays, l1
|
||||
|
||||
func (s *Store) listAutoRateLimits() ([]RateLimit, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT scope, ref_id, allowed_ips, max_messages, window_seconds, mode, auto_multiplier, auto_updated_at
|
||||
`SELECT scope, ref_id, max_messages, window_seconds, mode, auto_multiplier, auto_updated_at
|
||||
FROM rate_limits WHERE mode = ?`, RateLimitModeAuto,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -55,18 +55,16 @@ func (s *Store) listAutoRateLimits() ([]RateLimit, error) {
|
||||
for rows.Next() {
|
||||
var scope string
|
||||
var refID int64
|
||||
var ips sql.NullString
|
||||
var maxMsgs, windowSecs sql.NullInt64
|
||||
var mode sql.NullString
|
||||
var autoMult sql.NullFloat64
|
||||
var autoUpdated sql.NullString
|
||||
if err := rows.Scan(&scope, &refID, &ips, &maxMsgs, &windowSecs, &mode, &autoMult, &autoUpdated); err != nil {
|
||||
if err := rows.Scan(&scope, &refID, &maxMsgs, &windowSecs, &mode, &autoMult, &autoUpdated); err != nil {
|
||||
return nil, fmt.Errorf("scan auto rate limit: %w", err)
|
||||
}
|
||||
rl := RateLimit{
|
||||
Scope: scope,
|
||||
RefID: refID,
|
||||
AllowedIPs: splitIPs(ips.String),
|
||||
MaxMessages: int(maxMsgs.Int64),
|
||||
WindowSeconds: int(windowSecs.Int64),
|
||||
Mode: mode.String,
|
||||
|
||||
@@ -41,7 +41,6 @@ const (
|
||||
type RateLimit struct {
|
||||
Scope string
|
||||
RefID int64
|
||||
AllowedIPs []string // legacy column; unused for new rows
|
||||
MaxMessages int
|
||||
WindowSeconds int
|
||||
Mode string // manual | auto
|
||||
@@ -63,7 +62,7 @@ func (r RateLimit) Active() bool {
|
||||
// its id, for the panel's edit form. ok is false when none is configured.
|
||||
func (s *Store) GetRateLimit(scope string, refID int64) (RateLimit, bool, error) {
|
||||
row := s.db.QueryRow(
|
||||
`SELECT allowed_ips, max_messages, window_seconds, mode, auto_multiplier, auto_updated_at
|
||||
`SELECT max_messages, window_seconds, mode, auto_multiplier, auto_updated_at
|
||||
FROM rate_limits WHERE scope = ? AND ref_id = ?`, scope, refID)
|
||||
rl, err := scanRateLimit(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -93,16 +92,15 @@ func (s *Store) SetRateLimit(rl RateLimit) error {
|
||||
autoUpdated = rl.AutoUpdatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO rate_limits (scope, ref_id, allowed_ips, max_messages, window_seconds, mode, auto_multiplier, auto_updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`INSERT INTO rate_limits (scope, ref_id, max_messages, window_seconds, mode, auto_multiplier, auto_updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(scope, ref_id) DO UPDATE SET
|
||||
allowed_ips = excluded.allowed_ips,
|
||||
max_messages = excluded.max_messages,
|
||||
window_seconds = excluded.window_seconds,
|
||||
mode = excluded.mode,
|
||||
auto_multiplier = excluded.auto_multiplier,
|
||||
auto_updated_at = excluded.auto_updated_at`,
|
||||
rl.Scope, rl.RefID, strings.Join(rl.AllowedIPs, ","), rl.MaxMessages, rl.WindowSeconds,
|
||||
rl.Scope, rl.RefID, rl.MaxMessages, rl.WindowSeconds,
|
||||
mode, autoMult, autoUpdated,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -144,11 +142,11 @@ func (s *Store) RateLimit(scope, ref string) (RateLimit, bool, error) {
|
||||
var query string
|
||||
switch scope {
|
||||
case RateLimitScopeDomain:
|
||||
query = `SELECT rl.allowed_ips, rl.max_messages, rl.window_seconds, rl.mode, rl.auto_multiplier, rl.auto_updated_at
|
||||
query = `SELECT rl.max_messages, rl.window_seconds, rl.mode, rl.auto_multiplier, rl.auto_updated_at
|
||||
FROM rate_limits rl JOIN domains d ON d.id = rl.ref_id
|
||||
WHERE rl.scope = 'domain' AND d.name = ?`
|
||||
case RateLimitScopeApp:
|
||||
query = `SELECT rl.allowed_ips, rl.max_messages, rl.window_seconds, rl.mode, rl.auto_multiplier, rl.auto_updated_at
|
||||
query = `SELECT rl.max_messages, rl.window_seconds, rl.mode, rl.auto_multiplier, rl.auto_updated_at
|
||||
FROM rate_limits rl JOIN applications a ON a.id = rl.ref_id
|
||||
WHERE rl.scope = 'application' AND a.login = ?`
|
||||
default:
|
||||
@@ -196,23 +194,21 @@ func (s *Store) CountMessages(scope, ref string, since time.Time) (int64, error)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// scanRateLimit reads the three stored columns, tolerating NULL numeric columns
|
||||
// scanRateLimit reads the stored columns, tolerating NULL numeric columns
|
||||
// by leaving the corresponding field zero, which makes the limit inert via
|
||||
// Active() until max and window are both set.
|
||||
func scanRateLimit(r scanRow) (RateLimit, error) {
|
||||
var (
|
||||
ips sql.NullString
|
||||
maxMsgs sql.NullInt64
|
||||
windowSecs sql.NullInt64
|
||||
mode sql.NullString
|
||||
autoMult sql.NullFloat64
|
||||
autoUpdated sql.NullString
|
||||
)
|
||||
if err := r.Scan(&ips, &maxMsgs, &windowSecs, &mode, &autoMult, &autoUpdated); err != nil {
|
||||
if err := r.Scan(&maxMsgs, &windowSecs, &mode, &autoMult, &autoUpdated); err != nil {
|
||||
return RateLimit{}, err
|
||||
}
|
||||
rl := RateLimit{
|
||||
AllowedIPs: splitIPs(ips.String),
|
||||
MaxMessages: int(maxMsgs.Int64),
|
||||
WindowSeconds: int(windowSecs.Int64),
|
||||
Mode: mode.String,
|
||||
|
||||
@@ -20,7 +20,6 @@ func TestRateLimitSetGetDelete(t *testing.T) {
|
||||
want := RateLimit{
|
||||
Scope: RateLimitScopeDomain,
|
||||
RefID: d.ID,
|
||||
AllowedIPs: []string{"203.0.113.1", "203.0.113.2"},
|
||||
MaxMessages: 100,
|
||||
WindowSeconds: 3600,
|
||||
}
|
||||
@@ -32,19 +31,17 @@ func TestRateLimitSetGetDelete(t *testing.T) {
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("GetRateLimit after set: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if got.MaxMessages != 100 || got.WindowSeconds != 3600 || len(got.AllowedIPs) != 2 ||
|
||||
got.AllowedIPs[0] != "203.0.113.1" || got.AllowedIPs[1] != "203.0.113.2" {
|
||||
if got.MaxMessages != 100 || got.WindowSeconds != 3600 {
|
||||
t.Fatalf("roundtrip mismatch: %+v", got)
|
||||
}
|
||||
|
||||
// Upsert replaces in place (UNIQUE(scope, ref_id)).
|
||||
want.MaxMessages = 5
|
||||
want.AllowedIPs = []string{"198.51.100.9"}
|
||||
if err := st.SetRateLimit(want); err != nil {
|
||||
t.Fatalf("SetRateLimit upsert: %v", err)
|
||||
}
|
||||
got, _, _ = st.GetRateLimit(RateLimitScopeDomain, d.ID)
|
||||
if got.MaxMessages != 5 || len(got.AllowedIPs) != 1 || got.AllowedIPs[0] != "198.51.100.9" {
|
||||
if got.MaxMessages != 5 || got.WindowSeconds != 3600 {
|
||||
t.Fatalf("upsert did not replace: %+v", got)
|
||||
}
|
||||
|
||||
@@ -64,10 +61,10 @@ func TestRateLimitByNameAndLogin(t *testing.T) {
|
||||
t.Fatalf("AddApplication: %v", err)
|
||||
}
|
||||
|
||||
if err := st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: d.ID, AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 10, WindowSeconds: 60}); err != nil {
|
||||
if err := st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: d.ID, MaxMessages: 10, WindowSeconds: 60}); err != nil {
|
||||
t.Fatalf("set domain limit: %v", err)
|
||||
}
|
||||
if err := st.SetRateLimit(RateLimit{Scope: RateLimitScopeApp, RefID: a.ID, AllowedIPs: []string{"203.0.113.2"}, MaxMessages: 3, WindowSeconds: 60}); err != nil {
|
||||
if err := st.SetRateLimit(RateLimit{Scope: RateLimitScopeApp, RefID: a.ID, MaxMessages: 3, WindowSeconds: 60}); err != nil {
|
||||
t.Fatalf("set app limit: %v", err)
|
||||
}
|
||||
|
||||
@@ -131,9 +128,9 @@ func TestDeleteRateLimitsForDomain(t *testing.T) {
|
||||
a, _ := st.AddApplication(d.ID, "app1", AddressModeWildcard, nil)
|
||||
other, _ := st.AddDomain("other.example", "selfpost")
|
||||
|
||||
_ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: d.ID, AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 10, WindowSeconds: 60})
|
||||
_ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeApp, RefID: a.ID, AllowedIPs: []string{"203.0.113.2"}, MaxMessages: 3, WindowSeconds: 60})
|
||||
_ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: other.ID, AllowedIPs: []string{"203.0.113.9"}, MaxMessages: 1, WindowSeconds: 60})
|
||||
_ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: d.ID, MaxMessages: 10, WindowSeconds: 60})
|
||||
_ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeApp, RefID: a.ID, MaxMessages: 3, WindowSeconds: 60})
|
||||
_ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: other.ID, MaxMessages: 1, WindowSeconds: 60})
|
||||
|
||||
if err := st.DeleteRateLimitsForDomain(d.ID); err != nil {
|
||||
t.Fatalf("DeleteRateLimitsForDomain: %v", err)
|
||||
@@ -153,9 +150,9 @@ func TestDeleteRateLimitsForDomain(t *testing.T) {
|
||||
func TestRateLimitActive(t *testing.T) {
|
||||
inactive := []RateLimit{
|
||||
{},
|
||||
{Scope: RateLimitScopeDomain, AllowedIPs: []string{"203.0.113.1"}}, // no ceiling
|
||||
{Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window
|
||||
{Scope: RateLimitScopeApp, MaxMessages: 5}, // no window
|
||||
{Scope: RateLimitScopeDomain}, // no ceiling
|
||||
{Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window
|
||||
{Scope: RateLimitScopeApp, MaxMessages: 5}, // no window
|
||||
}
|
||||
for i, rl := range inactive {
|
||||
if rl.Active() {
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 2.0 starts from one baseline: the 1.x chain is gone from the binary, not
|
||||
// carried along (docs/schema-migrations.md).
|
||||
func TestBaselineIsTheOnlyMigration(t *testing.T) {
|
||||
entries, err := fs.ReadDir(migrationsFS, "migrations")
|
||||
if err != nil {
|
||||
t.Fatalf("read migrations: %v", err)
|
||||
}
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
if len(names) == 0 || names[0] != "0001_init.sql" {
|
||||
t.Fatalf("migrations = %v, want the chain to start at 0001_init.sql", names)
|
||||
}
|
||||
baseline, _ := migrationsFS.ReadFile("migrations/0001_init.sql")
|
||||
for _, gone := range []string{"CREATE TABLE admin", "DROP TABLE", "ALTER TABLE", "dmarc_report_email", "allowed_ips ", "domain_admin"} {
|
||||
if strings.Contains(string(baseline), gone) {
|
||||
t.Errorf("the baseline still contains %q — a leftover of the 1.x chain", gone)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The schema a fresh data directory gets, table by table and column by
|
||||
// column. A change here is a schema change: it ships as a new migration file
|
||||
// and a row in docs/schema-migrations.md, never as an edit of the baseline
|
||||
// after 2.0.0.
|
||||
func TestBaselineSchema(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
want := map[string]string{
|
||||
"settings": "key value",
|
||||
"users": "id username password_hash role email dmarc_default_mode dmarc_default_address all_domains all_inbound_domains created_at",
|
||||
"sessions": "token_hash username created_at expires_at",
|
||||
"domains": "id name dkim_selector dmarc_rua dmarc_rua_user_id created_at",
|
||||
"user_domains": "user_id domain_id",
|
||||
"applications": "id domain_id login address_mode auth_ip_restrict auth_allowed_ips created_at",
|
||||
"application_addresses": "id application_id address",
|
||||
"rate_limits": "id scope ref_id max_messages window_seconds mode auto_multiplier auto_updated_at",
|
||||
"send_log": "id queue_id domain app_login from_addr to_addr subject status created_at updated_at",
|
||||
"logtail_state": "path fingerprint read_offset updated_at",
|
||||
"inbound_domains": "id name recipient_mode created_at",
|
||||
"inbound_transports": "inbound_domain_id host port tls_mode",
|
||||
"inbound_recipients": "id inbound_domain_id address",
|
||||
"user_inbound_domains": "user_id inbound_domain_id",
|
||||
"dmarc_reports": "id domain reporter report_id period_begin period_end received_at contact_email policy_p policy_sp policy_pct policy_adkim policy_aspf pass_count fail_count recipient",
|
||||
"dmarc_report_records": "id report_row_id source_ip count disposition spf_result dkim_result header_from",
|
||||
}
|
||||
rows, err := st.db.Query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||
if err != nil {
|
||||
t.Fatalf("list tables: %v", err)
|
||||
}
|
||||
var tables []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tables = append(tables, name)
|
||||
}
|
||||
rows.Close()
|
||||
for _, name := range tables {
|
||||
cols, ok := want[name]
|
||||
if !ok {
|
||||
t.Errorf("unexpected table %q", name)
|
||||
continue
|
||||
}
|
||||
if got := strings.Join(tableColumns(t, st, name), " "); got != cols {
|
||||
t.Errorf("table %s has columns\n %s\nwant\n %s", name, got, cols)
|
||||
}
|
||||
delete(want, name)
|
||||
}
|
||||
for name := range want {
|
||||
t.Errorf("table %q is missing", name)
|
||||
}
|
||||
|
||||
var indexes []string
|
||||
rows, err = st.db.Query("SELECT name FROM sqlite_master WHERE type = 'index' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||
if err != nil {
|
||||
t.Fatalf("list indexes: %v", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
indexes = append(indexes, name)
|
||||
}
|
||||
rows.Close()
|
||||
wantIdx := []string{
|
||||
"idx_dmarc_report_records_report", "idx_dmarc_reports_domain", "idx_dmarc_reports_received",
|
||||
"idx_send_log_app_login_created_at", "idx_send_log_created_at", "idx_send_log_domain", "idx_send_log_queue_id",
|
||||
"idx_sessions_expires_at",
|
||||
}
|
||||
sort.Strings(wantIdx)
|
||||
if strings.Join(indexes, " ") != strings.Join(wantIdx, " ") {
|
||||
t.Errorf("indexes = %v\nwant %v", indexes, wantIdx)
|
||||
}
|
||||
|
||||
var version, appID int64
|
||||
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 1 {
|
||||
t.Errorf("user_version = %d, %v; want 1", version, err)
|
||||
}
|
||||
if err := st.db.QueryRow("PRAGMA application_id").Scan(&appID); err != nil || appID != applicationID {
|
||||
t.Errorf("application_id = %#x, %v; want %#x", appID, err, applicationID)
|
||||
}
|
||||
}
|
||||
|
||||
func tableColumns(t *testing.T, st *Store, table string) []string {
|
||||
t.Helper()
|
||||
rows, err := st.db.Query("SELECT name FROM pragma_table_info(?) ORDER BY cid", table)
|
||||
if err != nil {
|
||||
t.Fatalf("columns of %s: %v", table, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var cols []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cols = append(cols, name)
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
// Reopening a 2.x database applies nothing twice and keeps its data.
|
||||
func TestReopenKeepsTheDatabase(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "test.db")
|
||||
st, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if err := st.CreateGlobalUser("admin", "hash"); err != nil {
|
||||
t.Fatalf("CreateGlobalUser: %v", err)
|
||||
}
|
||||
st.Close()
|
||||
|
||||
st, err = Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
if ok, err := st.UserExists(); err != nil || !ok {
|
||||
t.Fatalf("after reopen: user exists = %v, %v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A 1.x data directory must be refused, loudly. Its user_version (10 at the
|
||||
// last 1.x release) would otherwise read as "the baseline is already applied",
|
||||
// and the panel would start on tables it does not know.
|
||||
func TestOpenRefusesA1xDatabase(t *testing.T) {
|
||||
for _, version := range []string{"1", "10"} {
|
||||
path := filepath.Join(t.TempDir(), "old.db")
|
||||
db, err := sql.Open("sqlite", "file:"+path)
|
||||
if err != nil {
|
||||
t.Fatalf("create old db: %v", err)
|
||||
}
|
||||
for _, stmt := range []string{
|
||||
"CREATE TABLE admin (id INTEGER PRIMARY KEY CHECK (id = 1), username TEXT NOT NULL, password_hash TEXT NOT NULL, created_at TEXT NOT NULL)",
|
||||
"INSERT INTO admin VALUES (1, 'admin', 'hash', '2026-01-01T00:00:00Z')",
|
||||
"PRAGMA user_version = " + version,
|
||||
} {
|
||||
if _, err := db.Exec(stmt); err != nil {
|
||||
t.Fatalf("%s: %v", stmt, err)
|
||||
}
|
||||
}
|
||||
db.Close()
|
||||
|
||||
st, err := Open(path)
|
||||
if err == nil {
|
||||
st.Close()
|
||||
t.Fatalf("a 1.x database at version %s was opened", version)
|
||||
}
|
||||
if !errors.Is(err, ErrForeignSchema) {
|
||||
t.Fatalf("version %s: error = %v, want ErrForeignSchema", version, err)
|
||||
}
|
||||
|
||||
// Refusing must not have touched the file: the operator still has
|
||||
// their 1.x data to go back to.
|
||||
db, _ = sql.Open("sqlite", "file:"+path)
|
||||
var n int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM admin").Scan(&n); err != nil || n != 1 {
|
||||
t.Errorf("version %s: the refused database was altered (admin rows = %d, %v)", version, n, err)
|
||||
}
|
||||
var tables int
|
||||
_ = db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table'").Scan(&tables)
|
||||
if tables != 1 {
|
||||
t.Errorf("version %s: the refused database now has %d tables", version, tables)
|
||||
}
|
||||
db.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// A database written by a newer build is not guessed at either.
|
||||
func TestOpenRefusesANewerSchema(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "test.db")
|
||||
st, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if _, err := st.db.Exec("PRAGMA user_version = 99"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.Close()
|
||||
if st, err := Open(path); err == nil {
|
||||
st.Close()
|
||||
t.Fatal("a database at schema version 99 was opened")
|
||||
}
|
||||
}
|
||||
|
||||
// The checks the baseline puts on a user row, and what the new assignment
|
||||
// table does when either side goes away.
|
||||
func TestBaselineConstraints(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
insert := func(role, mode string, all int) error {
|
||||
_, err := st.db.Exec(
|
||||
"INSERT INTO users (username, password_hash, role, dmarc_default_mode, all_domains, created_at) VALUES (?, 'h', ?, ?, ?, 'now')",
|
||||
role+mode, role, mode, all)
|
||||
return err
|
||||
}
|
||||
if err := insert("global", "none", 0); err != nil {
|
||||
t.Fatalf("a plain global user: %v", err)
|
||||
}
|
||||
if err := insert("domain", "hosted", 1); err != nil {
|
||||
t.Fatalf("a domain user with All: %v", err)
|
||||
}
|
||||
if err := insert("domain_admin", "none", 0); err == nil {
|
||||
t.Error("the 1.x role name domain_admin was accepted")
|
||||
}
|
||||
if err := insert("global", "profile", 0); err == nil {
|
||||
t.Error("an unknown dmarc_default_mode was accepted")
|
||||
}
|
||||
if err := insert("domain", "custom", 2); err == nil {
|
||||
t.Error("all_domains = 2 was accepted")
|
||||
}
|
||||
|
||||
u, err := st.GetUserByUsername("domainhosted")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByUsername: %v", err)
|
||||
}
|
||||
if !u.AllDomains || u.AllInboundDomains || u.DMARCDefaultMode != DMARCDefaultHosted {
|
||||
t.Errorf("flags read back as %+v", u)
|
||||
}
|
||||
|
||||
in, err := st.AddInboundDomain("in.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("AddInboundDomain: %v", err)
|
||||
}
|
||||
other, err := st.AddInboundDomain("other.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("AddInboundDomain: %v", err)
|
||||
}
|
||||
for _, id := range []int64{in.ID, other.ID} {
|
||||
if _, err := st.db.Exec("INSERT INTO user_inbound_domains (user_id, inbound_domain_id) VALUES (?, ?)", u.ID, id); err != nil {
|
||||
t.Fatalf("assign inbound domain: %v", err)
|
||||
}
|
||||
}
|
||||
if _, err := st.db.Exec("INSERT INTO user_inbound_domains (user_id, inbound_domain_id) VALUES (?, ?)", u.ID, in.ID+100); err == nil {
|
||||
t.Error("an assignment to a missing inbound domain was accepted")
|
||||
}
|
||||
u, _ = st.GetUser(u.ID)
|
||||
if len(u.InboundDomainIDs) != 2 || len(u.DomainIDs) != 0 {
|
||||
t.Fatalf("assignments = inbound %v, outbound %v; the two lists are separate", u.InboundDomainIDs, u.DomainIDs)
|
||||
}
|
||||
|
||||
if err := st.DeleteInboundDomain(in.ID); err != nil {
|
||||
t.Fatalf("DeleteInboundDomain: %v", err)
|
||||
}
|
||||
u, _ = st.GetUser(u.ID)
|
||||
if len(u.InboundDomainIDs) != 1 || u.InboundDomainIDs[0] != other.ID {
|
||||
t.Errorf("after deleting an inbound domain its assignment stayed: %v", u.InboundDomainIDs)
|
||||
}
|
||||
if err := st.DeleteUser(u.ID); err != nil {
|
||||
t.Fatalf("DeleteUser: %v", err)
|
||||
}
|
||||
var n int
|
||||
if err := st.db.QueryRow("SELECT COUNT(*) FROM user_inbound_domains").Scan(&n); err != nil || n != 0 {
|
||||
t.Errorf("after deleting the user %d inbound assignment(s) stayed (%v)", n, err)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ package store
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
@@ -50,6 +51,14 @@ func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// applicationID marks a database file as a 2.x SelfPost one ("SP20"). The
|
||||
// baseline migration sets it.
|
||||
const applicationID = 0x53503230
|
||||
|
||||
// ErrForeignSchema is returned by Open for a database that has a schema but
|
||||
// was not created by SelfPost 2.x — in practice a 1.x data directory.
|
||||
var ErrForeignSchema = errors.New("database was not created by SelfPost 2.x; 2.0 starts from an empty data directory (docs/schema-migrations.md)")
|
||||
|
||||
// migrate applies embedded migrations in filename order, tracking progress via
|
||||
// SQLite's PRAGMA user_version so each migration runs at most once.
|
||||
func (s *Store) migrate() error {
|
||||
@@ -70,6 +79,23 @@ func (s *Store) migrate() error {
|
||||
return fmt.Errorf("read schema version: %w", err)
|
||||
}
|
||||
|
||||
// A 1.x file carries a user_version from the deleted 1.x chain. Counting it
|
||||
// against today's files would skip the baseline and leave the panel running
|
||||
// on a schema it does not know, so such a file is refused outright; nothing
|
||||
// is migrated from 1.x (docs/schema-migrations.md).
|
||||
if version > 0 {
|
||||
var appID int64
|
||||
if err := s.db.QueryRow("PRAGMA application_id").Scan(&appID); err != nil {
|
||||
return fmt.Errorf("read application id: %w", err)
|
||||
}
|
||||
if appID != applicationID {
|
||||
return fmt.Errorf("schema version %d: %w", version, ErrForeignSchema)
|
||||
}
|
||||
}
|
||||
if version > len(names) {
|
||||
return fmt.Errorf("database schema version %d is newer than this build knows (%d)", version, len(names))
|
||||
}
|
||||
|
||||
for i, name := range names {
|
||||
target := i + 1
|
||||
if target <= version {
|
||||
|
||||
+166
-64
@@ -22,20 +22,95 @@ var ErrLastGlobal = errors.New("cannot remove last global administrator")
|
||||
// Role identifies a panel user's access level.
|
||||
type Role string
|
||||
|
||||
// The role is a reach, not a rank: everyone in the panel is an administrator,
|
||||
// of the whole instance or of the domains assigned to them.
|
||||
const (
|
||||
RoleGlobal Role = "global"
|
||||
RoleDomainAdmin Role = "domain_admin"
|
||||
RoleGlobal Role = "global"
|
||||
RoleDomain Role = "domain"
|
||||
)
|
||||
|
||||
// How a user's default DMARC report address is chosen (users.dmarc_default_mode).
|
||||
const (
|
||||
DMARCDefaultHosted = "hosted" // SelfPost's own mailbox for the domain
|
||||
DMARCDefaultAccount = "account" // the user's account e-mail
|
||||
DMARCDefaultCustom = "custom" // DMARCDefault.Address
|
||||
DMARCDefaultNone = "none" // no aggregate reports
|
||||
)
|
||||
|
||||
// DMARCDefault is a user's default report address: the choice a domain takes
|
||||
// over when it follows that user (Domain.DMARCRuaUserID).
|
||||
type DMARCDefault struct {
|
||||
UserID int64
|
||||
Username string
|
||||
Mode string
|
||||
Address string // for DMARCDefaultCustom
|
||||
Email string // the account e-mail, for DMARCDefaultAccount
|
||||
}
|
||||
|
||||
// Resolve returns the address the default stands for on one domain. hosted is
|
||||
// the SelfPost-hosted mailbox for that domain, "" when report ingest is off —
|
||||
// a hosted default then resolves to no reports.
|
||||
func (d DMARCDefault) Resolve(hosted string) string {
|
||||
switch d.Mode {
|
||||
case DMARCDefaultHosted:
|
||||
return hosted
|
||||
case DMARCDefaultAccount:
|
||||
return d.Email
|
||||
case DMARCDefaultCustom:
|
||||
return d.Address
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ValidDMARCDefaultMode reports whether mode is one of the stored choices.
|
||||
func ValidDMARCDefaultMode(mode string) bool {
|
||||
switch mode {
|
||||
case DMARCDefaultHosted, DMARCDefaultAccount, DMARCDefaultCustom, DMARCDefaultNone:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// User is a panel login (not an application SASL account).
|
||||
type User struct {
|
||||
ID int64
|
||||
Username string
|
||||
PasswordHash string
|
||||
Role Role
|
||||
DMARCReportEmail string
|
||||
CreatedAt time.Time
|
||||
DomainIDs []int64
|
||||
ID int64
|
||||
Username string
|
||||
PasswordHash string
|
||||
Role Role
|
||||
// Email is the user's own address — what the panel writes to. DMARC may
|
||||
// use it (DMARCDefaultAccount) but it is not a DMARC field.
|
||||
Email string
|
||||
DMARCDefaultMode string
|
||||
DMARCDefaultAddress string
|
||||
// AllDomains / AllInboundDomains widen a domain user's list to every
|
||||
// domain of that direction, including those added later; the id lists
|
||||
// are then ignored.
|
||||
AllDomains bool
|
||||
AllInboundDomains bool
|
||||
CreatedAt time.Time
|
||||
DomainIDs []int64
|
||||
InboundDomainIDs []int64
|
||||
}
|
||||
|
||||
// DMARCDefault returns the user's default report address choice.
|
||||
func (u User) DMARCDefault() DMARCDefault {
|
||||
return DMARCDefault{UserID: u.ID, Username: u.Username, Mode: u.DMARCDefaultMode, Address: u.DMARCDefaultAddress, Email: u.Email}
|
||||
}
|
||||
|
||||
const userColumns = "id, username, password_hash, role, email, dmarc_default_mode, dmarc_default_address, all_domains, all_inbound_domains, created_at"
|
||||
|
||||
func scanUser(r scanRow) (User, error) {
|
||||
var (
|
||||
u User
|
||||
createdAt string
|
||||
)
|
||||
err := r.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.Email, &u.DMARCDefaultMode, &u.DMARCDefaultAddress,
|
||||
&u.AllDomains, &u.AllInboundDomains, &createdAt)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// UserExists reports whether any panel user exists (setup complete).
|
||||
@@ -58,7 +133,7 @@ func (s *Store) CreateGlobalUser(username, passwordHash string) error {
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err = s.db.Exec(
|
||||
"INSERT INTO users (username, password_hash, role, dmarc_report_email, created_at) VALUES (?, ?, ?, '', ?)",
|
||||
"INSERT INTO users (username, password_hash, role, created_at) VALUES (?, ?, ?, ?)",
|
||||
username, passwordHash, RoleGlobal, now,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -69,23 +144,17 @@ func (s *Store) CreateGlobalUser(username, passwordHash string) error {
|
||||
|
||||
// GetUserByUsername returns a user with domain assignments loaded.
|
||||
func (s *Store) GetUserByUsername(username string) (User, error) {
|
||||
var (
|
||||
u User
|
||||
createdAt string
|
||||
)
|
||||
err := s.db.QueryRow(
|
||||
"SELECT id, username, password_hash, role, dmarc_report_email, created_at FROM users WHERE username = ?",
|
||||
username,
|
||||
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.DMARCReportEmail, &createdAt)
|
||||
u, err := scanUser(s.db.QueryRow("SELECT "+userColumns+" FROM users WHERE username = ?", username))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return User{}, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("get user by username: %w", err)
|
||||
}
|
||||
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
u.DomainIDs, err = s.listUserDomainIDs(u.ID)
|
||||
if err != nil {
|
||||
if u.DomainIDs, err = s.listUserDomainIDs(u.ID); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if u.InboundDomainIDs, err = s.listUserInboundDomainIDs(u.ID); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return u, nil
|
||||
@@ -93,47 +162,35 @@ func (s *Store) GetUserByUsername(username string) (User, error) {
|
||||
|
||||
// GetUser returns a user by id with domain assignments.
|
||||
func (s *Store) GetUser(id int64) (User, error) {
|
||||
var (
|
||||
u User
|
||||
createdAt string
|
||||
)
|
||||
err := s.db.QueryRow(
|
||||
"SELECT id, username, password_hash, role, dmarc_report_email, created_at FROM users WHERE id = ?",
|
||||
id,
|
||||
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.DMARCReportEmail, &createdAt)
|
||||
u, err := scanUser(s.db.QueryRow("SELECT "+userColumns+" FROM users WHERE id = ?", id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return User{}, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("get user: %w", err)
|
||||
}
|
||||
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
u.DomainIDs, err = s.listUserDomainIDs(u.ID)
|
||||
if err != nil {
|
||||
if u.DomainIDs, err = s.listUserDomainIDs(u.ID); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if u.InboundDomainIDs, err = s.listUserInboundDomainIDs(u.ID); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// ListUsers returns every panel user without domain ids.
|
||||
// ListUsers returns every panel user without the assigned domain ids.
|
||||
func (s *Store) ListUsers() ([]User, error) {
|
||||
rows, err := s.db.Query(
|
||||
"SELECT id, username, password_hash, role, dmarc_report_email, created_at FROM users ORDER BY id",
|
||||
)
|
||||
rows, err := s.db.Query("SELECT " + userColumns + " FROM users ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list users: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var users []User
|
||||
for rows.Next() {
|
||||
var (
|
||||
u User
|
||||
createdAt string
|
||||
)
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.DMARCReportEmail, &createdAt); err != nil {
|
||||
u, err := scanUser(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list users scan: %w", err)
|
||||
}
|
||||
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
users = append(users, u)
|
||||
}
|
||||
return users, rows.Err()
|
||||
@@ -177,12 +234,12 @@ func (s *Store) CountGlobalUsers() (int, error) {
|
||||
|
||||
// CreateUser inserts a panel user and optional domain assignments.
|
||||
func (s *Store) CreateUser(username, passwordHash string, role Role, domainIDs []int64) (int64, error) {
|
||||
if role == RoleDomainAdmin && len(domainIDs) == 0 {
|
||||
return 0, fmt.Errorf("create user: domain_admin requires domains")
|
||||
if role == RoleDomain && len(domainIDs) == 0 {
|
||||
return 0, fmt.Errorf("create user: the domain role requires domains")
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
res, err := s.db.Exec(
|
||||
"INSERT INTO users (username, password_hash, role, dmarc_report_email, created_at) VALUES (?, ?, ?, '', ?)",
|
||||
"INSERT INTO users (username, password_hash, role, created_at) VALUES (?, ?, ?, ?)",
|
||||
username, passwordHash, role, now,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -195,7 +252,7 @@ func (s *Store) CreateUser(username, passwordHash string, role Role, domainIDs [
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create user id: %w", err)
|
||||
}
|
||||
if role == RoleDomainAdmin {
|
||||
if role == RoleDomain {
|
||||
if err := s.setUserDomains(id, domainIDs); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -203,15 +260,11 @@ func (s *Store) CreateUser(username, passwordHash string, role Role, domainIDs [
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// UpdateUser replaces username, password hash, and DMARC email for a user.
|
||||
func (s *Store) UpdateUser(id int64, username, passwordHash, dmarcReportEmail string) error {
|
||||
u, err := s.GetUser(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// UpdateUser replaces a user's username, password hash and account e-mail.
|
||||
func (s *Store) UpdateUser(id int64, username, passwordHash, email string) error {
|
||||
res, err := s.db.Exec(
|
||||
"UPDATE users SET username = ?, password_hash = ?, dmarc_report_email = ? WHERE id = ?",
|
||||
username, passwordHash, dmarcReportEmail, id,
|
||||
"UPDATE users SET username = ?, password_hash = ?, email = ? WHERE id = ?",
|
||||
username, passwordHash, email, id,
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
@@ -226,14 +279,50 @@ func (s *Store) UpdateUser(id int64, username, passwordHash, dmarcReportEmail st
|
||||
if n == 0 {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
if u.Role == RoleGlobal {
|
||||
if err := s.SetSetting("dmarc_report_email", dmarcReportEmail); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetDMARCDefault stores the user's default report address choice. address is
|
||||
// kept only for DMARCDefaultCustom. Every domain that follows this user
|
||||
// resolves to the new choice from the next read on.
|
||||
func (s *Store) SetDMARCDefault(userID int64, mode, address string) error {
|
||||
if !ValidDMARCDefaultMode(mode) {
|
||||
return fmt.Errorf("set dmarc default: unknown mode %q", mode)
|
||||
}
|
||||
if mode != DMARCDefaultCustom {
|
||||
address = ""
|
||||
}
|
||||
res, err := s.db.Exec(
|
||||
"UPDATE users SET dmarc_default_mode = ?, dmarc_default_address = ? WHERE id = ?", mode, address, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set dmarc default: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("set dmarc default: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDMARCDefault returns a user's default report address choice, or
|
||||
// ErrUserNotFound.
|
||||
func (s *Store) GetDMARCDefault(userID int64) (DMARCDefault, error) {
|
||||
d := DMARCDefault{UserID: userID}
|
||||
err := s.db.QueryRow(
|
||||
"SELECT username, dmarc_default_mode, dmarc_default_address, email FROM users WHERE id = ?", userID,
|
||||
).Scan(&d.Username, &d.Mode, &d.Address, &d.Email)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return DMARCDefault{}, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return DMARCDefault{}, fmt.Errorf("get dmarc default: %w", err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// SetUserRole updates a user's role.
|
||||
func (s *Store) SetUserRole(userID int64, role Role) error {
|
||||
res, err := s.db.Exec("UPDATE users SET role = ? WHERE id = ?", role, userID)
|
||||
@@ -259,14 +348,14 @@ func (s *Store) ClearUserDomains(userID int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetUserDomains replaces domain assignments for a domain_admin user.
|
||||
// SetUserDomains replaces the outbound domain assignments of a domain user.
|
||||
func (s *Store) SetUserDomains(userID int64, domainIDs []int64) error {
|
||||
u, err := s.GetUser(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.Role != RoleDomainAdmin {
|
||||
return fmt.Errorf("set user domains: user is not domain_admin")
|
||||
if u.Role != RoleDomain {
|
||||
return fmt.Errorf("set user domains: user does not have the domain role")
|
||||
}
|
||||
if len(domainIDs) == 0 {
|
||||
return fmt.Errorf("set user domains: at least one domain required")
|
||||
@@ -303,9 +392,22 @@ func (s *Store) DeleteUser(id int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GlobalDMARCReportEmail returns the shared default rua= for domain inherit mode.
|
||||
func (s *Store) GlobalDMARCReportEmail() (string, error) {
|
||||
return s.GetSetting("dmarc_report_email")
|
||||
func (s *Store) listUserInboundDomainIDs(userID int64) ([]int64, error) {
|
||||
rows, err := s.db.Query(
|
||||
"SELECT inbound_domain_id FROM user_inbound_domains WHERE user_id = ? ORDER BY inbound_domain_id", userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list user inbound domains: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("list user inbound domains scan: %w", err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) listUserDomainIDs(userID int64) ([]int64, error) {
|
||||
|
||||
@@ -23,19 +23,20 @@ func TestUpdateUser(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("GetUser: %v", err)
|
||||
}
|
||||
if got.DMARCReportEmail != "reports@hub.example" {
|
||||
t.Fatalf("dmarc email = %q", got.DMARCReportEmail)
|
||||
if got.Email != "reports@hub.example" {
|
||||
t.Fatalf("email = %q", got.Email)
|
||||
}
|
||||
email, err := st.GlobalDMARCReportEmail()
|
||||
if err != nil {
|
||||
t.Fatalf("GlobalDMARCReportEmail: %v", err)
|
||||
// The account e-mail is the user's own: saving it does not make it anyone's
|
||||
// report address, and nothing is mirrored into the instance settings.
|
||||
if got.DMARCDefaultMode != DMARCDefaultNone {
|
||||
t.Fatalf("dmarc default mode = %q after setting the e-mail, want %q", got.DMARCDefaultMode, DMARCDefaultNone)
|
||||
}
|
||||
if email != "reports@hub.example" {
|
||||
t.Fatalf("settings dmarc = %q", email)
|
||||
if v, err := st.GetSetting("dmarc_report_email"); err == nil && v != "" {
|
||||
t.Fatalf("the e-mail leaked into the instance settings: %q", v)
|
||||
}
|
||||
|
||||
if err := st.UpdateUser(u.ID, "operator", "hash-three", ""); err != nil {
|
||||
t.Fatalf("clear dmarc email: %v", err)
|
||||
t.Fatalf("clear e-mail: %v", err)
|
||||
}
|
||||
got, err = st.GetUser(u.ID)
|
||||
if err != nil {
|
||||
@@ -73,7 +74,7 @@ func TestCreateDomainAdminUser(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("AddDomain: %v", err)
|
||||
}
|
||||
id, err := st.CreateUser("domainop", "hash2", RoleDomainAdmin, []int64{d.ID})
|
||||
id, err := st.CreateUser("domainop", "hash2", RoleDomain, []int64{d.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user