Fix DMARC guidance for send-only relays with optional rua= settings.
test / test (push) Has been cancelled
test / test (push) Has been cancelled
The panel now suggests policy-only DMARC by default, lets operators configure a default and per-domain report address, and DNS-checks hub _report._dmarc records. Future in-panel report ingestion is tracked as dmarc-reports in the roadmap. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -12,9 +12,10 @@ var ErrNoAdmin = errors.New("no administrator account")
|
||||
|
||||
// Admin is the single panel administrator (security.md).
|
||||
type Admin struct {
|
||||
Username string
|
||||
PasswordHash string
|
||||
CreatedAt time.Time
|
||||
Username string
|
||||
PasswordHash string
|
||||
DMARCReportEmail string // default rua= for all sending domains; empty = none
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// AdminExists reports whether the administrator account has been created. This
|
||||
@@ -47,10 +48,10 @@ func (s *Store) CreateAdmin(username, passwordHash string) error {
|
||||
// SASL logins applications authenticate with, which live in their own table.
|
||||
// ErrNoAdmin is returned if setup has not happened yet, so a change can never
|
||||
// silently create an account.
|
||||
func (s *Store) UpdateAdmin(username, passwordHash string) error {
|
||||
func (s *Store) UpdateAdmin(username, passwordHash, dmarcReportEmail string) error {
|
||||
res, err := s.db.Exec(
|
||||
"UPDATE admin SET username = ?, password_hash = ? WHERE id = 1",
|
||||
username, passwordHash,
|
||||
"UPDATE admin SET username = ?, password_hash = ?, dmarc_report_email = ? WHERE id = 1",
|
||||
username, passwordHash, dmarcReportEmail,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update admin: %w", err)
|
||||
@@ -71,8 +72,8 @@ func (s *Store) GetAdmin() (Admin, error) {
|
||||
a Admin
|
||||
createdAt string
|
||||
)
|
||||
err := s.db.QueryRow("SELECT username, password_hash, created_at FROM admin WHERE id = 1").
|
||||
Scan(&a.Username, &a.PasswordHash, &createdAt)
|
||||
err := s.db.QueryRow("SELECT username, password_hash, dmarc_report_email, created_at FROM admin WHERE id = 1").
|
||||
Scan(&a.Username, &a.PasswordHash, &a.DMARCReportEmail, &createdAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Admin{}, ErrNoAdmin
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ func TestUpdateAdmin(t *testing.T) {
|
||||
if err := st.CreateAdmin("admin", "hash-one"); err != nil {
|
||||
t.Fatalf("CreateAdmin: %v", err)
|
||||
}
|
||||
if err := st.UpdateAdmin("operator", "hash-two"); err != nil {
|
||||
if err := st.UpdateAdmin("operator", "hash-two", "reports@hub.example"); err != nil {
|
||||
t.Fatalf("UpdateAdmin: %v", err)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,18 @@ func TestUpdateAdmin(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("GetAdmin: %v", err)
|
||||
}
|
||||
if a.Username != "operator" || a.PasswordHash != "hash-two" {
|
||||
if a.DMARCReportEmail != "reports@hub.example" {
|
||||
t.Fatalf("dmarc email = %q", a.DMARCReportEmail)
|
||||
}
|
||||
|
||||
if err := st.UpdateAdmin("operator", "hash-three", ""); err != nil {
|
||||
t.Fatalf("clear dmarc email: %v", err)
|
||||
}
|
||||
a, err = st.GetAdmin()
|
||||
if err != nil {
|
||||
t.Fatalf("GetAdmin: %v", err)
|
||||
}
|
||||
if a.Username != "operator" || a.PasswordHash != "hash-three" {
|
||||
t.Fatalf("unexpected admin after update: %+v", a)
|
||||
}
|
||||
if a.CreatedAt.IsZero() {
|
||||
@@ -32,7 +43,7 @@ func TestUpdateAdmin(t *testing.T) {
|
||||
func TestUpdateAdminWithoutAdmin(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
|
||||
if err := st.UpdateAdmin("operator", "hash"); !errors.Is(err, ErrNoAdmin) {
|
||||
if err := st.UpdateAdmin("operator", "hash", ""); !errors.Is(err, ErrNoAdmin) {
|
||||
t.Fatalf("UpdateAdmin without admin = %v, want ErrNoAdmin", err)
|
||||
}
|
||||
exists, err := st.AdminExists()
|
||||
|
||||
@@ -24,6 +24,7 @@ type Domain struct {
|
||||
ID int64
|
||||
Name string
|
||||
DKIMSelector string
|
||||
DMARCRua sql.NullString // NULL = inherit profile; Valid+empty = no reports
|
||||
CreatedAt time.Time
|
||||
AppCount int
|
||||
}
|
||||
@@ -54,7 +55,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.created_at,
|
||||
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at,
|
||||
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
|
||||
FROM domains d
|
||||
ORDER BY d.name`)
|
||||
@@ -78,7 +79,7 @@ func (s *Store) ListDomains() ([]Domain, error) {
|
||||
// ErrDomainNotFound.
|
||||
func (s *Store) GetDomain(id int64) (Domain, error) {
|
||||
row := s.db.QueryRow(`
|
||||
SELECT d.id, d.name, d.dkim_selector, d.created_at,
|
||||
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at,
|
||||
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
|
||||
FROM domains d
|
||||
WHERE d.id = ?`, id)
|
||||
@@ -120,7 +121,7 @@ func scanDomain(r scanRow) (Domain, error) {
|
||||
d Domain
|
||||
createdAt string
|
||||
)
|
||||
if err := r.Scan(&d.ID, &d.Name, &d.DKIMSelector, &createdAt, &d.AppCount); err != nil {
|
||||
if err := r.Scan(&d.ID, &d.Name, &d.DKIMSelector, &d.DMARCRua, &createdAt, &d.AppCount); err != nil {
|
||||
return Domain{}, err
|
||||
}
|
||||
d.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
@@ -137,3 +138,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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update domain dmarc rua: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update domain dmarc rua rows: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrDomainNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDomainDMARCRua(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
d, err := st.AddDomain("example.com", "sel")
|
||||
if err != nil {
|
||||
t.Fatalf("AddDomain: %v", err)
|
||||
}
|
||||
got, err := st.GetDomain(d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDomain: %v", err)
|
||||
}
|
||||
if got.DMARCRua.Valid {
|
||||
t.Fatal("new domain should inherit profile")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
got, _ = st.GetDomain(d.ID)
|
||||
if !got.DMARCRua.Valid || got.DMARCRua.String != "" {
|
||||
t.Fatalf("none = %+v", got.DMARCRua)
|
||||
}
|
||||
|
||||
if err := st.UpdateDomainDMARCRua(d.ID, sql.NullString{}); err != nil {
|
||||
t.Fatalf("UpdateDomainDMARCRua inherit: %v", err)
|
||||
}
|
||||
got, _ = st.GetDomain(d.ID)
|
||||
if got.DMARCRua.Valid {
|
||||
t.Fatalf("inherit = %+v", got.DMARCRua)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 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;
|
||||
Reference in New Issue
Block a user