test / test (push) Has been cancelled
Review pass over the 1.4.0-1.9.1 work. Port 25 no longer inherits main.cf's submission milter chain: smtpd_milters is always overridden on smtp/inet, to the antispam milter or to nothing. Without that, OpenDKIM (strict, tempfail) gated relayed mail during a signing outage, and the journal-milter filed inbound mail into the outbound send log under the sender's domain -- so Deliveries showed other people's mail and a forged From: consumed that domain's level-2 budget. architecture.md already described the intended behaviour; the code now matches. DMARC reports are checked before they are stored: the policy domain must be a configured sending domain, and must match the +tag of a per-domain hosted address. The rua address form is predictable, so anyone able to reach it could file reports under any domain and evict genuine ones through the retention cap. Zip attachments are now decompressed as well as gzip. The auto level-2 ceiling is scaled to the level-1 window before the multiplier is applied; any window other than 3600s shifted the allowance by window/3600. Migration 0010 indexes send_log(app_login, created_at) -- both the per-MAIL FROM rate-limit count and the per-application statistics were scanning the created_at range. e2e now regenerates the Postfix config in-container for all four port-25 flag combinations and requires postfix check to pass; the 1.9.1 crash-loop shipped in 1.4.0 and survived three releases because only the flags-off path was exercised. Migration 0009 stays as shipped -- its conversion branch only fires for a database carrying application-scope allowed_ips from before 1.9.0. The schema reference notes that the old list was permissive and the new column is restrictive; the guide documents the allow-list as a sending control on a fail-open path, not an authentication boundary. Also: gofmt across cmd/ and internal/, unused duplicate validators dropped from internal/web/validate, export no longer swallows store errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
409 lines
12 KiB
Go
409 lines
12 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
"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, architecture.md § Mail path).
|
|
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 (product.md § Multi-domain model). 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 (product.md §
|
|
// Multi-domain model). The password is never stored here — only in sasldb2,
|
|
// hashed — so it can be shown exactly once at creation/regeneration
|
|
// (security.md). Addresses is populated only in 'list' mode.
|
|
type Application struct {
|
|
ID int64
|
|
DomainID int64
|
|
Login string
|
|
AddressMode string
|
|
CreatedAt time.Time
|
|
Addresses []string
|
|
AuthIPRestrict bool // when true, only AuthAllowedIPs may submit as this login
|
|
AuthAllowedIPs []string // client IPs permitted when AuthIPRestrict is set
|
|
}
|
|
|
|
// AllowsAuthFromIP reports whether a client at ip may submit mail authenticated
|
|
// as this application. When AuthIPRestrict is false, every IP is allowed.
|
|
func (a Application) AllowsAuthFromIP(ip string) bool {
|
|
if !a.AuthIPRestrict {
|
|
return true
|
|
}
|
|
c := net.ParseIP(ip)
|
|
if c == nil {
|
|
return false
|
|
}
|
|
for _, allowed := range a.AuthAllowedIPs {
|
|
if p := net.ParseIP(allowed); p != nil && p.Equal(c) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Binding is one sender-address → login pair, as consumed by the
|
|
// smtpd_sender_login_maps generator (architecture.md § Mail path). 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
|
|
// (security.md) 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 (product.md). 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, auth_ip_restrict, auth_allowed_ips 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
|
|
}
|
|
|
|
// GetApplicationByLogin returns one application by its globally unique SASL login.
|
|
func (s *Store) GetApplicationByLogin(login string) (Application, error) {
|
|
row := s.db.QueryRow(
|
|
"SELECT id, domain_id, login, address_mode, created_at, auth_ip_restrict, auth_allowed_ips FROM applications WHERE login = ?", login)
|
|
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
|
|
}
|
|
|
|
// ApplicationByLogin is the milter-facing alias for GetApplicationByLogin.
|
|
func (s *Store) ApplicationByLogin(login string) (Application, error) {
|
|
return s.GetApplicationByLogin(login)
|
|
}
|
|
|
|
// ListApplicationsByDomain returns a domain's applications ordered by login,
|
|
// each with its address list populated (product.md).
|
|
func (s *Store) ListApplicationsByDomain(domainID int64) ([]Application, error) {
|
|
rows, err := s.db.Query(
|
|
"SELECT id, domain_id, login, address_mode, created_at, auth_ip_restrict, auth_allowed_ips 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 (product.md).
|
|
// Logins are globally unique (architecture.md § Mail path), 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 (product.md).
|
|
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 (architecture.md §
|
|
// Mail path). 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()
|
|
}
|
|
|
|
// UpdateApplicationAuthIPs sets whether client IP restriction is active for an
|
|
// application and, when active, the permitted client addresses. The caller has
|
|
// already validated the IPs (security.md). Returns ErrApplicationNotFound when
|
|
// the id does not exist.
|
|
func (s *Store) UpdateApplicationAuthIPs(id int64, restrict bool, ips []string) error {
|
|
if !restrict {
|
|
ips = nil
|
|
}
|
|
res, err := s.db.Exec(
|
|
"UPDATE applications SET auth_ip_restrict = ?, auth_allowed_ips = ? WHERE id = ?",
|
|
boolToInt(restrict), strings.Join(ips, ","), id,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("update application auth IPs: %w", err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf("update application auth IPs rows: %w", err)
|
|
}
|
|
if n == 0 {
|
|
return ErrApplicationNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteApplication removes an application and its addresses (via cascade),
|
|
// returning the deleted application so the caller can drop its sasldb2 entry
|
|
// (product.md). 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
|
|
restrict int
|
|
ips sql.NullString
|
|
)
|
|
if err := r.Scan(&a.ID, &a.DomainID, &a.Login, &a.AddressMode, &createdAt, &restrict, &ips); err != nil {
|
|
return Application{}, err
|
|
}
|
|
a.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
|
a.AuthIPRestrict = restrict != 0
|
|
a.AuthAllowedIPs = splitIPs(ips.String)
|
|
return a, nil
|
|
}
|
|
|
|
func boolToInt(b bool) int {
|
|
if b {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|