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:
@@ -205,7 +205,6 @@ func (s *Service) RateLimit(appID int64) (store.RateLimit, bool, error) {
|
||||
func (s *Service) SaveRateLimit(appID int64, rl store.RateLimit) error {
|
||||
rl.Scope = store.RateLimitScopeApp
|
||||
rl.RefID = appID
|
||||
rl.AllowedIPs = nil
|
||||
return s.store.SetRateLimit(rl)
|
||||
}
|
||||
|
||||
|
||||
+24
-20
@@ -54,10 +54,6 @@ func (s *Service) AllowedRecipients() ([]string, error) {
|
||||
if !s.enabled || s.hostname == "" {
|
||||
return nil, nil
|
||||
}
|
||||
profile, err := s.store.GlobalDMARCReportEmail()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
var out []string
|
||||
add := func(addr string) {
|
||||
@@ -68,8 +64,24 @@ func (s *Service) AllowedRecipients() ([]string, error) {
|
||||
seen[addr] = true
|
||||
out = append(out, addr)
|
||||
}
|
||||
add(profile)
|
||||
if profile == "" {
|
||||
// A user's own default that points at this host is accepted even before a
|
||||
// domain follows it, so the address can be published first. With no such
|
||||
// default anywhere, the instance's generic mailbox stays open — the address
|
||||
// the guide tells a fresh install to publish.
|
||||
users, err := s.store.ListUsers()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
anyDefault := false
|
||||
for _, u := range users {
|
||||
if def := u.DMARCDefault(); def.Mode == store.DMARCDefaultAccount || def.Mode == store.DMARCDefaultCustom {
|
||||
if addr := def.Resolve(""); addr != "" {
|
||||
anyDefault = true
|
||||
add(addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !anyDefault {
|
||||
add(DefaultHostedReportAddress(s.hostname))
|
||||
}
|
||||
domains, err := s.store.ListDomains()
|
||||
@@ -77,21 +89,13 @@ func (s *Service) AllowedRecipients() ([]string, error) {
|
||||
return nil, err
|
||||
}
|
||||
for _, d := range domains {
|
||||
rua := dnscheck.ResolveDMARCRua(d.DMARCRua, profile)
|
||||
if rua == "" {
|
||||
continue
|
||||
}
|
||||
if IsHostedOnHostname(rua, s.hostname) {
|
||||
add(rua)
|
||||
continue
|
||||
}
|
||||
if d.DMARCRua.Valid && d.DMARCRua.String == "" {
|
||||
continue
|
||||
}
|
||||
hosted := HostedReportAddress(s.hostname, d.Name)
|
||||
if strings.EqualFold(rua, hosted) {
|
||||
add(rua)
|
||||
// add keeps only addresses on this host, so a domain reporting elsewhere
|
||||
// (or nowhere) opens nothing here.
|
||||
rua, err := s.store.DomainDMARCRua(d, HostedReportAddress(s.hostname, d.Name))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
add(rua)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package dnscheck
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
@@ -48,15 +47,6 @@ func SPFExample(hostname string, serverIPs []string) string {
|
||||
return "v=spf1 " + strings.Join(mechanisms, " ") + " -all"
|
||||
}
|
||||
|
||||
// ResolveDMARCRua picks the rua= mailbox for a sending domain: per-domain
|
||||
// override wins, then the administrator profile, then policy-only (empty).
|
||||
func ResolveDMARCRua(domainRua sql.NullString, profileEmail string) string {
|
||||
if domainRua.Valid {
|
||||
return domainRua.String
|
||||
}
|
||||
return profileEmail
|
||||
}
|
||||
|
||||
// EmailDomain returns the lower-case domain part of addr, or "" when invalid.
|
||||
func EmailDomain(addr string) string {
|
||||
addr = strings.TrimSpace(addr)
|
||||
|
||||
@@ -2,7 +2,6 @@ package dnscheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -41,21 +40,6 @@ func TestDMARCExample(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDMARCRua(t *testing.T) {
|
||||
inherit := sql.NullString{}
|
||||
if got := ResolveDMARCRua(inherit, "a@b.com"); got != "a@b.com" {
|
||||
t.Errorf("inherit profile = %q", got)
|
||||
}
|
||||
none := sql.NullString{Valid: true}
|
||||
if got := ResolveDMARCRua(none, "a@b.com"); got != "" {
|
||||
t.Errorf("explicit none = %q", got)
|
||||
}
|
||||
custom := sql.NullString{Valid: true, String: "x@y.com"}
|
||||
if got := ResolveDMARCRua(custom, "a@b.com"); got != "x@y.com" {
|
||||
t.Errorf("custom = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalReportAuth(t *testing.T) {
|
||||
name, value, ok := ExternalReportAuth("shop.com", "reports@hub.com")
|
||||
if !ok || name != "_report._dmarc.hub.com" || value != "v=DMARC1;" {
|
||||
|
||||
@@ -146,11 +146,9 @@ func (s *Service) RateLimit(domainID int64) (store.RateLimit, bool, error) {
|
||||
|
||||
// SaveRateLimit stores the domain-level rate limit. The caller has validated the
|
||||
// numbers (security.md); the milter reads the row live, so no reload is needed.
|
||||
// Domain limits do not use an IP allowlist.
|
||||
func (s *Service) SaveRateLimit(domainID int64, rl store.RateLimit) error {
|
||||
rl.Scope = store.RateLimitScopeDomain
|
||||
rl.RefID = domainID
|
||||
rl.AllowedIPs = nil
|
||||
return s.store.SetRateLimit(rl)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||
@@ -24,7 +23,7 @@ type DomainExport struct {
|
||||
Domain string `json:"domain"`
|
||||
DKIMSelector string `json:"dkim_selector"`
|
||||
DKIMPrivateKey string `json:"dkim_private_key"` // PKCS#1 PEM
|
||||
DMARCRua *string `json:"dmarc_rua,omitempty"` // nil = inherit profile; set = override ("" = none)
|
||||
DMARCRua *string `json:"dmarc_rua,omitempty"` // nil = follows a user's default; set = the domain's own address ("" = none)
|
||||
RateLimit *RateLimitExport `json:"rate_limit,omitempty"`
|
||||
Applications []AppExport `json:"applications"`
|
||||
}
|
||||
@@ -74,9 +73,11 @@ func (s *Service) Export(id int64) (DomainExport, error) {
|
||||
DKIMPrivateKey: string(pem),
|
||||
Applications: make([]AppExport, 0, len(apps)),
|
||||
}
|
||||
if d.DMARCRua.Valid {
|
||||
s := d.DMARCRua.String
|
||||
exp.DMARCRua = &s
|
||||
// A domain that follows a user's default exports no address: users do not
|
||||
// travel with a domain, and whoever imports it becomes the one it follows.
|
||||
if !d.DMARCRuaUserID.Valid {
|
||||
rua := d.DMARCRua
|
||||
exp.DMARCRua = &rua
|
||||
}
|
||||
rl, ok, err := s.store.GetRateLimit(store.RateLimitScopeDomain, id)
|
||||
if err != nil {
|
||||
@@ -147,11 +148,11 @@ func (s *Service) Import(exp DomainExport) (store.Domain, error) {
|
||||
}
|
||||
|
||||
if exp.DMARCRua != nil {
|
||||
if err := s.store.UpdateDomainDMARCRua(d.ID, sql.NullString{Valid: true, String: *exp.DMARCRua}); err != nil {
|
||||
if err := s.store.SetDomainDMARCAddress(d.ID, *exp.DMARCRua); err != nil {
|
||||
s.importRollback(d.ID)
|
||||
return store.Domain{}, err
|
||||
}
|
||||
d.DMARCRua = sql.NullString{Valid: true, String: *exp.DMARCRua}
|
||||
d.DMARCRua = *exp.DMARCRua
|
||||
}
|
||||
if exp.RateLimit != nil {
|
||||
if err := s.importRateLimit(store.RateLimitScopeDomain, d.ID, *exp.RateLimit); err != nil {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
@@ -73,7 +72,7 @@ func TestExportImportRoundTrip(t *testing.T) {
|
||||
if _, err := src.store.AddApplication(d.ID, "alerts", store.AddressModeList, []string{"a@example.com"}); err != nil {
|
||||
t.Fatalf("add alerts: %v", err)
|
||||
}
|
||||
if err := src.store.UpdateDomainDMARCRua(d.ID, sql.NullString{Valid: true, String: "reports@hub.example"}); err != nil {
|
||||
if err := src.store.SetDomainDMARCAddress(d.ID, "reports@hub.example"); err != nil {
|
||||
t.Fatalf("set dmarc rua: %v", err)
|
||||
}
|
||||
|
||||
@@ -111,8 +110,8 @@ func TestExportImportRoundTrip(t *testing.T) {
|
||||
if got.Name != "example.com" || got.DKIMSelector != "selfpost" {
|
||||
t.Errorf("imported domain = %+v", got)
|
||||
}
|
||||
if !got.DMARCRua.Valid || got.DMARCRua.String != "reports@hub.example" {
|
||||
t.Errorf("imported dmarc rua = %+v", got.DMARCRua)
|
||||
if got.DMARCRuaUserID.Valid || got.DMARCRua != "reports@hub.example" {
|
||||
t.Errorf("imported dmarc rua = %q (follows user: %v)", got.DMARCRua, got.DMARCRuaUserID.Valid)
|
||||
}
|
||||
if exp.DMARCRua == nil || *exp.DMARCRua != "reports@hub.example" {
|
||||
t.Errorf("exported dmarc rua = %v", exp.DMARCRua)
|
||||
|
||||
@@ -292,8 +292,7 @@ func TestRateLimitDomainAppliesToAnyIP(t *testing.T) {
|
||||
},
|
||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
||||
}
|
||||
// Domain ceilings apply to every client IP; leftover AllowedIPs on the row
|
||||
// are ignored.
|
||||
// Domain ceilings apply to every client IP.
|
||||
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
|
||||
t.Fatalf("domain over limit from any IP = %v, want TempFail", resp)
|
||||
}
|
||||
@@ -362,7 +361,7 @@ func TestAuthIPRestrictOffAllowsAnyIP(t *testing.T) {
|
||||
func TestRateLimitInactiveWithoutCeiling(t *testing.T) {
|
||||
rec := &fakeRecorder{
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeDomain + "|example.com": {AllowedIPs: []string{limitIP}}, // no max/window
|
||||
store.RateLimitScopeDomain + "|example.com": {}, // no max/window
|
||||
},
|
||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
||||
}
|
||||
|
||||
+44
-12
@@ -25,9 +25,13 @@ type Domain struct {
|
||||
ID int64
|
||||
Name string
|
||||
DKIMSelector string
|
||||
DMARCRua sql.NullString // NULL = inherit profile; Valid+empty = no reports
|
||||
CreatedAt time.Time
|
||||
AppCount int
|
||||
// Where DMARC aggregate reports go. With DMARCRuaUserID set the domain
|
||||
// follows that user's default (DMARCDefault) and DMARCRua is ignored;
|
||||
// otherwise DMARCRua is the address itself, "" meaning no reports.
|
||||
DMARCRua string
|
||||
DMARCRuaUserID sql.NullInt64
|
||||
CreatedAt time.Time
|
||||
AppCount int
|
||||
}
|
||||
|
||||
// AddDomain inserts a new sending domain. The caller is responsible for having
|
||||
@@ -56,7 +60,7 @@ func (s *Store) AddDomain(name, selector string) (Domain, error) {
|
||||
// ordered by name.
|
||||
func (s *Store) ListDomains() ([]Domain, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at,
|
||||
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.dmarc_rua_user_id, d.created_at,
|
||||
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
|
||||
FROM domains d
|
||||
ORDER BY d.name`)
|
||||
@@ -80,7 +84,7 @@ func (s *Store) ListDomains() ([]Domain, error) {
|
||||
// ordered by name.
|
||||
func (s *Store) ListDomainsForUser(userID int64) ([]Domain, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at,
|
||||
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.dmarc_rua_user_id, d.created_at,
|
||||
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
|
||||
FROM domains d
|
||||
INNER JOIN user_domains ud ON ud.domain_id = d.id
|
||||
@@ -106,7 +110,7 @@ func (s *Store) ListDomainsForUser(userID int64) ([]Domain, error) {
|
||||
// ErrDomainNotFound.
|
||||
func (s *Store) GetDomain(id int64) (Domain, error) {
|
||||
row := s.db.QueryRow(`
|
||||
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at,
|
||||
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.dmarc_rua_user_id, d.created_at,
|
||||
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
|
||||
FROM domains d
|
||||
WHERE d.id = ?`, id)
|
||||
@@ -148,7 +152,7 @@ func scanDomain(r scanRow) (Domain, error) {
|
||||
d Domain
|
||||
createdAt string
|
||||
)
|
||||
if err := r.Scan(&d.ID, &d.Name, &d.DKIMSelector, &d.DMARCRua, &createdAt, &d.AppCount); err != nil {
|
||||
if err := r.Scan(&d.ID, &d.Name, &d.DKIMSelector, &d.DMARCRua, &d.DMARCRuaUserID, &createdAt, &d.AppCount); err != nil {
|
||||
return Domain{}, err
|
||||
}
|
||||
d.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
@@ -166,11 +170,21 @@ func isUniqueViolation(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// UpdateDomainDMARCRua sets how this domain resolves its DMARC rua= destination.
|
||||
// NULL means inherit the administrator profile; Valid with an empty string means
|
||||
// policy-only with no aggregate reports for this domain.
|
||||
func (s *Store) UpdateDomainDMARCRua(id int64, rua sql.NullString) error {
|
||||
res, err := s.db.Exec("UPDATE domains SET dmarc_rua = ? WHERE id = ?", rua, id)
|
||||
// SetDomainDMARCAddress gives the domain a report address of its own; "" means
|
||||
// no aggregate reports. The domain stops following anyone's default.
|
||||
func (s *Store) SetDomainDMARCAddress(id int64, rua string) error {
|
||||
return s.updateDomainDMARC(id, rua, sql.NullInt64{})
|
||||
}
|
||||
|
||||
// SetDomainDMARCUser makes the domain follow userID's default report address
|
||||
// (DMARCDefault). If that user is later deleted the domain falls back to no
|
||||
// reports rather than to someone else's default.
|
||||
func (s *Store) SetDomainDMARCUser(id, userID int64) error {
|
||||
return s.updateDomainDMARC(id, "", sql.NullInt64{Int64: userID, Valid: true})
|
||||
}
|
||||
|
||||
func (s *Store) updateDomainDMARC(id int64, rua string, userID sql.NullInt64) error {
|
||||
res, err := s.db.Exec("UPDATE domains SET dmarc_rua = ?, dmarc_rua_user_id = ? WHERE id = ?", rua, userID, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update domain dmarc rua: %w", err)
|
||||
}
|
||||
@@ -184,6 +198,24 @@ func (s *Store) UpdateDomainDMARCRua(id int64, rua sql.NullString) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DomainDMARCRua resolves where the domain's aggregate reports go: its own
|
||||
// address, or the default of the user it follows. hosted is the SelfPost-hosted
|
||||
// mailbox for this domain, "" when report ingest is off (the store does not
|
||||
// know the server's hostname). The result is "" for a policy-only record.
|
||||
func (s *Store) DomainDMARCRua(d Domain, hosted string) (string, error) {
|
||||
if !d.DMARCRuaUserID.Valid {
|
||||
return d.DMARCRua, nil
|
||||
}
|
||||
def, err := s.GetDMARCDefault(d.DMARCRuaUserID.Int64)
|
||||
if errors.Is(err, ErrUserNotFound) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return def.Resolve(hosted), nil
|
||||
}
|
||||
|
||||
// DomainExists reports whether a sending domain with this name is configured.
|
||||
// Used by the DMARC ingest path to refuse reports for domains this relay does
|
||||
// not send for (security.md: report XML is attacker-supplied input).
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDomainDMARCRua(t *testing.T) {
|
||||
const hostedForTest = "dmarc+example.com@mail.example.org"
|
||||
|
||||
// A domain either has a report address of its own or follows a user's default;
|
||||
// the two never mix, and a fresh domain has neither.
|
||||
func TestDomainDMARCAddressAndUser(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
d, err := st.AddDomain("example.com", "sel")
|
||||
if err != nil {
|
||||
@@ -15,31 +19,123 @@ func TestDomainDMARCRua(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("GetDomain: %v", err)
|
||||
}
|
||||
if got.DMARCRua.Valid {
|
||||
t.Fatal("new domain should inherit profile")
|
||||
if got.DMARCRua != "" || got.DMARCRuaUserID.Valid {
|
||||
t.Fatalf("a new domain = %q / follows %v, want no reports", got.DMARCRua, got.DMARCRuaUserID)
|
||||
}
|
||||
|
||||
if err := st.UpdateDomainDMARCRua(d.ID, sql.NullString{Valid: true, String: "reports@hub.com"}); err != nil {
|
||||
t.Fatalf("UpdateDomainDMARCRua custom: %v", err)
|
||||
}
|
||||
got, err = st.GetDomain(d.ID)
|
||||
if err != nil || got.DMARCRua.String != "reports@hub.com" {
|
||||
t.Fatalf("custom = %+v, err=%v", got.DMARCRua, err)
|
||||
}
|
||||
|
||||
if err := st.UpdateDomainDMARCRua(d.ID, sql.NullString{Valid: true}); err != nil {
|
||||
t.Fatalf("UpdateDomainDMARCRua none: %v", err)
|
||||
if err := st.SetDomainDMARCAddress(d.ID, "reports@hub.com"); err != nil {
|
||||
t.Fatalf("SetDomainDMARCAddress: %v", err)
|
||||
}
|
||||
got, _ = st.GetDomain(d.ID)
|
||||
if !got.DMARCRua.Valid || got.DMARCRua.String != "" {
|
||||
t.Fatalf("none = %+v", got.DMARCRua)
|
||||
if rua, err := st.DomainDMARCRua(got, hostedForTest); err != nil || rua != "reports@hub.com" {
|
||||
t.Fatalf("own address resolves to %q, %v", rua, err)
|
||||
}
|
||||
|
||||
if err := st.UpdateDomainDMARCRua(d.ID, sql.NullString{}); err != nil {
|
||||
t.Fatalf("UpdateDomainDMARCRua inherit: %v", err)
|
||||
if err := st.CreateGlobalUser("admin", "hash"); err != nil {
|
||||
t.Fatalf("CreateGlobalUser: %v", err)
|
||||
}
|
||||
u, _ := st.GetUserByUsername("admin")
|
||||
if err := st.SetDomainDMARCUser(d.ID, u.ID); err != nil {
|
||||
t.Fatalf("SetDomainDMARCUser: %v", err)
|
||||
}
|
||||
got, _ = st.GetDomain(d.ID)
|
||||
if got.DMARCRua.Valid {
|
||||
t.Fatalf("inherit = %+v", got.DMARCRua)
|
||||
if got.DMARCRua != "" || !got.DMARCRuaUserID.Valid || got.DMARCRuaUserID.Int64 != u.ID {
|
||||
t.Fatalf("following a user left %q / %v; the own address must be dropped", got.DMARCRua, got.DMARCRuaUserID)
|
||||
}
|
||||
|
||||
if err := st.SetDomainDMARCAddress(d.ID, ""); err != nil {
|
||||
t.Fatalf("SetDomainDMARCAddress none: %v", err)
|
||||
}
|
||||
got, _ = st.GetDomain(d.ID)
|
||||
if got.DMARCRua != "" || got.DMARCRuaUserID.Valid {
|
||||
t.Fatalf("none = %q / %v", got.DMARCRua, got.DMARCRuaUserID)
|
||||
}
|
||||
|
||||
if err := st.SetDomainDMARCAddress(d.ID+100, "x@y.com"); !errors.Is(err, ErrDomainNotFound) {
|
||||
t.Fatalf("unknown domain = %v, want ErrDomainNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A domain that follows a user resolves to whatever that user's default says
|
||||
// today — every mode — and changes with it.
|
||||
func TestDomainFollowsUserDefault(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if err := st.CreateGlobalUser("admin", "hash"); err != nil {
|
||||
t.Fatalf("CreateGlobalUser: %v", err)
|
||||
}
|
||||
u, _ := st.GetUserByUsername("admin")
|
||||
if err := st.UpdateUser(u.ID, "admin", "hash", "mix@example.org"); err != nil {
|
||||
t.Fatalf("UpdateUser: %v", err)
|
||||
}
|
||||
d, _ := st.AddDomain("example.com", "sel")
|
||||
if err := st.SetDomainDMARCUser(d.ID, u.ID); err != nil {
|
||||
t.Fatalf("SetDomainDMARCUser: %v", err)
|
||||
}
|
||||
d, _ = st.GetDomain(d.ID)
|
||||
|
||||
for _, tc := range []struct {
|
||||
mode, address string
|
||||
hosted string
|
||||
want string
|
||||
}{
|
||||
{DMARCDefaultNone, "", hostedForTest, ""},
|
||||
{DMARCDefaultAccount, "", hostedForTest, "mix@example.org"},
|
||||
{DMARCDefaultCustom, "dmarc@hub.example", hostedForTest, "dmarc@hub.example"},
|
||||
{DMARCDefaultHosted, "", hostedForTest, hostedForTest},
|
||||
// Ingest switched off: a hosted default has no mailbox to point at.
|
||||
{DMARCDefaultHosted, "", "", ""},
|
||||
// The custom address belongs to the custom mode only.
|
||||
{DMARCDefaultAccount, "ignored@hub.example", hostedForTest, "mix@example.org"},
|
||||
} {
|
||||
if err := st.SetDMARCDefault(u.ID, tc.mode, tc.address); err != nil {
|
||||
t.Fatalf("SetDMARCDefault(%s): %v", tc.mode, err)
|
||||
}
|
||||
got, err := st.DomainDMARCRua(d, tc.hosted)
|
||||
if err != nil || got != tc.want {
|
||||
t.Errorf("mode %s, hosted %q: resolved %q, %v; want %q", tc.mode, tc.hosted, got, err, tc.want)
|
||||
}
|
||||
}
|
||||
def, err := st.GetDMARCDefault(u.ID)
|
||||
if err != nil || def.Address != "" || def.Username != "admin" {
|
||||
t.Errorf("default after a non-custom mode = %+v, %v; the custom address must be cleared", def, err)
|
||||
}
|
||||
if err := st.SetDMARCDefault(u.ID, "profile", ""); err == nil {
|
||||
t.Error("an unknown mode was stored")
|
||||
}
|
||||
if err := st.SetDMARCDefault(u.ID+100, DMARCDefaultNone, ""); !errors.Is(err, ErrUserNotFound) {
|
||||
t.Errorf("unknown user = %v, want ErrUserNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting the user a domain follows must leave the domain with no report
|
||||
// address — not with another user's default, and not with a dangling id.
|
||||
func TestDeletingTheFollowedUserLeavesNoReports(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if err := st.CreateGlobalUser("admin", "hash"); err != nil {
|
||||
t.Fatalf("CreateGlobalUser: %v", err)
|
||||
}
|
||||
d, _ := st.AddDomain("example.com", "sel")
|
||||
id, err := st.CreateUser("second", "hash", RoleGlobal, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
if err := st.SetDMARCDefault(id, DMARCDefaultCustom, "dmarc@hub.example"); err != nil {
|
||||
t.Fatalf("SetDMARCDefault: %v", err)
|
||||
}
|
||||
if err := st.SetDomainDMARCUser(d.ID, id); err != nil {
|
||||
t.Fatalf("SetDomainDMARCUser: %v", err)
|
||||
}
|
||||
if err := st.DeleteUser(id); err != nil {
|
||||
t.Fatalf("DeleteUser: %v", err)
|
||||
}
|
||||
got, err := st.GetDomain(d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDomain: %v", err)
|
||||
}
|
||||
if got.DMARCRuaUserID.Valid || got.DMARCRua != "" {
|
||||
t.Fatalf("after deleting the followed user: %q / %v, want no reports", got.DMARCRua, got.DMARCRuaUserID)
|
||||
}
|
||||
if rua, err := st.DomainDMARCRua(got, hostedForTest); err != nil || rua != "" {
|
||||
t.Fatalf("resolved %q, %v; want no reports", rua, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,103 @@
|
||||
-- Initial SelfPost schema. One SQLite file under /data holds the whole panel
|
||||
-- state so a single directory backup/restore is sufficient.
|
||||
-- SelfPost 2.0 baseline schema. One SQLite file under /data holds the whole
|
||||
-- panel state, so a single directory backup/restore is sufficient.
|
||||
--
|
||||
-- This file IS the 2.0 schema: the 1.x chain (0001..0010, with its admin table
|
||||
-- created and dropped again) was deleted, not squashed with an upgrade path —
|
||||
-- 2.0 starts from an empty data directory (docs/schema-migrations.md). Later
|
||||
-- changes ship as 0002_*.sql and onwards; this file is never edited again once
|
||||
-- 2.0.0 is released.
|
||||
|
||||
-- Single administrator account. Exactly one row is allowed; the presence of
|
||||
-- that row is what marks primary setup as complete, which is why the /setup
|
||||
-- route disappears once it exists.
|
||||
CREATE TABLE admin (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
-- Marks the file as a 2.x SelfPost database ("SP20"). store.Open refuses a
|
||||
-- database that has a schema version but not this mark, which is what a 1.x
|
||||
-- file looks like: its user_version would otherwise read as "already migrated".
|
||||
PRAGMA application_id = 1397764656;
|
||||
|
||||
-- Free-form key/value panel settings (retention overrides, misc flags).
|
||||
-- Free-form key/value settings of the instance: what is true whoever is signed
|
||||
-- in (send-log retention). Nothing that belongs to a user lives here.
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Sending domains managed through the panel. DKIM keys themselves live on disk
|
||||
-- under /data; this row records the selector and metadata.
|
||||
-- Panel logins (not application SASL accounts). The presence of a row is what
|
||||
-- marks primary setup as complete, which is why /setup disappears once one
|
||||
-- exists. role is a reach, not a rank: 'global' sees the whole instance,
|
||||
-- 'domain' only what is assigned below.
|
||||
--
|
||||
-- email is the user's own address — what the panel writes to. The DMARC
|
||||
-- default is a separate choice that may use it: dmarc_default_mode says where
|
||||
-- aggregate reports go for a domain that follows this user
|
||||
-- (domains.dmarc_rua_user_id): 'hosted' (SelfPost's own mailbox for that
|
||||
-- domain), 'account' (email above), 'custom' (dmarc_default_address) or 'none'.
|
||||
--
|
||||
-- all_domains / all_inbound_domains widen a 'domain' user's list to every
|
||||
-- domain of that direction, including the ones added later; with a flag set
|
||||
-- the rows of the matching assignment table are ignored.
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('global', 'domain')),
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
dmarc_default_mode TEXT NOT NULL DEFAULT 'none'
|
||||
CHECK (dmarc_default_mode IN ('hosted', 'account', 'custom', 'none')),
|
||||
dmarc_default_address TEXT NOT NULL DEFAULT '',
|
||||
all_domains INTEGER NOT NULL DEFAULT 0 CHECK (all_domains IN (0, 1)),
|
||||
all_inbound_domains INTEGER NOT NULL DEFAULT 0 CHECK (all_inbound_domains IN (0, 1)),
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Panel login sessions. Persisted so a login survives a container restart or
|
||||
-- redeploy; only the SHA-256 of the session token is stored, never the token
|
||||
-- itself, so a stolen database file cannot be used to sign in. expires_at
|
||||
-- implements the sliding idle timeout: it is pushed forward on activity rather
|
||||
-- than being fixed at creation time.
|
||||
CREATE TABLE sessions (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_sessions_expires_at ON sessions (expires_at);
|
||||
|
||||
-- Sending (outbound) domains. DKIM keys themselves live on disk under /data;
|
||||
-- this row records the selector and metadata.
|
||||
--
|
||||
-- Where the domain's DMARC aggregate reports go (rua=): with dmarc_rua_user_id
|
||||
-- set, the domain follows that user's default and dmarc_rua is ignored;
|
||||
-- otherwise dmarc_rua is the address itself, '' meaning no reports. Deleting
|
||||
-- the user leaves the domain at "none" rather than silently handing its
|
||||
-- reports to someone else.
|
||||
CREATE TABLE domains (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
dkim_selector TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
dkim_selector TEXT NOT NULL,
|
||||
dmarc_rua TEXT NOT NULL DEFAULT '',
|
||||
dmarc_rua_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Outbound domains assigned to a 'domain' user.
|
||||
CREATE TABLE user_domains (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, domain_id)
|
||||
);
|
||||
|
||||
-- Applications bound to a domain. address_mode is either the domain wildcard or
|
||||
-- an explicit address list; the SASL login is globally unique.
|
||||
--
|
||||
-- auth_ip_restrict / auth_allowed_ips restrict which client IPs may submit as
|
||||
-- the application at all (refused otherwise). That is independent of the
|
||||
-- level-2 rate limits below.
|
||||
CREATE TABLE applications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
||||
login TEXT NOT NULL UNIQUE,
|
||||
address_mode TEXT NOT NULL CHECK (address_mode IN ('wildcard', 'list')),
|
||||
created_at TEXT NOT NULL
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
||||
login TEXT NOT NULL UNIQUE,
|
||||
address_mode TEXT NOT NULL CHECK (address_mode IN ('wildcard', 'list')),
|
||||
auth_ip_restrict INTEGER NOT NULL DEFAULT 0,
|
||||
auth_allowed_ips TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Explicit sender addresses for applications in 'list' mode. Each address must
|
||||
@@ -45,6 +109,20 @@ CREATE TABLE application_addresses (
|
||||
UNIQUE (application_id, address)
|
||||
);
|
||||
|
||||
-- Level-2 rate limits per domain or application. In 'auto' mode max_messages
|
||||
-- is derived from 30-day send statistics times auto_multiplier.
|
||||
CREATE TABLE rate_limits (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('domain', 'application')),
|
||||
ref_id INTEGER NOT NULL,
|
||||
max_messages INTEGER,
|
||||
window_seconds INTEGER,
|
||||
mode TEXT NOT NULL DEFAULT 'manual' CHECK (mode IN ('manual', 'auto')),
|
||||
auto_multiplier REAL,
|
||||
auto_updated_at TEXT,
|
||||
UNIQUE (scope, ref_id)
|
||||
);
|
||||
|
||||
-- Structured send log. One row per (queue-id, recipient); the log-tailer
|
||||
-- advances status from queued to a final state.
|
||||
CREATE TABLE send_log (
|
||||
@@ -62,15 +140,91 @@ CREATE TABLE send_log (
|
||||
CREATE INDEX idx_send_log_queue_id ON send_log (queue_id);
|
||||
CREATE INDEX idx_send_log_domain ON send_log (domain);
|
||||
CREATE INDEX idx_send_log_created_at ON send_log (created_at);
|
||||
-- The journal milter counts messages for a level-2 limit on every MAIL FROM,
|
||||
-- and the domain page computes 30-day statistics per application on every
|
||||
-- render: equality on app_login, range on created_at.
|
||||
CREATE INDEX idx_send_log_app_login_created_at ON send_log (app_login, created_at);
|
||||
|
||||
-- Differentiated rate limits per domain/application. Both the IP binding and
|
||||
-- the message limit are optional.
|
||||
CREATE TABLE rate_limits (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('domain', 'application')),
|
||||
ref_id INTEGER NOT NULL,
|
||||
allowed_ips TEXT,
|
||||
max_messages INTEGER,
|
||||
window_seconds INTEGER,
|
||||
UNIQUE (scope, ref_id)
|
||||
-- Log-tailer read position. Without it the tailer starts at end-of-file on
|
||||
-- every start, so delivery lines written while the panel was down are never
|
||||
-- parsed and their send-log rows stay "queued" forever. One row per followed
|
||||
-- path; fingerprint identifies the file the offset belongs to (the head bytes
|
||||
-- of the log), so a rotated or recreated mail.log is detected across a restart,
|
||||
-- where os.SameFile cannot help.
|
||||
CREATE TABLE logtail_state (
|
||||
path TEXT PRIMARY KEY,
|
||||
fingerprint TEXT NOT NULL,
|
||||
read_offset INTEGER NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Optional inbound relay (backup-MX / forwarder). A separate entity from the
|
||||
-- sending domains, even under the same name: these rows exist while
|
||||
-- INBOUND_RELAY_ENABLE is false, but the listener, the Postfix maps and the
|
||||
-- panel pages are generated only when that flag is on.
|
||||
CREATE TABLE inbound_domains (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
recipient_mode TEXT NOT NULL CHECK (recipient_mode IN ('list', 'any')),
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- One upstream per inbound domain (host:port + TLS policy for the hand-off).
|
||||
CREATE TABLE inbound_transports (
|
||||
inbound_domain_id INTEGER PRIMARY KEY REFERENCES inbound_domains(id) ON DELETE CASCADE,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535),
|
||||
tls_mode TEXT NOT NULL CHECK (tls_mode IN ('may', 'encrypt', 'none'))
|
||||
);
|
||||
|
||||
-- Explicit recipients for recipient_mode = 'list'. Ignored when mode is 'any'.
|
||||
CREATE TABLE inbound_recipients (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
inbound_domain_id INTEGER NOT NULL REFERENCES inbound_domains(id) ON DELETE CASCADE,
|
||||
address TEXT NOT NULL,
|
||||
UNIQUE (inbound_domain_id, address)
|
||||
);
|
||||
|
||||
-- Inbound domains assigned to a 'domain' user — granted separately from the
|
||||
-- outbound ones above; the same name on both lists is two assignments.
|
||||
CREATE TABLE user_inbound_domains (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
inbound_domain_id INTEGER NOT NULL REFERENCES inbound_domains(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, inbound_domain_id)
|
||||
);
|
||||
|
||||
-- DMARC aggregate report summaries. One row per parsed report; per-source rows
|
||||
-- hang off it. Forensic (ruf=) payloads are not stored.
|
||||
CREATE TABLE dmarc_reports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain TEXT NOT NULL,
|
||||
reporter TEXT NOT NULL,
|
||||
report_id TEXT NOT NULL,
|
||||
period_begin TEXT NOT NULL,
|
||||
period_end TEXT NOT NULL,
|
||||
received_at TEXT NOT NULL,
|
||||
contact_email TEXT NOT NULL DEFAULT '',
|
||||
policy_p TEXT NOT NULL DEFAULT '',
|
||||
policy_sp TEXT NOT NULL DEFAULT '',
|
||||
policy_pct INTEGER NOT NULL DEFAULT 100,
|
||||
policy_adkim TEXT NOT NULL DEFAULT '',
|
||||
policy_aspf TEXT NOT NULL DEFAULT '',
|
||||
pass_count INTEGER NOT NULL DEFAULT 0,
|
||||
fail_count INTEGER NOT NULL DEFAULT 0,
|
||||
recipient TEXT NOT NULL DEFAULT '',
|
||||
UNIQUE (reporter, report_id, domain)
|
||||
);
|
||||
CREATE INDEX idx_dmarc_reports_domain ON dmarc_reports (domain);
|
||||
CREATE INDEX idx_dmarc_reports_received ON dmarc_reports (received_at);
|
||||
|
||||
CREATE TABLE dmarc_report_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
report_row_id INTEGER NOT NULL REFERENCES dmarc_reports(id) ON DELETE CASCADE,
|
||||
source_ip TEXT NOT NULL DEFAULT '',
|
||||
count INTEGER NOT NULL,
|
||||
disposition TEXT NOT NULL DEFAULT '',
|
||||
spf_result TEXT NOT NULL DEFAULT '',
|
||||
dkim_result TEXT NOT NULL DEFAULT '',
|
||||
header_from TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX idx_dmarc_report_records_report ON dmarc_report_records (report_row_id);
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
-- Panel login sessions. Persisted so a login survives a container restart or
|
||||
-- redeploy; only the SHA-256 of the session token is stored, never the token
|
||||
-- itself, so a stolen database file cannot be used to sign in. expires_at
|
||||
-- implements the sliding idle timeout: it is pushed forward on activity rather
|
||||
-- than being fixed at creation time.
|
||||
CREATE TABLE sessions (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_sessions_expires_at ON sessions (expires_at);
|
||||
@@ -1,12 +0,0 @@
|
||||
-- Log-tailer read position. Without it the tailer starts at end-of-file on
|
||||
-- every start, so delivery lines written while the panel was down are never
|
||||
-- parsed and their send-log rows stay "queued" forever. One row per followed
|
||||
-- path; fingerprint identifies the file the offset belongs to (the head bytes
|
||||
-- of the log), so a rotated or recreated mail.log is detected across a restart,
|
||||
-- where os.SameFile cannot help.
|
||||
CREATE TABLE logtail_state (
|
||||
path TEXT PRIMARY KEY,
|
||||
fingerprint TEXT NOT NULL,
|
||||
read_offset INTEGER NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -1,5 +0,0 @@
|
||||
-- Optional DMARC aggregate-report destination (rua=) for the panel administrator
|
||||
-- and per-domain overrides. NULL dmarc_rua on a domain means inherit the profile.
|
||||
|
||||
ALTER TABLE admin ADD COLUMN dmarc_report_email TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE domains ADD COLUMN dmarc_rua TEXT;
|
||||
@@ -1,26 +0,0 @@
|
||||
-- Panel users and domain-admin assignments. Migrates the single admin row into
|
||||
-- a global user; drops the admin table.
|
||||
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('global', 'domain_admin')),
|
||||
dmarc_report_email TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE user_domains (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, domain_id)
|
||||
);
|
||||
|
||||
INSERT INTO users (username, password_hash, role, dmarc_report_email, created_at)
|
||||
SELECT username, password_hash, 'global', dmarc_report_email, created_at
|
||||
FROM admin WHERE id = 1;
|
||||
|
||||
INSERT OR REPLACE INTO settings (key, value)
|
||||
SELECT 'dmarc_report_email', dmarc_report_email FROM admin WHERE id = 1;
|
||||
|
||||
DROP TABLE admin;
|
||||
@@ -1,26 +0,0 @@
|
||||
-- Optional inbound relay (backup-MX / forwarder). Separate from sending
|
||||
-- domains: these rows exist even when INBOUND_RELAY_ENABLE is false, but the
|
||||
-- listener, Postfix maps and panel UI are generated only when that flag is on.
|
||||
|
||||
CREATE TABLE inbound_domains (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
recipient_mode TEXT NOT NULL CHECK (recipient_mode IN ('list', 'any')),
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- One upstream per inbound domain (host:port + TLS policy for the hand-off).
|
||||
CREATE TABLE inbound_transports (
|
||||
inbound_domain_id INTEGER PRIMARY KEY REFERENCES inbound_domains(id) ON DELETE CASCADE,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535),
|
||||
tls_mode TEXT NOT NULL CHECK (tls_mode IN ('may', 'encrypt', 'none'))
|
||||
);
|
||||
|
||||
-- Explicit recipients for recipient_mode = 'list'. Ignored when mode is 'any'.
|
||||
CREATE TABLE inbound_recipients (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
inbound_domain_id INTEGER NOT NULL REFERENCES inbound_domains(id) ON DELETE CASCADE,
|
||||
address TEXT NOT NULL,
|
||||
UNIQUE (inbound_domain_id, address)
|
||||
);
|
||||
@@ -1,7 +0,0 @@
|
||||
-- Level-2 rate limit auto mode: operator sets a multiplier; max_messages is
|
||||
-- derived from 30-day send stats (plan domain-stats-auto-ratelimit).
|
||||
|
||||
ALTER TABLE rate_limits ADD COLUMN mode TEXT NOT NULL DEFAULT 'manual'
|
||||
CHECK (mode IN ('manual', 'auto'));
|
||||
ALTER TABLE rate_limits ADD COLUMN auto_multiplier REAL;
|
||||
ALTER TABLE rate_limits ADD COLUMN auto_updated_at TEXT;
|
||||
@@ -1,38 +0,0 @@
|
||||
-- DMARC aggregate report summaries (plans/dmarc-reports.md). One row per parsed
|
||||
-- report; per-source rows hang off it. Forensic (ruf=) payloads are not stored.
|
||||
|
||||
CREATE TABLE dmarc_reports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain TEXT NOT NULL,
|
||||
reporter TEXT NOT NULL,
|
||||
report_id TEXT NOT NULL,
|
||||
period_begin TEXT NOT NULL,
|
||||
period_end TEXT NOT NULL,
|
||||
received_at TEXT NOT NULL,
|
||||
contact_email TEXT NOT NULL DEFAULT '',
|
||||
policy_p TEXT NOT NULL DEFAULT '',
|
||||
policy_sp TEXT NOT NULL DEFAULT '',
|
||||
policy_pct INTEGER NOT NULL DEFAULT 100,
|
||||
policy_adkim TEXT NOT NULL DEFAULT '',
|
||||
policy_aspf TEXT NOT NULL DEFAULT '',
|
||||
pass_count INTEGER NOT NULL DEFAULT 0,
|
||||
fail_count INTEGER NOT NULL DEFAULT 0,
|
||||
recipient TEXT NOT NULL DEFAULT '',
|
||||
UNIQUE (reporter, report_id, domain)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_dmarc_reports_domain ON dmarc_reports (domain);
|
||||
CREATE INDEX idx_dmarc_reports_received ON dmarc_reports (received_at);
|
||||
|
||||
CREATE TABLE dmarc_report_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
report_row_id INTEGER NOT NULL REFERENCES dmarc_reports(id) ON DELETE CASCADE,
|
||||
source_ip TEXT NOT NULL DEFAULT '',
|
||||
count INTEGER NOT NULL,
|
||||
disposition TEXT NOT NULL DEFAULT '',
|
||||
spf_result TEXT NOT NULL DEFAULT '',
|
||||
dkim_result TEXT NOT NULL DEFAULT '',
|
||||
header_from TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX idx_dmarc_report_records_report ON dmarc_report_records (report_row_id);
|
||||
@@ -1,22 +0,0 @@
|
||||
-- Client IP restriction for application SASL authorization (independent of
|
||||
-- level-2 rate limits). When enabled, only listed IPs may submit mail as the
|
||||
-- application; when disabled, any client IP is allowed.
|
||||
|
||||
ALTER TABLE applications ADD COLUMN auth_ip_restrict INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE applications ADD COLUMN auth_allowed_ips TEXT;
|
||||
|
||||
-- Migrate trusted IPs from application rate_limits into auth_allowed_ips.
|
||||
UPDATE applications
|
||||
SET auth_ip_restrict = 1,
|
||||
auth_allowed_ips = (
|
||||
SELECT allowed_ips FROM rate_limits
|
||||
WHERE scope = 'application' AND ref_id = applications.id
|
||||
)
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM rate_limits
|
||||
WHERE scope = 'application' AND ref_id = applications.id
|
||||
AND allowed_ips IS NOT NULL AND TRIM(allowed_ips) != ''
|
||||
);
|
||||
|
||||
UPDATE rate_limits SET allowed_ips = NULL
|
||||
WHERE scope = 'application' AND allowed_ips IS NOT NULL;
|
||||
@@ -1,9 +0,0 @@
|
||||
-- Send-log lookups by application login run on two hot paths: the journal
|
||||
-- milter counts messages for a level-2 rate limit on every MAIL FROM, and the
|
||||
-- domain page computes 30-day statistics per application on every render.
|
||||
-- 0001 indexed queue_id, domain and created_at, but not app_login, so both
|
||||
-- fell back to scanning the created_at range. The composite matches the shape
|
||||
-- of those queries (equality on app_login, range on created_at).
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_send_log_app_login_created_at
|
||||
ON send_log (app_login, created_at);
|
||||
@@ -43,7 +43,7 @@ func (s *Store) RecalcAutoRateLimit(scope string, refID int64, retentionDays, l1
|
||||
|
||||
func (s *Store) listAutoRateLimits() ([]RateLimit, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT scope, ref_id, allowed_ips, max_messages, window_seconds, mode, auto_multiplier, auto_updated_at
|
||||
`SELECT scope, ref_id, max_messages, window_seconds, mode, auto_multiplier, auto_updated_at
|
||||
FROM rate_limits WHERE mode = ?`, RateLimitModeAuto,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -55,18 +55,16 @@ func (s *Store) listAutoRateLimits() ([]RateLimit, error) {
|
||||
for rows.Next() {
|
||||
var scope string
|
||||
var refID int64
|
||||
var ips sql.NullString
|
||||
var maxMsgs, windowSecs sql.NullInt64
|
||||
var mode sql.NullString
|
||||
var autoMult sql.NullFloat64
|
||||
var autoUpdated sql.NullString
|
||||
if err := rows.Scan(&scope, &refID, &ips, &maxMsgs, &windowSecs, &mode, &autoMult, &autoUpdated); err != nil {
|
||||
if err := rows.Scan(&scope, &refID, &maxMsgs, &windowSecs, &mode, &autoMult, &autoUpdated); err != nil {
|
||||
return nil, fmt.Errorf("scan auto rate limit: %w", err)
|
||||
}
|
||||
rl := RateLimit{
|
||||
Scope: scope,
|
||||
RefID: refID,
|
||||
AllowedIPs: splitIPs(ips.String),
|
||||
MaxMessages: int(maxMsgs.Int64),
|
||||
WindowSeconds: int(windowSecs.Int64),
|
||||
Mode: mode.String,
|
||||
|
||||
@@ -41,7 +41,6 @@ const (
|
||||
type RateLimit struct {
|
||||
Scope string
|
||||
RefID int64
|
||||
AllowedIPs []string // legacy column; unused for new rows
|
||||
MaxMessages int
|
||||
WindowSeconds int
|
||||
Mode string // manual | auto
|
||||
@@ -63,7 +62,7 @@ func (r RateLimit) Active() bool {
|
||||
// its id, for the panel's edit form. ok is false when none is configured.
|
||||
func (s *Store) GetRateLimit(scope string, refID int64) (RateLimit, bool, error) {
|
||||
row := s.db.QueryRow(
|
||||
`SELECT allowed_ips, max_messages, window_seconds, mode, auto_multiplier, auto_updated_at
|
||||
`SELECT max_messages, window_seconds, mode, auto_multiplier, auto_updated_at
|
||||
FROM rate_limits WHERE scope = ? AND ref_id = ?`, scope, refID)
|
||||
rl, err := scanRateLimit(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -93,16 +92,15 @@ func (s *Store) SetRateLimit(rl RateLimit) error {
|
||||
autoUpdated = rl.AutoUpdatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO rate_limits (scope, ref_id, allowed_ips, max_messages, window_seconds, mode, auto_multiplier, auto_updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`INSERT INTO rate_limits (scope, ref_id, max_messages, window_seconds, mode, auto_multiplier, auto_updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(scope, ref_id) DO UPDATE SET
|
||||
allowed_ips = excluded.allowed_ips,
|
||||
max_messages = excluded.max_messages,
|
||||
window_seconds = excluded.window_seconds,
|
||||
mode = excluded.mode,
|
||||
auto_multiplier = excluded.auto_multiplier,
|
||||
auto_updated_at = excluded.auto_updated_at`,
|
||||
rl.Scope, rl.RefID, strings.Join(rl.AllowedIPs, ","), rl.MaxMessages, rl.WindowSeconds,
|
||||
rl.Scope, rl.RefID, rl.MaxMessages, rl.WindowSeconds,
|
||||
mode, autoMult, autoUpdated,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -144,11 +142,11 @@ func (s *Store) RateLimit(scope, ref string) (RateLimit, bool, error) {
|
||||
var query string
|
||||
switch scope {
|
||||
case RateLimitScopeDomain:
|
||||
query = `SELECT rl.allowed_ips, rl.max_messages, rl.window_seconds, rl.mode, rl.auto_multiplier, rl.auto_updated_at
|
||||
query = `SELECT rl.max_messages, rl.window_seconds, rl.mode, rl.auto_multiplier, rl.auto_updated_at
|
||||
FROM rate_limits rl JOIN domains d ON d.id = rl.ref_id
|
||||
WHERE rl.scope = 'domain' AND d.name = ?`
|
||||
case RateLimitScopeApp:
|
||||
query = `SELECT rl.allowed_ips, rl.max_messages, rl.window_seconds, rl.mode, rl.auto_multiplier, rl.auto_updated_at
|
||||
query = `SELECT rl.max_messages, rl.window_seconds, rl.mode, rl.auto_multiplier, rl.auto_updated_at
|
||||
FROM rate_limits rl JOIN applications a ON a.id = rl.ref_id
|
||||
WHERE rl.scope = 'application' AND a.login = ?`
|
||||
default:
|
||||
@@ -196,23 +194,21 @@ func (s *Store) CountMessages(scope, ref string, since time.Time) (int64, error)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// scanRateLimit reads the three stored columns, tolerating NULL numeric columns
|
||||
// scanRateLimit reads the stored columns, tolerating NULL numeric columns
|
||||
// by leaving the corresponding field zero, which makes the limit inert via
|
||||
// Active() until max and window are both set.
|
||||
func scanRateLimit(r scanRow) (RateLimit, error) {
|
||||
var (
|
||||
ips sql.NullString
|
||||
maxMsgs sql.NullInt64
|
||||
windowSecs sql.NullInt64
|
||||
mode sql.NullString
|
||||
autoMult sql.NullFloat64
|
||||
autoUpdated sql.NullString
|
||||
)
|
||||
if err := r.Scan(&ips, &maxMsgs, &windowSecs, &mode, &autoMult, &autoUpdated); err != nil {
|
||||
if err := r.Scan(&maxMsgs, &windowSecs, &mode, &autoMult, &autoUpdated); err != nil {
|
||||
return RateLimit{}, err
|
||||
}
|
||||
rl := RateLimit{
|
||||
AllowedIPs: splitIPs(ips.String),
|
||||
MaxMessages: int(maxMsgs.Int64),
|
||||
WindowSeconds: int(windowSecs.Int64),
|
||||
Mode: mode.String,
|
||||
|
||||
@@ -20,7 +20,6 @@ func TestRateLimitSetGetDelete(t *testing.T) {
|
||||
want := RateLimit{
|
||||
Scope: RateLimitScopeDomain,
|
||||
RefID: d.ID,
|
||||
AllowedIPs: []string{"203.0.113.1", "203.0.113.2"},
|
||||
MaxMessages: 100,
|
||||
WindowSeconds: 3600,
|
||||
}
|
||||
@@ -32,19 +31,17 @@ func TestRateLimitSetGetDelete(t *testing.T) {
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("GetRateLimit after set: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if got.MaxMessages != 100 || got.WindowSeconds != 3600 || len(got.AllowedIPs) != 2 ||
|
||||
got.AllowedIPs[0] != "203.0.113.1" || got.AllowedIPs[1] != "203.0.113.2" {
|
||||
if got.MaxMessages != 100 || got.WindowSeconds != 3600 {
|
||||
t.Fatalf("roundtrip mismatch: %+v", got)
|
||||
}
|
||||
|
||||
// Upsert replaces in place (UNIQUE(scope, ref_id)).
|
||||
want.MaxMessages = 5
|
||||
want.AllowedIPs = []string{"198.51.100.9"}
|
||||
if err := st.SetRateLimit(want); err != nil {
|
||||
t.Fatalf("SetRateLimit upsert: %v", err)
|
||||
}
|
||||
got, _, _ = st.GetRateLimit(RateLimitScopeDomain, d.ID)
|
||||
if got.MaxMessages != 5 || len(got.AllowedIPs) != 1 || got.AllowedIPs[0] != "198.51.100.9" {
|
||||
if got.MaxMessages != 5 || got.WindowSeconds != 3600 {
|
||||
t.Fatalf("upsert did not replace: %+v", got)
|
||||
}
|
||||
|
||||
@@ -64,10 +61,10 @@ func TestRateLimitByNameAndLogin(t *testing.T) {
|
||||
t.Fatalf("AddApplication: %v", err)
|
||||
}
|
||||
|
||||
if err := st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: d.ID, AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 10, WindowSeconds: 60}); err != nil {
|
||||
if err := st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: d.ID, MaxMessages: 10, WindowSeconds: 60}); err != nil {
|
||||
t.Fatalf("set domain limit: %v", err)
|
||||
}
|
||||
if err := st.SetRateLimit(RateLimit{Scope: RateLimitScopeApp, RefID: a.ID, AllowedIPs: []string{"203.0.113.2"}, MaxMessages: 3, WindowSeconds: 60}); err != nil {
|
||||
if err := st.SetRateLimit(RateLimit{Scope: RateLimitScopeApp, RefID: a.ID, MaxMessages: 3, WindowSeconds: 60}); err != nil {
|
||||
t.Fatalf("set app limit: %v", err)
|
||||
}
|
||||
|
||||
@@ -131,9 +128,9 @@ func TestDeleteRateLimitsForDomain(t *testing.T) {
|
||||
a, _ := st.AddApplication(d.ID, "app1", AddressModeWildcard, nil)
|
||||
other, _ := st.AddDomain("other.example", "selfpost")
|
||||
|
||||
_ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: d.ID, AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 10, WindowSeconds: 60})
|
||||
_ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeApp, RefID: a.ID, AllowedIPs: []string{"203.0.113.2"}, MaxMessages: 3, WindowSeconds: 60})
|
||||
_ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: other.ID, AllowedIPs: []string{"203.0.113.9"}, MaxMessages: 1, WindowSeconds: 60})
|
||||
_ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: d.ID, MaxMessages: 10, WindowSeconds: 60})
|
||||
_ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeApp, RefID: a.ID, MaxMessages: 3, WindowSeconds: 60})
|
||||
_ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: other.ID, MaxMessages: 1, WindowSeconds: 60})
|
||||
|
||||
if err := st.DeleteRateLimitsForDomain(d.ID); err != nil {
|
||||
t.Fatalf("DeleteRateLimitsForDomain: %v", err)
|
||||
@@ -153,9 +150,9 @@ func TestDeleteRateLimitsForDomain(t *testing.T) {
|
||||
func TestRateLimitActive(t *testing.T) {
|
||||
inactive := []RateLimit{
|
||||
{},
|
||||
{Scope: RateLimitScopeDomain, AllowedIPs: []string{"203.0.113.1"}}, // no ceiling
|
||||
{Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window
|
||||
{Scope: RateLimitScopeApp, MaxMessages: 5}, // no window
|
||||
{Scope: RateLimitScopeDomain}, // no ceiling
|
||||
{Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window
|
||||
{Scope: RateLimitScopeApp, MaxMessages: 5}, // no window
|
||||
}
|
||||
for i, rl := range inactive {
|
||||
if rl.Active() {
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 2.0 starts from one baseline: the 1.x chain is gone from the binary, not
|
||||
// carried along (docs/schema-migrations.md).
|
||||
func TestBaselineIsTheOnlyMigration(t *testing.T) {
|
||||
entries, err := fs.ReadDir(migrationsFS, "migrations")
|
||||
if err != nil {
|
||||
t.Fatalf("read migrations: %v", err)
|
||||
}
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
if len(names) == 0 || names[0] != "0001_init.sql" {
|
||||
t.Fatalf("migrations = %v, want the chain to start at 0001_init.sql", names)
|
||||
}
|
||||
baseline, _ := migrationsFS.ReadFile("migrations/0001_init.sql")
|
||||
for _, gone := range []string{"CREATE TABLE admin", "DROP TABLE", "ALTER TABLE", "dmarc_report_email", "allowed_ips ", "domain_admin"} {
|
||||
if strings.Contains(string(baseline), gone) {
|
||||
t.Errorf("the baseline still contains %q — a leftover of the 1.x chain", gone)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The schema a fresh data directory gets, table by table and column by
|
||||
// column. A change here is a schema change: it ships as a new migration file
|
||||
// and a row in docs/schema-migrations.md, never as an edit of the baseline
|
||||
// after 2.0.0.
|
||||
func TestBaselineSchema(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
want := map[string]string{
|
||||
"settings": "key value",
|
||||
"users": "id username password_hash role email dmarc_default_mode dmarc_default_address all_domains all_inbound_domains created_at",
|
||||
"sessions": "token_hash username created_at expires_at",
|
||||
"domains": "id name dkim_selector dmarc_rua dmarc_rua_user_id created_at",
|
||||
"user_domains": "user_id domain_id",
|
||||
"applications": "id domain_id login address_mode auth_ip_restrict auth_allowed_ips created_at",
|
||||
"application_addresses": "id application_id address",
|
||||
"rate_limits": "id scope ref_id max_messages window_seconds mode auto_multiplier auto_updated_at",
|
||||
"send_log": "id queue_id domain app_login from_addr to_addr subject status created_at updated_at",
|
||||
"logtail_state": "path fingerprint read_offset updated_at",
|
||||
"inbound_domains": "id name recipient_mode created_at",
|
||||
"inbound_transports": "inbound_domain_id host port tls_mode",
|
||||
"inbound_recipients": "id inbound_domain_id address",
|
||||
"user_inbound_domains": "user_id inbound_domain_id",
|
||||
"dmarc_reports": "id domain reporter report_id period_begin period_end received_at contact_email policy_p policy_sp policy_pct policy_adkim policy_aspf pass_count fail_count recipient",
|
||||
"dmarc_report_records": "id report_row_id source_ip count disposition spf_result dkim_result header_from",
|
||||
}
|
||||
rows, err := st.db.Query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||
if err != nil {
|
||||
t.Fatalf("list tables: %v", err)
|
||||
}
|
||||
var tables []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tables = append(tables, name)
|
||||
}
|
||||
rows.Close()
|
||||
for _, name := range tables {
|
||||
cols, ok := want[name]
|
||||
if !ok {
|
||||
t.Errorf("unexpected table %q", name)
|
||||
continue
|
||||
}
|
||||
if got := strings.Join(tableColumns(t, st, name), " "); got != cols {
|
||||
t.Errorf("table %s has columns\n %s\nwant\n %s", name, got, cols)
|
||||
}
|
||||
delete(want, name)
|
||||
}
|
||||
for name := range want {
|
||||
t.Errorf("table %q is missing", name)
|
||||
}
|
||||
|
||||
var indexes []string
|
||||
rows, err = st.db.Query("SELECT name FROM sqlite_master WHERE type = 'index' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||
if err != nil {
|
||||
t.Fatalf("list indexes: %v", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
indexes = append(indexes, name)
|
||||
}
|
||||
rows.Close()
|
||||
wantIdx := []string{
|
||||
"idx_dmarc_report_records_report", "idx_dmarc_reports_domain", "idx_dmarc_reports_received",
|
||||
"idx_send_log_app_login_created_at", "idx_send_log_created_at", "idx_send_log_domain", "idx_send_log_queue_id",
|
||||
"idx_sessions_expires_at",
|
||||
}
|
||||
sort.Strings(wantIdx)
|
||||
if strings.Join(indexes, " ") != strings.Join(wantIdx, " ") {
|
||||
t.Errorf("indexes = %v\nwant %v", indexes, wantIdx)
|
||||
}
|
||||
|
||||
var version, appID int64
|
||||
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 1 {
|
||||
t.Errorf("user_version = %d, %v; want 1", version, err)
|
||||
}
|
||||
if err := st.db.QueryRow("PRAGMA application_id").Scan(&appID); err != nil || appID != applicationID {
|
||||
t.Errorf("application_id = %#x, %v; want %#x", appID, err, applicationID)
|
||||
}
|
||||
}
|
||||
|
||||
func tableColumns(t *testing.T, st *Store, table string) []string {
|
||||
t.Helper()
|
||||
rows, err := st.db.Query("SELECT name FROM pragma_table_info(?) ORDER BY cid", table)
|
||||
if err != nil {
|
||||
t.Fatalf("columns of %s: %v", table, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var cols []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cols = append(cols, name)
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
// Reopening a 2.x database applies nothing twice and keeps its data.
|
||||
func TestReopenKeepsTheDatabase(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "test.db")
|
||||
st, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if err := st.CreateGlobalUser("admin", "hash"); err != nil {
|
||||
t.Fatalf("CreateGlobalUser: %v", err)
|
||||
}
|
||||
st.Close()
|
||||
|
||||
st, err = Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
if ok, err := st.UserExists(); err != nil || !ok {
|
||||
t.Fatalf("after reopen: user exists = %v, %v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A 1.x data directory must be refused, loudly. Its user_version (10 at the
|
||||
// last 1.x release) would otherwise read as "the baseline is already applied",
|
||||
// and the panel would start on tables it does not know.
|
||||
func TestOpenRefusesA1xDatabase(t *testing.T) {
|
||||
for _, version := range []string{"1", "10"} {
|
||||
path := filepath.Join(t.TempDir(), "old.db")
|
||||
db, err := sql.Open("sqlite", "file:"+path)
|
||||
if err != nil {
|
||||
t.Fatalf("create old db: %v", err)
|
||||
}
|
||||
for _, stmt := range []string{
|
||||
"CREATE TABLE admin (id INTEGER PRIMARY KEY CHECK (id = 1), username TEXT NOT NULL, password_hash TEXT NOT NULL, created_at TEXT NOT NULL)",
|
||||
"INSERT INTO admin VALUES (1, 'admin', 'hash', '2026-01-01T00:00:00Z')",
|
||||
"PRAGMA user_version = " + version,
|
||||
} {
|
||||
if _, err := db.Exec(stmt); err != nil {
|
||||
t.Fatalf("%s: %v", stmt, err)
|
||||
}
|
||||
}
|
||||
db.Close()
|
||||
|
||||
st, err := Open(path)
|
||||
if err == nil {
|
||||
st.Close()
|
||||
t.Fatalf("a 1.x database at version %s was opened", version)
|
||||
}
|
||||
if !errors.Is(err, ErrForeignSchema) {
|
||||
t.Fatalf("version %s: error = %v, want ErrForeignSchema", version, err)
|
||||
}
|
||||
|
||||
// Refusing must not have touched the file: the operator still has
|
||||
// their 1.x data to go back to.
|
||||
db, _ = sql.Open("sqlite", "file:"+path)
|
||||
var n int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM admin").Scan(&n); err != nil || n != 1 {
|
||||
t.Errorf("version %s: the refused database was altered (admin rows = %d, %v)", version, n, err)
|
||||
}
|
||||
var tables int
|
||||
_ = db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table'").Scan(&tables)
|
||||
if tables != 1 {
|
||||
t.Errorf("version %s: the refused database now has %d tables", version, tables)
|
||||
}
|
||||
db.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// A database written by a newer build is not guessed at either.
|
||||
func TestOpenRefusesANewerSchema(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "test.db")
|
||||
st, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if _, err := st.db.Exec("PRAGMA user_version = 99"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.Close()
|
||||
if st, err := Open(path); err == nil {
|
||||
st.Close()
|
||||
t.Fatal("a database at schema version 99 was opened")
|
||||
}
|
||||
}
|
||||
|
||||
// The checks the baseline puts on a user row, and what the new assignment
|
||||
// table does when either side goes away.
|
||||
func TestBaselineConstraints(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
insert := func(role, mode string, all int) error {
|
||||
_, err := st.db.Exec(
|
||||
"INSERT INTO users (username, password_hash, role, dmarc_default_mode, all_domains, created_at) VALUES (?, 'h', ?, ?, ?, 'now')",
|
||||
role+mode, role, mode, all)
|
||||
return err
|
||||
}
|
||||
if err := insert("global", "none", 0); err != nil {
|
||||
t.Fatalf("a plain global user: %v", err)
|
||||
}
|
||||
if err := insert("domain", "hosted", 1); err != nil {
|
||||
t.Fatalf("a domain user with All: %v", err)
|
||||
}
|
||||
if err := insert("domain_admin", "none", 0); err == nil {
|
||||
t.Error("the 1.x role name domain_admin was accepted")
|
||||
}
|
||||
if err := insert("global", "profile", 0); err == nil {
|
||||
t.Error("an unknown dmarc_default_mode was accepted")
|
||||
}
|
||||
if err := insert("domain", "custom", 2); err == nil {
|
||||
t.Error("all_domains = 2 was accepted")
|
||||
}
|
||||
|
||||
u, err := st.GetUserByUsername("domainhosted")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByUsername: %v", err)
|
||||
}
|
||||
if !u.AllDomains || u.AllInboundDomains || u.DMARCDefaultMode != DMARCDefaultHosted {
|
||||
t.Errorf("flags read back as %+v", u)
|
||||
}
|
||||
|
||||
in, err := st.AddInboundDomain("in.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("AddInboundDomain: %v", err)
|
||||
}
|
||||
other, err := st.AddInboundDomain("other.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("AddInboundDomain: %v", err)
|
||||
}
|
||||
for _, id := range []int64{in.ID, other.ID} {
|
||||
if _, err := st.db.Exec("INSERT INTO user_inbound_domains (user_id, inbound_domain_id) VALUES (?, ?)", u.ID, id); err != nil {
|
||||
t.Fatalf("assign inbound domain: %v", err)
|
||||
}
|
||||
}
|
||||
if _, err := st.db.Exec("INSERT INTO user_inbound_domains (user_id, inbound_domain_id) VALUES (?, ?)", u.ID, in.ID+100); err == nil {
|
||||
t.Error("an assignment to a missing inbound domain was accepted")
|
||||
}
|
||||
u, _ = st.GetUser(u.ID)
|
||||
if len(u.InboundDomainIDs) != 2 || len(u.DomainIDs) != 0 {
|
||||
t.Fatalf("assignments = inbound %v, outbound %v; the two lists are separate", u.InboundDomainIDs, u.DomainIDs)
|
||||
}
|
||||
|
||||
if err := st.DeleteInboundDomain(in.ID); err != nil {
|
||||
t.Fatalf("DeleteInboundDomain: %v", err)
|
||||
}
|
||||
u, _ = st.GetUser(u.ID)
|
||||
if len(u.InboundDomainIDs) != 1 || u.InboundDomainIDs[0] != other.ID {
|
||||
t.Errorf("after deleting an inbound domain its assignment stayed: %v", u.InboundDomainIDs)
|
||||
}
|
||||
if err := st.DeleteUser(u.ID); err != nil {
|
||||
t.Fatalf("DeleteUser: %v", err)
|
||||
}
|
||||
var n int
|
||||
if err := st.db.QueryRow("SELECT COUNT(*) FROM user_inbound_domains").Scan(&n); err != nil || n != 0 {
|
||||
t.Errorf("after deleting the user %d inbound assignment(s) stayed (%v)", n, err)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ package store
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
@@ -50,6 +51,14 @@ func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// applicationID marks a database file as a 2.x SelfPost one ("SP20"). The
|
||||
// baseline migration sets it.
|
||||
const applicationID = 0x53503230
|
||||
|
||||
// ErrForeignSchema is returned by Open for a database that has a schema but
|
||||
// was not created by SelfPost 2.x — in practice a 1.x data directory.
|
||||
var ErrForeignSchema = errors.New("database was not created by SelfPost 2.x; 2.0 starts from an empty data directory (docs/schema-migrations.md)")
|
||||
|
||||
// migrate applies embedded migrations in filename order, tracking progress via
|
||||
// SQLite's PRAGMA user_version so each migration runs at most once.
|
||||
func (s *Store) migrate() error {
|
||||
@@ -70,6 +79,23 @@ func (s *Store) migrate() error {
|
||||
return fmt.Errorf("read schema version: %w", err)
|
||||
}
|
||||
|
||||
// A 1.x file carries a user_version from the deleted 1.x chain. Counting it
|
||||
// against today's files would skip the baseline and leave the panel running
|
||||
// on a schema it does not know, so such a file is refused outright; nothing
|
||||
// is migrated from 1.x (docs/schema-migrations.md).
|
||||
if version > 0 {
|
||||
var appID int64
|
||||
if err := s.db.QueryRow("PRAGMA application_id").Scan(&appID); err != nil {
|
||||
return fmt.Errorf("read application id: %w", err)
|
||||
}
|
||||
if appID != applicationID {
|
||||
return fmt.Errorf("schema version %d: %w", version, ErrForeignSchema)
|
||||
}
|
||||
}
|
||||
if version > len(names) {
|
||||
return fmt.Errorf("database schema version %d is newer than this build knows (%d)", version, len(names))
|
||||
}
|
||||
|
||||
for i, name := range names {
|
||||
target := i + 1
|
||||
if target <= version {
|
||||
|
||||
+166
-64
@@ -22,20 +22,95 @@ var ErrLastGlobal = errors.New("cannot remove last global administrator")
|
||||
// Role identifies a panel user's access level.
|
||||
type Role string
|
||||
|
||||
// The role is a reach, not a rank: everyone in the panel is an administrator,
|
||||
// of the whole instance or of the domains assigned to them.
|
||||
const (
|
||||
RoleGlobal Role = "global"
|
||||
RoleDomainAdmin Role = "domain_admin"
|
||||
RoleGlobal Role = "global"
|
||||
RoleDomain Role = "domain"
|
||||
)
|
||||
|
||||
// How a user's default DMARC report address is chosen (users.dmarc_default_mode).
|
||||
const (
|
||||
DMARCDefaultHosted = "hosted" // SelfPost's own mailbox for the domain
|
||||
DMARCDefaultAccount = "account" // the user's account e-mail
|
||||
DMARCDefaultCustom = "custom" // DMARCDefault.Address
|
||||
DMARCDefaultNone = "none" // no aggregate reports
|
||||
)
|
||||
|
||||
// DMARCDefault is a user's default report address: the choice a domain takes
|
||||
// over when it follows that user (Domain.DMARCRuaUserID).
|
||||
type DMARCDefault struct {
|
||||
UserID int64
|
||||
Username string
|
||||
Mode string
|
||||
Address string // for DMARCDefaultCustom
|
||||
Email string // the account e-mail, for DMARCDefaultAccount
|
||||
}
|
||||
|
||||
// Resolve returns the address the default stands for on one domain. hosted is
|
||||
// the SelfPost-hosted mailbox for that domain, "" when report ingest is off —
|
||||
// a hosted default then resolves to no reports.
|
||||
func (d DMARCDefault) Resolve(hosted string) string {
|
||||
switch d.Mode {
|
||||
case DMARCDefaultHosted:
|
||||
return hosted
|
||||
case DMARCDefaultAccount:
|
||||
return d.Email
|
||||
case DMARCDefaultCustom:
|
||||
return d.Address
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ValidDMARCDefaultMode reports whether mode is one of the stored choices.
|
||||
func ValidDMARCDefaultMode(mode string) bool {
|
||||
switch mode {
|
||||
case DMARCDefaultHosted, DMARCDefaultAccount, DMARCDefaultCustom, DMARCDefaultNone:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// User is a panel login (not an application SASL account).
|
||||
type User struct {
|
||||
ID int64
|
||||
Username string
|
||||
PasswordHash string
|
||||
Role Role
|
||||
DMARCReportEmail string
|
||||
CreatedAt time.Time
|
||||
DomainIDs []int64
|
||||
ID int64
|
||||
Username string
|
||||
PasswordHash string
|
||||
Role Role
|
||||
// Email is the user's own address — what the panel writes to. DMARC may
|
||||
// use it (DMARCDefaultAccount) but it is not a DMARC field.
|
||||
Email string
|
||||
DMARCDefaultMode string
|
||||
DMARCDefaultAddress string
|
||||
// AllDomains / AllInboundDomains widen a domain user's list to every
|
||||
// domain of that direction, including those added later; the id lists
|
||||
// are then ignored.
|
||||
AllDomains bool
|
||||
AllInboundDomains bool
|
||||
CreatedAt time.Time
|
||||
DomainIDs []int64
|
||||
InboundDomainIDs []int64
|
||||
}
|
||||
|
||||
// DMARCDefault returns the user's default report address choice.
|
||||
func (u User) DMARCDefault() DMARCDefault {
|
||||
return DMARCDefault{UserID: u.ID, Username: u.Username, Mode: u.DMARCDefaultMode, Address: u.DMARCDefaultAddress, Email: u.Email}
|
||||
}
|
||||
|
||||
const userColumns = "id, username, password_hash, role, email, dmarc_default_mode, dmarc_default_address, all_domains, all_inbound_domains, created_at"
|
||||
|
||||
func scanUser(r scanRow) (User, error) {
|
||||
var (
|
||||
u User
|
||||
createdAt string
|
||||
)
|
||||
err := r.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.Email, &u.DMARCDefaultMode, &u.DMARCDefaultAddress,
|
||||
&u.AllDomains, &u.AllInboundDomains, &createdAt)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// UserExists reports whether any panel user exists (setup complete).
|
||||
@@ -58,7 +133,7 @@ func (s *Store) CreateGlobalUser(username, passwordHash string) error {
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err = s.db.Exec(
|
||||
"INSERT INTO users (username, password_hash, role, dmarc_report_email, created_at) VALUES (?, ?, ?, '', ?)",
|
||||
"INSERT INTO users (username, password_hash, role, created_at) VALUES (?, ?, ?, ?)",
|
||||
username, passwordHash, RoleGlobal, now,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -69,23 +144,17 @@ func (s *Store) CreateGlobalUser(username, passwordHash string) error {
|
||||
|
||||
// GetUserByUsername returns a user with domain assignments loaded.
|
||||
func (s *Store) GetUserByUsername(username string) (User, error) {
|
||||
var (
|
||||
u User
|
||||
createdAt string
|
||||
)
|
||||
err := s.db.QueryRow(
|
||||
"SELECT id, username, password_hash, role, dmarc_report_email, created_at FROM users WHERE username = ?",
|
||||
username,
|
||||
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.DMARCReportEmail, &createdAt)
|
||||
u, err := scanUser(s.db.QueryRow("SELECT "+userColumns+" FROM users WHERE username = ?", username))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return User{}, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("get user by username: %w", err)
|
||||
}
|
||||
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
u.DomainIDs, err = s.listUserDomainIDs(u.ID)
|
||||
if err != nil {
|
||||
if u.DomainIDs, err = s.listUserDomainIDs(u.ID); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if u.InboundDomainIDs, err = s.listUserInboundDomainIDs(u.ID); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return u, nil
|
||||
@@ -93,47 +162,35 @@ func (s *Store) GetUserByUsername(username string) (User, error) {
|
||||
|
||||
// GetUser returns a user by id with domain assignments.
|
||||
func (s *Store) GetUser(id int64) (User, error) {
|
||||
var (
|
||||
u User
|
||||
createdAt string
|
||||
)
|
||||
err := s.db.QueryRow(
|
||||
"SELECT id, username, password_hash, role, dmarc_report_email, created_at FROM users WHERE id = ?",
|
||||
id,
|
||||
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.DMARCReportEmail, &createdAt)
|
||||
u, err := scanUser(s.db.QueryRow("SELECT "+userColumns+" FROM users WHERE id = ?", id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return User{}, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("get user: %w", err)
|
||||
}
|
||||
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
u.DomainIDs, err = s.listUserDomainIDs(u.ID)
|
||||
if err != nil {
|
||||
if u.DomainIDs, err = s.listUserDomainIDs(u.ID); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if u.InboundDomainIDs, err = s.listUserInboundDomainIDs(u.ID); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// ListUsers returns every panel user without domain ids.
|
||||
// ListUsers returns every panel user without the assigned domain ids.
|
||||
func (s *Store) ListUsers() ([]User, error) {
|
||||
rows, err := s.db.Query(
|
||||
"SELECT id, username, password_hash, role, dmarc_report_email, created_at FROM users ORDER BY id",
|
||||
)
|
||||
rows, err := s.db.Query("SELECT " + userColumns + " FROM users ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list users: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var users []User
|
||||
for rows.Next() {
|
||||
var (
|
||||
u User
|
||||
createdAt string
|
||||
)
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.DMARCReportEmail, &createdAt); err != nil {
|
||||
u, err := scanUser(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list users scan: %w", err)
|
||||
}
|
||||
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
users = append(users, u)
|
||||
}
|
||||
return users, rows.Err()
|
||||
@@ -177,12 +234,12 @@ func (s *Store) CountGlobalUsers() (int, error) {
|
||||
|
||||
// CreateUser inserts a panel user and optional domain assignments.
|
||||
func (s *Store) CreateUser(username, passwordHash string, role Role, domainIDs []int64) (int64, error) {
|
||||
if role == RoleDomainAdmin && len(domainIDs) == 0 {
|
||||
return 0, fmt.Errorf("create user: domain_admin requires domains")
|
||||
if role == RoleDomain && len(domainIDs) == 0 {
|
||||
return 0, fmt.Errorf("create user: the domain role requires domains")
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
res, err := s.db.Exec(
|
||||
"INSERT INTO users (username, password_hash, role, dmarc_report_email, created_at) VALUES (?, ?, ?, '', ?)",
|
||||
"INSERT INTO users (username, password_hash, role, created_at) VALUES (?, ?, ?, ?)",
|
||||
username, passwordHash, role, now,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -195,7 +252,7 @@ func (s *Store) CreateUser(username, passwordHash string, role Role, domainIDs [
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create user id: %w", err)
|
||||
}
|
||||
if role == RoleDomainAdmin {
|
||||
if role == RoleDomain {
|
||||
if err := s.setUserDomains(id, domainIDs); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -203,15 +260,11 @@ func (s *Store) CreateUser(username, passwordHash string, role Role, domainIDs [
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// UpdateUser replaces username, password hash, and DMARC email for a user.
|
||||
func (s *Store) UpdateUser(id int64, username, passwordHash, dmarcReportEmail string) error {
|
||||
u, err := s.GetUser(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// UpdateUser replaces a user's username, password hash and account e-mail.
|
||||
func (s *Store) UpdateUser(id int64, username, passwordHash, email string) error {
|
||||
res, err := s.db.Exec(
|
||||
"UPDATE users SET username = ?, password_hash = ?, dmarc_report_email = ? WHERE id = ?",
|
||||
username, passwordHash, dmarcReportEmail, id,
|
||||
"UPDATE users SET username = ?, password_hash = ?, email = ? WHERE id = ?",
|
||||
username, passwordHash, email, id,
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
@@ -226,14 +279,50 @@ func (s *Store) UpdateUser(id int64, username, passwordHash, dmarcReportEmail st
|
||||
if n == 0 {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
if u.Role == RoleGlobal {
|
||||
if err := s.SetSetting("dmarc_report_email", dmarcReportEmail); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetDMARCDefault stores the user's default report address choice. address is
|
||||
// kept only for DMARCDefaultCustom. Every domain that follows this user
|
||||
// resolves to the new choice from the next read on.
|
||||
func (s *Store) SetDMARCDefault(userID int64, mode, address string) error {
|
||||
if !ValidDMARCDefaultMode(mode) {
|
||||
return fmt.Errorf("set dmarc default: unknown mode %q", mode)
|
||||
}
|
||||
if mode != DMARCDefaultCustom {
|
||||
address = ""
|
||||
}
|
||||
res, err := s.db.Exec(
|
||||
"UPDATE users SET dmarc_default_mode = ?, dmarc_default_address = ? WHERE id = ?", mode, address, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set dmarc default: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("set dmarc default: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDMARCDefault returns a user's default report address choice, or
|
||||
// ErrUserNotFound.
|
||||
func (s *Store) GetDMARCDefault(userID int64) (DMARCDefault, error) {
|
||||
d := DMARCDefault{UserID: userID}
|
||||
err := s.db.QueryRow(
|
||||
"SELECT username, dmarc_default_mode, dmarc_default_address, email FROM users WHERE id = ?", userID,
|
||||
).Scan(&d.Username, &d.Mode, &d.Address, &d.Email)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return DMARCDefault{}, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return DMARCDefault{}, fmt.Errorf("get dmarc default: %w", err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// SetUserRole updates a user's role.
|
||||
func (s *Store) SetUserRole(userID int64, role Role) error {
|
||||
res, err := s.db.Exec("UPDATE users SET role = ? WHERE id = ?", role, userID)
|
||||
@@ -259,14 +348,14 @@ func (s *Store) ClearUserDomains(userID int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetUserDomains replaces domain assignments for a domain_admin user.
|
||||
// SetUserDomains replaces the outbound domain assignments of a domain user.
|
||||
func (s *Store) SetUserDomains(userID int64, domainIDs []int64) error {
|
||||
u, err := s.GetUser(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.Role != RoleDomainAdmin {
|
||||
return fmt.Errorf("set user domains: user is not domain_admin")
|
||||
if u.Role != RoleDomain {
|
||||
return fmt.Errorf("set user domains: user does not have the domain role")
|
||||
}
|
||||
if len(domainIDs) == 0 {
|
||||
return fmt.Errorf("set user domains: at least one domain required")
|
||||
@@ -303,9 +392,22 @@ func (s *Store) DeleteUser(id int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GlobalDMARCReportEmail returns the shared default rua= for domain inherit mode.
|
||||
func (s *Store) GlobalDMARCReportEmail() (string, error) {
|
||||
return s.GetSetting("dmarc_report_email")
|
||||
func (s *Store) listUserInboundDomainIDs(userID int64) ([]int64, error) {
|
||||
rows, err := s.db.Query(
|
||||
"SELECT inbound_domain_id FROM user_inbound_domains WHERE user_id = ? ORDER BY inbound_domain_id", userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list user inbound domains: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("list user inbound domains scan: %w", err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) listUserDomainIDs(userID int64) ([]int64, error) {
|
||||
|
||||
@@ -23,19 +23,20 @@ func TestUpdateUser(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("GetUser: %v", err)
|
||||
}
|
||||
if got.DMARCReportEmail != "reports@hub.example" {
|
||||
t.Fatalf("dmarc email = %q", got.DMARCReportEmail)
|
||||
if got.Email != "reports@hub.example" {
|
||||
t.Fatalf("email = %q", got.Email)
|
||||
}
|
||||
email, err := st.GlobalDMARCReportEmail()
|
||||
if err != nil {
|
||||
t.Fatalf("GlobalDMARCReportEmail: %v", err)
|
||||
// The account e-mail is the user's own: saving it does not make it anyone's
|
||||
// report address, and nothing is mirrored into the instance settings.
|
||||
if got.DMARCDefaultMode != DMARCDefaultNone {
|
||||
t.Fatalf("dmarc default mode = %q after setting the e-mail, want %q", got.DMARCDefaultMode, DMARCDefaultNone)
|
||||
}
|
||||
if email != "reports@hub.example" {
|
||||
t.Fatalf("settings dmarc = %q", email)
|
||||
if v, err := st.GetSetting("dmarc_report_email"); err == nil && v != "" {
|
||||
t.Fatalf("the e-mail leaked into the instance settings: %q", v)
|
||||
}
|
||||
|
||||
if err := st.UpdateUser(u.ID, "operator", "hash-three", ""); err != nil {
|
||||
t.Fatalf("clear dmarc email: %v", err)
|
||||
t.Fatalf("clear e-mail: %v", err)
|
||||
}
|
||||
got, err = st.GetUser(u.ID)
|
||||
if err != nil {
|
||||
@@ -73,7 +74,7 @@ func TestCreateDomainAdminUser(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("AddDomain: %v", err)
|
||||
}
|
||||
id, err := st.CreateUser("domainop", "hash2", RoleDomainAdmin, []int64{d.ID})
|
||||
id, err := st.CreateUser("domainop", "hash2", RoleDomain, []int64{d.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ const (
|
||||
type Role = store.Role
|
||||
|
||||
const (
|
||||
RoleGlobal = store.RoleGlobal
|
||||
RoleDomainAdmin = store.RoleDomainAdmin
|
||||
RoleGlobal = store.RoleGlobal
|
||||
RoleDomain = store.RoleDomain
|
||||
)
|
||||
|
||||
// Principal is the authenticated panel user attached to a request.
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dmarc"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
)
|
||||
@@ -35,6 +36,22 @@ func (h *Handlers) assignedDomains(p auth.Principal) ([]store.Domain, error) {
|
||||
return h.store.ListDomainsForUser(p.ID)
|
||||
}
|
||||
|
||||
// hostedDMARCAddress is the SelfPost-hosted report mailbox for a sending
|
||||
// domain, "" when report ingest is off on this server.
|
||||
func (h *Handlers) hostedDMARCAddress(domainName string) string {
|
||||
if h.dmarc == nil || !h.cfg.DMARCEnabled {
|
||||
return ""
|
||||
}
|
||||
return dmarc.HostedReportAddress(h.cfg.Hostname, domainName)
|
||||
}
|
||||
|
||||
// domainReportAddress resolves where a domain's DMARC aggregate reports go:
|
||||
// its own address, or the default of the user it follows. "" is a policy-only
|
||||
// record.
|
||||
func (h *Handlers) domainReportAddress(d store.Domain) (string, error) {
|
||||
return h.store.DomainDMARCRua(d, h.hostedDMARCAddress(d.Name))
|
||||
}
|
||||
|
||||
func domainNameSet(domains []store.Domain) map[string]bool {
|
||||
m := make(map[string]bool, len(domains))
|
||||
for _, d := range domains {
|
||||
|
||||
@@ -153,29 +153,37 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
|
||||
// What DNS actually publishes for the domain today, checked against the key
|
||||
// this server signs with. Cached by the checker, so re-rendering the page
|
||||
// after a form post costs nothing.
|
||||
profileEmail, err := h.store.GlobalDMARCReportEmail()
|
||||
reportEmail, err := h.domainReportAddress(d)
|
||||
if err != nil {
|
||||
logf("panel: domain %d: global dmarc email: %v", d.ID, err)
|
||||
logf("panel: domain %d: dmarc report address: %v", d.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
reportEmail := dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail)
|
||||
dns, srv := h.domainDNS(d, record, profileEmail, false)
|
||||
dns, srv := h.domainDNS(d, record, reportEmail, false)
|
||||
reportAuthName, reportAuthValue, needsReportAuth := dnscheck.ExternalReportAuth(d.Name, reportEmail)
|
||||
hostedAddr := h.hostedDMARCAddress(d.Name)
|
||||
// The form's "inherit" is a domain that follows a user's default. What it
|
||||
// offers next to that choice is the address following would give: the
|
||||
// followed user's when the domain already follows one, otherwise the
|
||||
// signed-in user's own — choosing it makes the domain follow them.
|
||||
dmarcMode := "inherit"
|
||||
dmarcCustom := ""
|
||||
hostedAddr := ""
|
||||
if h.dmarc != nil && h.cfg.DMARCEnabled {
|
||||
hostedAddr = dmarc.HostedReportAddress(h.cfg.Hostname, d.Name)
|
||||
}
|
||||
if d.DMARCRua.Valid {
|
||||
if d.DMARCRua.String == "" {
|
||||
profileEmail := reportEmail
|
||||
if !d.DMARCRuaUserID.Valid {
|
||||
profileEmail = ""
|
||||
if p, ok := h.principal(r); ok {
|
||||
if def, err := h.store.GetDMARCDefault(p.ID); err == nil {
|
||||
profileEmail = def.Resolve(hostedAddr)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case d.DMARCRua == "":
|
||||
dmarcMode = "none"
|
||||
} else if hostedAddr != "" && strings.EqualFold(d.DMARCRua.String, hostedAddr) {
|
||||
case hostedAddr != "" && strings.EqualFold(d.DMARCRua, hostedAddr):
|
||||
dmarcMode = "hosted"
|
||||
} else {
|
||||
default:
|
||||
dmarcMode = "custom"
|
||||
dmarcCustom = d.DMARCRua.String
|
||||
dmarcCustom = d.DMARCRua
|
||||
}
|
||||
}
|
||||
dmarcSource := "policy"
|
||||
@@ -186,7 +194,7 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
|
||||
dmarcSource = "custom"
|
||||
case dmarcMode == "none":
|
||||
dmarcSource = "none"
|
||||
case profileEmail != "":
|
||||
case reportEmail != "":
|
||||
dmarcSource = "settings"
|
||||
}
|
||||
|
||||
@@ -250,7 +258,7 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
|
||||
// and no extra environment variable is needed. That server result is returned
|
||||
// alongside, because the page's suggested SPF record is built from the same
|
||||
// addresses. force bypasses the cache, for the Re-check button.
|
||||
func (h *Handlers) domainDNS(d store.Domain, record domain.DKIMRecord, profileEmail string, force bool) (dnscheck.Domain, dnscheck.Server) {
|
||||
func (h *Handlers) domainDNS(d store.Domain, record domain.DKIMRecord, reportEmail string, force bool) (dnscheck.Domain, dnscheck.Server) {
|
||||
srv := h.dns.Server(h.cfg.Hostname, false)
|
||||
return h.dns.Domain(dnscheck.Query{
|
||||
Name: d.Name,
|
||||
@@ -258,7 +266,7 @@ func (h *Handlers) domainDNS(d store.Domain, record domain.DKIMRecord, profileEm
|
||||
ExpectedDKIM: record.Value,
|
||||
Hostname: srv.Hostname,
|
||||
ServerIPs: srv.IPs,
|
||||
DMARCReportEmail: dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail),
|
||||
DMARCReportEmail: reportEmail,
|
||||
}, force), srv
|
||||
}
|
||||
|
||||
@@ -275,13 +283,13 @@ func (h *Handlers) HandleDomainDNSRecheck(w http.ResponseWriter, r *http.Request
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
profileEmail, err := h.store.GlobalDMARCReportEmail()
|
||||
reportEmail, err := h.domainReportAddress(d)
|
||||
if err != nil {
|
||||
logf("panel: domain %d: global dmarc email: %v", d.ID, err)
|
||||
logf("panel: domain %d: dmarc report address: %v", d.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.domainDNS(d, record, profileEmail, true)
|
||||
h.domainDNS(d, record, reportEmail, true)
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?rechecked=1", d.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
||||
@@ -277,6 +277,13 @@ func (h *Handlers) HandleImportDomain(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderBackupPage(w, r, status, msg)
|
||||
return
|
||||
}
|
||||
// An export without an address of its own followed someone's default on the
|
||||
// server it came from; here it follows whoever imports it.
|
||||
if exp.DMARCRua == nil {
|
||||
if p, ok := h.principal(r); ok {
|
||||
h.followCreator(d.ID, p.ID)
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?imported=1", d.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -26,12 +25,14 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var rua sql.NullString
|
||||
// "inherit" makes the domain follow the default of the user who chose it;
|
||||
// every other mode gives the domain an address of its own ("" = none).
|
||||
follow := false
|
||||
rua := ""
|
||||
switch strings.TrimSpace(r.PostFormValue("dmarc_rua_mode")) {
|
||||
case "inherit":
|
||||
rua = sql.NullString{}
|
||||
follow = true
|
||||
case "none":
|
||||
rua = sql.NullString{Valid: true, String: ""}
|
||||
case "custom":
|
||||
email := strings.TrimSpace(r.PostFormValue("dmarc_rua_email"))
|
||||
if err := validate.Email(email); err != nil {
|
||||
@@ -42,20 +43,25 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Enter a custom report address or choose another mode."})
|
||||
return
|
||||
}
|
||||
rua = sql.NullString{Valid: true, String: email}
|
||||
rua = email
|
||||
case "hosted":
|
||||
if h.dmarc == nil || !h.dmarc.Enabled() {
|
||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "SelfPost-hosted reports are not enabled on this server."})
|
||||
return
|
||||
}
|
||||
addr := dmarc.HostedReportAddress(h.cfg.Hostname, d.Name)
|
||||
rua = sql.NullString{Valid: true, String: addr}
|
||||
rua = dmarc.HostedReportAddress(h.cfg.Hostname, d.Name)
|
||||
default:
|
||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Choose how aggregate reports are addressed for this domain."})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.UpdateDomainDMARCRua(d.ID, rua); err != nil {
|
||||
var err error
|
||||
if p, ok := h.principal(r); follow && ok {
|
||||
err = h.store.SetDomainDMARCUser(d.ID, p.ID)
|
||||
} else {
|
||||
err = h.store.SetDomainDMARCAddress(d.ID, rua)
|
||||
}
|
||||
if err != nil {
|
||||
logf("panel: domain %d: save dmarc rua: %v", d.ID, err)
|
||||
h.renderDomainDetail(w, r, http.StatusInternalServerError, d, detailView{FormErr: "Could not save DMARC settings. Please check the logs and try again."})
|
||||
return
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
)
|
||||
|
||||
// postFormAs sends a form to a handler as the given principal.
|
||||
func postFormAs(h http.HandlerFunc, p auth.Principal, target string, pathValues map[string]string, values url.Values) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(values.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
for k, v := range pathValues {
|
||||
req.SetPathValue(k, v)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, auth.RequestWithPrincipal(req, p))
|
||||
return rec
|
||||
}
|
||||
|
||||
// The domain form's "Same as Settings" is, on the 2.0 schema, a domain that
|
||||
// follows the default of the user who chose it; every other choice gives the
|
||||
// domain an address of its own and stops it following anyone.
|
||||
func TestDomainDMARCFollowsTheUserWhoChoseInherit(t *testing.T) {
|
||||
h, _ := settingsServer(t)
|
||||
h.domains = domain.NewService(h.store, nil, nil, "selfpost")
|
||||
d, err := h.store.AddDomain("example.com", "selfpost")
|
||||
if err != nil {
|
||||
t.Fatalf("AddDomain: %v", err)
|
||||
}
|
||||
admin, err := h.store.GetUserByUsername("admin")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByUsername: %v", err)
|
||||
}
|
||||
p := auth.Principal{ID: admin.ID, Username: "admin", Role: auth.RoleGlobal}
|
||||
if err := h.store.UpdateUser(admin.ID, "admin", admin.PasswordHash, "mix@example.org"); err != nil {
|
||||
t.Fatalf("UpdateUser: %v", err)
|
||||
}
|
||||
if err := h.store.SetDMARCDefault(admin.ID, store.DMARCDefaultAccount, ""); err != nil {
|
||||
t.Fatalf("SetDMARCDefault: %v", err)
|
||||
}
|
||||
save := func(values url.Values) store.Domain {
|
||||
t.Helper()
|
||||
rec := postFormAs(h.HandleDomainDMARC, p, "/domains/1/dmarc", map[string]string{"id": "1"}, values)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("POST %v = %d, want 303:\n%s", values, rec.Code, rec.Body.String())
|
||||
}
|
||||
got, err := h.store.GetDomain(d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDomain: %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
got := save(url.Values{"dmarc_rua_mode": {"inherit"}})
|
||||
if !got.DMARCRuaUserID.Valid || got.DMARCRuaUserID.Int64 != admin.ID {
|
||||
t.Fatalf("inherit: the domain follows %v, want user %d", got.DMARCRuaUserID, admin.ID)
|
||||
}
|
||||
if rua, err := h.domainReportAddress(got); err != nil || rua != "mix@example.org" {
|
||||
t.Fatalf("inherit resolves to %q, %v; want the user's account e-mail", rua, err)
|
||||
}
|
||||
|
||||
got = save(url.Values{"dmarc_rua_mode": {"custom"}, "dmarc_rua_email": {"dmarc@hub.example"}})
|
||||
if got.DMARCRuaUserID.Valid || got.DMARCRua != "dmarc@hub.example" {
|
||||
t.Fatalf("custom: %q / follows %v", got.DMARCRua, got.DMARCRuaUserID)
|
||||
}
|
||||
|
||||
got = save(url.Values{"dmarc_rua_mode": {"none"}})
|
||||
if got.DMARCRuaUserID.Valid || got.DMARCRua != "" {
|
||||
t.Fatalf("none: %q / follows %v", got.DMARCRua, got.DMARCRuaUserID)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Until the Account page replaces the old Settings form, its single address
|
||||
// field is the account e-mail and switches the user's DMARC default between
|
||||
// "my account e-mail" and "none". Nothing is written to the instance settings.
|
||||
func TestSettingsFormMapsToAccountEmailAndDefault(t *testing.T) {
|
||||
h, password := settingsServer(t)
|
||||
submit := func(email string) store.User {
|
||||
t.Helper()
|
||||
rec := postFormAs(h.HandleSettings, globalPrincipal, "/settings", nil, url.Values{
|
||||
"username": {"admin"}, "current_password": {password}, "dmarc_report_email": {email},
|
||||
})
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("POST /settings (%q) = %d, want 303:\n%s", email, rec.Code, rec.Body.String())
|
||||
}
|
||||
u, err := h.store.GetUser(globalPrincipal.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUser: %v", err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
u := submit("mix@example.org")
|
||||
if u.Email != "mix@example.org" || u.DMARCDefaultMode != store.DMARCDefaultAccount {
|
||||
t.Fatalf("after setting the address: e-mail %q, default %q", u.Email, u.DMARCDefaultMode)
|
||||
}
|
||||
if v, err := h.store.GetSetting("dmarc_report_email"); err == nil && v != "" {
|
||||
t.Fatalf("the address was mirrored into the instance settings: %q", v)
|
||||
}
|
||||
|
||||
u = submit("")
|
||||
if u.Email != "" || u.DMARCDefaultMode != store.DMARCDefaultNone {
|
||||
t.Fatalf("after clearing the address: e-mail %q, default %q", u.Email, u.DMARCDefaultMode)
|
||||
}
|
||||
}
|
||||
|
||||
// A new domain takes its report address from whoever created it.
|
||||
func TestFollowCreator(t *testing.T) {
|
||||
h, _ := settingsServer(t)
|
||||
d, err := h.store.AddDomain("example.com", "selfpost")
|
||||
if err != nil {
|
||||
t.Fatalf("AddDomain: %v", err)
|
||||
}
|
||||
h.followCreator(d.ID, globalPrincipal.ID)
|
||||
got, err := h.store.GetDomain(d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDomain: %v", err)
|
||||
}
|
||||
if !got.DMARCRuaUserID.Valid || got.DMARCRuaUserID.Int64 != globalPrincipal.ID {
|
||||
t.Fatalf("the domain follows %v, want its creator (user %d)", got.DMARCRuaUserID, globalPrincipal.ID)
|
||||
}
|
||||
// The creator's default is "none" until they choose one, so the new
|
||||
// domain publishes a policy-only record — as a fresh 1.x install did.
|
||||
if rua, err := h.domainReportAddress(got); err != nil || rua != "" {
|
||||
t.Fatalf("resolved %q, %v; want no report address yet", rua, err)
|
||||
}
|
||||
}
|
||||
@@ -51,10 +51,6 @@ func (h *Handlers) renderDashboard(w http.ResponseWriter, r *http.Request, statu
|
||||
}
|
||||
|
||||
func (h *Handlers) domainRows(domains []store.Domain) []domainRow {
|
||||
profileEmail := ""
|
||||
if email, err := h.store.GlobalDMARCReportEmail(); err == nil {
|
||||
profileEmail = email
|
||||
}
|
||||
rows := make([]domainRow, len(domains))
|
||||
var wg sync.WaitGroup
|
||||
for i, d := range domains {
|
||||
@@ -67,7 +63,12 @@ func (h *Handlers) domainRows(domains []store.Domain) []domainRow {
|
||||
logf("panel: dashboard: domain %d: dkim record: %v", d.ID, err)
|
||||
return
|
||||
}
|
||||
dns, _ := h.domainDNS(d, record, profileEmail, false)
|
||||
reportEmail, err := h.domainReportAddress(d)
|
||||
if err != nil {
|
||||
logf("panel: dashboard: domain %d: dmarc report address: %v", d.ID, err)
|
||||
return
|
||||
}
|
||||
dns, _ := h.domainDNS(d, record, reportEmail, false)
|
||||
rows[i].DNS = dns.Overall
|
||||
}()
|
||||
}
|
||||
@@ -86,7 +87,8 @@ func dashboardFlash(r *http.Request) string {
|
||||
// OpenDKIM reload), and redirects to the domain's page so the DNS record to
|
||||
// publish is shown (product.md).
|
||||
func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
p, ok := h.requireGlobal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
@@ -111,9 +113,26 @@ func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
|
||||
"Could not add the domain. Please check the logs and try again.", raw)
|
||||
return
|
||||
}
|
||||
h.followCreator(d.ID, p.ID)
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// followCreator makes a new domain take its DMARC report address from the
|
||||
// default of whoever created it (plan § Account e-mail). The domain exists
|
||||
// either way: a failure here leaves it with no report address, which its page
|
||||
// shows and its DMARC form can change.
|
||||
func (h *Handlers) followCreator(domainID, userID int64) {
|
||||
if err := h.store.SetDomainDMARCUser(domainID, userID); err != nil {
|
||||
logf("panel: domain %d: follow user %d's dmarc default: %v", domainID, userID, err)
|
||||
return
|
||||
}
|
||||
if h.dmarc != nil && h.dmarc.Enabled() {
|
||||
if err := h.dmarc.Resync(); err != nil {
|
||||
logf("panel: domain %d: dmarc resync: %v", domainID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeleteConfirm shows the cascade warning before a domain is removed.
|
||||
func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
|
||||
@@ -373,7 +373,7 @@ func domainAdmin(t *testing.T, st *store.Store, username string, domainIDs ...in
|
||||
if err != nil {
|
||||
t.Fatalf("add placeholder domain: %v", err)
|
||||
}
|
||||
id, err := st.CreateUser(username, hash, store.RoleDomainAdmin, []int64{placeholder.ID})
|
||||
id, err := st.CreateUser(username, hash, store.RoleDomain, []int64{placeholder.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("create domain admin %s: %v", username, err)
|
||||
}
|
||||
@@ -387,7 +387,7 @@ func domainAdmin(t *testing.T, st *store.Store, username string, domainIDs ...in
|
||||
}
|
||||
return auth.Principal{ID: u.ID, Username: u.Username, Role: u.Role, Domains: u.DomainIDs}
|
||||
}
|
||||
id, err := st.CreateUser(username, hash, store.RoleDomainAdmin, domainIDs)
|
||||
id, err := st.CreateUser(username, hash, store.RoleDomain, domainIDs)
|
||||
if err != nil {
|
||||
t.Fatalf("create domain admin %s: %v", username, err)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ func (h *Handlers) HandleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.renderSettings(w, r, http.StatusOK, "", u.Username, u.DMARCReportEmail, h.sendLogRetentionDays(), p.IsGlobal())
|
||||
h.renderSettings(w, r, http.StatusOK, "", u.Username, u.Email, h.sendLogRetentionDays(), p.IsGlobal())
|
||||
case http.MethodPost:
|
||||
h.submitSettings(w, r)
|
||||
default:
|
||||
@@ -128,7 +128,7 @@ func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) {
|
||||
dmarcEmail := strings.TrimSpace(r.PostFormValue("dmarc_report_email"))
|
||||
formRetention := currentRetention
|
||||
if !p.IsGlobal() {
|
||||
dmarcEmail = user.DMARCReportEmail
|
||||
dmarcEmail = user.Email
|
||||
} else if raw := strings.TrimSpace(r.PostFormValue("send_log_retention_days")); raw != "" {
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
@@ -161,7 +161,7 @@ func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
emailChanging := p.IsGlobal() && dmarcEmail != user.DMARCReportEmail
|
||||
emailChanging := p.IsGlobal() && dmarcEmail != user.Email
|
||||
|
||||
retentionChanging := false
|
||||
if p.IsGlobal() && formRetention != currentRetention {
|
||||
@@ -218,6 +218,23 @@ func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Until the Account page replaces this form (stage 2), its one "default
|
||||
// report address" field stands for both halves of the 2.0 model: it is the
|
||||
// account e-mail, and the user's DMARC default is "my account e-mail" while
|
||||
// it is filled and "none" once it is cleared.
|
||||
if emailChanging {
|
||||
mode := store.DMARCDefaultNone
|
||||
if dmarcEmail != "" {
|
||||
mode = store.DMARCDefaultAccount
|
||||
}
|
||||
if err := h.store.SetDMARCDefault(user.ID, mode, ""); err != nil {
|
||||
logf("panel: settings: set dmarc default failed: %v", err)
|
||||
h.renderSettings(w, r, http.StatusInternalServerError,
|
||||
"Could not save the changes. Please check the logs and try again.", username, dmarcEmail, formRetention, p.IsGlobal())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if retentionChanging {
|
||||
if err := h.store.SetSendLogRetentionDays(formRetention); err != nil {
|
||||
logf("panel: settings: set send-log retention failed: %v", err)
|
||||
|
||||
@@ -58,7 +58,7 @@ func (h *Handlers) HandleUserNew(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.renderUserForm(w, r, http.StatusOK, 0, userFormView{FormRole: string(store.RoleDomainAdmin)})
|
||||
h.renderUserForm(w, r, http.StatusOK, 0, userFormView{FormRole: string(store.RoleDomain)})
|
||||
case http.MethodPost:
|
||||
h.submitUserCreate(w, r)
|
||||
default:
|
||||
@@ -162,11 +162,11 @@ func (h *Handlers) submitUserCreate(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
|
||||
return
|
||||
}
|
||||
if role != store.RoleGlobal && role != store.RoleDomainAdmin {
|
||||
if role != store.RoleGlobal && role != store.RoleDomain {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
|
||||
return
|
||||
}
|
||||
if role == store.RoleDomainAdmin && len(domainIDs) == 0 {
|
||||
if role == store.RoleDomain && len(domainIDs) == 0 {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Select at least one domain for a domain administrator.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
|
||||
return
|
||||
}
|
||||
@@ -213,16 +213,16 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||
return
|
||||
}
|
||||
if role != store.RoleGlobal && role != store.RoleDomainAdmin {
|
||||
if role != store.RoleGlobal && role != store.RoleDomain {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||
return
|
||||
}
|
||||
if role == store.RoleDomainAdmin && len(domainIDs) == 0 {
|
||||
if role == store.RoleDomain && len(domainIDs) == 0 {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Select at least one domain for a domain administrator.", FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||
return
|
||||
}
|
||||
|
||||
if u.Role == store.RoleGlobal && role == store.RoleDomainAdmin {
|
||||
if u.Role == store.RoleGlobal && role == store.RoleDomain {
|
||||
n, err := h.store.CountGlobalUsers()
|
||||
if err != nil || n <= 1 {
|
||||
msg := "Cannot demote the last global administrator."
|
||||
@@ -249,7 +249,7 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
|
||||
hash = string(newHash)
|
||||
}
|
||||
|
||||
if err := h.store.UpdateUser(u.ID, username, hash, u.DMARCReportEmail); err != nil {
|
||||
if err := h.store.UpdateUser(u.ID, username, hash, u.Email); err != nil {
|
||||
if errors.Is(err, store.ErrUserExists) {
|
||||
h.renderUserForm(w, r, http.StatusConflict, u.ID, userFormView{FormErr: "That username is already in use.", FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||
return
|
||||
@@ -272,7 +272,7 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
|
||||
}
|
||||
}
|
||||
|
||||
if role == store.RoleDomainAdmin {
|
||||
if role == store.RoleDomain {
|
||||
if err := h.store.SetUserDomains(u.ID, domainIDs); err != nil {
|
||||
logf("panel: set user domains: %v", err)
|
||||
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not save domain assignments.", FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<label for="role">Role</label>
|
||||
{{if .LastGlobalLocked}}<input type="hidden" name="role" value="{{.FormRole}}">{{end}}
|
||||
<select id="role" name="role" data-global-role="{{.GlobalRole}}"{{if .LastGlobalLocked}} disabled{{end}}>
|
||||
<option value="domain_admin" {{if eq .FormRole "domain_admin"}}selected{{end}}>Domain administrator</option>
|
||||
<option value="domain" {{if eq .FormRole "domain"}}selected{{end}}>Domain administrator</option>
|
||||
<option value="global" {{if eq .FormRole "global"}}selected{{end}}>Global administrator</option>
|
||||
</select>
|
||||
{{if .LastGlobalLocked}}<p class="muted">The only global administrator cannot be demoted.</p>{{end}}
|
||||
|
||||
Reference in New Issue
Block a user