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:
mix
2026-10-09 18:31:43 +03:00
co-authored by Claude Opus 5.5
parent 53c83f690c
commit 8d1af89e1a
42 changed files with 1284 additions and 599 deletions
-1
View File
@@ -205,7 +205,6 @@ func (s *Service) RateLimit(appID int64) (store.RateLimit, bool, error) {
func (s *Service) SaveRateLimit(appID int64, rl store.RateLimit) error {
rl.Scope = store.RateLimitScopeApp
rl.RefID = appID
rl.AllowedIPs = nil
return s.store.SetRateLimit(rl)
}
+24 -20
View File
@@ -54,10 +54,6 @@ func (s *Service) AllowedRecipients() ([]string, error) {
if !s.enabled || s.hostname == "" {
return nil, nil
}
profile, err := s.store.GlobalDMARCReportEmail()
if err != nil {
return nil, err
}
seen := make(map[string]bool)
var out []string
add := func(addr string) {
@@ -68,8 +64,24 @@ func (s *Service) AllowedRecipients() ([]string, error) {
seen[addr] = true
out = append(out, addr)
}
add(profile)
if profile == "" {
// A user's own default that points at this host is accepted even before a
// domain follows it, so the address can be published first. With no such
// default anywhere, the instance's generic mailbox stays open — the address
// the guide tells a fresh install to publish.
users, err := s.store.ListUsers()
if err != nil {
return nil, err
}
anyDefault := false
for _, u := range users {
if def := u.DMARCDefault(); def.Mode == store.DMARCDefaultAccount || def.Mode == store.DMARCDefaultCustom {
if addr := def.Resolve(""); addr != "" {
anyDefault = true
add(addr)
}
}
}
if !anyDefault {
add(DefaultHostedReportAddress(s.hostname))
}
domains, err := s.store.ListDomains()
@@ -77,21 +89,13 @@ func (s *Service) AllowedRecipients() ([]string, error) {
return nil, err
}
for _, d := range domains {
rua := dnscheck.ResolveDMARCRua(d.DMARCRua, profile)
if rua == "" {
continue
}
if IsHostedOnHostname(rua, s.hostname) {
add(rua)
continue
}
if d.DMARCRua.Valid && d.DMARCRua.String == "" {
continue
}
hosted := HostedReportAddress(s.hostname, d.Name)
if strings.EqualFold(rua, hosted) {
add(rua)
// add keeps only addresses on this host, so a domain reporting elsewhere
// (or nowhere) opens nothing here.
rua, err := s.store.DomainDMARCRua(d, HostedReportAddress(s.hostname, d.Name))
if err != nil {
return nil, err
}
add(rua)
}
sort.Strings(out)
return out, nil
-10
View File
@@ -1,7 +1,6 @@
package dnscheck
import (
"database/sql"
"net"
"strings"
)
@@ -48,15 +47,6 @@ func SPFExample(hostname string, serverIPs []string) string {
return "v=spf1 " + strings.Join(mechanisms, " ") + " -all"
}
// ResolveDMARCRua picks the rua= mailbox for a sending domain: per-domain
// override wins, then the administrator profile, then policy-only (empty).
func ResolveDMARCRua(domainRua sql.NullString, profileEmail string) string {
if domainRua.Valid {
return domainRua.String
}
return profileEmail
}
// EmailDomain returns the lower-case domain part of addr, or "" when invalid.
func EmailDomain(addr string) string {
addr = strings.TrimSpace(addr)
-16
View File
@@ -2,7 +2,6 @@ package dnscheck
import (
"context"
"database/sql"
"strings"
"testing"
@@ -41,21 +40,6 @@ func TestDMARCExample(t *testing.T) {
}
}
func TestResolveDMARCRua(t *testing.T) {
inherit := sql.NullString{}
if got := ResolveDMARCRua(inherit, "a@b.com"); got != "a@b.com" {
t.Errorf("inherit profile = %q", got)
}
none := sql.NullString{Valid: true}
if got := ResolveDMARCRua(none, "a@b.com"); got != "" {
t.Errorf("explicit none = %q", got)
}
custom := sql.NullString{Valid: true, String: "x@y.com"}
if got := ResolveDMARCRua(custom, "a@b.com"); got != "x@y.com" {
t.Errorf("custom = %q", got)
}
}
func TestExternalReportAuth(t *testing.T) {
name, value, ok := ExternalReportAuth("shop.com", "reports@hub.com")
if !ok || name != "_report._dmarc.hub.com" || value != "v=DMARC1;" {
-2
View File
@@ -146,11 +146,9 @@ func (s *Service) RateLimit(domainID int64) (store.RateLimit, bool, error) {
// SaveRateLimit stores the domain-level rate limit. The caller has validated the
// numbers (security.md); the milter reads the row live, so no reload is needed.
// Domain limits do not use an IP allowlist.
func (s *Service) SaveRateLimit(domainID int64, rl store.RateLimit) error {
rl.Scope = store.RateLimitScopeDomain
rl.RefID = domainID
rl.AllowedIPs = nil
return s.store.SetRateLimit(rl)
}
+8 -7
View File
@@ -1,7 +1,6 @@
package domain
import (
"database/sql"
"fmt"
"github.com/mixeme/selfpost/internal/buildinfo"
@@ -24,7 +23,7 @@ type DomainExport struct {
Domain string `json:"domain"`
DKIMSelector string `json:"dkim_selector"`
DKIMPrivateKey string `json:"dkim_private_key"` // PKCS#1 PEM
DMARCRua *string `json:"dmarc_rua,omitempty"` // nil = inherit profile; set = override ("" = none)
DMARCRua *string `json:"dmarc_rua,omitempty"` // nil = follows a user's default; set = the domain's own address ("" = none)
RateLimit *RateLimitExport `json:"rate_limit,omitempty"`
Applications []AppExport `json:"applications"`
}
@@ -74,9 +73,11 @@ func (s *Service) Export(id int64) (DomainExport, error) {
DKIMPrivateKey: string(pem),
Applications: make([]AppExport, 0, len(apps)),
}
if d.DMARCRua.Valid {
s := d.DMARCRua.String
exp.DMARCRua = &s
// A domain that follows a user's default exports no address: users do not
// travel with a domain, and whoever imports it becomes the one it follows.
if !d.DMARCRuaUserID.Valid {
rua := d.DMARCRua
exp.DMARCRua = &rua
}
rl, ok, err := s.store.GetRateLimit(store.RateLimitScopeDomain, id)
if err != nil {
@@ -147,11 +148,11 @@ func (s *Service) Import(exp DomainExport) (store.Domain, error) {
}
if exp.DMARCRua != nil {
if err := s.store.UpdateDomainDMARCRua(d.ID, sql.NullString{Valid: true, String: *exp.DMARCRua}); err != nil {
if err := s.store.SetDomainDMARCAddress(d.ID, *exp.DMARCRua); err != nil {
s.importRollback(d.ID)
return store.Domain{}, err
}
d.DMARCRua = sql.NullString{Valid: true, String: *exp.DMARCRua}
d.DMARCRua = *exp.DMARCRua
}
if exp.RateLimit != nil {
if err := s.importRateLimit(store.RateLimitScopeDomain, d.ID, *exp.RateLimit); err != nil {
+3 -4
View File
@@ -1,7 +1,6 @@
package domain
import (
"database/sql"
"errors"
"fmt"
"testing"
@@ -73,7 +72,7 @@ func TestExportImportRoundTrip(t *testing.T) {
if _, err := src.store.AddApplication(d.ID, "alerts", store.AddressModeList, []string{"a@example.com"}); err != nil {
t.Fatalf("add alerts: %v", err)
}
if err := src.store.UpdateDomainDMARCRua(d.ID, sql.NullString{Valid: true, String: "reports@hub.example"}); err != nil {
if err := src.store.SetDomainDMARCAddress(d.ID, "reports@hub.example"); err != nil {
t.Fatalf("set dmarc rua: %v", err)
}
@@ -111,8 +110,8 @@ func TestExportImportRoundTrip(t *testing.T) {
if got.Name != "example.com" || got.DKIMSelector != "selfpost" {
t.Errorf("imported domain = %+v", got)
}
if !got.DMARCRua.Valid || got.DMARCRua.String != "reports@hub.example" {
t.Errorf("imported dmarc rua = %+v", got.DMARCRua)
if got.DMARCRuaUserID.Valid || got.DMARCRua != "reports@hub.example" {
t.Errorf("imported dmarc rua = %q (follows user: %v)", got.DMARCRua, got.DMARCRuaUserID.Valid)
}
if exp.DMARCRua == nil || *exp.DMARCRua != "reports@hub.example" {
t.Errorf("exported dmarc rua = %v", exp.DMARCRua)
+2 -3
View File
@@ -292,8 +292,7 @@ func TestRateLimitDomainAppliesToAnyIP(t *testing.T) {
},
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
}
// Domain ceilings apply to every client IP; leftover AllowedIPs on the row
// are ignored.
// Domain ceilings apply to every client IP.
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
t.Fatalf("domain over limit from any IP = %v, want TempFail", resp)
}
@@ -362,7 +361,7 @@ func TestAuthIPRestrictOffAllowsAnyIP(t *testing.T) {
func TestRateLimitInactiveWithoutCeiling(t *testing.T) {
rec := &fakeRecorder{
limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": {AllowedIPs: []string{limitIP}}, // no max/window
store.RateLimitScopeDomain + "|example.com": {}, // no max/window
},
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
}
+44 -12
View File
@@ -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).
+116 -20
View File
@@ -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)
}
}
+187 -33
View File
@@ -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);
+2 -4
View File
@@ -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,
+8 -12
View File
@@ -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,
+10 -13
View File
@@ -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() {
+291
View File
@@ -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)
}
}
+26
View File
@@ -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
View File
@@ -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) {
+10 -9
View File
@@ -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)
}
+2 -2
View File
@@ -18,8 +18,8 @@ const (
type Role = store.Role
const (
RoleGlobal = store.RoleGlobal
RoleDomainAdmin = store.RoleDomainAdmin
RoleGlobal = store.RoleGlobal
RoleDomain = store.RoleDomain
)
// Principal is the authenticated panel user attached to a request.
+17
View File
@@ -3,6 +3,7 @@ package handlers
import (
"net/http"
"github.com/mixeme/selfpost/internal/dmarc"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
)
@@ -35,6 +36,22 @@ func (h *Handlers) assignedDomains(p auth.Principal) ([]store.Domain, error) {
return h.store.ListDomainsForUser(p.ID)
}
// hostedDMARCAddress is the SelfPost-hosted report mailbox for a sending
// domain, "" when report ingest is off on this server.
func (h *Handlers) hostedDMARCAddress(domainName string) string {
if h.dmarc == nil || !h.cfg.DMARCEnabled {
return ""
}
return dmarc.HostedReportAddress(h.cfg.Hostname, domainName)
}
// domainReportAddress resolves where a domain's DMARC aggregate reports go:
// its own address, or the default of the user it follows. "" is a policy-only
// record.
func (h *Handlers) domainReportAddress(d store.Domain) (string, error) {
return h.store.DomainDMARCRua(d, h.hostedDMARCAddress(d.Name))
}
func domainNameSet(domains []store.Domain) map[string]bool {
m := make(map[string]bool, len(domains))
for _, d := range domains {
+27 -19
View File
@@ -153,29 +153,37 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
// What DNS actually publishes for the domain today, checked against the key
// this server signs with. Cached by the checker, so re-rendering the page
// after a form post costs nothing.
profileEmail, err := h.store.GlobalDMARCReportEmail()
reportEmail, err := h.domainReportAddress(d)
if err != nil {
logf("panel: domain %d: global dmarc email: %v", d.ID, err)
logf("panel: domain %d: dmarc report address: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
reportEmail := dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail)
dns, srv := h.domainDNS(d, record, profileEmail, false)
dns, srv := h.domainDNS(d, record, reportEmail, false)
reportAuthName, reportAuthValue, needsReportAuth := dnscheck.ExternalReportAuth(d.Name, reportEmail)
hostedAddr := h.hostedDMARCAddress(d.Name)
// The form's "inherit" is a domain that follows a user's default. What it
// offers next to that choice is the address following would give: the
// followed user's when the domain already follows one, otherwise the
// signed-in user's own — choosing it makes the domain follow them.
dmarcMode := "inherit"
dmarcCustom := ""
hostedAddr := ""
if h.dmarc != nil && h.cfg.DMARCEnabled {
hostedAddr = dmarc.HostedReportAddress(h.cfg.Hostname, d.Name)
}
if d.DMARCRua.Valid {
if d.DMARCRua.String == "" {
profileEmail := reportEmail
if !d.DMARCRuaUserID.Valid {
profileEmail = ""
if p, ok := h.principal(r); ok {
if def, err := h.store.GetDMARCDefault(p.ID); err == nil {
profileEmail = def.Resolve(hostedAddr)
}
}
switch {
case d.DMARCRua == "":
dmarcMode = "none"
} else if hostedAddr != "" && strings.EqualFold(d.DMARCRua.String, hostedAddr) {
case hostedAddr != "" && strings.EqualFold(d.DMARCRua, hostedAddr):
dmarcMode = "hosted"
} else {
default:
dmarcMode = "custom"
dmarcCustom = d.DMARCRua.String
dmarcCustom = d.DMARCRua
}
}
dmarcSource := "policy"
@@ -186,7 +194,7 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
dmarcSource = "custom"
case dmarcMode == "none":
dmarcSource = "none"
case profileEmail != "":
case reportEmail != "":
dmarcSource = "settings"
}
@@ -250,7 +258,7 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
// and no extra environment variable is needed. That server result is returned
// alongside, because the page's suggested SPF record is built from the same
// addresses. force bypasses the cache, for the Re-check button.
func (h *Handlers) domainDNS(d store.Domain, record domain.DKIMRecord, profileEmail string, force bool) (dnscheck.Domain, dnscheck.Server) {
func (h *Handlers) domainDNS(d store.Domain, record domain.DKIMRecord, reportEmail string, force bool) (dnscheck.Domain, dnscheck.Server) {
srv := h.dns.Server(h.cfg.Hostname, false)
return h.dns.Domain(dnscheck.Query{
Name: d.Name,
@@ -258,7 +266,7 @@ func (h *Handlers) domainDNS(d store.Domain, record domain.DKIMRecord, profileEm
ExpectedDKIM: record.Value,
Hostname: srv.Hostname,
ServerIPs: srv.IPs,
DMARCReportEmail: dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail),
DMARCReportEmail: reportEmail,
}, force), srv
}
@@ -275,13 +283,13 @@ func (h *Handlers) HandleDomainDNSRecheck(w http.ResponseWriter, r *http.Request
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
profileEmail, err := h.store.GlobalDMARCReportEmail()
reportEmail, err := h.domainReportAddress(d)
if err != nil {
logf("panel: domain %d: global dmarc email: %v", d.ID, err)
logf("panel: domain %d: dmarc report address: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.domainDNS(d, record, profileEmail, true)
h.domainDNS(d, record, reportEmail, true)
http.Redirect(w, r, fmt.Sprintf("/domains/%d?rechecked=1", d.ID), http.StatusSeeOther)
}
+7
View File
@@ -277,6 +277,13 @@ func (h *Handlers) HandleImportDomain(w http.ResponseWriter, r *http.Request) {
h.renderBackupPage(w, r, status, msg)
return
}
// An export without an address of its own followed someone's default on the
// server it came from; here it follows whoever imports it.
if exp.DMARCRua == nil {
if p, ok := h.principal(r); ok {
h.followCreator(d.ID, p.ID)
}
}
http.Redirect(w, r, fmt.Sprintf("/domains/%d?imported=1", d.ID), http.StatusSeeOther)
}
+14 -8
View File
@@ -1,7 +1,6 @@
package handlers
import (
"database/sql"
"fmt"
"net/http"
"strings"
@@ -26,12 +25,14 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
return
}
var rua sql.NullString
// "inherit" makes the domain follow the default of the user who chose it;
// every other mode gives the domain an address of its own ("" = none).
follow := false
rua := ""
switch strings.TrimSpace(r.PostFormValue("dmarc_rua_mode")) {
case "inherit":
rua = sql.NullString{}
follow = true
case "none":
rua = sql.NullString{Valid: true, String: ""}
case "custom":
email := strings.TrimSpace(r.PostFormValue("dmarc_rua_email"))
if err := validate.Email(email); err != nil {
@@ -42,20 +43,25 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Enter a custom report address or choose another mode."})
return
}
rua = sql.NullString{Valid: true, String: email}
rua = email
case "hosted":
if h.dmarc == nil || !h.dmarc.Enabled() {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "SelfPost-hosted reports are not enabled on this server."})
return
}
addr := dmarc.HostedReportAddress(h.cfg.Hostname, d.Name)
rua = sql.NullString{Valid: true, String: addr}
rua = dmarc.HostedReportAddress(h.cfg.Hostname, d.Name)
default:
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Choose how aggregate reports are addressed for this domain."})
return
}
if err := h.store.UpdateDomainDMARCRua(d.ID, rua); err != nil {
var err error
if p, ok := h.principal(r); follow && ok {
err = h.store.SetDomainDMARCUser(d.ID, p.ID)
} else {
err = h.store.SetDomainDMARCAddress(d.ID, rua)
}
if err != nil {
logf("panel: domain %d: save dmarc rua: %v", d.ID, err)
h.renderDomainDetail(w, r, http.StatusInternalServerError, d, detailView{FormErr: "Could not save DMARC settings. Please check the logs and try again."})
return
@@ -0,0 +1,135 @@
package handlers
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
)
// postFormAs sends a form to a handler as the given principal.
func postFormAs(h http.HandlerFunc, p auth.Principal, target string, pathValues map[string]string, values url.Values) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(values.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for k, v := range pathValues {
req.SetPathValue(k, v)
}
rec := httptest.NewRecorder()
h(rec, auth.RequestWithPrincipal(req, p))
return rec
}
// The domain form's "Same as Settings" is, on the 2.0 schema, a domain that
// follows the default of the user who chose it; every other choice gives the
// domain an address of its own and stops it following anyone.
func TestDomainDMARCFollowsTheUserWhoChoseInherit(t *testing.T) {
h, _ := settingsServer(t)
h.domains = domain.NewService(h.store, nil, nil, "selfpost")
d, err := h.store.AddDomain("example.com", "selfpost")
if err != nil {
t.Fatalf("AddDomain: %v", err)
}
admin, err := h.store.GetUserByUsername("admin")
if err != nil {
t.Fatalf("GetUserByUsername: %v", err)
}
p := auth.Principal{ID: admin.ID, Username: "admin", Role: auth.RoleGlobal}
if err := h.store.UpdateUser(admin.ID, "admin", admin.PasswordHash, "mix@example.org"); err != nil {
t.Fatalf("UpdateUser: %v", err)
}
if err := h.store.SetDMARCDefault(admin.ID, store.DMARCDefaultAccount, ""); err != nil {
t.Fatalf("SetDMARCDefault: %v", err)
}
save := func(values url.Values) store.Domain {
t.Helper()
rec := postFormAs(h.HandleDomainDMARC, p, "/domains/1/dmarc", map[string]string{"id": "1"}, values)
if rec.Code != http.StatusSeeOther {
t.Fatalf("POST %v = %d, want 303:\n%s", values, rec.Code, rec.Body.String())
}
got, err := h.store.GetDomain(d.ID)
if err != nil {
t.Fatalf("GetDomain: %v", err)
}
return got
}
got := save(url.Values{"dmarc_rua_mode": {"inherit"}})
if !got.DMARCRuaUserID.Valid || got.DMARCRuaUserID.Int64 != admin.ID {
t.Fatalf("inherit: the domain follows %v, want user %d", got.DMARCRuaUserID, admin.ID)
}
if rua, err := h.domainReportAddress(got); err != nil || rua != "mix@example.org" {
t.Fatalf("inherit resolves to %q, %v; want the user's account e-mail", rua, err)
}
got = save(url.Values{"dmarc_rua_mode": {"custom"}, "dmarc_rua_email": {"dmarc@hub.example"}})
if got.DMARCRuaUserID.Valid || got.DMARCRua != "dmarc@hub.example" {
t.Fatalf("custom: %q / follows %v", got.DMARCRua, got.DMARCRuaUserID)
}
got = save(url.Values{"dmarc_rua_mode": {"none"}})
if got.DMARCRuaUserID.Valid || got.DMARCRua != "" {
t.Fatalf("none: %q / follows %v", got.DMARCRua, got.DMARCRuaUserID)
}
}
// Until the Account page replaces the old Settings form, its single address
// field is the account e-mail and switches the user's DMARC default between
// "my account e-mail" and "none". Nothing is written to the instance settings.
func TestSettingsFormMapsToAccountEmailAndDefault(t *testing.T) {
h, password := settingsServer(t)
submit := func(email string) store.User {
t.Helper()
rec := postFormAs(h.HandleSettings, globalPrincipal, "/settings", nil, url.Values{
"username": {"admin"}, "current_password": {password}, "dmarc_report_email": {email},
})
if rec.Code != http.StatusSeeOther {
t.Fatalf("POST /settings (%q) = %d, want 303:\n%s", email, rec.Code, rec.Body.String())
}
u, err := h.store.GetUser(globalPrincipal.ID)
if err != nil {
t.Fatalf("GetUser: %v", err)
}
return u
}
u := submit("mix@example.org")
if u.Email != "mix@example.org" || u.DMARCDefaultMode != store.DMARCDefaultAccount {
t.Fatalf("after setting the address: e-mail %q, default %q", u.Email, u.DMARCDefaultMode)
}
if v, err := h.store.GetSetting("dmarc_report_email"); err == nil && v != "" {
t.Fatalf("the address was mirrored into the instance settings: %q", v)
}
u = submit("")
if u.Email != "" || u.DMARCDefaultMode != store.DMARCDefaultNone {
t.Fatalf("after clearing the address: e-mail %q, default %q", u.Email, u.DMARCDefaultMode)
}
}
// A new domain takes its report address from whoever created it.
func TestFollowCreator(t *testing.T) {
h, _ := settingsServer(t)
d, err := h.store.AddDomain("example.com", "selfpost")
if err != nil {
t.Fatalf("AddDomain: %v", err)
}
h.followCreator(d.ID, globalPrincipal.ID)
got, err := h.store.GetDomain(d.ID)
if err != nil {
t.Fatalf("GetDomain: %v", err)
}
if !got.DMARCRuaUserID.Valid || got.DMARCRuaUserID.Int64 != globalPrincipal.ID {
t.Fatalf("the domain follows %v, want its creator (user %d)", got.DMARCRuaUserID, globalPrincipal.ID)
}
// The creator's default is "none" until they choose one, so the new
// domain publishes a policy-only record — as a fresh 1.x install did.
if rua, err := h.domainReportAddress(got); err != nil || rua != "" {
t.Fatalf("resolved %q, %v; want no report address yet", rua, err)
}
}
+25 -6
View File
@@ -51,10 +51,6 @@ func (h *Handlers) renderDashboard(w http.ResponseWriter, r *http.Request, statu
}
func (h *Handlers) domainRows(domains []store.Domain) []domainRow {
profileEmail := ""
if email, err := h.store.GlobalDMARCReportEmail(); err == nil {
profileEmail = email
}
rows := make([]domainRow, len(domains))
var wg sync.WaitGroup
for i, d := range domains {
@@ -67,7 +63,12 @@ func (h *Handlers) domainRows(domains []store.Domain) []domainRow {
logf("panel: dashboard: domain %d: dkim record: %v", d.ID, err)
return
}
dns, _ := h.domainDNS(d, record, profileEmail, false)
reportEmail, err := h.domainReportAddress(d)
if err != nil {
logf("panel: dashboard: domain %d: dmarc report address: %v", d.ID, err)
return
}
dns, _ := h.domainDNS(d, record, reportEmail, false)
rows[i].DNS = dns.Overall
}()
}
@@ -86,7 +87,8 @@ func dashboardFlash(r *http.Request) string {
// OpenDKIM reload), and redirects to the domain's page so the DNS record to
// publish is shown (product.md).
func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
p, ok := h.requireGlobal(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
@@ -111,9 +113,26 @@ func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
"Could not add the domain. Please check the logs and try again.", raw)
return
}
h.followCreator(d.ID, p.ID)
http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther)
}
// followCreator makes a new domain take its DMARC report address from the
// default of whoever created it (plan § Account e-mail). The domain exists
// either way: a failure here leaves it with no report address, which its page
// shows and its DMARC form can change.
func (h *Handlers) followCreator(domainID, userID int64) {
if err := h.store.SetDomainDMARCUser(domainID, userID); err != nil {
logf("panel: domain %d: follow user %d's dmarc default: %v", domainID, userID, err)
return
}
if h.dmarc != nil && h.dmarc.Enabled() {
if err := h.dmarc.Resync(); err != nil {
logf("panel: domain %d: dmarc resync: %v", domainID, err)
}
}
}
// HandleDeleteConfirm shows the cascade warning before a domain is removed.
func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
@@ -373,7 +373,7 @@ func domainAdmin(t *testing.T, st *store.Store, username string, domainIDs ...in
if err != nil {
t.Fatalf("add placeholder domain: %v", err)
}
id, err := st.CreateUser(username, hash, store.RoleDomainAdmin, []int64{placeholder.ID})
id, err := st.CreateUser(username, hash, store.RoleDomain, []int64{placeholder.ID})
if err != nil {
t.Fatalf("create domain admin %s: %v", username, err)
}
@@ -387,7 +387,7 @@ func domainAdmin(t *testing.T, st *store.Store, username string, domainIDs ...in
}
return auth.Principal{ID: u.ID, Username: u.Username, Role: u.Role, Domains: u.DomainIDs}
}
id, err := st.CreateUser(username, hash, store.RoleDomainAdmin, domainIDs)
id, err := st.CreateUser(username, hash, store.RoleDomain, domainIDs)
if err != nil {
t.Fatalf("create domain admin %s: %v", username, err)
}
+20 -3
View File
@@ -30,7 +30,7 @@ func (h *Handlers) HandleSettings(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.renderSettings(w, r, http.StatusOK, "", u.Username, u.DMARCReportEmail, h.sendLogRetentionDays(), p.IsGlobal())
h.renderSettings(w, r, http.StatusOK, "", u.Username, u.Email, h.sendLogRetentionDays(), p.IsGlobal())
case http.MethodPost:
h.submitSettings(w, r)
default:
@@ -128,7 +128,7 @@ func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) {
dmarcEmail := strings.TrimSpace(r.PostFormValue("dmarc_report_email"))
formRetention := currentRetention
if !p.IsGlobal() {
dmarcEmail = user.DMARCReportEmail
dmarcEmail = user.Email
} else if raw := strings.TrimSpace(r.PostFormValue("send_log_retention_days")); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil {
@@ -161,7 +161,7 @@ func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) {
}
}
emailChanging := p.IsGlobal() && dmarcEmail != user.DMARCReportEmail
emailChanging := p.IsGlobal() && dmarcEmail != user.Email
retentionChanging := false
if p.IsGlobal() && formRetention != currentRetention {
@@ -218,6 +218,23 @@ func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) {
}
}
// Until the Account page replaces this form (stage 2), its one "default
// report address" field stands for both halves of the 2.0 model: it is the
// account e-mail, and the user's DMARC default is "my account e-mail" while
// it is filled and "none" once it is cleared.
if emailChanging {
mode := store.DMARCDefaultNone
if dmarcEmail != "" {
mode = store.DMARCDefaultAccount
}
if err := h.store.SetDMARCDefault(user.ID, mode, ""); err != nil {
logf("panel: settings: set dmarc default failed: %v", err)
h.renderSettings(w, r, http.StatusInternalServerError,
"Could not save the changes. Please check the logs and try again.", username, dmarcEmail, formRetention, p.IsGlobal())
return
}
}
if retentionChanging {
if err := h.store.SetSendLogRetentionDays(formRetention); err != nil {
logf("panel: settings: set send-log retention failed: %v", err)
+8 -8
View File
@@ -58,7 +58,7 @@ func (h *Handlers) HandleUserNew(w http.ResponseWriter, r *http.Request) {
}
switch r.Method {
case http.MethodGet:
h.renderUserForm(w, r, http.StatusOK, 0, userFormView{FormRole: string(store.RoleDomainAdmin)})
h.renderUserForm(w, r, http.StatusOK, 0, userFormView{FormRole: string(store.RoleDomain)})
case http.MethodPost:
h.submitUserCreate(w, r)
default:
@@ -162,11 +162,11 @@ func (h *Handlers) submitUserCreate(w http.ResponseWriter, r *http.Request) {
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
return
}
if role != store.RoleGlobal && role != store.RoleDomainAdmin {
if role != store.RoleGlobal && role != store.RoleDomain {
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
return
}
if role == store.RoleDomainAdmin && len(domainIDs) == 0 {
if role == store.RoleDomain && len(domainIDs) == 0 {
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Select at least one domain for a domain administrator.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
return
}
@@ -213,16 +213,16 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: selected})
return
}
if role != store.RoleGlobal && role != store.RoleDomainAdmin {
if role != store.RoleGlobal && role != store.RoleDomain {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormRole: string(role), FormDomains: selected})
return
}
if role == store.RoleDomainAdmin && len(domainIDs) == 0 {
if role == store.RoleDomain && len(domainIDs) == 0 {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Select at least one domain for a domain administrator.", FormUsername: username, FormRole: string(role), FormDomains: selected})
return
}
if u.Role == store.RoleGlobal && role == store.RoleDomainAdmin {
if u.Role == store.RoleGlobal && role == store.RoleDomain {
n, err := h.store.CountGlobalUsers()
if err != nil || n <= 1 {
msg := "Cannot demote the last global administrator."
@@ -249,7 +249,7 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
hash = string(newHash)
}
if err := h.store.UpdateUser(u.ID, username, hash, u.DMARCReportEmail); err != nil {
if err := h.store.UpdateUser(u.ID, username, hash, u.Email); err != nil {
if errors.Is(err, store.ErrUserExists) {
h.renderUserForm(w, r, http.StatusConflict, u.ID, userFormView{FormErr: "That username is already in use.", FormUsername: username, FormRole: string(role), FormDomains: selected})
return
@@ -272,7 +272,7 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
}
}
if role == store.RoleDomainAdmin {
if role == store.RoleDomain {
if err := h.store.SetUserDomains(u.ID, domainIDs); err != nil {
logf("panel: set user domains: %v", err)
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not save domain assignments.", FormUsername: username, FormRole: string(role), FormDomains: selected})
+1 -1
View File
@@ -16,7 +16,7 @@
<label for="role">Role</label>
{{if .LastGlobalLocked}}<input type="hidden" name="role" value="{{.FormRole}}">{{end}}
<select id="role" name="role" data-global-role="{{.GlobalRole}}"{{if .LastGlobalLocked}} disabled{{end}}>
<option value="domain_admin" {{if eq .FormRole "domain_admin"}}selected{{end}}>Domain administrator</option>
<option value="domain" {{if eq .FormRole "domain"}}selected{{end}}>Domain administrator</option>
<option value="global" {{if eq .FormRole "global"}}selected{{end}}>Global administrator</option>
</select>
{{if .LastGlobalLocked}}<p class="muted">The only global administrator cannot be demoted.</p>{{end}}