release: 0.1.0: Phases 0-11: panel, Postfix/DKIM, milter, backup, Docker image
Initial feature-complete implementation of the v1.0 specification (phases 0-11 of `docs/implementation-plan.md`). Added: - Panel (Go, single static binary) with SQLite persistence, one-time - Domain management with per-domain DKIM (RSA-2048, generated in pure Go) and - Application (sender identity) management: SASL credentials via `sasldb2`, - Full Postfix relay config generated from env at container start: SMTPS 465, - Journal milter (pure Go, `go-milter`) recording every send to `send_log`; … (6 more in CHANGELOG) Full detail: CHANGELOG.md (0.1.0).
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrNoAdmin is returned by GetAdmin when primary setup has not happened yet.
|
||||
var ErrNoAdmin = errors.New("no administrator account")
|
||||
|
||||
// Admin is the single panel administrator (spec 7.6.1).
|
||||
type Admin struct {
|
||||
Username string
|
||||
PasswordHash string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// AdminExists reports whether the administrator account has been created. This
|
||||
// doubles as the "primary setup complete" flag: once true, the /setup route is
|
||||
// permanently gone (spec 7.6.1).
|
||||
func (s *Store) AdminExists() (bool, error) {
|
||||
var n int
|
||||
if err := s.db.QueryRow("SELECT COUNT(*) FROM admin").Scan(&n); err != nil {
|
||||
return false, fmt.Errorf("count admin: %w", err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// CreateAdmin inserts the administrator row. It fails if one already exists,
|
||||
// which — combined with the id=1 constraint — makes admin creation one-shot
|
||||
// even under a race between two setup submissions.
|
||||
func (s *Store) CreateAdmin(username, passwordHash string) error {
|
||||
_, err := s.db.Exec(
|
||||
"INSERT INTO admin (id, username, password_hash, created_at) VALUES (1, ?, ?, ?)",
|
||||
username, passwordHash, time.Now().UTC().Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create admin: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateAdmin replaces the administrator's username and password hash. It
|
||||
// touches only the admin row (id = 1): panel credentials are unrelated to the
|
||||
// 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 {
|
||||
res, err := s.db.Exec(
|
||||
"UPDATE admin SET username = ?, password_hash = ? WHERE id = 1",
|
||||
username, passwordHash,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update admin: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update admin: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNoAdmin
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAdmin returns the administrator account, or ErrNoAdmin if setup is pending.
|
||||
func (s *Store) GetAdmin() (Admin, error) {
|
||||
var (
|
||||
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)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Admin{}, ErrNoAdmin
|
||||
}
|
||||
if err != nil {
|
||||
return Admin{}, fmt.Errorf("get admin: %w", err)
|
||||
}
|
||||
a.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
return a, nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUpdateAdmin(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
|
||||
if err := st.CreateAdmin("admin", "hash-one"); err != nil {
|
||||
t.Fatalf("CreateAdmin: %v", err)
|
||||
}
|
||||
if err := st.UpdateAdmin("operator", "hash-two"); err != nil {
|
||||
t.Fatalf("UpdateAdmin: %v", err)
|
||||
}
|
||||
|
||||
a, err := st.GetAdmin()
|
||||
if err != nil {
|
||||
t.Fatalf("GetAdmin: %v", err)
|
||||
}
|
||||
if a.Username != "operator" || a.PasswordHash != "hash-two" {
|
||||
t.Fatalf("unexpected admin after update: %+v", a)
|
||||
}
|
||||
if a.CreatedAt.IsZero() {
|
||||
t.Fatal("update dropped created_at")
|
||||
}
|
||||
}
|
||||
|
||||
// An update before setup must not create the account: only the one-time setup
|
||||
// flow may do that (spec 7.6.1).
|
||||
func TestUpdateAdminWithoutAdmin(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
|
||||
if err := st.UpdateAdmin("operator", "hash"); !errors.Is(err, ErrNoAdmin) {
|
||||
t.Fatalf("UpdateAdmin without admin = %v, want ErrNoAdmin", err)
|
||||
}
|
||||
exists, err := st.AdminExists()
|
||||
if err != nil {
|
||||
t.Fatalf("AdminExists: %v", err)
|
||||
}
|
||||
if exists {
|
||||
t.Fatal("UpdateAdmin created an administrator")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrLoginExists is returned when an application login is already taken. The
|
||||
// login is globally unique because it is the SASL identity Postfix authenticates
|
||||
// (one sasldb2 across all domains, spec 5.1).
|
||||
var ErrLoginExists = errors.New("application login already exists")
|
||||
|
||||
// ErrApplicationNotFound is returned when an application id does not exist.
|
||||
var ErrApplicationNotFound = errors.New("application not found")
|
||||
|
||||
// Address modes (spec 4.1). Kept in sync with the CHECK constraint in the schema.
|
||||
const (
|
||||
AddressModeWildcard = "wildcard" // any address within the application's domain
|
||||
AddressModeList = "list" // only the explicitly listed addresses
|
||||
)
|
||||
|
||||
// Application is a SASL account bound to a single domain (spec 4.1, 5.1). The
|
||||
// password is never stored here — only in sasldb2, hashed — so it can be shown
|
||||
// exactly once at creation/regeneration (spec 7.6.1). Addresses is populated only
|
||||
// in 'list' mode.
|
||||
type Application struct {
|
||||
ID int64
|
||||
DomainID int64
|
||||
Login string
|
||||
AddressMode string
|
||||
CreatedAt time.Time
|
||||
Addresses []string
|
||||
}
|
||||
|
||||
// Binding is one sender-address → login pair, as consumed by the
|
||||
// smtpd_sender_login_maps generator (spec 5.1). For a wildcard application the
|
||||
// Address is the domain wildcard "@example.com"; for a list application there is
|
||||
// one Binding per listed address.
|
||||
type Binding struct {
|
||||
Address string
|
||||
Login string
|
||||
}
|
||||
|
||||
// AddApplication inserts an application and, in list mode, its addresses, in a
|
||||
// single transaction. The caller must have validated login and every address
|
||||
// (spec 7.6.2) beforehand; the query is parameterised regardless. A duplicate
|
||||
// login maps to ErrLoginExists.
|
||||
func (s *Store) AddApplication(domainID int64, login, mode string, addresses []string) (Application, error) {
|
||||
now := time.Now().UTC()
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return Application{}, fmt.Errorf("begin add application: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
res, err := tx.Exec(
|
||||
"INSERT INTO applications (domain_id, login, address_mode, created_at) VALUES (?, ?, ?, ?)",
|
||||
domainID, login, mode, now.Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return Application{}, ErrLoginExists
|
||||
}
|
||||
return Application{}, fmt.Errorf("insert application: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return Application{}, fmt.Errorf("application id: %w", err)
|
||||
}
|
||||
if err := insertAddresses(tx, id, mode, addresses); err != nil {
|
||||
return Application{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Application{}, fmt.Errorf("commit add application: %w", err)
|
||||
}
|
||||
return Application{
|
||||
ID: id, DomainID: domainID, Login: login, AddressMode: mode,
|
||||
CreatedAt: now, Addresses: normalizedList(mode, addresses),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateApplicationMode switches an application's address mode and replaces its
|
||||
// address list atomically (spec 7.2.7). The login and password are untouched.
|
||||
// Returns ErrApplicationNotFound if the id does not exist.
|
||||
func (s *Store) UpdateApplicationMode(id int64, mode string, addresses []string) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin update application: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
res, err := tx.Exec("UPDATE applications SET address_mode = ? WHERE id = ?", mode, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update application mode: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update application rows: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrApplicationNotFound
|
||||
}
|
||||
if _, err := tx.Exec("DELETE FROM application_addresses WHERE application_id = ?", id); err != nil {
|
||||
return fmt.Errorf("clear addresses: %w", err)
|
||||
}
|
||||
if err := insertAddresses(tx, id, mode, addresses); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit update application: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// insertAddresses writes the address rows for a list-mode application. In
|
||||
// wildcard mode no address rows are stored (the wildcard is derived from the
|
||||
// domain at map-generation time).
|
||||
func insertAddresses(tx *sql.Tx, appID int64, mode string, addresses []string) error {
|
||||
if mode != AddressModeList {
|
||||
return nil
|
||||
}
|
||||
for _, addr := range addresses {
|
||||
if _, err := tx.Exec(
|
||||
"INSERT INTO application_addresses (application_id, address) VALUES (?, ?)",
|
||||
appID, addr,
|
||||
); err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
continue // a repeated address in the same submission is harmless
|
||||
}
|
||||
return fmt.Errorf("insert address %q: %w", addr, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizedList(mode string, addresses []string) []string {
|
||||
if mode != AddressModeList {
|
||||
return nil
|
||||
}
|
||||
return addresses
|
||||
}
|
||||
|
||||
// GetApplication returns one application (with its addresses) by id, or
|
||||
// ErrApplicationNotFound.
|
||||
func (s *Store) GetApplication(id int64) (Application, error) {
|
||||
row := s.db.QueryRow(
|
||||
"SELECT id, domain_id, login, address_mode, created_at FROM applications WHERE id = ?", id)
|
||||
a, err := scanApplication(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Application{}, ErrApplicationNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Application{}, err
|
||||
}
|
||||
addrs, err := s.applicationAddresses(a.ID)
|
||||
if err != nil {
|
||||
return Application{}, err
|
||||
}
|
||||
a.Addresses = addrs
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ListApplicationsByDomain returns a domain's applications ordered by login,
|
||||
// each with its address list populated (spec 7.2.6).
|
||||
func (s *Store) ListApplicationsByDomain(domainID int64) ([]Application, error) {
|
||||
rows, err := s.db.Query(
|
||||
"SELECT id, domain_id, login, address_mode, created_at FROM applications WHERE domain_id = ? ORDER BY login",
|
||||
domainID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list applications: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Application
|
||||
for rows.Next() {
|
||||
a, err := scanApplication(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Fill address lists after the first query is drained (MaxOpenConns is 1).
|
||||
for i := range out {
|
||||
addrs, err := s.applicationAddresses(out[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[i].Addresses = addrs
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListApplicationLogins returns every application login across all domains,
|
||||
// ordered, for the send-log monitoring screen's filter dropdown (spec 7.2).
|
||||
// Logins are globally unique (spec 5.1), so no domain qualifier is needed.
|
||||
func (s *Store) ListApplicationLogins() ([]string, error) {
|
||||
rows, err := s.db.Query("SELECT login FROM applications ORDER BY login")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list application logins: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var login string
|
||||
if err := rows.Scan(&login); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, login)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListLoginsByDomain returns the SASL logins of a domain's applications. Used to
|
||||
// purge sasldb2 entries before a domain (and its applications via cascade) is
|
||||
// deleted, while the logins are still known (spec 7.2.4).
|
||||
func (s *Store) ListLoginsByDomain(domainID int64) ([]string, error) {
|
||||
rows, err := s.db.Query("SELECT login FROM applications WHERE domain_id = ? ORDER BY login", domainID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list logins: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var login string
|
||||
if err := rows.Scan(&login); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, login)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListBindings returns every sender-address → login pair across all domains, the
|
||||
// raw material for the smtpd_sender_login_maps file (spec 5.1). Wildcard
|
||||
// applications yield a single "@domain" binding; list applications yield one
|
||||
// binding per address. Ordered deterministically so the generated map is stable.
|
||||
func (s *Store) ListBindings() ([]Binding, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT '@' || d.name, a.login
|
||||
FROM applications a
|
||||
JOIN domains d ON d.id = a.domain_id
|
||||
WHERE a.address_mode = 'wildcard'
|
||||
UNION ALL
|
||||
SELECT aa.address, a.login
|
||||
FROM application_addresses aa
|
||||
JOIN applications a ON a.id = aa.application_id
|
||||
WHERE a.address_mode = 'list'
|
||||
ORDER BY 1, 2`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list bindings: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Binding
|
||||
for rows.Next() {
|
||||
var b Binding
|
||||
if err := rows.Scan(&b.Address, &b.Login); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteApplication removes an application and its addresses (via cascade),
|
||||
// returning the deleted application so the caller can drop its sasldb2 entry
|
||||
// (spec 7.2.8). Returns ErrApplicationNotFound if no such row existed.
|
||||
func (s *Store) DeleteApplication(id int64) (Application, error) {
|
||||
a, err := s.GetApplication(id)
|
||||
if err != nil {
|
||||
return Application{}, err
|
||||
}
|
||||
res, err := s.db.Exec("DELETE FROM applications WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return Application{}, fmt.Errorf("delete application: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return Application{}, fmt.Errorf("delete application rows: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return Application{}, ErrApplicationNotFound
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (s *Store) applicationAddresses(appID int64) ([]string, error) {
|
||||
rows, err := s.db.Query(
|
||||
"SELECT address FROM application_addresses WHERE application_id = ? ORDER BY address", appID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("application addresses: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var a string
|
||||
if err := rows.Scan(&a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanApplication(r scanRow) (Application, error) {
|
||||
var (
|
||||
a Application
|
||||
createdAt string
|
||||
)
|
||||
if err := r.Scan(&a.ID, &a.DomainID, &a.Login, &a.AddressMode, &createdAt); err != nil {
|
||||
return Application{}, err
|
||||
}
|
||||
a.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
return a, nil
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func addTestDomain(t *testing.T, st *Store, name string) Domain {
|
||||
t.Helper()
|
||||
d, err := st.AddDomain(name, "selfpost")
|
||||
if err != nil {
|
||||
t.Fatalf("AddDomain(%q): %v", name, err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func TestAddApplicationWildcard(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
d := addTestDomain(t, st, "example.com")
|
||||
|
||||
a, err := st.AddApplication(d.ID, "alerts", AddressModeWildcard, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("AddApplication: %v", err)
|
||||
}
|
||||
if a.ID == 0 || a.Login != "alerts" || a.AddressMode != AddressModeWildcard {
|
||||
t.Fatalf("unexpected application: %+v", a)
|
||||
}
|
||||
if len(a.Addresses) != 0 {
|
||||
t.Errorf("wildcard app should have no addresses, got %v", a.Addresses)
|
||||
}
|
||||
|
||||
got, err := st.GetApplication(a.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetApplication: %v", err)
|
||||
}
|
||||
if got.Login != "alerts" || len(got.Addresses) != 0 {
|
||||
t.Fatalf("round-trip mismatch: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddApplicationListStoresAddresses(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
d := addTestDomain(t, st, "example.com")
|
||||
|
||||
addrs := []string{"noreply@example.com", "alerts@example.com"}
|
||||
a, err := st.AddApplication(d.ID, "app1", AddressModeList, addrs)
|
||||
if err != nil {
|
||||
t.Fatalf("AddApplication: %v", err)
|
||||
}
|
||||
got, err := st.GetApplication(a.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Addresses come back sorted.
|
||||
if len(got.Addresses) != 2 || got.Addresses[0] != "alerts@example.com" || got.Addresses[1] != "noreply@example.com" {
|
||||
t.Fatalf("addresses = %v", got.Addresses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddApplicationDuplicateLogin(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
d := addTestDomain(t, st, "example.com")
|
||||
d2 := addTestDomain(t, st, "other.com")
|
||||
|
||||
if _, err := st.AddApplication(d.ID, "shared", AddressModeWildcard, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Same login under a different domain must still collide (global uniqueness).
|
||||
_, err := st.AddApplication(d2.ID, "shared", AddressModeWildcard, nil)
|
||||
if !errors.Is(err, ErrLoginExists) {
|
||||
t.Fatalf("duplicate login error = %v, want ErrLoginExists", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateApplicationMode(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
d := addTestDomain(t, st, "example.com")
|
||||
a, err := st.AddApplication(d.ID, "app1", AddressModeList, []string{"a@example.com"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// list -> wildcard drops the addresses.
|
||||
if err := st.UpdateApplicationMode(a.ID, AddressModeWildcard, nil); err != nil {
|
||||
t.Fatalf("UpdateApplicationMode: %v", err)
|
||||
}
|
||||
got, _ := st.GetApplication(a.ID)
|
||||
if got.AddressMode != AddressModeWildcard || len(got.Addresses) != 0 {
|
||||
t.Fatalf("after wildcard switch: %+v", got)
|
||||
}
|
||||
|
||||
// wildcard -> list adds a fresh set.
|
||||
if err := st.UpdateApplicationMode(a.ID, AddressModeList, []string{"b@example.com", "c@example.com"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ = st.GetApplication(a.ID)
|
||||
if got.AddressMode != AddressModeList || len(got.Addresses) != 2 {
|
||||
t.Fatalf("after list switch: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateApplicationModeNotFound(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if err := st.UpdateApplicationMode(999, AddressModeWildcard, nil); !errors.Is(err, ErrApplicationNotFound) {
|
||||
t.Fatalf("UpdateApplicationMode(missing) = %v, want ErrApplicationNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteApplication(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
d := addTestDomain(t, st, "example.com")
|
||||
a, err := st.AddApplication(d.ID, "app1", AddressModeList, []string{"a@example.com"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
deleted, err := st.DeleteApplication(a.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteApplication: %v", err)
|
||||
}
|
||||
if deleted.Login != "app1" {
|
||||
t.Errorf("deleted login = %q, want app1", deleted.Login)
|
||||
}
|
||||
assertCount(t, st, "applications", 0)
|
||||
assertCount(t, st, "application_addresses", 0)
|
||||
|
||||
if _, err := st.DeleteApplication(a.ID); !errors.Is(err, ErrApplicationNotFound) {
|
||||
t.Fatalf("second delete = %v, want ErrApplicationNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListBindingsMixedModes(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
d1 := addTestDomain(t, st, "example.com")
|
||||
d2 := addTestDomain(t, st, "other.com")
|
||||
|
||||
if _, err := st.AddApplication(d1.ID, "wild", AddressModeWildcard, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.AddApplication(d1.ID, "listed", AddressModeList,
|
||||
[]string{"alerts@example.com", "noreply@example.com"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.AddApplication(d2.ID, "wild2", AddressModeWildcard, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
bindings, err := st.ListBindings()
|
||||
if err != nil {
|
||||
t.Fatalf("ListBindings: %v", err)
|
||||
}
|
||||
want := []Binding{
|
||||
{"@example.com", "wild"},
|
||||
{"@other.com", "wild2"},
|
||||
{"alerts@example.com", "listed"},
|
||||
{"noreply@example.com", "listed"},
|
||||
}
|
||||
if len(bindings) != len(want) {
|
||||
t.Fatalf("bindings = %+v, want %+v", bindings, want)
|
||||
}
|
||||
for i := range want {
|
||||
if bindings[i] != want[i] {
|
||||
t.Errorf("binding[%d] = %+v, want %+v", i, bindings[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListLoginsByDomain(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
d := addTestDomain(t, st, "example.com")
|
||||
other := addTestDomain(t, st, "other.com")
|
||||
if _, err := st.AddApplication(d.ID, "a", AddressModeWildcard, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.AddApplication(d.ID, "b", AddressModeWildcard, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.AddApplication(other.ID, "c", AddressModeWildcard, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
logins, err := st.ListLoginsByDomain(d.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(logins) != 2 || logins[0] != "a" || logins[1] != "b" {
|
||||
t.Fatalf("logins = %v, want [a b]", logins)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"modernc.org/sqlite"
|
||||
sqlite3 "modernc.org/sqlite/lib"
|
||||
)
|
||||
|
||||
// ErrDomainExists is returned by AddDomain when the domain is already managed.
|
||||
var ErrDomainExists = errors.New("domain already exists")
|
||||
|
||||
// ErrDomainNotFound is returned when a domain id/name does not exist.
|
||||
var ErrDomainNotFound = errors.New("domain not found")
|
||||
|
||||
// Domain is a sending domain managed through the panel (spec 4.1). The DKIM key
|
||||
// material itself lives on disk under /data; this row records the selector and
|
||||
// metadata. AppCount is populated by the listing queries, not stored.
|
||||
type Domain struct {
|
||||
ID int64
|
||||
Name string
|
||||
DKIMSelector string
|
||||
CreatedAt time.Time
|
||||
AppCount int
|
||||
}
|
||||
|
||||
// AddDomain inserts a new sending domain. The caller is responsible for having
|
||||
// validated name (spec 7.6.2) before it reaches SQL; the query is parameterised
|
||||
// regardless. A duplicate name maps to ErrDomainExists.
|
||||
func (s *Store) AddDomain(name, selector string) (Domain, error) {
|
||||
now := time.Now().UTC()
|
||||
res, err := s.db.Exec(
|
||||
"INSERT INTO domains (name, dkim_selector, created_at) VALUES (?, ?, ?)",
|
||||
name, selector, now.Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return Domain{}, ErrDomainExists
|
||||
}
|
||||
return Domain{}, fmt.Errorf("insert domain: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return Domain{}, fmt.Errorf("domain id: %w", err)
|
||||
}
|
||||
return Domain{ID: id, Name: name, DKIMSelector: selector, CreatedAt: now}, nil
|
||||
}
|
||||
|
||||
// ListDomains returns every domain with its bound-application count (spec 7.2.2),
|
||||
// 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 COUNT(*) FROM applications a WHERE a.domain_id = d.id)
|
||||
FROM domains d
|
||||
ORDER BY d.name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list domains: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Domain
|
||||
for rows.Next() {
|
||||
d, err := scanDomain(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetDomain returns a single domain (with its application count) by id, or
|
||||
// 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 COUNT(*) FROM applications a WHERE a.domain_id = d.id)
|
||||
FROM domains d
|
||||
WHERE d.id = ?`, id)
|
||||
d, err := scanDomain(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Domain{}, ErrDomainNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Domain{}, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// DeleteDomain removes a domain. Its applications and their address/binding rows
|
||||
// go with it via ON DELETE CASCADE (spec 7.2.4). Returns ErrDomainNotFound if no
|
||||
// such row existed.
|
||||
func (s *Store) DeleteDomain(id int64) error {
|
||||
res, err := s.db.Exec("DELETE FROM domains WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete domain: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete domain rows: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrDomainNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanRow is the minimal surface shared by *sql.Row and *sql.Rows.
|
||||
type scanRow interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanDomain(r scanRow) (Domain, error) {
|
||||
var (
|
||||
d Domain
|
||||
createdAt string
|
||||
)
|
||||
if err := r.Scan(&d.ID, &d.Name, &d.DKIMSelector, &createdAt, &d.AppCount); err != nil {
|
||||
return Domain{}, err
|
||||
}
|
||||
d.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// isUniqueViolation reports whether err is a SQLite UNIQUE/PRIMARY-KEY conflict,
|
||||
// so callers can turn a duplicate insert into a friendly domain-level error.
|
||||
func isUniqueViolation(err error) bool {
|
||||
var se *sqlite.Error
|
||||
if errors.As(err, &se) {
|
||||
code := se.Code()
|
||||
return code == sqlite3.SQLITE_CONSTRAINT_UNIQUE || code == sqlite3.SQLITE_CONSTRAINT_PRIMARYKEY
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func openTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
st, err := Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
func TestAddAndListDomains(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
|
||||
d, err := st.AddDomain("example.com", "selfpost")
|
||||
if err != nil {
|
||||
t.Fatalf("AddDomain: %v", err)
|
||||
}
|
||||
if d.ID == 0 || d.Name != "example.com" || d.DKIMSelector != "selfpost" {
|
||||
t.Fatalf("unexpected domain: %+v", d)
|
||||
}
|
||||
|
||||
list, err := st.ListDomains()
|
||||
if err != nil {
|
||||
t.Fatalf("ListDomains: %v", err)
|
||||
}
|
||||
if len(list) != 1 || list[0].Name != "example.com" || list[0].AppCount != 0 {
|
||||
t.Fatalf("unexpected list: %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddDomainDuplicate(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if _, err := st.AddDomain("example.com", "selfpost"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := st.AddDomain("example.com", "other")
|
||||
if !errors.Is(err, ErrDomainExists) {
|
||||
t.Fatalf("duplicate AddDomain error = %v, want ErrDomainExists", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomainNotFound(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if _, err := st.GetDomain(999); !errors.Is(err, ErrDomainNotFound) {
|
||||
t.Fatalf("GetDomain(missing) = %v, want ErrDomainNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDomainCascadesApplications(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
d, err := st.AddDomain("example.com", "selfpost")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Insert an application + address directly (the AddApplication API lands in
|
||||
// Phase 4); this verifies the ON DELETE CASCADE wiring now.
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
res, err := st.db.Exec(
|
||||
"INSERT INTO applications (domain_id, login, address_mode, created_at) VALUES (?, ?, 'wildcard', ?)",
|
||||
d.ID, "app1", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appID, _ := res.LastInsertId()
|
||||
if _, err := st.db.Exec(
|
||||
"INSERT INTO application_addresses (application_id, address) VALUES (?, ?)",
|
||||
appID, "noreply@example.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := st.GetDomain(d.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.AppCount != 1 {
|
||||
t.Fatalf("AppCount = %d, want 1", got.AppCount)
|
||||
}
|
||||
|
||||
if err := st.DeleteDomain(d.ID); err != nil {
|
||||
t.Fatalf("DeleteDomain: %v", err)
|
||||
}
|
||||
|
||||
assertCount(t, st, "applications", 0)
|
||||
assertCount(t, st, "application_addresses", 0)
|
||||
if _, err := st.GetDomain(d.ID); !errors.Is(err, ErrDomainNotFound) {
|
||||
t.Errorf("domain still present after delete: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDomainNotFound(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if err := st.DeleteDomain(123); !errors.Is(err, ErrDomainNotFound) {
|
||||
t.Fatalf("DeleteDomain(missing) = %v, want ErrDomainNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertCount(t *testing.T, st *Store, table string, want int) {
|
||||
t.Helper()
|
||||
var n int
|
||||
// table is a trusted literal from the test, not user input.
|
||||
if err := st.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&n); err != nil {
|
||||
t.Fatalf("count %s: %v", table, err)
|
||||
}
|
||||
if n != want {
|
||||
t.Errorf("%s count = %d, want %d", table, n, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
-- Initial SelfPost schema (spec 9). One SQLite file under /data holds the whole
|
||||
-- panel state so a single directory backup/restore is sufficient (spec 7.5.A).
|
||||
|
||||
-- Single administrator account (spec 7.6.1). 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
|
||||
);
|
||||
|
||||
-- Free-form key/value panel settings (retention overrides, misc flags).
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Sending domains managed through the panel (spec 4.1). DKIM keys themselves
|
||||
-- live on disk under /data; this row records the selector and metadata.
|
||||
CREATE TABLE domains (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
dkim_selector TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Applications bound to a domain (spec 4.1). address_mode is either the domain
|
||||
-- wildcard or an explicit address list; the SASL login is globally unique.
|
||||
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
|
||||
);
|
||||
|
||||
-- Explicit sender addresses for applications in 'list' mode. Each address must
|
||||
-- belong to the application's domain (validated in the panel, spec 7.6.2).
|
||||
CREATE TABLE application_addresses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
application_id INTEGER NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
|
||||
address TEXT NOT NULL,
|
||||
UNIQUE (application_id, address)
|
||||
);
|
||||
|
||||
-- Structured send log (spec 7.3). One row per (queue-id, recipient); the
|
||||
-- log-tailer advances status from queued to a final state.
|
||||
CREATE TABLE send_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
queue_id TEXT,
|
||||
domain TEXT,
|
||||
app_login TEXT,
|
||||
from_addr TEXT,
|
||||
to_addr TEXT,
|
||||
subject TEXT,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
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);
|
||||
|
||||
-- Differentiated rate limits per domain/application (spec 7.4). 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)
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Panel login sessions (spec 7.6.6, plan B.1). 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);
|
||||
@@ -0,0 +1,214 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Rate-limit scopes (spec 7.4). A level-2 limit is attached either to a domain
|
||||
// (counted across all its applications and IPs) or to a single application.
|
||||
const (
|
||||
RateLimitScopeDomain = "domain"
|
||||
RateLimitScopeApp = "application"
|
||||
)
|
||||
|
||||
// RateLimit is a differentiated level-2 rate limit (spec 7.4): an optional set
|
||||
// of expected client IPs plus a message ceiling over a sliding window, attached
|
||||
// to a domain or an application. It is enforced in the journal-milter; level 1
|
||||
// (Postfix anvil, spec 5) is the IP backstop that always applies even when this
|
||||
// is absent or the milter is down.
|
||||
//
|
||||
// Both the IP binding and the ceiling are optional in the schema, but a limit is
|
||||
// only enforced when it is Active(): the design deliberately allows an admin to
|
||||
// leave the IP binding empty for apps that send from changing IPs, in which case
|
||||
// only level 1 protects them (spec 7.4's caveat).
|
||||
type RateLimit struct {
|
||||
Scope string
|
||||
RefID int64
|
||||
AllowedIPs []string // canonical client IPs this limit applies to
|
||||
MaxMessages int
|
||||
WindowSeconds int
|
||||
}
|
||||
|
||||
// Active reports whether the limit is fully configured and should be enforced.
|
||||
// A missing IP binding, ceiling or window leaves the differentiated limit inert
|
||||
// (spec 7.4): the IP binding is what scopes the limit to a known sender.
|
||||
func (r RateLimit) Active() bool {
|
||||
return len(r.AllowedIPs) > 0 && r.MaxMessages > 0 && r.WindowSeconds > 0
|
||||
}
|
||||
|
||||
// AllowsIP reports whether ip is one of the limit's registered client IPs. The
|
||||
// comparison parses both sides so equivalent textual forms of the same address
|
||||
// match; a client IP outside the list means the differentiated limit does not
|
||||
// apply to it (level 1 still does).
|
||||
func (r RateLimit) AllowsIP(ip string) bool {
|
||||
c := net.ParseIP(ip)
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
for _, a := range r.AllowedIPs {
|
||||
if p := net.ParseIP(a); p != nil && p.Equal(c) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetRateLimit loads the level-2 limit configured for a domain or application by
|
||||
// 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
|
||||
FROM rate_limits WHERE scope = ? AND ref_id = ?`, scope, refID)
|
||||
rl, err := scanRateLimit(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return RateLimit{Scope: scope, RefID: refID}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return RateLimit{}, false, fmt.Errorf("get rate limit: %w", err)
|
||||
}
|
||||
rl.Scope, rl.RefID = scope, refID
|
||||
return rl, true, nil
|
||||
}
|
||||
|
||||
// SetRateLimit upserts the level-2 limit for a domain or application. The caller
|
||||
// (panel) has already validated the IPs and numbers (spec 7.6.2); values are
|
||||
// stored via bound parameters and read back live by the milter.
|
||||
func (s *Store) SetRateLimit(rl RateLimit) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO rate_limits (scope, ref_id, allowed_ips, max_messages, window_seconds)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(scope, ref_id) DO UPDATE SET
|
||||
allowed_ips = excluded.allowed_ips,
|
||||
max_messages = excluded.max_messages,
|
||||
window_seconds = excluded.window_seconds`,
|
||||
rl.Scope, rl.RefID, strings.Join(rl.AllowedIPs, ","), rl.MaxMessages, rl.WindowSeconds,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set rate limit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteRateLimit removes the level-2 limit for a domain or application, so the
|
||||
// admin can clear it and fall back to level 1 only.
|
||||
func (s *Store) DeleteRateLimit(scope string, refID int64) error {
|
||||
if _, err := s.db.Exec(`DELETE FROM rate_limits WHERE scope = ? AND ref_id = ?`, scope, refID); err != nil {
|
||||
return fmt.Errorf("delete rate limit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteRateLimitsForDomain removes the domain's own limit and the limits of all
|
||||
// its applications in one statement. It is called on domain deletion, before the
|
||||
// application rows are cascade-deleted, to avoid leaving orphan limit rows.
|
||||
func (s *Store) DeleteRateLimitsForDomain(domainID int64) error {
|
||||
_, err := s.db.Exec(
|
||||
`DELETE FROM rate_limits
|
||||
WHERE (scope = 'domain' AND ref_id = ?)
|
||||
OR (scope = 'application' AND ref_id IN (SELECT id FROM applications WHERE domain_id = ?))`,
|
||||
domainID, domainID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete domain rate limits: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RateLimit resolves the level-2 limit that applies to a message, keyed by the
|
||||
// human-readable reference the milter has on the receive path: the sending
|
||||
// domain name (scope "domain") or the SASL login (scope "application"). ok is
|
||||
// false when no limit is configured for that reference.
|
||||
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
|
||||
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
|
||||
FROM rate_limits rl JOIN applications a ON a.id = rl.ref_id
|
||||
WHERE rl.scope = 'application' AND a.login = ?`
|
||||
default:
|
||||
return RateLimit{}, false, fmt.Errorf("unknown rate-limit scope %q", scope)
|
||||
}
|
||||
rl, err := scanRateLimit(s.db.QueryRow(query, ref))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return RateLimit{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return RateLimit{}, false, fmt.Errorf("rate limit for %s %q: %w", scope, ref, err)
|
||||
}
|
||||
rl.Scope = scope
|
||||
return rl, true, nil
|
||||
}
|
||||
|
||||
// CountMessages returns how many distinct messages the reference (a domain name
|
||||
// or an application login) has queued since t, for the level-2 sliding window
|
||||
// (spec 7.4). It counts distinct queue-ids — one message with many recipients is
|
||||
// one message, matching level 1's per-message semantics — and excludes rows that
|
||||
// were themselves rejected by a limit (they were never sent). It reuses the send
|
||||
// log the journal already writes (spec 7.4: "переиспользует данные журнала").
|
||||
func (s *Store) CountMessages(scope, ref string, since time.Time) (int64, error) {
|
||||
var column string
|
||||
switch scope {
|
||||
case RateLimitScopeDomain:
|
||||
column = "domain"
|
||||
case RateLimitScopeApp:
|
||||
column = "app_login"
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown rate-limit scope %q", scope)
|
||||
}
|
||||
var n int64
|
||||
// created_at is stored as RFC3339 UTC, so a lexical comparison against the
|
||||
// same format is chronologically correct (as in DeleteSendLogBefore).
|
||||
err := s.db.QueryRow(
|
||||
`SELECT COUNT(DISTINCT queue_id) FROM send_log
|
||||
WHERE `+column+` = ? AND status != ? AND created_at >= ?`,
|
||||
ref, StatusRejected, since.UTC().Format(time.RFC3339),
|
||||
).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count messages for %s %q: %w", scope, ref, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// scanRateLimit reads the three stored columns, tolerating NULL numeric columns
|
||||
// (an IP-only draft) by leaving the corresponding field zero, which makes the
|
||||
// limit inert via Active().
|
||||
func scanRateLimit(r scanRow) (RateLimit, error) {
|
||||
var (
|
||||
ips sql.NullString
|
||||
maxMsgs sql.NullInt64
|
||||
windowSecs sql.NullInt64
|
||||
)
|
||||
if err := r.Scan(&ips, &maxMsgs, &windowSecs); err != nil {
|
||||
return RateLimit{}, err
|
||||
}
|
||||
return RateLimit{
|
||||
AllowedIPs: splitIPs(ips.String),
|
||||
MaxMessages: int(maxMsgs.Int64),
|
||||
WindowSeconds: int(windowSecs.Int64),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// splitIPs parses the comma-separated storage form back into a slice, dropping
|
||||
// empties so an empty column yields nil (an inactive limit).
|
||||
func splitIPs(s string) []string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRateLimitSetGetDelete(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
d, err := st.AddDomain("example.com", "selfpost")
|
||||
if err != nil {
|
||||
t.Fatalf("AddDomain: %v", err)
|
||||
}
|
||||
|
||||
if _, ok, err := st.GetRateLimit(RateLimitScopeDomain, d.ID); err != nil || ok {
|
||||
t.Fatalf("GetRateLimit on empty: ok=%v err=%v", ok, err)
|
||||
}
|
||||
|
||||
want := RateLimit{
|
||||
Scope: RateLimitScopeDomain,
|
||||
RefID: d.ID,
|
||||
AllowedIPs: []string{"203.0.113.1", "203.0.113.2"},
|
||||
MaxMessages: 100,
|
||||
WindowSeconds: 3600,
|
||||
}
|
||||
if err := st.SetRateLimit(want); err != nil {
|
||||
t.Fatalf("SetRateLimit: %v", err)
|
||||
}
|
||||
|
||||
got, ok, err := st.GetRateLimit(RateLimitScopeDomain, d.ID)
|
||||
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" {
|
||||
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" {
|
||||
t.Fatalf("upsert did not replace: %+v", got)
|
||||
}
|
||||
|
||||
if err := st.DeleteRateLimit(RateLimitScopeDomain, d.ID); err != nil {
|
||||
t.Fatalf("DeleteRateLimit: %v", err)
|
||||
}
|
||||
if _, ok, _ := st.GetRateLimit(RateLimitScopeDomain, d.ID); ok {
|
||||
t.Fatalf("limit still present after delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitByNameAndLogin(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
d, _ := st.AddDomain("example.com", "selfpost")
|
||||
a, err := st.AddApplication(d.ID, "app1", AddressModeWildcard, nil)
|
||||
if err != nil {
|
||||
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 {
|
||||
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 {
|
||||
t.Fatalf("set app limit: %v", err)
|
||||
}
|
||||
|
||||
// The milter resolves limits by domain name and by SASL login.
|
||||
rl, ok, err := st.RateLimit(RateLimitScopeDomain, "example.com")
|
||||
if err != nil || !ok || rl.MaxMessages != 10 {
|
||||
t.Fatalf("RateLimit domain: ok=%v err=%v rl=%+v", ok, err, rl)
|
||||
}
|
||||
rl, ok, err = st.RateLimit(RateLimitScopeApp, "app1")
|
||||
if err != nil || !ok || rl.MaxMessages != 3 {
|
||||
t.Fatalf("RateLimit app: ok=%v err=%v rl=%+v", ok, err, rl)
|
||||
}
|
||||
if _, ok, _ := st.RateLimit(RateLimitScopeDomain, "unknown.example"); ok {
|
||||
t.Fatalf("RateLimit for unknown domain should be not-ok")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountMessagesDistinctAndWindowed(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
|
||||
// Two recipients share a queue-id → one message. A second message → two.
|
||||
for _, to := range []string{"a@x.net", "b@x.net"} {
|
||||
if err := st.InsertQueued(SendLogEntry{QueueID: "Q1", Domain: "example.com", AppLogin: "app1", To: to}); err != nil {
|
||||
t.Fatalf("InsertQueued: %v", err)
|
||||
}
|
||||
}
|
||||
if err := st.InsertQueued(SendLogEntry{QueueID: "Q2", Domain: "example.com", AppLogin: "app1", To: "c@x.net"}); err != nil {
|
||||
t.Fatalf("InsertQueued: %v", err)
|
||||
}
|
||||
// A rejected row must not count toward the window.
|
||||
if err := st.InsertRejected(SendLogEntry{Domain: "example.com", AppLogin: "app1", From: "s@example.com"}); err != nil {
|
||||
t.Fatalf("InsertRejected: %v", err)
|
||||
}
|
||||
|
||||
n, err := st.CountMessages(RateLimitScopeDomain, "example.com", time.Now().Add(-time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("CountMessages: %v", err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Fatalf("distinct-message count = %d, want 2 (two queue-ids, rejected excluded)", n)
|
||||
}
|
||||
n, _ = st.CountMessages(RateLimitScopeApp, "app1", time.Now().Add(-time.Hour))
|
||||
if n != 2 {
|
||||
t.Fatalf("app count = %d, want 2", n)
|
||||
}
|
||||
|
||||
// Backdate Q1 beyond the window: only Q2 remains inside a 30-minute window.
|
||||
old := time.Now().UTC().Add(-2 * time.Hour).Format(time.RFC3339)
|
||||
if _, err := st.db.Exec(`UPDATE send_log SET created_at = ? WHERE queue_id = 'Q1'`, old); err != nil {
|
||||
t.Fatalf("backdate: %v", err)
|
||||
}
|
||||
n, _ = st.CountMessages(RateLimitScopeDomain, "example.com", time.Now().Add(-30*time.Minute))
|
||||
if n != 1 {
|
||||
t.Fatalf("windowed count = %d, want 1 (Q1 aged out)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRateLimitsForDomain(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
d, _ := st.AddDomain("example.com", "selfpost")
|
||||
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})
|
||||
|
||||
if err := st.DeleteRateLimitsForDomain(d.ID); err != nil {
|
||||
t.Fatalf("DeleteRateLimitsForDomain: %v", err)
|
||||
}
|
||||
if _, ok, _ := st.GetRateLimit(RateLimitScopeDomain, d.ID); ok {
|
||||
t.Fatalf("domain limit survived")
|
||||
}
|
||||
if _, ok, _ := st.GetRateLimit(RateLimitScopeApp, a.ID); ok {
|
||||
t.Fatalf("application limit survived")
|
||||
}
|
||||
// The unrelated domain's limit is untouched.
|
||||
if _, ok, _ := st.GetRateLimit(RateLimitScopeDomain, other.ID); !ok {
|
||||
t.Fatalf("unrelated domain limit was deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitActiveAndAllowsIP(t *testing.T) {
|
||||
inactive := []RateLimit{
|
||||
{},
|
||||
{AllowedIPs: []string{"203.0.113.1"}}, // no ceiling
|
||||
{AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 5}, // no window
|
||||
{MaxMessages: 5, WindowSeconds: 60}, // no IPs
|
||||
}
|
||||
for i, rl := range inactive {
|
||||
if rl.Active() {
|
||||
t.Fatalf("case %d: %+v should be inactive", i, rl)
|
||||
}
|
||||
}
|
||||
active := RateLimit{AllowedIPs: []string{"203.0.113.1", "2001:db8::1"}, MaxMessages: 5, WindowSeconds: 60}
|
||||
if !active.Active() {
|
||||
t.Fatalf("should be active: %+v", active)
|
||||
}
|
||||
if !active.AllowsIP("203.0.113.1") || !active.AllowsIP("2001:db8::1") {
|
||||
t.Fatalf("registered IPs should match")
|
||||
}
|
||||
// Equivalent textual form of the IPv6 address must still match.
|
||||
if !active.AllowsIP("2001:0db8:0000:0000:0000:0000:0000:0001") {
|
||||
t.Fatalf("expanded IPv6 form should match")
|
||||
}
|
||||
if active.AllowsIP("198.51.100.7") || active.AllowsIP("not-an-ip") || active.AllowsIP("") {
|
||||
t.Fatalf("unregistered/invalid IPs must not match")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Send-log status values (spec 7.3). "queued" is written by the journal-milter
|
||||
// when a message is accepted; the log-tailer advances it to one of the final
|
||||
// states as Postfix reports delivery per recipient.
|
||||
const (
|
||||
StatusQueued = "queued"
|
||||
StatusSent = "sent"
|
||||
StatusDeferred = "deferred"
|
||||
StatusBounced = "bounced"
|
||||
// StatusRejected marks a message the journal-milter refused with a 4xx under
|
||||
// a level-2 rate limit (spec 7.4). Such a row never gets a queue-id and is
|
||||
// excluded from the level-2 message count (it was never sent).
|
||||
StatusRejected = "rejected"
|
||||
)
|
||||
|
||||
// SendLogEntry is a single queued send-log row. The journal-milter creates one
|
||||
// per (queue-id, recipient) pair at end-of-message (spec 7.3.3); every field
|
||||
// except the status/timestamps comes from the accepted message.
|
||||
type SendLogEntry struct {
|
||||
QueueID string
|
||||
Domain string
|
||||
AppLogin string
|
||||
From string
|
||||
To string
|
||||
Subject string
|
||||
}
|
||||
|
||||
// InsertQueued records an accepted message in the send log with status
|
||||
// "queued". It is called from the journal-milter hot path, so it returns any
|
||||
// error for the caller to log rather than deciding policy here; the milter must
|
||||
// stay fail-open regardless (spec 7.3).
|
||||
func (s *Store) InsertQueued(e SendLogEntry) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO send_log
|
||||
(queue_id, domain, app_login, from_addr, to_addr, subject, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
e.QueueID, e.Domain, e.AppLogin, e.From, e.To, e.Subject, StatusQueued, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert send_log: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InsertRejected records a message the journal-milter refused under a level-2
|
||||
// rate limit (spec 7.4), so the rejection is visible in the send-log UI. Only
|
||||
// the fields known at MAIL FROM are set (domain, sender, app login); there is no
|
||||
// queue-id or recipient because the message was rejected before it was queued.
|
||||
func (s *Store) InsertRejected(e SendLogEntry) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO send_log
|
||||
(queue_id, domain, app_login, from_addr, to_addr, subject, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
e.QueueID, e.Domain, e.AppLogin, e.From, e.To, e.Subject, StatusRejected, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert rejected send_log: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateStatus advances the delivery status of the send-log rows matching a
|
||||
// (queue-id, recipient) pair, which the log-tailer parses out of mail.log.
|
||||
// Recipient matching is case-insensitive because Postfix may normalise address
|
||||
// case between the milter (envelope) and the delivery log. It returns the
|
||||
// number of rows updated so the caller can tell whether the line matched a
|
||||
// journal entry.
|
||||
func (s *Store) UpdateStatus(queueID, recipient, status string) (int64, error) {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
res, err := s.db.Exec(
|
||||
`UPDATE send_log SET status = ?, updated_at = ?
|
||||
WHERE queue_id = ? AND to_addr = ? COLLATE NOCASE`,
|
||||
status, now, queueID, recipient,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("update send_log status: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// SendLogRow is one row as returned to the monitoring UI (spec 7.2, 7.3.3): a
|
||||
// SendLogEntry plus the fields that only exist once a row has been written
|
||||
// (id, current status, timestamps).
|
||||
type SendLogRow struct {
|
||||
ID int64
|
||||
QueueID string
|
||||
Domain string
|
||||
AppLogin string
|
||||
From string
|
||||
To string
|
||||
Subject string
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// SendLogFilter narrows QuerySendLog/CountSendLog by domain and/or
|
||||
// application login. An empty field matches everything.
|
||||
type SendLogFilter struct {
|
||||
Domain string
|
||||
AppLogin string
|
||||
}
|
||||
|
||||
// QuerySendLog returns send-log rows matching filter, newest first, for the
|
||||
// monitoring screen's server-side pagination (spec 7.2's send-log view).
|
||||
func (s *Store) QuerySendLog(filter SendLogFilter, limit, offset int) ([]SendLogRow, error) {
|
||||
where, args := sendLogWhere(filter)
|
||||
args = append(args, limit, offset)
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, queue_id, domain, app_login, from_addr, to_addr, subject, status, created_at
|
||||
FROM send_log`+where+`
|
||||
ORDER BY id DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query send_log: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []SendLogRow
|
||||
for rows.Next() {
|
||||
var (
|
||||
row SendLogRow
|
||||
createdAt string
|
||||
)
|
||||
if err := rows.Scan(&row.ID, &row.QueueID, &row.Domain, &row.AppLogin,
|
||||
&row.From, &row.To, &row.Subject, &row.Status, &createdAt); err != nil {
|
||||
return nil, fmt.Errorf("scan send_log row: %w", err)
|
||||
}
|
||||
row.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CountSendLog returns how many send-log rows match filter, so the monitoring
|
||||
// screen can render page numbers/next-prev links.
|
||||
func (s *Store) CountSendLog(filter SendLogFilter) (int64, error) {
|
||||
where, args := sendLogWhere(filter)
|
||||
var n int64
|
||||
if err := s.db.QueryRow(`SELECT COUNT(*) FROM send_log`+where, args...).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("count send_log: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func sendLogWhere(f SendLogFilter) (string, []any) {
|
||||
var clauses []string
|
||||
var args []any
|
||||
if f.Domain != "" {
|
||||
clauses = append(clauses, "domain = ?")
|
||||
args = append(args, f.Domain)
|
||||
}
|
||||
if f.AppLogin != "" {
|
||||
clauses = append(clauses, "app_login = ?")
|
||||
args = append(args, f.AppLogin)
|
||||
}
|
||||
if len(clauses) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return " WHERE " + strings.Join(clauses, " AND "), args
|
||||
}
|
||||
|
||||
// DeleteSendLogBefore removes send-log rows created before cutoff, implementing
|
||||
// the configurable retention window (spec 7.3, SEND_LOG_RETENTION_DAYS). It
|
||||
// returns the number of rows pruned. created_at is stored as RFC3339 UTC, so a
|
||||
// lexical comparison against the same format is chronologically correct.
|
||||
func (s *Store) DeleteSendLogBefore(cutoff time.Time) (int64, error) {
|
||||
res, err := s.db.Exec(
|
||||
`DELETE FROM send_log WHERE created_at < ?`,
|
||||
cutoff.UTC().Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("prune send_log: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// readSendLog returns every send_log row ordered by id. Phase 6 has no read
|
||||
// query yet (the monitoring UI is Phase 7), so tests read the table directly.
|
||||
type sendLogRow struct {
|
||||
QueueID string
|
||||
Domain string
|
||||
AppLogin string
|
||||
From string
|
||||
To string
|
||||
Subject string
|
||||
Status string
|
||||
}
|
||||
|
||||
func readSendLog(t *testing.T, s *Store) []sendLogRow {
|
||||
t.Helper()
|
||||
rows, err := s.db.Query(
|
||||
`SELECT queue_id, domain, app_login, from_addr, to_addr, subject, status
|
||||
FROM send_log ORDER BY id`)
|
||||
if err != nil {
|
||||
t.Fatalf("query send_log: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []sendLogRow
|
||||
for rows.Next() {
|
||||
var r sendLogRow
|
||||
if err := rows.Scan(&r.QueueID, &r.Domain, &r.AppLogin, &r.From, &r.To, &r.Subject, &r.Status); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestInsertQueuedAndUpdateStatus(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
|
||||
// Two recipients on the same queue-id → two independent rows (spec 7.3.3).
|
||||
for _, to := range []string{"a@example.net", "b@example.net"} {
|
||||
if err := st.InsertQueued(SendLogEntry{
|
||||
QueueID: "ABC123",
|
||||
Domain: "example.com",
|
||||
AppLogin: "app1",
|
||||
From: "noreply@example.com",
|
||||
To: to,
|
||||
Subject: "Hello",
|
||||
}); err != nil {
|
||||
t.Fatalf("InsertQueued: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
rows := readSendLog(t, st)
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("want 2 rows, got %d: %+v", len(rows), rows)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.Status != StatusQueued {
|
||||
t.Fatalf("new row should be queued, got %q", r.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// One recipient goes to sent; the other stays queued.
|
||||
n, err := st.UpdateStatus("ABC123", "a@example.net", StatusSent)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateStatus: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("want 1 row updated, got %d", n)
|
||||
}
|
||||
|
||||
rows = readSendLog(t, st)
|
||||
if rows[0].Status != StatusSent || rows[1].Status != StatusQueued {
|
||||
t.Fatalf("unexpected statuses: %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateStatusRecipientCaseInsensitive(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if err := st.InsertQueued(SendLogEntry{QueueID: "Q1", To: "User@Example.NET"}); err != nil {
|
||||
t.Fatalf("InsertQueued: %v", err)
|
||||
}
|
||||
// mail.log may report a differently-cased recipient; matching must still hit.
|
||||
n, err := st.UpdateStatus("Q1", "user@example.net", StatusBounced)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateStatus: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("case-insensitive match failed, updated %d rows", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateStatusNoMatch(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if err := st.InsertQueued(SendLogEntry{QueueID: "Q1", To: "a@example.net"}); err != nil {
|
||||
t.Fatalf("InsertQueued: %v", err)
|
||||
}
|
||||
// A queue-id/recipient the milter never recorded must be a no-op, not an error.
|
||||
n, err := st.UpdateStatus("Q1", "unknown@example.net", StatusSent)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateStatus: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("want 0 rows updated, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteSendLogBefore(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
|
||||
// Insert one row, then backdate it beyond the retention window by rewriting
|
||||
// created_at directly (InsertQueued always stamps "now").
|
||||
if err := st.InsertQueued(SendLogEntry{QueueID: "OLD", To: "a@example.net"}); err != nil {
|
||||
t.Fatalf("InsertQueued: %v", err)
|
||||
}
|
||||
old := time.Now().UTC().AddDate(0, 0, -100).Format(time.RFC3339)
|
||||
if _, err := st.db.Exec(`UPDATE send_log SET created_at = ? WHERE queue_id = 'OLD'`, old); err != nil {
|
||||
t.Fatalf("backdate: %v", err)
|
||||
}
|
||||
if err := st.InsertQueued(SendLogEntry{QueueID: "NEW", To: "b@example.net"}); err != nil {
|
||||
t.Fatalf("InsertQueued: %v", err)
|
||||
}
|
||||
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -90)
|
||||
n, err := st.DeleteSendLogBefore(cutoff)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteSendLogBefore: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("want 1 row pruned, got %d", n)
|
||||
}
|
||||
rows := readSendLog(t, st)
|
||||
if len(rows) != 1 || rows[0].QueueID != "NEW" {
|
||||
t.Fatalf("retention kept wrong rows: %+v", rows)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionRow is a persisted login session, keyed by the SHA-256 of its token
|
||||
// (see internal/web, which owns the token itself).
|
||||
type SessionRow struct {
|
||||
Username string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// CreateSession inserts a new session row.
|
||||
func (s *Store) CreateSession(tokenHash, username string, expiresAt time.Time) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO sessions (token_hash, username, created_at, expires_at) VALUES (?, ?, ?, ?)`,
|
||||
tokenHash, username, now, expiresAt.UTC().Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LookupSession returns the session for tokenHash, if any. It does not check
|
||||
// expiry itself — callers compare ExpiresAt against time.Now() and call
|
||||
// DeleteSession on an expired row, keeping the read side lock-free.
|
||||
func (s *Store) LookupSession(tokenHash string) (SessionRow, bool, error) {
|
||||
var (
|
||||
row SessionRow
|
||||
expiresAt string
|
||||
)
|
||||
err := s.db.QueryRow(
|
||||
`SELECT username, expires_at FROM sessions WHERE token_hash = ?`,
|
||||
tokenHash,
|
||||
).Scan(&row.Username, &expiresAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return SessionRow{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return SessionRow{}, false, fmt.Errorf("lookup session: %w", err)
|
||||
}
|
||||
row.ExpiresAt, err = time.Parse(time.RFC3339, expiresAt)
|
||||
if err != nil {
|
||||
return SessionRow{}, false, fmt.Errorf("parse session expiry: %w", err)
|
||||
}
|
||||
return row, true, nil
|
||||
}
|
||||
|
||||
// RenewSession pushes a session's expiry forward, implementing the sliding
|
||||
// idle timeout.
|
||||
func (s *Store) RenewSession(tokenHash string, expiresAt time.Time) error {
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE sessions SET expires_at = ? WHERE token_hash = ?`,
|
||||
expiresAt.UTC().Format(time.RFC3339), tokenHash,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("renew session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenameSession updates the username carried by a session, keeping its
|
||||
// expiry, so a session stays usable after the administrator renames their own
|
||||
// account.
|
||||
func (s *Store) RenameSession(tokenHash, username string) error {
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE sessions SET username = ? WHERE token_hash = ?`,
|
||||
username, tokenHash,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteSession removes a session row (logout, or a lookup finding it
|
||||
// expired).
|
||||
func (s *Store) DeleteSession(tokenHash string) error {
|
||||
if _, err := s.db.Exec(`DELETE FROM sessions WHERE token_hash = ?`, tokenHash); err != nil {
|
||||
return fmt.Errorf("delete session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteOtherSessions removes every session except keepHash. It is called
|
||||
// when the administrator changes their password: a stolen cookie issued
|
||||
// under the old password must stop working, while the session performing the
|
||||
// change stays signed in.
|
||||
func (s *Store) DeleteOtherSessions(keepHash string) error {
|
||||
if _, err := s.db.Exec(`DELETE FROM sessions WHERE token_hash != ?`, keepHash); err != nil {
|
||||
return fmt.Errorf("delete other sessions: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteExpiredSessions prunes rows whose expiry has already passed, so an
|
||||
// abandoned session (cookie never presented again) does not sit in the table
|
||||
// forever. It piggybacks on session creation rather than running as its own
|
||||
// background loop.
|
||||
func (s *Store) DeleteExpiredSessions(now time.Time) (int64, error) {
|
||||
res, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, now.UTC().Format(time.RFC3339))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("prune sessions: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Package store owns the SelfPost SQLite database: the single file under /data
|
||||
// that persists the administrator account, sending domains and applications,
|
||||
// the send log and rate-limit settings (spec 9). It exposes typed queries so
|
||||
// the rest of the panel never builds SQL by hand.
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
|
||||
_ "modernc.org/sqlite" // pure-Go SQLite driver (no cgo), keeps the static build
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// Store wraps the database connection pool.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// Open opens (creating if needed) the SQLite database at path, enables WAL and
|
||||
// foreign keys, and applies any pending migrations. The caller owns Close.
|
||||
func Open(path string) (*Store, error) {
|
||||
// _pragma parameters are applied on every pooled connection by the driver,
|
||||
// so foreign-key enforcement and WAL survive connection churn.
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)", path)
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
// modernc's driver serializes writes anyway; a small pool avoids
|
||||
// "database is locked" surprises under WAL.
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
s := &Store{db: db}
|
||||
if err := s.migrate(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Close closes the underlying database.
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// 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 {
|
||||
entries, err := fs.ReadDir(migrationsFS, "migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migrations: %w", err)
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
var version int
|
||||
if err := s.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
|
||||
return fmt.Errorf("read schema version: %w", err)
|
||||
}
|
||||
|
||||
for i, name := range names {
|
||||
target := i + 1
|
||||
if target <= version {
|
||||
continue
|
||||
}
|
||||
sqlBytes, err := migrationsFS.ReadFile("migrations/" + name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", name, err)
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin migration %s: %w", name, err)
|
||||
}
|
||||
if _, err := tx.Exec(string(sqlBytes)); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("apply migration %s: %w", name, err)
|
||||
}
|
||||
// PRAGMA does not accept a bound parameter, and target is a trusted
|
||||
// loop index, so formatting it in is safe.
|
||||
if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", target)); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("bump schema version for %s: %w", name, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user