release: 1.3.0: 1.3.0-1.3.1: security pass, queue persist, full backup layout

[1.3.0]
Security and quality after 1.2.5: domain-admin send-log authorization, fail-closed sign-in and application delete, level-2 rate-limit race fix, restore Resync, expanded tests, operator docs, release CI, and OFL for IBM Plex. Upgrading from 1.2.x is a tag bump; no migration.
Added:
  - licence: the SIL Open Font License 1.1 text now travels with the IBM Plex
  - docs: agreed roadmap item **queue-retries** — show this Postfix's retry
Security:
  - The independent security review of the send-log authorization and
Fixed:
  - test (e2e): send-log status scrapers follow the badge markup in
  - security (panel): the Deliveries list is scoped to a domain administrator's
  - mail (level-2 rate limit): the ceiling is no longer overshot by messages that
  - panel (sign-in): a session that cannot be written to the database no longer
  - panel (applications): deleting an application removes its SASL credentials
  … (2 more in CHANGELOG)
Changed:
  - docs: operator and as-built docs aligned with the code after a full
  - docs: [guide.md](docs/guide.md) reorganised into **Installation**, **Instance
  - docs: the 2026-08-13 full-tree review plan is complete — every phase (P0–P7)
  - licence: [NOTICE](NOTICE) tells modifiers to update `SourceURL` in
  - ci: the release image is published only for a **published** GitHub Release
  … (9 more in CHANGELOG)
[1.3.1]
Retry policy in the panel, persistent Postfix queue, and self-contained full backups. Mail queue and delivery history show this Postfix's first retry, backoff cap, and queue lifetime. Deferred mail survives container recreate. Full backups now archive `data/`, compose, `.env`, and `certs/`; the archive layout breaks 1.3.0 flat backups (unpack those with `tar xzf backup.tar.gz -C ./data` as before). Upgrading from 1.3.0 is a tag bump; no schema migration.
Added:
  - Mail queue and a delivery's history show this Postfix's retry policy
  - docs: **Plan checklists** in [development.md](docs/development.md) — format,
  - docs: **Implementation checklists** with model routing in
  - docs: roadmap candidates **send-log-retention** (panel Settings for delivery
  - Postfix queue under `/data/postfix/queue` — deferred and active mail survive
  … (1 more in CHANGELOG)
Changed:
  - **Breaking:** full-backup archive layout — paths are prefixed with `data/`;
  - docs: operator guide, architecture, security, and backup UI updated for
  - docs: HTML mockups for a full panel UI refresh
Changed:
  - docs: panel UI mockups after review — Status restored to live readings
  - docs: panel UI design system and mockups rebuilt as separate pages
  - docs: panel UI mockups — add-domain sits in the list card (Domains and
Fixed:
  - ci (GHCR): per-arch package tags (`X.Y.Z-amd64`, `X.Y.Z-arm64`) are dropped

Squashed CHANGELOG sections: [1.3.0], [1.3.1].

Full detail: CHANGELOG.md (1.3.0, 1.3.1).
This commit is contained in:
mix
2026-09-22 15:24:52 +03:00
parent 60e08e802e
commit 0730b2090e
124 changed files with 8464 additions and 1121 deletions
+11 -1
View File
@@ -166,14 +166,24 @@ func (s *Service) RegeneratePassword(id int64) (string, error) {
// Delete removes an application: its SASL account, its registry row (and address
// rows via cascade) and its sender-map bindings, then reloads Postfix (spec
// 7.2.8). The domain and other applications are untouched.
//
// The order matches domain deletion: the SASL account goes first, while the
// login is still in the registry. Dropping the row first would, on a
// saslpasswd2 failure, leave an account that can still authenticate to Postfix
// but that the panel no longer knows about — an orphan no operator can see or
// remove. Failing before the row is deleted is recoverable: the application is
// still listed and the delete can be retried.
func (s *Service) Delete(id int64) error {
a, err := s.store.DeleteApplication(id)
a, err := s.store.GetApplication(id)
if err != nil {
return err
}
if err := s.sasl.Delete(a.Login); err != nil {
return err
}
if _, err := s.store.DeleteApplication(id); err != nil {
return err
}
// Drop the application's level-2 limit, if any (guide § Rate limiting);
// rate_limits has no cascade of its own.
if err := s.store.DeleteRateLimit(store.RateLimitScopeApp, id); err != nil {
+31
View File
@@ -181,6 +181,37 @@ func TestServiceDelete(t *testing.T) {
}
}
// If sasldb2 cannot be updated the application must stay in the registry: an
// account that still authenticates but has no panel row is invisible to the
// operator and cannot be deleted again.
func TestServiceDeleteKeepsRowWhenSASLFails(t *testing.T) {
svc, st, rec, _ := newServiceHarness(t)
d := addDomain(t, st, "example.com")
a, _, err := svc.Create(d.ID, "app1", store.AddressModeWildcard, nil)
if err != nil {
t.Fatal(err)
}
rec.failNext = true // saslpasswd2 -d fails
if err := svc.Delete(a.ID); err == nil {
t.Fatal("Delete reported success although the SASL account was not removed")
}
apps, _ := st.ListApplicationsByDomain(d.ID)
if len(apps) != 1 {
t.Fatalf("registry row dropped while the SASL account can still authenticate: %+v", apps)
}
if _, ok := rec.set["app1"]; !ok {
t.Fatal("SASL account gone despite the failure — the harness no longer proves the ordering")
}
// The delete is retryable now that the row is still there.
if err := svc.Delete(a.ID); err != nil {
t.Fatalf("retried Delete: %v", err)
}
if _, ok := rec.set["app1"]; ok {
t.Error("SASL account not deleted on retry")
}
}
func TestServiceUpdateMode(t *testing.T) {
svc, st, _, maps := newServiceHarness(t)
d := addDomain(t, st, "example.com")
+136 -59
View File
@@ -1,18 +1,26 @@
// Package backup implements SelfPost's full-server backup and the restore
// version guard (architecture.md § Persistence). A full backup is a
// gzip-compressed tar of the consolidated persistent state under /data — the
// SQLite database (as a consistent snapshot), the per-domain DKIM keys and the
// SASL database — plus a manifest recording the SelfPost version that produced
// it. TLS certificates (the reverse proxy's responsibility) and the Postfix
// queue are deliberately excluded (architecture.md § Persistence).
// SQLite database (as a consistent snapshot), the per-domain DKIM keys, the
// SASL database, and the Postfix queue — plus docker-compose.yml, .env, and
// certs/ from the operator's deploy directory, and a manifest recording the
// SelfPost version that produced it. Postfix delivery logs under log/ are
// excluded (diagnostics, not state).
//
// Restore is intentionally not a separate code path: a backup is extracted
// into the /data bind mount before first start, and the panel regenerates
// Postfix and OpenDKIM from the restored SQLite state exactly as on any normal
// start. The only restore-specific step is CheckRestore, which refuses to boot
// if the manifest's version does not match the running binary, so
// Restore is not a separate code path in the panel: a backup is extracted into
// the operator's project directory (data/, docker-compose.yml, .env, certs/)
// before first start. The archive carries everything needed to bring the
// instance back — DKIM keys, sasldb2, sender map, queue, and deploy files —
// so the operator only adjusts hostname or proxy settings on a new host. The
// restore-specific steps the panel runs are CheckRestore, which refuses to
// boot if the manifest's version does not match the running binary so
// schema/format skew between versions cannot silently corrupt state
// (architecture.md § Persistence).
// (architecture.md § Persistence), and a one-time Resync of OpenDKIM's tables
// and the Postfix sender map from SQLite on that first boot, so any drift
// between the archive and the database is healed before mail flows. If
// on-disk state drifts again later — for example after a manual edit under
// /data — the Status page's "Reload configuration" button runs the same
// Resync on demand.
package backup
import (
@@ -34,11 +42,21 @@ import (
// FormatFull identifies a full-server backup manifest.
const FormatFull = "selfpost-full-backup"
// ManifestName is the manifest's filename, both inside the archive and, after a
// restore extraction, at the root of the data directory where CheckRestore
// looks for it.
// ManifestName is the manifest's filename inside the data directory. After
// restore extraction it lives at data/manifest.json under the project root;
// CheckRestore reads it from the /data bind mount.
const ManifestName = "manifest.json"
// DataArchivePrefix is the path prefix for every /data entry in the archive.
const DataArchivePrefix = "data/"
// ComposeFileName and EnvFileName are required deploy files at the archive root.
const (
ComposeFileName = "docker-compose.yml"
EnvFileName = ".env"
CertsDirName = "certs"
)
// Manifest is the small JSON document embedded in every backup archive. Its
// Version is the single fact that makes restore safe: the panel refuses to
// boot a data directory whose manifest version does not match its own binary
@@ -52,21 +70,23 @@ type Manifest struct {
// Params configures a backup. DataDir is the consolidated state root (/data);
// DBPath is the live SQLite file within it, snapshotted consistently rather than
// copied byte-for-byte while it may be mid-write; Version is stamped into the
// manifest.
// manifest; DeployRoot is the host project directory mounted read-only (holds
// docker-compose.yml, .env, and optionally certs/). OnWarn is called for
// non-fatal issues such as a missing certs/ directory.
type Params struct {
DataDir string
DBPath string
Version string
DataDir string
DBPath string
Version string
DeployRoot string
OnWarn func(string)
}
// excludedFromArchive lists the data-directory entries a backup never carries.
// The live database files are replaced by a consistent VACUUM INTO snapshot
// written under the canonical name; the setup token is transient bootstrap
// state; a stale manifest from a previous restore must not be re-captured (a
// fresh one is written instead); a "tls" directory holds the reverse proxy's
// certificates, which are explicitly out of scope for a SelfPost backup
// (architecture.md § Persistence) — excluding it keeps that guarantee even when
// an operator points TLS_CERT_FILE inside /data; and "log" is Postfix's raw
// fresh one is written instead); a "tls" directory under /data is skipped when
// an operator pointed TLS_CERT_FILE inside /data; and "log" is Postfix's raw
// delivery log plus its fourteen rotated files, which is diagnostic output, not
// state to restore, and by far the largest thing under /data.
var excludedFromArchive = map[string]bool{
@@ -80,16 +100,22 @@ var excludedFromArchive = map[string]bool{
ManifestName: true,
}
// Create writes a gzip-compressed tar backup to w. Archive entries are named
// relative to DataDir, so extracting the archive into the /data bind mount
// reconstructs the state in place (architecture.md § Persistence). The SQLite
// database is added as a consistent snapshot under "selfpost.db"; everything
// else under DataDir is copied as-is except the entries in
// excludedFromArchive.
// Create writes a gzip-compressed tar backup to w. Archive layout:
//
// data/manifest.json, data/selfpost.db, data/<rest of /data>
// docker-compose.yml, .env, certs/...
//
// Extract the archive into an empty project directory, then docker compose up.
func Create(w io.Writer, p Params) error {
if p.DataDir == "" || p.DBPath == "" {
return fmt.Errorf("backup: DataDir and DBPath are required")
}
if p.DeployRoot == "" {
return fmt.Errorf("backup: DeployRoot is required (mount the project directory at SELFPOST_DEPLOY_ROOT)")
}
if err := validateDeployRoot(p.DeployRoot); err != nil {
return err
}
snapshot, cleanup, err := snapshotDB(p.DBPath)
if err != nil {
@@ -109,17 +135,21 @@ func Create(w io.Writer, p Params) error {
if err != nil {
return fmt.Errorf("backup: encode manifest: %w", err)
}
if err := writeTarBytes(tw, ManifestName, 0o600, manifestJSON); err != nil {
if err := writeTarBytes(tw, DataArchivePrefix+ManifestName, 0o600, manifestJSON); err != nil {
return err
}
// The consistent SQLite snapshot, under the canonical filename the panel
// opens on start (the live file and its WAL/SHM are excluded from the walk).
if err := writeTarFile(tw, "selfpost.db", 0o640, snapshot); err != nil {
if err := writeTarFile(tw, DataArchivePrefix+"selfpost.db", 0o640, snapshot); err != nil {
return err
}
if err := addTree(tw, p.DataDir); err != nil {
if err := addTree(tw, p.DataDir, DataArchivePrefix, excludedFromArchive); err != nil {
return err
}
if err := addDeployFiles(tw, p.DeployRoot, p.OnWarn); err != nil {
return err
}
@@ -132,33 +162,79 @@ func Create(w io.Writer, p Params) error {
return nil
}
// addTree walks dataDir and adds every regular file (and directory, to preserve
// empty ones and modes) to tw under its path relative to dataDir, skipping the
// excluded entries. Non-regular, non-directory entries (symlinks, sockets) are
// skipped: /data holds none in normal operation, and copying them into a backup
// would be meaningless or unsafe.
func addTree(tw *tar.Writer, dataDir string) error {
return filepath.WalkDir(dataDir, func(path string, d fs.DirEntry, err error) error {
// ValidateDeployRoot checks that the operator project directory is mounted and
// contains the files a full backup requires. Call before streaming a response.
func ValidateDeployRoot(deployRoot string) error {
if deployRoot == "" {
return fmt.Errorf("backup: DeployRoot is required (mount the project directory at SELFPOST_DEPLOY_ROOT)")
}
return validateDeployRoot(deployRoot)
}
func validateDeployRoot(deployRoot string) error {
for _, name := range []string{ComposeFileName, EnvFileName} {
path := filepath.Join(deployRoot, name)
if _, err := os.Stat(path); err != nil {
return fmt.Errorf("backup: deploy root %q is missing %s: %w", deployRoot, name, err)
}
}
return nil
}
func addDeployFiles(tw *tar.Writer, deployRoot string, onWarn func(string)) error {
for _, name := range []string{ComposeFileName, EnvFileName} {
src := filepath.Join(deployRoot, name)
info, err := os.Stat(src)
if err != nil {
return fmt.Errorf("backup: stat deploy file %s: %w", name, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("backup: deploy file %s is not a regular file", name)
}
if err := writeTarFile(tw, name, info.Mode().Perm(), src); err != nil {
return err
}
}
certsDir := filepath.Join(deployRoot, CertsDirName)
if _, err := os.Stat(certsDir); err != nil {
if os.IsNotExist(err) {
if onWarn != nil {
onWarn("certs/ not found in deploy root; backup will not include TLS material")
}
return nil
}
return fmt.Errorf("backup: stat %s: %w", CertsDirName, err)
}
return addTree(tw, certsDir, CertsDirName+"/", nil)
}
// addTree walks root and adds every regular file (and directory, to preserve
// empty ones and modes) to tw under archivePrefix + path relative to root.
// When exclude is non-nil, top-level names relative to root are skipped.
// Non-regular, non-directory entries (symlinks, sockets) are skipped.
func addTree(tw *tar.Writer, root, archivePrefix string, exclude map[string]bool) error {
return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(dataDir, path)
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
if rel == "." {
return nil // the data root itself is implicit
}
// filepath.Rel yields OS separators; tar names use forward slashes.
name := filepath.ToSlash(rel)
// Exclude by top-level name (the live DB, setup token and stale manifest
// all live at the data root).
if excludedFromArchive[name] {
if d.IsDir() {
return fs.SkipDir
}
return nil
}
name := archivePrefix + filepath.ToSlash(rel)
if exclude != nil {
top := strings.Split(filepath.ToSlash(rel), "/")[0]
if exclude[top] {
if d.IsDir() {
return fs.SkipDir
}
return nil
}
}
info, err := d.Info()
if err != nil {
@@ -176,7 +252,7 @@ func addTree(tw *tar.Writer, dataDir string) error {
case info.Mode().IsRegular():
return writeTarFile(tw, name, info.Mode().Perm(), path)
default:
return nil // skip symlinks/sockets/devices
return nil
}
})
}
@@ -264,33 +340,34 @@ func snapshotDB(dbPath string) (path string, cleanup func(), err error) {
// directory), its version must match binaryVersion or the panel refuses to
// start, telling the operator which image tag to use. On a match the manifest
// is consumed (deleted) so it guards only the first boot after a restore and
// never blocks a later in-place image upgrade. Absence of the manifest is the
// normal case and returns nil.
func CheckRestore(manifestPath, binaryVersion string) error {
// never blocks a later in-place image upgrade, and restored is true so the
// caller can heal drifted daemon maps once. Absence of the manifest is the
// normal case and returns restored == false with a nil error.
func CheckRestore(manifestPath, binaryVersion string) (restored bool, err error) {
data, err := os.ReadFile(manifestPath)
if os.IsNotExist(err) {
return nil // ordinary start, not a restore
return false, nil // ordinary start, not a restore
}
if err != nil {
return fmt.Errorf("backup: read restore manifest: %w", err)
return false, fmt.Errorf("backup: read restore manifest: %w", err)
}
var m Manifest
if err := json.Unmarshal(data, &m); err != nil {
return fmt.Errorf("backup: restore manifest %s is not valid JSON: %w", manifestPath, err)
return false, fmt.Errorf("backup: restore manifest %s is not valid JSON: %w", manifestPath, err)
}
if m.Format != FormatFull {
return fmt.Errorf("backup: %s is not a SelfPost full backup manifest (format %q)", manifestPath, m.Format)
return false, fmt.Errorf("backup: %s is not a SelfPost full backup manifest (format %q)", manifestPath, m.Format)
}
if m.Version != binaryVersion {
return fmt.Errorf(
return false, fmt.Errorf(
"backup: this backup was created by SelfPost %s but this image is %s — restore into the matching image (selfpost:%s)",
m.Version, binaryVersion, m.Version)
}
// Version matches: consume the manifest so subsequent normal starts (and
// in-place upgrades) are not gated by it.
if err := os.Remove(manifestPath); err != nil {
return fmt.Errorf("backup: consume restore manifest: %w", err)
return false, fmt.Errorf("backup: consume restore manifest: %w", err)
}
return nil
return true, nil
}
+60 -19
View File
@@ -15,10 +15,15 @@ import (
)
// seedDataDir builds a realistic /data tree: a migrated SQLite database plus the
// DKIM key, SASL and transient files a backup must include or exclude.
func seedDataDir(t *testing.T) (dataDir, dbPath string) {
// DKIM key, SASL and transient files a backup must include or exclude. It also
// seeds a deploy root beside data/ with compose, .env, and certs/.
func seedDataDir(t *testing.T) (dataDir, dbPath, deployRoot string) {
t.Helper()
dataDir = t.TempDir()
deployRoot = t.TempDir()
dataDir = filepath.Join(deployRoot, "data")
if err := os.MkdirAll(dataDir, 0o750); err != nil {
t.Fatalf("mkdir data: %v", err)
}
dbPath = filepath.Join(dataDir, "selfpost.db")
st, err := store.Open(dbPath)
@@ -35,6 +40,7 @@ func seedDataDir(t *testing.T) (dataDir, dbPath string) {
writeFile(t, filepath.Join(dataDir, "opendkim", "keys", "example.com", "selfpost.private"), "PRIVATE KEY")
writeFile(t, filepath.Join(dataDir, "sasl", "sasldb2"), "SASLDB")
writeFile(t, filepath.Join(dataDir, "postfix", "sender_login_maps"), "@example.com login")
writeFile(t, filepath.Join(dataDir, "postfix", "queue", "deferred", "sample"), "queue-file")
// Transient files that must NOT be archived.
writeFile(t, filepath.Join(dataDir, "setup-token"), "secret-token")
writeFile(t, filepath.Join(dataDir, "selfpost.db-wal"), "wal")
@@ -43,7 +49,11 @@ func seedDataDir(t *testing.T) (dataDir, dbPath string) {
// state, and the bulkiest thing under /data.
writeFile(t, filepath.Join(dataDir, "log", "mail.log"), "Aug 8 07:26:41 mail postfix/smtp[1]: ABC: to=<a@example.net>, status=sent (ok)")
writeFile(t, filepath.Join(dataDir, "log", "mail.log.1"), "older")
return dataDir, dbPath
writeFile(t, filepath.Join(deployRoot, ComposeFileName), "services:\n selfpost:\n image: test\n")
writeFile(t, filepath.Join(deployRoot, EnvFileName), "SELFPOST_HOSTNAME=mail.example.com\n")
writeFile(t, filepath.Join(deployRoot, CertsDirName, "fullchain.pem"), "CERT")
writeFile(t, filepath.Join(deployRoot, CertsDirName, "privkey.pem"), "KEY")
return dataDir, dbPath, deployRoot
}
func writeFile(t *testing.T, path, content string) {
@@ -87,21 +97,26 @@ func readArchive(t *testing.T, data []byte) map[string]string {
}
func TestCreateIncludesStateExcludesTransient(t *testing.T) {
dataDir, dbPath := seedDataDir(t)
dataDir, dbPath, deployRoot := seedDataDir(t)
var buf bytes.Buffer
if err := Create(&buf, Params{DataDir: dataDir, DBPath: dbPath, Version: "1.2.3"}); err != nil {
if err := Create(&buf, Params{DataDir: dataDir, DBPath: dbPath, Version: "1.2.3", DeployRoot: deployRoot}); err != nil {
t.Fatalf("Create: %v", err)
}
files := readArchive(t, buf.Bytes())
// Present.
for _, name := range []string{
ManifestName,
"selfpost.db",
"opendkim/keys/example.com/selfpost.private",
"sasl/sasldb2",
"postfix/sender_login_maps",
DataArchivePrefix + ManifestName,
DataArchivePrefix + "selfpost.db",
DataArchivePrefix + "opendkim/keys/example.com/selfpost.private",
DataArchivePrefix + "sasl/sasldb2",
DataArchivePrefix + "postfix/sender_login_maps",
DataArchivePrefix + "postfix/queue/deferred/sample",
ComposeFileName,
EnvFileName,
CertsDirName + "/fullchain.pem",
CertsDirName + "/privkey.pem",
} {
if _, ok := files[name]; !ok {
t.Errorf("archive missing %s", name)
@@ -109,8 +124,11 @@ func TestCreateIncludesStateExcludesTransient(t *testing.T) {
}
// Excluded.
for _, name := range []string{
"setup-token", "selfpost.db-wal", "selfpost.db-shm",
"log/mail.log", "log/mail.log.1",
DataArchivePrefix + "setup-token",
DataArchivePrefix + "selfpost.db-wal",
DataArchivePrefix + "selfpost.db-shm",
DataArchivePrefix + "log/mail.log",
DataArchivePrefix + "log/mail.log.1",
} {
if _, ok := files[name]; ok {
t.Errorf("archive should not contain %s", name)
@@ -119,7 +137,7 @@ func TestCreateIncludesStateExcludesTransient(t *testing.T) {
// Manifest is well-formed and carries the version.
var m Manifest
if err := json.Unmarshal([]byte(files[ManifestName]), &m); err != nil {
if err := json.Unmarshal([]byte(files[DataArchivePrefix+ManifestName]), &m); err != nil {
t.Fatalf("manifest json: %v", err)
}
if m.Format != FormatFull || m.Version != "1.2.3" {
@@ -128,7 +146,7 @@ func TestCreateIncludesStateExcludesTransient(t *testing.T) {
// The archived selfpost.db is a real, openable SQLite snapshot with our data.
snapPath := filepath.Join(t.TempDir(), "restored.db")
if err := os.WriteFile(snapPath, []byte(files["selfpost.db"]), 0o640); err != nil {
if err := os.WriteFile(snapPath, []byte(files[DataArchivePrefix+"selfpost.db"]), 0o640); err != nil {
t.Fatalf("write snapshot: %v", err)
}
st, err := store.Open(snapPath)
@@ -145,6 +163,14 @@ func TestCreateIncludesStateExcludesTransient(t *testing.T) {
}
}
func TestCreateRequiresDeployRoot(t *testing.T) {
dataDir, dbPath, _ := seedDataDir(t)
var buf bytes.Buffer
if err := Create(&buf, Params{DataDir: dataDir, DBPath: dbPath, Version: "1.0.0"}); err == nil {
t.Fatal("Create without DeployRoot succeeded")
}
}
func writeManifest(t *testing.T, dir, format, version string) string {
t.Helper()
path := filepath.Join(dir, ManifestName)
@@ -156,17 +182,25 @@ func writeManifest(t *testing.T, dir, format, version string) string {
}
func TestCheckRestoreNoManifestIsNormalStart(t *testing.T) {
if err := CheckRestore(filepath.Join(t.TempDir(), "manifest.json"), "1.0.0"); err != nil {
restored, err := CheckRestore(filepath.Join(t.TempDir(), "manifest.json"), "1.0.0")
if err != nil {
t.Errorf("CheckRestore with no manifest = %v, want nil", err)
}
if restored {
t.Error("CheckRestore with no manifest reported a restore")
}
}
func TestCheckRestoreMatchConsumesManifest(t *testing.T) {
dir := t.TempDir()
path := writeManifest(t, dir, FormatFull, "1.0.0")
if err := CheckRestore(path, "1.0.0"); err != nil {
restored, err := CheckRestore(path, "1.0.0")
if err != nil {
t.Fatalf("CheckRestore matching = %v, want nil", err)
}
if !restored {
t.Fatal("CheckRestore matching did not report a restore")
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("manifest should be consumed after a matching restore, stat err = %v", err)
}
@@ -175,10 +209,13 @@ func TestCheckRestoreMatchConsumesManifest(t *testing.T) {
func TestCheckRestoreVersionMismatchRefusesAndKeeps(t *testing.T) {
dir := t.TempDir()
path := writeManifest(t, dir, FormatFull, "1.0.0")
err := CheckRestore(path, "2.0.0")
restored, err := CheckRestore(path, "2.0.0")
if err == nil {
t.Fatal("CheckRestore mismatch = nil, want error")
}
if restored {
t.Error("CheckRestore mismatch reported a restore")
}
if !strings.Contains(err.Error(), "1.0.0") || !strings.Contains(err.Error(), "2.0.0") {
t.Errorf("error should name both versions: %v", err)
}
@@ -190,7 +227,11 @@ func TestCheckRestoreVersionMismatchRefusesAndKeeps(t *testing.T) {
func TestCheckRestoreWrongFormatRejected(t *testing.T) {
dir := t.TempDir()
path := writeManifest(t, dir, "something-else", "1.0.0")
if err := CheckRestore(path, "1.0.0"); err == nil {
restored, err := CheckRestore(path, "1.0.0")
if err == nil {
t.Error("CheckRestore accepted a non-backup manifest")
}
if restored {
t.Error("CheckRestore wrong format reported a restore")
}
}
+6 -6
View File
@@ -70,12 +70,12 @@ type Domain struct {
// compares DNS against the key this server actually signs with. Hostname and
// ServerIPs identify this server and come from a preceding Server check.
type Query struct {
Name string
Selector string
ExpectedDKIM string
Hostname string
ServerIPs []string
DMARCReportEmail string // resolved rua= destination; empty = policy-only template
Name string
Selector string
ExpectedDKIM string
Hostname string
ServerIPs []string
DMARCReportEmail string // resolved rua= destination; empty = policy-only template
}
// resolver is the slice of *net.Resolver this package uses, as an interface so
+8
View File
@@ -167,6 +167,14 @@ func (o *OpenDKIM) Reload() error {
return o.reload()
}
// SetReloadHook replaces how configuration is applied after a rebuild. Tests
// that cannot reach supervisord use this to verify file regeneration alone.
func (o *OpenDKIM) SetReloadHook(fn func() error) {
if fn != nil {
o.reload = fn
}
}
// renderTables builds the KeyTable and SigningTable byte contents for a domain
// set, sorted by name so the output is deterministic. Every domain is
// re-checked for shell/config-injection safety before being written
+1 -1
View File
@@ -23,7 +23,7 @@ type DomainExport struct {
Version string `json:"version"`
Domain string `json:"domain"`
DKIMSelector string `json:"dkim_selector"`
DKIMPrivateKey string `json:"dkim_private_key"` // PKCS#1 PEM
DKIMPrivateKey string `json:"dkim_private_key"` // PKCS#1 PEM
DMARCRua *string `json:"dmarc_rua,omitempty"` // nil = inherit profile; set = override ("" = none)
Applications []AppExport `json:"applications"`
}
+3 -2
View File
@@ -14,8 +14,9 @@ import (
var License []byte
// SourceURL is where Corresponding Source for the published upstream
// releases lives. Operators who ship a modified version must point their
// users at their own sources instead (NOTICE; AGPL-3.0 §13).
// releases lives. It is injected into the panel footer by view.go.
// Operators who ship a modified version must change this constant so the
// footer points at their own sources (NOTICE; AGPL-3.0 §13).
const SourceURL = "https://github.com/mixeme/selfpost"
// CopyrightLine is the short copyright notice shown in the panel footer.
+19
View File
@@ -19,3 +19,22 @@ func TestEmbeddedLicenseMatchesRoot(t *testing.T) {
t.Fatal("embedded LICENSE is empty")
}
}
// NOTICE used to tell modifiers to edit layout.html for the Source URL. The
// footer reads legal.SourceURL; a fork that only changed the template would
// still advertise the upstream repo.
func TestNoticePointsAtSourceURLConstant(t *testing.T) {
notice, err := os.ReadFile(filepath.Join("..", "..", "NOTICE"))
if err != nil {
t.Fatalf("read NOTICE: %v", err)
}
if !bytes.Contains(notice, []byte("internal/legal/legal.go")) {
t.Error("NOTICE must tell modifiers to update SourceURL in internal/legal/legal.go")
}
if bytes.Contains(notice, []byte("layout.html")) {
t.Error("NOTICE still tells modifiers to edit layout.html for the Source URL")
}
if !bytes.Contains(notice, []byte("internal/web/view/static/OFL.txt")) {
t.Error("NOTICE must point at the OFL text that travels with the Plex fonts")
}
}
+30 -8
View File
@@ -36,6 +36,29 @@ type inflight struct {
m map[string]map[*reservation]struct{}
}
// tryAdmit decides whether one more message may be sent for key and, if so,
// claims its slot — both under a single lock. Counting and reserving in two
// separate critical sections would let two SMTP sessions that reach MAIL FROM
// at the same moment observe the same total and each take the last free slot,
// which is exactly the overshoot the in-flight registry exists to prevent.
//
// stored is the count the send log already holds for the limit's window and max
// is the ceiling; the caller supplies both because only it can query the store.
// The returned total is what was measured, for the refusal log line.
func (f *inflight) tryAdmit(key string, since time.Time, stored, max int64) (*reservation, int64, bool) {
if f == nil {
return nil, stored, stored < max // no in-flight accounting (tests)
}
f.mu.Lock()
defer f.mu.Unlock()
total := stored + f.countLocked(key, since)
if total >= max {
return nil, total, false
}
return f.reserveLocked(key), total, true
}
// count returns how many reservations for key were taken within the limit's
// window (at or after since), pruning any that outlived reservationTTL.
func (f *inflight) count(key string, since time.Time) int64 {
@@ -45,6 +68,11 @@ func (f *inflight) count(key string, since time.Time) int64 {
f.mu.Lock()
defer f.mu.Unlock()
return f.countLocked(key, since)
}
// countLocked is count's body; the caller holds mu.
func (f *inflight) countLocked(key string, since time.Time) int64 {
set := f.m[key]
cutoff := time.Now().Add(-reservationTTL)
var n int64
@@ -63,14 +91,8 @@ func (f *inflight) count(key string, since time.Time) int64 {
return n
}
// reserve claims a slot for key until the message is recorded or released.
func (f *inflight) reserve(key string) *reservation {
if f == nil {
return nil
}
f.mu.Lock()
defer f.mu.Unlock()
// reserveLocked is reserve's body; the caller holds mu.
func (f *inflight) reserveLocked(key string) *reservation {
if f.m == nil {
f.m = make(map[string]map[*reservation]struct{})
}
+111 -1
View File
@@ -3,6 +3,7 @@ package milter
import (
"errors"
"net"
"sync"
"testing"
"time"
@@ -15,7 +16,10 @@ import (
// swallows recorder errors and still accepts the message. By default it reports
// no configured rate limit, so the level-2 check is inert unless a test sets
// limits (see fakeRecorder fields).
// mu guards the recorded slices so several sessions may drive one recorder
// concurrently, as they do in the real server.
type fakeRecorder struct {
mu sync.Mutex
entries []store.SendLogEntry
rejected []store.SendLogEntry
fail bool
@@ -27,17 +31,25 @@ type fakeRecorder struct {
counts map[string]int64
lookupErr error
countErr error
// onCount, if set, runs inside CountMessages. It lets a test hold every
// racing session at the store lookup until they can all proceed together.
onCount func()
}
func (f *fakeRecorder) InsertQueued(e store.SendLogEntry) error {
if f.fail {
return errors.New("boom")
}
f.mu.Lock()
defer f.mu.Unlock()
f.entries = append(f.entries, e)
return nil
}
func (f *fakeRecorder) InsertRejected(e store.SendLogEntry) error {
f.mu.Lock()
defer f.mu.Unlock()
f.rejected = append(f.rejected, e)
return nil
}
@@ -57,6 +69,9 @@ func (f *fakeRecorder) CountMessages(scope, ref string, _ time.Time) (int64, err
if f.countErr != nil {
return 0, f.countErr
}
if f.onCount != nil {
f.onCount()
}
return f.counts[scope+"|"+ref], nil
}
@@ -415,6 +430,58 @@ func TestRateLimitCountsInFlightMessages(t *testing.T) {
}
}
// The case above is sequential: the second session reads the stored count after
// the first has already reserved. Here every session reads it first — the gate
// holds them all inside the lookup — which is what concurrent SMTP connections
// actually do. However many then race for the single free slot, exactly one may
// pass. (TestTryAdmitHandsOutEachSlotOnce is the test that fails when counting
// and reserving are not one step; this one pins the session-level behaviour.)
func TestRateLimitAdmitsOnlyOneRacingSession(t *testing.T) {
rec := limitedRecorder(4) // one below the ceiling of 5
fl := &inflight{}
gate := make(chan struct{})
// Every session is held inside the stored-count lookup until all of them
// have read it, which is the state the race needs: none of them can see
// another's reservation, because none has been taken yet.
const racers = 32
var atCount, done sync.WaitGroup
atCount.Add(racers)
go func() { atCount.Wait(); close(gate) }()
rec.onCount = func() { atCount.Done(); <-gate }
responses := make([]milter.Response, racers)
for i := range racers {
done.Add(1)
go func() {
defer done.Done()
// Connect is skipped so every goroutine starts from the same point;
// the client IP is what Connect would have captured.
s := &session{rec: rec, flight: fl, clientIP: limitIP}
resp, err := s.MailFrom("a@example.com", mods(map[string]string{"auth_authen": "app1"}))
if err != nil {
resp = nil // reported as a missing Continue below
}
responses[i] = resp
}()
}
done.Wait()
admitted := 0
for _, resp := range responses {
if resp == milter.RespContinue {
admitted++
}
}
if admitted != 1 {
t.Fatalf("%d of %d racing sessions admitted, want exactly 1 — the last slot was handed out twice",
admitted, racers)
}
if n := fl.count(store.RateLimitScopeDomain+"|example.com", time.Now().Add(-time.Hour)); n != 1 {
t.Fatalf("in-flight reservations = %d, want 1", n)
}
}
// Once the message is recorded the stored count sees it, so its reservation
// must be given back — otherwise it would be counted twice and the ceiling
// would drift closed.
@@ -477,12 +544,55 @@ func TestRefusalDoesNotLeaveDomainReservation(t *testing.T) {
}
}
// The ceiling is handed out exactly max times however the sessions interleave.
// Counting and reserving in two critical sections passes the sequential tests
// above and still overshoots here, because between one session's count and its
// reservation any number of others can pass the same check.
func TestTryAdmitHandsOutEachSlotOnce(t *testing.T) {
const (
max = 500
workers = 8
)
fl := &inflight{}
since := time.Now().Add(-time.Hour)
start := make(chan struct{})
admitted := make([]int, workers)
var wg sync.WaitGroup
for i := range workers {
wg.Add(1)
go func() {
defer wg.Done()
<-start
for {
_, _, ok := fl.tryAdmit("domain|example.com", since, 0, max)
if !ok {
return
}
admitted[i]++
}
}()
}
close(start)
wg.Wait()
total := 0
for _, n := range admitted {
total += n
}
if total != max {
t.Fatalf("admitted %d messages under a ceiling of %d", total, max)
}
}
// The in-flight count only covers the limit's own window: a reservation older
// than it (a session stuck mid-DATA for longer than the window) must not be
// counted against a window it no longer belongs to.
func TestInflightIgnoresReservationsOutsideWindow(t *testing.T) {
fl := &inflight{}
r := fl.reserve("domain|example.com")
r, _, ok := fl.tryAdmit("domain|example.com", time.Now().Add(-time.Hour), 0, 1)
if !ok {
t.Fatal("tryAdmit refused the first message under a ceiling of 1")
}
r.at = time.Now().Add(-time.Minute)
if n := fl.count("domain|example.com", time.Now().Add(-time.Hour)); n != 1 {
+7 -6
View File
@@ -54,22 +54,23 @@ func (s *session) overLimit() bool {
}
// enforceLimit counts recent messages for scope/ref and refuses when at or
// above the ceiling. On admit it reserves an in-flight slot on the session.
// above the ceiling. The stored count and the in-flight slots are weighed and
// the admitted message's own slot is taken in one atomic step (tryAdmit), so
// two sessions racing at MAIL FROM cannot both claim the last free slot.
func (s *session) enforceLimit(scope, ref string, rl store.RateLimit) bool {
since := time.Now().Add(-time.Duration(rl.WindowSeconds) * time.Second)
n, err := s.rec.CountMessages(scope, ref, since)
stored, err := s.rec.CountMessages(scope, ref, since)
if err != nil {
log.Printf("journal-milter: rate-limit count %s %q: %v (fail-open)", scope, ref, err)
return false
}
key := scope + "|" + ref
n += s.flight.count(key, since)
if n >= int64(rl.MaxMessages) {
r, n, ok := s.flight.tryAdmit(scope+"|"+ref, since, stored, int64(rl.MaxMessages))
if !ok {
log.Printf("journal-milter: %s %q over limit: %d/%d in %ds from %s — refusing 4xx",
scope, ref, n, rl.MaxMessages, rl.WindowSeconds, s.clientIP)
return true
}
s.reserved = append(s.reserved, s.flight.reserve(key))
s.reserved = append(s.reserved, r)
return false
}
+151
View File
@@ -0,0 +1,151 @@
package postfix
import (
"fmt"
"strconv"
"strings"
"time"
"unicode"
)
// ParseDuration converts a Postfix time value to a duration. Units are those
// postconf prints and accepts: s, m, h, d, w. A bare number is seconds. Values
// may concatenate units (`1h7m`), matching Postfix's own conv_time.
func ParseDuration(s string) (time.Duration, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, fmt.Errorf("postfix: empty time value")
}
var total time.Duration
i := 0
for i < len(s) {
for i < len(s) && unicode.IsSpace(rune(s[i])) {
i++
}
if i >= len(s) {
break
}
if s[i] == '-' {
return 0, fmt.Errorf("postfix: negative time value %q", s)
}
if s[i] == '+' {
i++
}
start := i
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
if i == start {
return 0, fmt.Errorf("postfix: invalid time value %q", s)
}
n, err := strconv.ParseInt(s[start:i], 10, 64)
if err != nil {
return 0, fmt.Errorf("postfix: invalid time value %q", s)
}
unit := byte('s')
if i < len(s) && isTimeUnit(s[i]) {
unit = s[i] | 0x20 // ASCII fold to lowercase
i++
}
part, err := durationForUnit(n, unit)
if err != nil {
return 0, err
}
total += part
}
return total, nil
}
func isTimeUnit(c byte) bool {
switch c | 0x20 {
case 's', 'm', 'h', 'd', 'w':
return true
}
return false
}
func durationForUnit(n int64, unit byte) (time.Duration, error) {
var unitDur time.Duration
switch unit {
case 's':
unitDur = time.Second
case 'm':
unitDur = time.Minute
case 'h':
unitDur = time.Hour
case 'd':
unitDur = 24 * time.Hour
case 'w':
unitDur = 7 * 24 * time.Hour
default:
return 0, fmt.Errorf("postfix: unknown time unit %q", string(unit))
}
return time.Duration(n) * unitDur, nil
}
// FormatDuration renders a Postfix interval the way the Mail queue card and
// delivery history share it, so the two cannot drift. Exact day/hour/minute/
// second values stay exact (`5 minutes`, `5 days`, `1 hour`); a remainder that
// is rounded to the nearest minute is marked `about` (`about 1 hour 7 minutes`
// for the stock 4000s backoff cap).
func FormatDuration(d time.Duration) string {
if d < 0 {
d = -d
}
sec := int64(d / time.Second)
if sec == 0 {
return "0 seconds"
}
days := sec / 86400
rem := sec % 86400
hours := rem / 3600
rem %= 3600
minutes := rem / 60
seconds := rem % 60
about := false
if days > 0 || hours > 0 {
if seconds >= 30 {
minutes++
about = true
} else if seconds > 0 {
about = true
}
seconds = 0
if minutes >= 60 {
hours++
minutes = 0
}
if hours >= 24 {
days++
hours = 0
}
}
parts := make([]string, 0, 4)
if days > 0 {
parts = append(parts, counted(days, "day"))
}
if hours > 0 {
parts = append(parts, counted(hours, "hour"))
}
if minutes > 0 {
parts = append(parts, counted(minutes, "minute"))
}
if seconds > 0 {
parts = append(parts, counted(seconds, "second"))
}
s := strings.Join(parts, " ")
if about {
return "about " + s
}
return s
}
func counted(n int64, unit string) string {
if n == 1 {
return "1 " + unit
}
return strconv.FormatInt(n, 10) + " " + unit + "s"
}
+64
View File
@@ -0,0 +1,64 @@
package postfix
import (
"testing"
"time"
)
func TestParseDuration(t *testing.T) {
cases := []struct {
in string
want time.Duration
}{
{"5d", 5 * 24 * time.Hour},
{"300s", 300 * time.Second},
{"4000s", 4000 * time.Second},
{"1h", time.Hour},
{"0", 0},
{"0s", 0},
{"300", 300 * time.Second},
{" 300s ", 300 * time.Second},
{"1h7m", time.Hour + 7*time.Minute},
{"1w", 7 * 24 * time.Hour},
{"2m", 2 * time.Minute},
}
for _, tc := range cases {
got, err := ParseDuration(tc.in)
if err != nil {
t.Errorf("ParseDuration(%q): %v", tc.in, err)
continue
}
if got != tc.want {
t.Errorf("ParseDuration(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}
func TestParseDurationRejectsInvalid(t *testing.T) {
for _, in := range []string{"", "foo", "5x", "-300s", "s", "1h 5x"} {
if _, err := ParseDuration(in); err == nil {
t.Errorf("ParseDuration(%q) = nil, want error", in)
}
}
}
func TestFormatDuration(t *testing.T) {
cases := []struct {
in time.Duration
want string
}{
{5 * 24 * time.Hour, "5 days"},
{300 * time.Second, "5 minutes"},
{4000 * time.Second, "about 1 hour 7 minutes"},
{time.Hour, "1 hour"},
{0, "0 seconds"},
{time.Second, "1 second"},
{2 * time.Minute, "2 minutes"},
{24 * time.Hour, "1 day"},
}
for _, tc := range cases {
if got := FormatDuration(tc.in); got != tc.want {
t.Errorf("FormatDuration(%v) = %q, want %q", tc.in, got, tc.want)
}
}
}
+8
View File
@@ -75,6 +75,14 @@ func (p *Postfix) Reload() error {
return p.reload()
}
// SetReloadHook replaces how configuration is applied after a rebuild. Tests
// that cannot reach supervisord use this to verify file regeneration alone.
func (p *Postfix) SetReloadHook(fn func() error) {
if fn != nil {
p.reload = fn
}
}
// renderSenderLoginMaps builds the sender_login_maps file contents. Keys are
// sorted for deterministic output and the logins under each key are sorted and
// de-duplicated. Every address and login is re-checked for injection safety
+135
View File
@@ -0,0 +1,135 @@
package postfix
import (
"fmt"
"log"
"os/exec"
"strings"
"time"
)
// RetryPolicy is this Postfix's deferred-mail retry timings, as postconf
// reports them. The panel loads it once at HTTP start (architecture.md) and
// never re-reads it for a request.
type RetryPolicy struct {
QueueRunDelay time.Duration
MinimalBackoff time.Duration
MaximalBackoff time.Duration
MaximalQueueLifetime time.Duration
BounceQueueLifetime time.Duration
DelayWarningTime time.Duration
// FromDefaults is true when postconf could not be read and the compiled-in
// Postfix 3.x values were substituted. The Mail queue card shows a muted
// note in that case so an operator who overrode the parameters is not
// silently shown the stock numbers.
FromDefaults bool
}
// DefaultRetryPolicy is Postfix 3.x compiled-in values for the six parameters
// build/postfix-config.sh does not set. Used when postconf is missing (unit
// tests, a binary outside the container) so the panel still starts.
func DefaultRetryPolicy() RetryPolicy {
return RetryPolicy{
QueueRunDelay: 300 * time.Second,
MinimalBackoff: 300 * time.Second,
MaximalBackoff: 4000 * time.Second,
MaximalQueueLifetime: 5 * 24 * time.Hour,
BounceQueueLifetime: 5 * 24 * time.Hour,
DelayWarningTime: 0,
FromDefaults: true,
}
}
// retryConfKeys is the fixed argv tail for `postconf -h`. Order matches the
// fields of RetryPolicy. No user input is interpolated (security.md).
var retryConfKeys = []string{
"queue_run_delay",
"minimal_backoff_time",
"maximal_backoff_time",
"maximal_queue_lifetime",
"bounce_queue_lifetime",
"delay_warning_time",
}
// readRetryConf runs `postconf -h` for the retry-policy keys. Tests replace it
// the same way logtail stubs queueIDs.
var readRetryConf = postconfRetryValues
func postconfRetryValues() ([]string, error) {
// Fixed argv, no user input — same pattern as Queue (security.md).
cmd := exec.Command("postconf", "-h",
"queue_run_delay",
"minimal_backoff_time",
"maximal_backoff_time",
"maximal_queue_lifetime",
"bounce_queue_lifetime",
"delay_warning_time",
)
out, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("postconf -h: %w: %s", err, strings.TrimSpace(string(out)))
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) != len(retryConfKeys) {
return nil, fmt.Errorf("postconf -h: got %d values, want %d", len(lines), len(retryConfKeys))
}
for i := range lines {
lines[i] = strings.TrimSpace(lines[i])
}
return lines, nil
}
// LoadRetryPolicy reads the effective Postfix retry parameters. On any failure
// it logs a warning and returns DefaultRetryPolicy; the HTTP role must not
// refuse to start because postconf is absent.
func LoadRetryPolicy() RetryPolicy {
lines, err := readRetryConf()
if err != nil {
log.Printf("postfix: retry policy: %v; using compiled-in defaults", err)
return DefaultRetryPolicy()
}
p, err := parseRetryPolicy(lines)
if err != nil {
log.Printf("postfix: retry policy: %v; using compiled-in defaults", err)
return DefaultRetryPolicy()
}
return p
}
func parseRetryPolicy(lines []string) (RetryPolicy, error) {
if len(lines) != len(retryConfKeys) {
return RetryPolicy{}, fmt.Errorf("got %d values, want %d", len(lines), len(retryConfKeys))
}
var durs [6]time.Duration
for i, line := range lines {
d, err := ParseDuration(line)
if err != nil {
return RetryPolicy{}, fmt.Errorf("%s: %w", retryConfKeys[i], err)
}
durs[i] = d
}
return RetryPolicy{
QueueRunDelay: durs[0],
MinimalBackoff: durs[1],
MaximalBackoff: durs[2],
MaximalQueueLifetime: durs[3],
BounceQueueLifetime: durs[4],
DelayWarningTime: durs[5],
}, nil
}
// FirstRetry is the human string for the first deferred retry (minimal
// backoff), shared by the Mail queue card and delivery history.
func (p RetryPolicy) FirstRetry() string {
return FormatDuration(p.MinimalBackoff)
}
// BackoffCap is the human string for maximal_backoff_time.
func (p RetryPolicy) BackoffCap() string {
return FormatDuration(p.MaximalBackoff)
}
// QueueLifetime is the human string for maximal_queue_lifetime.
func (p RetryPolicy) QueueLifetime() string {
return FormatDuration(p.MaximalQueueLifetime)
}
+79
View File
@@ -0,0 +1,79 @@
package postfix
import (
"errors"
"testing"
"time"
)
func TestLoadRetryPolicyUsesPostconfValues(t *testing.T) {
old := readRetryConf
readRetryConf = func() ([]string, error) {
return []string{"300s", "300s", "4000s", "2d", "5d", "0"}, nil
}
t.Cleanup(func() { readRetryConf = old })
p := LoadRetryPolicy()
if p.FromDefaults {
t.Fatal("FromDefaults = true, want live postconf values")
}
if p.MinimalBackoff != 300*time.Second {
t.Errorf("MinimalBackoff = %v, want 300s", p.MinimalBackoff)
}
if p.MaximalBackoff != 4000*time.Second {
t.Errorf("MaximalBackoff = %v, want 4000s", p.MaximalBackoff)
}
if p.MaximalQueueLifetime != 2*24*time.Hour {
t.Errorf("MaximalQueueLifetime = %v, want 2d", p.MaximalQueueLifetime)
}
if p.BounceQueueLifetime != 5*24*time.Hour {
t.Errorf("BounceQueueLifetime = %v, want 5d", p.BounceQueueLifetime)
}
if p.DelayWarningTime != 0 {
t.Errorf("DelayWarningTime = %v, want 0", p.DelayWarningTime)
}
}
func TestLoadRetryPolicyFallsBackWhenPostconfFails(t *testing.T) {
old := readRetryConf
readRetryConf = func() ([]string, error) {
return nil, errors.New("exec: not found")
}
t.Cleanup(func() { readRetryConf = old })
p := LoadRetryPolicy()
want := DefaultRetryPolicy()
if !p.FromDefaults {
t.Error("FromDefaults = false, want true when postconf fails")
}
if p.QueueRunDelay != want.QueueRunDelay || p.MinimalBackoff != want.MinimalBackoff ||
p.MaximalBackoff != want.MaximalBackoff || p.MaximalQueueLifetime != want.MaximalQueueLifetime {
t.Errorf("fallback = %+v, want compiled-in defaults %+v", p, want)
}
}
func TestLoadRetryPolicyFallsBackOnUnparseableValues(t *testing.T) {
old := readRetryConf
readRetryConf = func() ([]string, error) {
return []string{"300s", "nope", "4000s", "5d", "5d", "0"}, nil
}
t.Cleanup(func() { readRetryConf = old })
p := LoadRetryPolicy()
if !p.FromDefaults {
t.Error("FromDefaults = false, want true when a value cannot be parsed")
}
}
func TestParseRetryPolicyStock(t *testing.T) {
p, err := parseRetryPolicy([]string{"300s", "300s", "4000s", "5d", "5d", "0"})
if err != nil {
t.Fatalf("parseRetryPolicy: %v", err)
}
if p.FromDefaults {
t.Error("parsed policy should not be marked FromDefaults")
}
if p.QueueRunDelay != 300*time.Second || p.MaximalQueueLifetime != 5*24*time.Hour {
t.Errorf("parsed = %+v", p)
}
}
+26
View File
@@ -75,6 +75,32 @@ func (s *Store) ListDomains() ([]Domain, error) {
return out, rows.Err()
}
// ListDomainsForUser returns domains assigned to userID with application counts,
// ordered by name.
func (s *Store) ListDomainsForUser(userID int64) ([]Domain, error) {
rows, err := s.db.Query(`
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at,
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
FROM domains d
INNER JOIN user_domains ud ON ud.domain_id = d.id
WHERE ud.user_id = ?
ORDER BY d.name`, userID)
if err != nil {
return nil, fmt.Errorf("list domains for user: %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) {
+3 -3
View File
@@ -152,9 +152,9 @@ func TestDeleteRateLimitsForDomain(t *testing.T) {
func TestRateLimitActiveAndAllowsIP(t *testing.T) {
inactive := []RateLimit{
{},
{Scope: RateLimitScopeDomain, AllowedIPs: []string{"203.0.113.1"}}, // no ceiling
{Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window
{Scope: RateLimitScopeApp, MaxMessages: 5, WindowSeconds: 60}, // app needs IPs
{Scope: RateLimitScopeDomain, AllowedIPs: []string{"203.0.113.1"}}, // no ceiling
{Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window
{Scope: RateLimitScopeApp, MaxMessages: 5, WindowSeconds: 60}, // app needs IPs
{Scope: RateLimitScopeApp, AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 5}, // no window
}
for i, rl := range inactive {
+32 -4
View File
@@ -178,15 +178,29 @@ func (s *Store) GetSendLog(id int64) (SendLogRow, error) {
return row, nil
}
// SendLogFilter narrows QuerySendLog/CountSendLog by domain and/or
// application login. An empty field matches everything.
// SendLogFilter narrows QuerySendLog/CountSendLog. It carries two kinds of
// narrowing, and they behave in opposite ways on purpose.
//
// Domain and AppLogin are the operator's own filters, chosen in the UI: an
// empty field matches everything.
//
// Domains and AllDomains are the authorization scope, which no query parameter
// may widen. Domains is the exhaustive set of domain names the caller is
// entitled to read, applied as an IN constraint; AllDomains lifts that
// restriction and is the only way to read the whole journal. A zero-valued
// filter therefore matches *no* rows: a caller that forgets to state a scope
// gets an empty log rather than every tenant's mail, which is the failure mode
// this struct exists to make impossible.
type SendLogFilter struct {
Domain string
AppLogin string
Domain string
AppLogin string
Domains []string
AllDomains bool
}
// QuerySendLog returns send-log rows matching filter, newest first, for the
// monitoring screen's server-side pagination (product.md's send-log view).
// The filter's authorization scope is mandatory: see SendLogFilter.
func (s *Store) QuerySendLog(filter SendLogFilter, limit, offset int) ([]SendLogRow, error) {
where, args := sendLogWhere(filter)
args = append(args, limit, offset)
@@ -233,6 +247,20 @@ func (s *Store) CountSendLog(filter SendLogFilter) (int64, error) {
func sendLogWhere(f SendLogFilter) (string, []any) {
var clauses []string
var args []any
if !f.AllDomains {
// No scope is not "no restriction": a domain administrator whose last
// assignment was deleted owns nothing in the journal and must see
// nothing, and the same clause catches a caller that never set a scope.
if len(f.Domains) == 0 {
return " WHERE 1 = 0", nil
}
marks := make([]string, len(f.Domains))
for i, name := range f.Domains {
marks[i] = "?"
args = append(args, name)
}
clauses = append(clauses, "domain IN ("+strings.Join(marks, ", ")+")")
}
if f.Domain != "" {
clauses = append(clauses, "domain = ?")
args = append(args, f.Domain)
+57
View File
@@ -1,6 +1,8 @@
package store
import (
"sort"
"strings"
"testing"
"time"
)
@@ -162,6 +164,61 @@ func TestUpdateStatusNoMatch(t *testing.T) {
}
}
// The journal is read by principals who are only entitled to part of it, so
// the scope is part of the query rather than something the caller remembers to
// apply afterwards. A filter that states no scope is a caller that has not
// decided who is asking, and the safe answer to that is nothing.
func TestSendLogScopeIsMandatory(t *testing.T) {
st := openTestStore(t)
for _, domain := range []string{"first.example.ru", "second.example.ru"} {
if err := st.InsertQueued(SendLogEntry{
QueueID: "Q-" + domain, Domain: domain, AppLogin: "app-" + domain,
From: "noreply@" + domain, To: "public@example.net", Subject: domain,
}); err != nil {
t.Fatalf("InsertQueued: %v", err)
}
}
for name, tc := range map[string]struct {
filter SendLogFilter
want []string
}{
"no scope": {SendLogFilter{}, nil},
"empty scope": {SendLogFilter{Domains: []string{}}, nil},
"all domains": {SendLogFilter{AllDomains: true}, []string{"first.example.ru", "second.example.ru"}},
"one domain": {SendLogFilter{Domains: []string{"first.example.ru"}}, []string{"first.example.ru"}},
"two domains": {SendLogFilter{Domains: []string{"first.example.ru", "second.example.ru"}}, []string{"first.example.ru", "second.example.ru"}},
"unknown domain": {SendLogFilter{Domains: []string{"third.example.ru"}}, nil},
"filter within": {SendLogFilter{Domain: "first.example.ru", Domains: []string{"first.example.ru", "second.example.ru"}}, []string{"first.example.ru"}},
"filter outside": {SendLogFilter{Domain: "second.example.ru", Domains: []string{"first.example.ru"}}, nil},
"app filter outside": {SendLogFilter{AppLogin: "app-second.example.ru", Domains: []string{"first.example.ru"}}, nil},
} {
rows, err := st.QuerySendLog(tc.filter, 50, 0)
if err != nil {
t.Fatalf("%s: QuerySendLog: %v", name, err)
}
var got []string
for _, r := range rows {
got = append(got, r.Domain)
}
// Which rows came back is the question here; the page's own order is
// newest-first and is tested where it matters.
sort.Strings(got)
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
t.Errorf("%s: rows for %v, want %v", name, got, tc.want)
}
// The count drives pagination, so it has to agree with the page or the
// UI advertises pages of rows the reader is not allowed to see.
n, err := st.CountSendLog(tc.filter)
if err != nil {
t.Fatalf("%s: CountSendLog: %v", name, err)
}
if int(n) != len(tc.want) {
t.Errorf("%s: count %d, want %d", name, n, len(tc.want))
}
}
}
func TestDeleteSendLogBefore(t *testing.T) {
st := openTestStore(t)
+4 -3
View File
@@ -1,7 +1,8 @@
// 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 (architecture.md § Persistence). It
// exposes typed queries so the rest of the panel never builds SQL by hand.
// that persists panel users (global administrators and domain-admins), sending
// domains and applications, the send log and rate-limit settings
// (architecture.md § Persistence). It exposes typed queries so the rest of the
// panel never builds SQL by hand.
package store
import (
+2
View File
@@ -51,6 +51,8 @@ func New(st *store.Store, cfg Config, v *view.Engine, setupTokenPath string) *Mo
loginLimiter: newRateLimiter(10, 15*time.Minute),
trustedProxies: cfg.TrustedProxyCIDRs,
}
m.setupLimiter.startSweeper()
m.loginLimiter.startSweeper()
m.setup = newSetupManager(st, cfg.Hostname, setupTokenPath)
return m
}
+74 -7
View File
@@ -1,13 +1,17 @@
package auth
import (
"database/sql"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/view"
)
@@ -22,6 +26,15 @@ func newTestSessionStore(t *testing.T) *sessionStore {
return newSessionStore(st, 7*24*time.Hour)
}
func mustCreate(t *testing.T, s *sessionStore, username string) string {
t.Helper()
token, err := s.Create(username)
if err != nil {
t.Fatalf("create session: %v", err)
}
return token
}
func mustView(t *testing.T) *view.Engine {
t.Helper()
v, err := view.New("test")
@@ -86,7 +99,7 @@ func TestSessionTokenIgnoresTheOtherName(t *testing.T) {
func TestRequireAuthRejectsDuplicateCookies(t *testing.T) {
m := testModule(t, false)
token := m.sessions.Create("admin")
token := mustCreate(t, m.sessions, "admin")
reached := false
h := m.RequireAuth(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true }))
@@ -107,7 +120,7 @@ func TestRequireAuthRejectsDuplicateCookies(t *testing.T) {
func TestLogoutClearsBothCookieNames(t *testing.T) {
m := testModule(t, true)
token := m.sessions.Create("admin")
token := mustCreate(t, m.sessions, "admin")
r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/logout", nil)
r.Host = "panel.example.com"
@@ -132,9 +145,63 @@ func TestLogoutClearsBothCookieNames(t *testing.T) {
}
}
// A session that could not be stored must not turn into a cookie: the browser
// would look signed in, and every request it made would be bounced to /login
// with no explanation. Only the sessions table is broken here, so the request
// gets past the user lookup and password check and fails exactly where the
// session is written.
func TestLoginSetsNoCookieWhenTheSessionCannotBeStored(t *testing.T) {
path := filepath.Join(t.TempDir(), "test.db")
st, err := store.Open(path)
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { st.Close() })
hash, err := bcrypt.GenerateFromPassword([]byte("correct-horse-battery"), bcrypt.MinCost)
if err != nil {
t.Fatalf("hash password: %v", err)
}
if err := st.CreateGlobalUser("admin", string(hash)); err != nil {
t.Fatalf("create user: %v", err)
}
dropSessionsTable(t, path)
m := New(st, Config{}, mustView(t), "")
form := url.Values{"username": {"admin"}, "password": {"correct-horse-battery"}}
r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/login",
strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
m.HandleLogin(rec, r)
if got := rec.Header().Values("Set-Cookie"); len(got) != 0 {
t.Errorf("a session cookie was issued for a session that was never stored: %v", got)
}
if rec.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500 (the login failed)", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "" {
t.Errorf("the browser was sent to %q as if it were signed in", loc)
}
}
// dropSessionsTable breaks session persistence while leaving the rest of the
// schema usable. The SQLite driver is registered by internal/store.
func dropSessionsTable(t *testing.T, path string) {
t.Helper()
db, err := sql.Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)")
if err != nil {
t.Fatalf("open database directly: %v", err)
}
defer db.Close()
if _, err := db.Exec("DROP TABLE sessions"); err != nil {
t.Fatalf("drop sessions table: %v", err)
}
}
func TestSessionRename(t *testing.T) {
s := newTestSessionStore(t)
token := s.Create("admin")
token := mustCreate(t, s, "admin")
s.Rename(token, "operator")
@@ -149,8 +216,8 @@ func TestSessionRename(t *testing.T) {
func TestSessionDestroyOthers(t *testing.T) {
s := newTestSessionStore(t)
keep := s.Create("admin")
other := s.Create("admin")
keep := mustCreate(t, s, "admin")
other := mustCreate(t, s, "admin")
s.DestroyOthers(keep)
@@ -165,7 +232,7 @@ func TestSessionDestroyOthers(t *testing.T) {
func TestSessionLookupRejectsExpired(t *testing.T) {
s := newTestSessionStore(t)
s.idle = -time.Minute
token := s.Create("admin")
token := mustCreate(t, s, "admin")
if _, ok := s.Lookup(token); ok {
t.Fatal("expired session was accepted")
@@ -174,7 +241,7 @@ func TestSessionLookupRejectsExpired(t *testing.T) {
func TestSessionTouchThrottled(t *testing.T) {
s := newTestSessionStore(t)
token := s.Create("admin")
token := mustCreate(t, s, "admin")
if s.Touch(token) {
t.Fatal("touch renewed a session created moments ago")
+6 -1
View File
@@ -120,7 +120,12 @@ func (m *Module) submitLogin(w http.ResponseWriter, r *http.Request) {
return
}
token := m.sessions.Create(user.Username)
token, err := m.sessions.Create(user.Username)
if err != nil {
logf("panel: login: create session failed: %v", err)
m.renderLogin(w, http.StatusInternalServerError, "Internal error. Please try again.")
return
}
m.setSessionCookie(w, token)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
+284
View File
@@ -0,0 +1,284 @@
package auth
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"golang.org/x/crypto/bcrypt"
)
const testPassword = "correct-horse-battery"
// moduleWithAdmin returns a panel that has already been through setup, with one
// global administrator whose password is testPassword.
func moduleWithAdmin(t *testing.T) *Module {
t.Helper()
m := testModule(t, false)
hash, err := bcrypt.GenerateFromPassword([]byte(testPassword), bcrypt.MinCost)
if err != nil {
t.Fatalf("hash password: %v", err)
}
if err := m.store.CreateGlobalUser("admin", string(hash)); err != nil {
t.Fatalf("create user: %v", err)
}
return m
}
// postLogin submits the sign-in form from remoteAddr (the limiter's key) and
// returns what the handler wrote.
func postLogin(m *Module, remoteAddr, username, password string) *httptest.ResponseRecorder {
form := url.Values{"username": {username}, "password": {password}}
r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/login",
strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.RemoteAddr = remoteAddr
rec := httptest.NewRecorder()
m.HandleLogin(rec, r)
return rec
}
// sessionCookieValue returns the session token the response issued, or "" if it
// issued none.
func sessionCookieValue(t *testing.T, m *Module, rec *httptest.ResponseRecorder) string {
t.Helper()
for _, c := range rec.Result().Cookies() {
if c.Name == m.sessionCookie() {
return c.Value
}
}
return ""
}
func TestLoginSignsInWithTheRightPassword(t *testing.T) {
m := moduleWithAdmin(t)
rec := postLogin(m, "203.0.113.7:5000", "admin", testPassword)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
t.Fatalf("status = %d, Location = %q; want a redirect to /", rec.Code, rec.Header().Get("Location"))
}
token := sessionCookieValue(t, m, rec)
if token == "" {
t.Fatal("no session cookie was issued")
}
name, ok := m.sessions.Lookup(token)
if !ok || name != "admin" {
t.Fatalf("the cookie's session resolves to %q, %t; want admin", name, ok)
}
}
// A refused sign-in must not say which half was wrong: the panel is public, and
// distinguishable answers would turn the form into a list of usernames.
func TestLoginRefusesBadCredentialsWithoutSayingWhy(t *testing.T) {
m := moduleWithAdmin(t)
bodies := make(map[string]string, 2)
for name, creds := range map[string][2]string{
"wrong password": {"admin", "not-the-password"},
"unknown user": {"nobody", testPassword},
} {
rec := postLogin(m, "203.0.113.7:5000", creds[0], creds[1])
if rec.Code != http.StatusUnauthorized {
t.Errorf("%s: status = %d, want 401", name, rec.Code)
}
if got := sessionCookieValue(t, m, rec); got != "" {
t.Errorf("%s: a session cookie was issued: %q", name, got)
}
bodies[name] = rec.Body.String()
}
if bodies["wrong password"] != bodies["unknown user"] {
t.Error("the two refusals differ, so the form tells an attacker which usernames exist")
}
}
// The lockout is what makes online guessing pointless, so it has to hold even
// for the request that finally carries the right password — and it has to be
// scoped to the address that spent the attempts.
func TestLoginLocksOutAfterTooManyAttempts(t *testing.T) {
m := moduleWithAdmin(t)
const attacker = "203.0.113.7:5000"
for i := 0; i < 10; i++ {
if rec := postLogin(m, attacker, "admin", "guess"); rec.Code != http.StatusUnauthorized {
t.Fatalf("attempt %d: status = %d, want 401 (still under the limit)", i+1, rec.Code)
}
}
rec := postLogin(m, attacker, "admin", testPassword)
if rec.Code != http.StatusTooManyRequests {
t.Errorf("status = %d, want 429; the lockout was bypassed by guessing right", rec.Code)
}
if got := sessionCookieValue(t, m, rec); got != "" {
t.Errorf("a locked-out request was signed in: %q", got)
}
if rec := postLogin(m, "198.51.100.9:5000", "admin", testPassword); rec.Code != http.StatusSeeOther {
t.Errorf("another address got %d; one guesser locked out the whole internet", rec.Code)
}
}
// Before the first administrator exists there is nothing to sign in as, so the
// form is replaced by a pointer to the setup link rather than a password box
// that can never succeed.
func TestLoginPointsAtSetupBeforeTheFirstAdministrator(t *testing.T) {
m := testModule(t, false)
rec := httptest.NewRecorder()
m.HandleLogin(rec, httptest.NewRequest(http.MethodGet, "http://panel.example.com/login", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "No administrator has been created yet") {
t.Errorf("the login page does not point at the setup link:\n%s", body)
}
if strings.Contains(body, `name="password"`) {
t.Errorf("the login page offers a password field with no account to use it:\n%s", body)
}
}
// getSetup performs the GET the operator's browser makes when it follows the
// one-time link.
func getSetup(m *Module, token string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
m.HandleSetup(rec, httptest.NewRequest(http.MethodGet, "http://panel.example.com/setup/"+token, nil))
return rec
}
func postSetup(m *Module, token string, form url.Values) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/setup/"+token,
strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
m.HandleSetup(rec, r)
return rec
}
func setupForm(username, password, confirm string) url.Values {
return url.Values{
"username": {username},
"password": {password},
"password_confirm": {confirm},
}
}
// The setup link creates the first global administrator and then stops
// existing: the persistent fact is the user row, so the link is dead after a
// restart too, not only for the process that served it.
func TestSetupCreatesTheFirstAdministratorAndThenCloses(t *testing.T) {
m := testModule(t, false)
token, ok := m.setup.activeToken()
if !ok {
t.Fatal("no setup token on a panel with no users")
}
if rec := getSetup(m, token); rec.Code != http.StatusOK {
t.Fatalf("GET the setup link = %d, want the form", rec.Code)
}
rec := postSetup(m, token, setupForm("operator", "a-long-enough-password", "a-long-enough-password"))
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/login" {
t.Fatalf("status = %d, Location = %q; want a redirect to /login", rec.Code, rec.Header().Get("Location"))
}
u, err := m.store.GetUserByUsername("operator")
if err != nil {
t.Fatalf("the administrator was not created: %v", err)
}
if u.Role != RoleGlobal {
t.Errorf("the first administrator has role %q, want global", u.Role)
}
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte("a-long-enough-password")); err != nil {
t.Errorf("the stored hash does not match the password that was set: %v", err)
}
if rec := getSetup(m, token); rec.Code != http.StatusNotFound {
t.Errorf("the setup link still answers %d after setup completed, want 404", rec.Code)
}
if rec := postSetup(m, token, setupForm("second", "a-long-enough-password", "a-long-enough-password")); rec.Code != http.StatusNotFound {
t.Errorf("a second administrator could be created through the setup link (%d)", rec.Code)
}
}
// A token that is wrong, or one that has aged out and been replaced, is not a
// hint that setup exists: both answer 404, the same as any unknown path.
func TestSetupRejectsAWrongOrExpiredToken(t *testing.T) {
m := testModule(t, false)
token, ok := m.setup.activeToken()
if !ok {
t.Fatal("no setup token on a panel with no users")
}
if rec := getSetup(m, token+"x"); rec.Code != http.StatusNotFound {
t.Errorf("a wrong token answered %d, want 404", rec.Code)
}
expireSetupToken(m)
if rec := getSetup(m, token); rec.Code != http.StatusNotFound {
t.Errorf("the expired token still opens setup (%d)", rec.Code)
}
fresh, _ := m.setup.activeToken()
if fresh == token {
t.Fatal("the expired token was not replaced")
}
if rec := getSetup(m, fresh); rec.Code != http.StatusOK {
t.Errorf("the reissued token does not open setup (%d)", rec.Code)
}
}
// The first account is the one that can never be locked out of the panel from
// outside, so the rules that apply to every other user apply here too — before
// anything is written.
func TestSetupRejectsCredentialsItWouldNotAcceptLater(t *testing.T) {
for name, form := range map[string]url.Values{
"username too short": setupForm("op", "a-long-enough-password", "a-long-enough-password"),
"username not ASCII": setupForm("оператор", "a-long-enough-password", "a-long-enough-password"),
"passwords differ": setupForm("operator", "a-long-enough-password", "a-long-enough-passwerd"),
"password too short": setupForm("operator", "short", "short"),
"no password at all": setupForm("operator", "", ""),
"no username at all": setupForm("", "a-long-enough-password", "a-long-enough-password"),
} {
m := testModule(t, false)
token, _ := m.setup.activeToken()
rec := postSetup(m, token, form)
if rec.Code != http.StatusBadRequest {
t.Errorf("%s: status = %d, want 400", name, rec.Code)
}
if exists, err := m.store.UserExists(); err != nil || exists {
t.Errorf("%s: an administrator was created anyway (err=%v)", name, err)
}
if rec := getSetup(m, token); rec.Code != http.StatusOK {
t.Errorf("%s: the setup link was burned by a rejected form (%d)", name, rec.Code)
}
}
}
// Setup is unauthenticated by definition, so the only thing between the token
// and an offline guesser is the limiter in front of it.
func TestSetupIsRateLimited(t *testing.T) {
m := testModule(t, false)
for i := 0; i < 10; i++ {
if rec := getSetup(m, "wrong-token"); rec.Code != http.StatusNotFound {
t.Fatalf("attempt %d: status = %d, want 404 (still under the limit)", i+1, rec.Code)
}
}
if rec := getSetup(m, "wrong-token"); rec.Code != http.StatusTooManyRequests {
t.Errorf("status = %d, want 429 after the eleventh attempt", rec.Code)
}
}
// expireSetupToken ages the current token out, the state the panel reaches when
// nobody follows the link within setupTokenTTL.
func expireSetupToken(m *Module) {
m.setup.mu.Lock()
defer m.setup.mu.Unlock()
m.setup.expiresAt = m.setup.expiresAt.Add(-2 * setupTokenTTL)
}
-12
View File
@@ -44,15 +44,3 @@ func CurrentUser(r *http.Request) string {
}
return ""
}
// RequireGlobal wraps a handler that only global administrators may reach.
func RequireGlobal(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := CurrentPrincipal(r.Context())
if !ok || !p.IsGlobal() {
http.NotFound(w, r)
return
}
next.ServeHTTP(w, r)
})
}
+2 -2
View File
@@ -10,8 +10,8 @@ import (
type ctxKey int
const (
usernameKey ctxKey = 0
principalKey ctxKey = 1
usernameKey ctxKey = 0
principalKey ctxKey = 1
)
// Role is a panel user's access level.
+48 -5
View File
@@ -5,11 +5,14 @@ import (
"time"
)
const defaultMaxBuckets = 4096
// rateLimiter is a simple fixed-window per-key counter used to throttle the
// setup and login routes (security.md). Keys are client IPs.
type rateLimiter struct {
max int
window time.Duration
max int
window time.Duration
maxBuckets int
mu sync.Mutex
buckets map[string]*rlBucket
@@ -22,12 +25,25 @@ type rlBucket struct {
func newRateLimiter(max int, window time.Duration) *rateLimiter {
return &rateLimiter{
max: max,
window: window,
buckets: make(map[string]*rlBucket),
max: max,
window: window,
maxBuckets: defaultMaxBuckets,
buckets: make(map[string]*rlBucket),
}
}
func (r *rateLimiter) startSweeper() {
go func() {
ticker := time.NewTicker(r.window)
defer ticker.Stop()
for range ticker.C {
r.mu.Lock()
r.sweep(time.Now())
r.mu.Unlock()
}
}()
}
func (r *rateLimiter) Allow(key string) bool {
now := time.Now()
r.mu.Lock()
@@ -35,6 +51,7 @@ func (r *rateLimiter) Allow(key string) bool {
b := r.buckets[key]
if b == nil || now.After(b.windowEnds) {
r.makeRoom(now)
r.buckets[key] = &rlBucket{count: 1, windowEnds: now.Add(r.window)}
r.sweep(now)
return true
@@ -46,6 +63,32 @@ func (r *rateLimiter) Allow(key string) bool {
return true
}
func (r *rateLimiter) makeRoom(now time.Time) {
if r.maxBuckets <= 0 || len(r.buckets) < r.maxBuckets {
return
}
r.sweep(now)
for len(r.buckets) >= r.maxBuckets {
r.evictOldest()
}
}
func (r *rateLimiter) evictOldest() {
var oldestKey string
var oldestEnds time.Time
first := true
for k, b := range r.buckets {
if first || b.windowEnds.Before(oldestEnds) {
oldestKey = k
oldestEnds = b.windowEnds
first = false
}
}
if oldestKey != "" {
delete(r.buckets, oldestKey)
}
}
func (r *rateLimiter) sweep(now time.Time) {
for k, b := range r.buckets {
if now.After(b.windowEnds) {
+125
View File
@@ -0,0 +1,125 @@
package auth
import (
"testing"
"time"
)
// The limiter is what stands between the public login form and an unlimited
// guessing rate (security.md), so the ceiling has to be exact: the configured
// number of attempts go through and the next one does not, however often it is
// repeated.
func TestRateLimiterStopsAtTheCeiling(t *testing.T) {
r := newRateLimiter(3, time.Minute)
for i := 1; i <= 3; i++ {
if !r.Allow("203.0.113.7") {
t.Fatalf("attempt %d of 3 was refused before the ceiling", i)
}
}
for i := 4; i <= 6; i++ {
if r.Allow("203.0.113.7") {
t.Fatalf("attempt %d passed after the ceiling of 3", i)
}
}
}
// Buckets are per key, so one locked-out address must not lock out the rest of
// the internet — a shared counter would turn a single guesser into a denial of
// service against every operator.
func TestRateLimiterKeepsKeysApart(t *testing.T) {
r := newRateLimiter(1, time.Minute)
if !r.Allow("203.0.113.7") || r.Allow("203.0.113.7") {
t.Fatal("the first key did not use up its single attempt")
}
if !r.Allow("198.51.100.9") {
t.Fatal("a second address was refused because another one was locked out")
}
}
// The window is fixed, not sliding: once it has elapsed the count starts again
// from zero rather than being carried over. Time is moved by ageing the bucket
// instead of sleeping, so the test states the boundary rather than approaching
// it.
func TestRateLimiterReopensAfterTheWindow(t *testing.T) {
r := newRateLimiter(2, time.Minute)
r.Allow("203.0.113.7")
r.Allow("203.0.113.7")
if r.Allow("203.0.113.7") {
t.Fatal("the ceiling was not reached")
}
expire(r, "203.0.113.7")
if !r.Allow("203.0.113.7") {
t.Fatal("the key is still locked out after its window ended")
}
if !r.Allow("203.0.113.7") {
t.Fatal("the new window did not start from an empty count")
}
if r.Allow("203.0.113.7") {
t.Fatal("the new window allowed more than the ceiling")
}
}
// Every address that ever tried to sign in gets a bucket, and the only thing
// that removes the finished ones is the sweep on a new window. It runs on the
// key that triggered it as well as on the others, so a long-running panel does
// not accumulate a bucket per source address for ever.
func TestRateLimiterSweepsFinishedBuckets(t *testing.T) {
r := newRateLimiter(2, time.Minute)
for _, key := range []string{"203.0.113.7", "198.51.100.9"} {
r.Allow(key)
expire(r, key)
}
r.Allow("192.0.2.5") // still inside its window
r.Allow("203.0.113.7") // new window for this key: sweeps the rest
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.buckets["198.51.100.9"]; ok {
t.Error("a bucket whose window ended is still held")
}
for _, key := range []string{"203.0.113.7", "192.0.2.5"} {
if _, ok := r.buckets[key]; !ok {
t.Errorf("the sweep dropped %s, whose window is still open", key)
}
}
}
// expire moves a key's window into the past, the same state it would reach by
// waiting for the window to elapse.
func expire(r *rateLimiter, key string) {
r.mu.Lock()
defer r.mu.Unlock()
if b := r.buckets[key]; b != nil {
b.windowEnds = time.Now().Add(-time.Second)
}
}
// A long-running panel can see many unique client addresses. Finished buckets
// are swept on every new window, and a hard cap evicts the oldest when the map
// would otherwise grow without bound.
func TestRateLimiterCapsBucketCount(t *testing.T) {
r := newRateLimiter(1, time.Minute)
r.maxBuckets = 3
for i, key := range []string{"203.0.113.7", "198.51.100.9", "192.0.2.5"} {
if !r.Allow(key) {
t.Fatalf("attempt %d for %s was refused under the cap", i+1, key)
}
expire(r, key)
}
if !r.Allow("203.0.113.8") {
t.Fatal("a fourth address was refused even though room was made")
}
r.mu.Lock()
defer r.mu.Unlock()
if len(r.buckets) > 3 {
t.Fatalf("bucket count = %d, want at most 3", len(r.buckets))
}
}
+9 -4
View File
@@ -41,17 +41,22 @@ func hashToken(token string) string {
return hex.EncodeToString(sum[:])
}
// Create issues a new session for username and returns its token.
func (s *sessionStore) Create(username string) string {
// Create issues a new session for username and returns its token. It fails
// closed: if the row cannot be written the caller gets an error and must not
// hand out a cookie, because a token that is not in the database looks like a
// signed-in browser while every request it makes bounces back to /login.
func (s *sessionStore) Create(username string) (string, error) {
token := randomToken(32)
now := time.Now()
if err := s.store.CreateSession(hashToken(token), username, now.Add(s.idle)); err != nil {
logf("panel: session: create failed: %v", err)
return "", err
}
// Pruning is housekeeping: the new session is already valid, so a failure
// here is logged and does not fail the login.
if _, err := s.store.DeleteExpiredSessions(now); err != nil {
logf("panel: session: prune expired failed: %v", err)
}
return token
return token, nil
}
// Lookup returns the session username for a token if it exists and is
+3 -2
View File
@@ -16,8 +16,9 @@ const setupTokenTTL = 10 * time.Minute
// setupManager owns the one-time administrator setup token. The token itself is
// ephemeral (regenerated on restart or expiry) and lives only in memory; the
// persistent "setup complete" fact is the presence of the admin row in the
// store, so once that exists the token is gone for good (security.md).
// persistent "setup complete" fact is the presence of any row in the store's
// users table (`store.UserExists`), so once the first global administrator is
// created the token is gone for good (security.md).
type setupManager struct {
store *store.Store
hostname string
+1 -11
View File
@@ -32,17 +32,7 @@ func (h *Handlers) assignedDomains(p auth.Principal) ([]store.Domain, error) {
if p.IsGlobal() {
return h.store.ListDomains()
}
all, err := h.store.ListDomains()
if err != nil {
return nil, err
}
var out []store.Domain
for _, d := range all {
if p.CanAccessDomain(d.ID) {
out = append(out, d)
}
}
return out, nil
return h.store.ListDomainsForUser(p.ID)
}
func domainNameSet(domains []store.Domain) map[string]bool {
+125
View File
@@ -0,0 +1,125 @@
package handlers
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/mixeme/selfpost/internal/web/auth"
)
// route is one entry of the authenticated mux, named the way web.go registers
// it so a route added there without a guard is visible as a missing case here.
type route struct {
method string
target string
handler func(*Handlers) http.HandlerFunc
// pathValues are the {id}-style segments the router would have bound.
pathValues map[string]string
}
// globalOnlyRoutes is every page and action that only a global administrator
// may reach: the panel's users, the whole-server backup and domain import, the
// machine-wide status and log views, and the domain lifecycle. A domain
// administrator is answered 404 rather than 403 so the panel does not confirm
// that the page exists (security.md).
var globalOnlyRoutes = []route{
{"GET", "/users", func(h *Handlers) http.HandlerFunc { return h.HandleUsers }, nil},
{"GET", "/users/new", func(h *Handlers) http.HandlerFunc { return h.HandleUserNew }, nil},
{"POST", "/users/new", func(h *Handlers) http.HandlerFunc { return h.HandleUserNew }, nil},
{"GET", "/users/1", func(h *Handlers) http.HandlerFunc { return h.HandleUserEdit }, map[string]string{"uid": "1"}},
{"POST", "/users/1", func(h *Handlers) http.HandlerFunc { return h.HandleUserEdit }, map[string]string{"uid": "1"}},
{"GET", "/users/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleUserDeleteConfirm }, map[string]string{"uid": "1"}},
{"POST", "/users/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleUserDelete }, map[string]string{"uid": "1"}},
{"GET", "/backup", func(h *Handlers) http.HandlerFunc { return h.HandleBackupPage }, nil},
{"POST", "/backup", func(h *Handlers) http.HandlerFunc { return h.HandleBackup }, nil},
{"POST", "/domains/import", func(h *Handlers) http.HandlerFunc { return h.HandleImportDomain }, nil},
{"GET", "/status", func(h *Handlers) http.HandlerFunc { return h.HandleStatus }, nil},
{"GET", "/status/fragment", func(h *Handlers) http.HandlerFunc { return h.HandleStatusFragment }, nil},
{"POST", "/status/recheck", func(h *Handlers) http.HandlerFunc { return h.HandleStatusRecheck }, nil},
{"GET", "/mail-queue", func(h *Handlers) http.HandlerFunc { return h.HandleMailQueue }, nil},
{"GET", "/mail-queue/body", func(h *Handlers) http.HandlerFunc { return h.HandleMailQueueBody }, nil},
{"GET", "/system-log", func(h *Handlers) http.HandlerFunc { return h.HandleSystemLog }, nil},
{"GET", "/system-log/body", func(h *Handlers) http.HandlerFunc { return h.HandleSystemLogBody }, nil},
{"POST", "/domains", func(h *Handlers) http.HandlerFunc { return h.HandleAddDomain }, nil},
{"GET", "/domains/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleDeleteConfirm }, map[string]string{"id": "1"}},
{"POST", "/domains/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleDeleteDomain }, map[string]string{"id": "1"}},
{"POST", "/reload", func(h *Handlers) http.HandlerFunc { return h.HandleReload }, nil},
}
// A domain administrator has an account on the panel, so authentication is not
// what keeps them off these pages — the per-handler role check is. Each of them
// is reached here with a valid session for a principal that owns a domain, the
// case the send-log leak (P0, code-review.md) showed is easy to get wrong.
func TestGlobalOnlyRoutesAnswerADomainAdmin404(t *testing.T) {
h, domains := serverWithTwoDomains(t)
p := domainAdmin(t, h.store, "global-only", domains["first.example.ru"].ID)
for _, rt := range globalOnlyRoutes {
rec := call(h, rt, p)
if rec.Code != http.StatusNotFound {
t.Errorf("%s %s as a domain administrator = %d, want 404:\n%s",
rt.method, rt.target, rec.Code, rec.Body.String())
}
}
}
// The same 404 covers a request that carries no principal at all: the auth
// middleware normally redirects those, but a handler must not depend on
// middleware it cannot see for the role it enforces itself.
func TestGlobalOnlyRoutesAnswerAnUnknownPrincipal404(t *testing.T) {
h, _ := serverWithTwoDomains(t)
for _, rt := range globalOnlyRoutes {
rec := httptest.NewRecorder()
req := httptest.NewRequest(rt.method, rt.target, nil)
for k, v := range rt.pathValues {
req.SetPathValue(k, v)
}
rt.handler(h)(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("%s %s with no principal = %d, want 404", rt.method, rt.target, rec.Code)
}
}
}
// The 404s above would also pass if a handler were broken into always returning
// one, so at least the two pages that need nothing but the store and the view
// have to be shown opening for a global administrator.
func TestGlobalOnlyRoutesOpenForAGlobalAdministrator(t *testing.T) {
h, _ := serverWithTwoDomains(t)
for _, target := range []string{"/users", "/backup"} {
rt := getRoute(t, target)
if rec := call(h, rt, globalPrincipal); rec.Code != http.StatusOK {
t.Errorf("GET %s as a global administrator = %d, want 200:\n%s",
target, rec.Code, rec.Body.String())
}
}
}
func getRoute(t *testing.T, target string) route {
t.Helper()
for _, rt := range globalOnlyRoutes {
if rt.method == http.MethodGet && rt.target == target {
return rt
}
}
t.Fatalf("no GET %s among the global-only routes", target)
return route{}
}
func call(h *Handlers, rt route, p auth.Principal) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
req := httptest.NewRequest(rt.method, rt.target, nil)
req = auth.RequestWithPrincipal(req, p)
for k, v := range rt.pathValues {
req.SetPathValue(k, v)
}
rt.handler(h)(rec, req)
return rec
}
@@ -0,0 +1,46 @@
package handlers
import (
"regexp"
"strings"
"testing"
)
// TestE2ESendLogStatusMarkupDrift renders the real deliveries_rows fragment and
// checks whether the e2e gate's HTML scrapers still match it.
func TestE2ESendLogStatusMarkupDrift(t *testing.T) {
h, _ := serverWithDelivery(t)
rendered := getBody(t, h.HandleDeliveriesRows, "/deliveries/rows")
// These mirror test/e2e/main_test.go — keep in sync when fixing the e2e gate.
statusCellPattern := regexp.MustCompile(`class="st st-[^"]+">(queued|sent|deferred|bounced|rejected)</span>`)
containsCell := func(html, needle string) bool {
return strings.Contains(html, `<span class="st st-`) && strings.Contains(html, `">`+needle+`</span>`)
}
if statusCellPattern.FindStringSubmatch(rendered) == nil {
t.Fatalf("e2e statusCellPattern does not match rendered send-log rows:\n%s", snippet(rendered, `class="status"`))
}
if !containsCell(rendered, "sent") {
t.Fatalf("e2e containsCell does not match rendered send-log rows:\n%s", snippet(rendered, `class="status"`))
}
}
func snippet(s, needle string) string {
i := strings.Index(s, needle)
if i < 0 {
if len(s) > 200 {
return s[:200] + "..."
}
return s
}
start := i - 20
if start < 0 {
start = 0
}
end := i + 120
if end > len(s) {
end = len(s)
}
return s[start:end]
}
+6
View File
@@ -8,6 +8,7 @@ import (
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
"github.com/mixeme/selfpost/internal/web/view"
@@ -20,6 +21,7 @@ type Config struct {
MailLogPath string
DataDir string
DBPath string
DeployRoot string
Version string
TLSCertFile string
OpenDKIMSocket string
@@ -28,6 +30,10 @@ type Config struct {
// and used to cap domain/app level-2 ceilings (guide § Rate limiting).
RateLimitMessagesPerIP int
RateLimitWindowSeconds int
// RetryPolicy is this Postfix's deferred-mail timings, snapshotted once
// when the HTTP role starts (architecture.md). The Mail queue card and
// delivery history read it from here; they never call postconf.
RetryPolicy postfix.RetryPolicy
}
// Handlers holds dependencies for authenticated panel routes.
+21 -3
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/mixeme/selfpost/internal/backup"
@@ -70,6 +71,11 @@ func (h *Handlers) HandleBackup(w http.ResponseWriter, r *http.Request) {
h.renderBackupPageWith(w, r, http.StatusBadRequest, "", pwErr)
return
}
if err := backup.ValidateDeployRoot(h.cfg.DeployRoot); err != nil {
logf("panel: full backup: %v", err)
h.renderBackupPageWith(w, r, http.StatusBadRequest, "", deployBackupErr(err))
return
}
stamp := time.Now().UTC().Format("20060102-150405")
filename := fmt.Sprintf("selfpost-backup-%s.tar.gz", stamp)
@@ -105,9 +111,13 @@ func (h *Handlers) HandleBackup(w http.ResponseWriter, r *http.Request) {
}
if err := backup.Create(sink, backup.Params{
DataDir: h.cfg.DataDir,
DBPath: h.cfg.DBPath,
Version: h.cfg.Version,
DataDir: h.cfg.DataDir,
DBPath: h.cfg.DBPath,
Version: h.cfg.Version,
DeployRoot: h.cfg.DeployRoot,
OnWarn: func(msg string) {
logf("panel: full backup: %s", msg)
},
}); err != nil {
logf("panel: full backup failed: %v", err)
return
@@ -310,6 +320,14 @@ func decryptErrorMessage(err error) string {
}
}
// deployBackupErr phrases a pre-flight backup failure for the operator.
func deployBackupErr(err error) string {
if strings.Contains(err.Error(), "DeployRoot") || strings.Contains(err.Error(), "deploy root") {
return "Full backup needs the project directory mounted read-only at /selfpost-deploy — add <code>.:/selfpost-deploy:ro</code> to docker-compose.yml and recreate the container."
}
return "Could not create the backup: " + err.Error()
}
// importErrorMessage maps a domain-import failure (already logged by the caller)
// to an HTTP status and a user-facing message. Duplicate domain/login are called
// out specifically; other failures — validation errors describing what is wrong
+4 -4
View File
@@ -124,10 +124,10 @@ func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
return
}
h.view.Render(w, http.StatusOK, "domain_delete", map[string]any{
"Title": "SelfPost — delete " + d.Name,
"User": auth.CurrentUser(r),
"Active": "domains",
"Domain": d,
"Title": "SelfPost — delete " + d.Name,
"User": auth.CurrentUser(r),
"Active": "domains",
"Domain": d,
"IsGlobal": true,
})
}
+65 -45
View File
@@ -2,6 +2,7 @@ package handlers
import (
"errors"
"fmt"
"io/fs"
"net/http"
"net/url"
@@ -107,15 +108,15 @@ func (h *Handlers) HandleDelivery(w http.ResponseWriter, r *http.Request) {
row.Subject = mailhdr.DecodeSubject(row.Subject)
logRows, logNote := h.deliveryLog(row)
h.view.Render(w, http.StatusOK, "delivery", map[string]any{
"Title": "SelfPost — delivery",
"User": auth.CurrentUser(r),
"Active": "deliveries",
"Title": "SelfPost — delivery",
"User": auth.CurrentUser(r),
"Active": "deliveries",
"IsGlobal": p.IsGlobal(),
"Row": row,
"Row": row,
// The status in the panel's own badge vocabulary, so the headline reads
// the same way as every other health signal in the panel.
"Level": deliveryLevel(row.Status),
"Events": deliveryEvents(row),
"Events": deliveryEvents(row, h.cfg.RetryPolicy),
// The mail.log lines for this message, and — when there are none — the
// reason, which is a normal outcome rather than a failure.
"LogRows": logRows,
@@ -173,7 +174,9 @@ type deliveryEvent struct {
// timestamps *are* the history, and stating them as steps is what makes a row
// whose created_at and updated_at differ by six hours legible as "queued for
// six hours, then delivered" rather than as two dates in a list of fields.
func deliveryEvents(row store.SendLogRow) []deliveryEvent {
// policy supplies the human intervals for deferred and bounced copy, the same
// strings the Mail queue card prints, so the two cannot drift.
func deliveryEvents(row store.SendLogRow, policy postfix.RetryPolicy) []deliveryEvent {
// A rejected message has no second step, and its first one is not an
// acceptance: the journal-milter refused it, so Postfix never queued it.
if row.Status == store.StatusRejected {
@@ -217,7 +220,8 @@ func deliveryEvents(row store.SendLogRow) []deliveryEvent {
Level: "warn",
Status: store.StatusDeferred,
Title: "Deferred, will be retried",
Detail: "The receiving server could not take the message yet. Postfix keeps it queued and retries until it is delivered or the queue lifetime runs out.",
Detail: fmt.Sprintf("The receiving server could not take the message yet. Postfix retries: first after %s, then with increasing gaps up to %s, for up to %s. There is no fixed attempt count — a deferred message stays in the queue until it is delivered or that lifetime runs out.",
policy.FirstRetry(), policy.BackoffCap(), policy.QueueLifetime()),
})
case store.StatusBounced:
return append(events, deliveryEvent{
@@ -225,7 +229,8 @@ func deliveryEvents(row store.SendLogRow) []deliveryEvent {
Level: "error",
Status: store.StatusBounced,
Title: "Bounced",
Detail: "Delivery failed for good: the receiving server refused the message permanently, or Postfix gave up after the queue lifetime. The reason is in the delivery log below.",
Detail: fmt.Sprintf("Delivery failed for good: the receiving server refused the message permanently, or Postfix gave up after %s in the queue. The reason is in the delivery log below.",
policy.QueueLifetime()),
})
default:
// A status the log-tailer learns to write before this switch does.
@@ -299,16 +304,22 @@ func deliveriesBackURL(r *http.Request) string {
// sendLogData reads the domain/app filters and page number off the query
// string, queries the store, and assembles everything the template needs
// (filter dropdown options plus the current selection, rows, and pagination).
//
// The invariant this function owes the journal: a principal who is not global
// only ever reads rows for the domains assigned to them. That scope is stated
// to the store as SendLogFilter.Domains and holds for every number of
// assignments, including none — a domain administrator whose last domain was
// deleted gets an empty log, not the whole one. The query parameters are
// filters *within* that scope and can only narrow it: both are checked against
// the assigned domains and their applications before the query runs, because a
// dropdown that offers only permitted values is a courtesy to the browser, not
// a check on the request.
func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) {
p, ok := h.principal(r)
if !ok {
return nil, errors.New("no principal")
}
q := r.URL.Query()
filter := store.SendLogFilter{
Domain: q.Get("domain"),
AppLogin: q.Get("app"),
}
assigned, err := h.assignedDomains(p)
if err != nil {
@@ -316,31 +327,6 @@ func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) {
}
allowedNames := domainNameSet(assigned)
if !p.IsGlobal() {
if filter.Domain != "" && !allowedNames[filter.Domain] {
filter.Domain = ""
}
if filter.Domain == "" && len(assigned) == 1 {
filter.Domain = assigned[0].Name
}
}
page := parsePage(q.Get("p"))
total, err := h.store.CountSendLog(filter)
if err != nil {
return nil, err
}
rows, err := h.store.QuerySendLog(filter, sendLogPageSize, (page-1)*sendLogPageSize)
if err != nil {
return nil, err
}
view := make([]sendLogRow, len(rows))
for i := range rows {
rows[i].Subject = mailhdr.DecodeSubject(rows[i].Subject)
view[i] = sendLogRow{SendLogRow: rows[i], Level: deliveryLevel(rows[i].Status)}
}
domainNames := make([]string, 0, len(assigned))
for _, d := range assigned {
domainNames = append(domainNames, d.Name)
@@ -362,8 +348,37 @@ func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) {
}
sort.Strings(logins)
if !p.IsGlobal() && filter.AppLogin != "" && !loginSet[filter.AppLogin] {
filter.AppLogin = ""
filter := store.SendLogFilter{
Domain: q.Get("domain"),
AppLogin: q.Get("app"),
// A global administrator reads the whole journal, including rows left
// behind by a domain that has since been deleted.
Domains: domainNames,
AllDomains: p.IsGlobal(),
}
if !p.IsGlobal() {
if filter.Domain != "" && !allowedNames[filter.Domain] {
filter.Domain = ""
}
if filter.AppLogin != "" && !loginSet[filter.AppLogin] {
filter.AppLogin = ""
}
}
page := parsePage(q.Get("p"))
total, err := h.store.CountSendLog(filter)
if err != nil {
return nil, err
}
rows, err := h.store.QuerySendLog(filter, sendLogPageSize, (page-1)*sendLogPageSize)
if err != nil {
return nil, err
}
view := make([]sendLogRow, len(rows))
for i := range rows {
rows[i].Subject = mailhdr.DecodeSubject(rows[i].Subject)
view[i] = sendLogRow{SendLogRow: rows[i], Level: deliveryLevel(rows[i].Status)}
}
lastPage := 1
@@ -402,13 +417,18 @@ func (h *Handlers) HandleMailQueue(w http.ResponseWriter, r *http.Request) {
return
}
out, errText := readQueue()
policy := h.cfg.RetryPolicy
h.view.Render(w, http.StatusOK, "mail_queue", map[string]any{
"Title": "SelfPost — mail queue",
"User": auth.CurrentUser(r),
"Active": "mail_queue",
"IsGlobal": true,
"Output": out,
"Error": errText,
"Title": "SelfPost — mail queue",
"User": auth.CurrentUser(r),
"Active": "mail_queue",
"IsGlobal": true,
"Output": out,
"Error": errText,
"FirstRetry": policy.FirstRetry(),
"BackoffCap": policy.BackoffCap(),
"QueueLifetime": policy.QueueLifetime(),
"RetryFromDefaults": policy.FromDefaults,
})
}
+269 -10
View File
@@ -8,7 +8,9 @@ import (
"strconv"
"strings"
"testing"
"time"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
)
@@ -129,7 +131,7 @@ func TestDeliveryPageMarksAQueuedMessageAsStillWaiting(t *testing.T) {
}); err != nil {
t.Fatalf("insert: %v", err)
}
rows, err := h.store.QuerySendLog(store.SendLogFilter{}, 1, 0)
rows, err := h.store.QuerySendLog(store.SendLogFilter{AllDomains: true}, 1, 0)
if err != nil || len(rows) != 1 {
t.Fatalf("query: %v (%d rows)", err, len(rows))
}
@@ -215,6 +217,174 @@ func TestDeliveryPageNotFound(t *testing.T) {
}
}
// A domain administrator reads the journal of the domains assigned to them and
// nothing else. The list used to be scoped only when exactly one domain was
// assigned, which meant two assignments read as none at all.
func TestSendLogScopedToAssignedDomains(t *testing.T) {
h, domains := serverWithTwoDomains(t)
for name, tc := range map[string]struct {
username string
domainIDs []int64
want []string
unwanted []string
}{
"global sees both": {
"", nil, []string{"First message", "Second message"}, nil,
},
"one assigned domain": {
"one-domain", []int64{domains["first.example.ru"].ID},
[]string{"First message"}, []string{"Second message", "second-app"},
},
"two assigned domains": {
"two-domains", []int64{domains["first.example.ru"].ID, domains["second.example.ru"].ID},
[]string{"First message", "Second message"}, nil,
},
// Every assigned domain deleted cascades the assignments away. That
// leaves a principal entitled to nothing, which is an empty log — the
// case that used to hand over the whole journal.
"no assigned domains": {
"no-domains", nil, []string{"No messages logged yet."},
[]string{"First message", "Second message"},
},
} {
var p auth.Principal
if tc.username == "" {
p = globalPrincipal
} else {
p = domainAdmin(t, h.store, tc.username, tc.domainIDs...)
}
for view, handler := range map[string]http.HandlerFunc{
"page": h.HandleDeliveries,
"fragment": h.HandleDeliveriesRows,
} {
out := getBodyAs(t, handler, "/deliveries", p)
for _, want := range tc.want {
if !strings.Contains(out, want) {
t.Errorf("%s (%s): missing %q:\n%s", name, view, want, out)
}
}
for _, unwanted := range tc.unwanted {
if strings.Contains(out, unwanted) {
t.Errorf("%s (%s): leaks %q:\n%s", name, view, unwanted, out)
}
}
}
}
}
// The filter dropdowns offer only permitted values, so a leak through them can
// only come from a hand-written URL — which is exactly why the values are
// checked against the principal's own domains and applications rather than
// trusted for having been rendered by us.
func TestSendLogIgnoresForgedFilters(t *testing.T) {
h, domains := serverWithTwoDomains(t)
p := domainAdmin(t, h.store, "forged-filter", domains["first.example.ru"].ID)
for _, target := range []string{
"/deliveries?domain=second.example.ru",
"/deliveries?app=second-app",
"/deliveries?domain=second.example.ru&app=second-app",
} {
out := getBodyAs(t, h.HandleDeliveries, target, p)
if strings.Contains(out, "Second message") {
t.Errorf("GET %s leaks another domain's journal:\n%s", target, out)
}
if !strings.Contains(out, "First message") {
t.Errorf("GET %s hid the principal's own journal:\n%s", target, out)
}
}
}
// The detail page has always checked membership; keep it checked, because the
// list and the page are two ways to the same row.
func TestDeliveryPageForeignDomainNotFound(t *testing.T) {
h, domains := serverWithTwoDomains(t)
rows, err := h.store.QuerySendLog(store.SendLogFilter{Domain: "second.example.ru", AllDomains: true}, 1, 0)
if err != nil || len(rows) != 1 {
t.Fatalf("query: %v (%d rows)", err, len(rows))
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/deliveries/"+itoa(rows[0].ID), nil)
req.SetPathValue("id", itoa(rows[0].ID))
req = auth.RequestWithPrincipal(req, domainAdmin(t, h.store, "foreign-detail", domains["first.example.ru"].ID))
h.HandleDelivery(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("delivery page for a foreign domain = %d, want 404", rec.Code)
}
}
// serverWithTwoDomains builds a panel over a store holding two domains, one
// application and one delivered message each, so a scoping test can tell "my
// rows" from "every row" by reading the page.
func serverWithTwoDomains(t *testing.T) (*Handlers, map[string]store.Domain) {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
domains := make(map[string]store.Domain, 2)
for _, d := range []struct{ name, app, subject string }{
{"first.example.ru", "first-app", "First message"},
{"second.example.ru", "second-app", "Second message"},
} {
dom, err := st.AddDomain(d.name, "mail")
if err != nil {
t.Fatalf("add domain %s: %v", d.name, err)
}
if _, err := st.AddApplication(dom.ID, d.app, store.AddressModeWildcard, nil); err != nil {
t.Fatalf("add application %s: %v", d.app, err)
}
if err := st.InsertQueued(store.SendLogEntry{
QueueID: "Q" + d.app, Domain: d.name, AppLogin: d.app,
From: "noreply@" + d.name, To: "public@example.net", Subject: d.subject,
}); err != nil {
t.Fatalf("insert %s: %v", d.subject, err)
}
domains[d.name] = dom
}
return &Handlers{store: st, view: mustView(t), cfg: Config{Version: "test"}}, domains
}
var globalPrincipal = auth.Principal{ID: 1, Username: "admin", Role: auth.RoleGlobal}
func domainAdmin(t *testing.T, st *store.Store, username string, domainIDs ...int64) auth.Principal {
t.Helper()
const hash = "test-hash"
if len(domainIDs) == 0 {
placeholder, err := st.AddDomain(username+".placeholder.invalid", "mail")
if err != nil {
t.Fatalf("add placeholder domain: %v", err)
}
id, err := st.CreateUser(username, hash, store.RoleDomainAdmin, []int64{placeholder.ID})
if err != nil {
t.Fatalf("create domain admin %s: %v", username, err)
}
if err := st.DeleteDomain(placeholder.ID); err != nil {
t.Fatalf("delete placeholder domain: %v", err)
}
domainIDs = nil
u, err := st.GetUser(id)
if err != nil {
t.Fatalf("get domain admin %s: %v", username, err)
}
return auth.Principal{ID: u.ID, Username: u.Username, Role: u.Role, Domains: u.DomainIDs}
}
id, err := st.CreateUser(username, hash, store.RoleDomainAdmin, domainIDs)
if err != nil {
t.Fatalf("create domain admin %s: %v", username, err)
}
u, err := st.GetUser(id)
if err != nil {
t.Fatalf("get domain admin %s: %v", username, err)
}
return auth.Principal{ID: u.ID, Username: u.Username, Role: u.Role, Domains: u.DomainIDs}
}
// serverWithDelivery builds a panel over a store holding one delivery, written
// the way the journal-milter wrote them before it decoded subjects itself.
func serverWithDelivery(t *testing.T) (*Handlers, store.SendLogRow) {
@@ -238,7 +408,7 @@ func serverWithDelivery(t *testing.T) (*Handlers, store.SendLogRow) {
if _, err := st.UpdateStatus("4A1B2C3D", "public@example.ru", store.StatusSent); err != nil {
t.Fatalf("update status: %v", err)
}
rows, err := st.QuerySendLog(store.SendLogFilter{}, 1, 0)
rows, err := st.QuerySendLog(store.SendLogFilter{AllDomains: true}, 1, 0)
if err != nil || len(rows) != 1 {
t.Fatalf("query: %v (%d rows)", err, len(rows))
}
@@ -246,18 +416,20 @@ func serverWithDelivery(t *testing.T) (*Handlers, store.SendLogRow) {
return &Handlers{store: st, view: mustView(t), cfg: Config{Version: "test"}}, rows[0]
}
// getBody runs one handler over a GET and returns the page it wrote, failing
// the test on any non-200. The path's {id} is bound by hand because these calls
// bypass the router that would otherwise fill it in.
// getBody runs one handler over a GET as the global administrator.
func getBody(t *testing.T, h http.HandlerFunc, target string) string {
t.Helper()
return getBodyAs(t, h, target, globalPrincipal)
}
// getBodyAs runs one handler over a GET as the given principal and returns the
// page it wrote, failing the test on any non-200. The path's {id} is bound by
// hand because these calls bypass the router that would otherwise fill it in.
func getBodyAs(t *testing.T, h http.HandlerFunc, target string, p auth.Principal) string {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, target, nil)
req = auth.RequestWithPrincipal(req, auth.Principal{
ID: 1,
Username: "admin",
Role: auth.RoleGlobal,
})
req = auth.RequestWithPrincipal(req, p)
if rest, ok := strings.CutPrefix(req.URL.Path, "/deliveries/"); ok && rest != "rows" {
req.SetPathValue("id", rest)
}
@@ -280,3 +452,90 @@ func writeMailLog(t *testing.T, lines ...string) string {
}
return path
}
// fixtureRetryPolicy is a distinctive policy so tests can tell the Config
// snapshot from live postconf and from compiled-in defaults (5 minutes / 5 days).
func fixtureRetryPolicy() postfix.RetryPolicy {
return postfix.RetryPolicy{
QueueRunDelay: 10 * time.Minute,
MinimalBackoff: 10 * time.Minute,
MaximalBackoff: 4000 * time.Second,
MaximalQueueLifetime: 2 * 24 * time.Hour,
BounceQueueLifetime: 2 * 24 * time.Hour,
}
}
// The retry card sits on the page itself, outside the HTMX poll, and prints
// whatever policy was cached on Config — never a live postconf.
func TestMailQueueShowsRetryPolicyCard(t *testing.T) {
h := &Handlers{view: mustView(t), cfg: Config{Version: "test", RetryPolicy: fixtureRetryPolicy()}}
out := getBody(t, h.HandleMailQueue, "/mail-queue")
for _, want := range []string{
"How delivery retries work",
"id=\"retry-policy\"",
">10 minutes<",
"doubling, cap about 1 hour 7 minutes",
">2 days<",
} {
if !strings.Contains(out, want) {
t.Errorf("mail queue is missing %q:\n%s", want, out)
}
}
if strings.Contains(out, ">5 minutes<") || strings.Contains(out, ">5 days<") {
t.Errorf("mail queue shows stock defaults instead of the fixture:\n%s", out)
}
if strings.Contains(out, "compiled-in defaults") {
t.Error("a fixture policy must not show the fallback note")
}
}
func TestMailQueueBodyOmitsRetryPolicyCard(t *testing.T) {
h := &Handlers{view: mustView(t), cfg: Config{RetryPolicy: fixtureRetryPolicy()}}
out := getBody(t, h.HandleMailQueueBody, "/mail-queue/body")
if strings.Contains(out, "How delivery retries work") || strings.Contains(out, "10 minutes") {
t.Errorf("HTMX fragment includes the retry card:\n%s", out)
}
}
func TestMailQueueNotesCompiledInFallback(t *testing.T) {
h := &Handlers{view: mustView(t), cfg: Config{RetryPolicy: postfix.DefaultRetryPolicy()}}
out := getBody(t, h.HandleMailQueue, "/mail-queue")
if !strings.Contains(out, "compiled-in defaults") {
t.Errorf("fallback note missing:\n%s", out)
}
}
func TestDeliveryPageDeferredUsesRetryPolicy(t *testing.T) {
h, row := serverWithDelivery(t)
h.cfg.RetryPolicy = fixtureRetryPolicy()
if _, err := h.store.UpdateStatus(row.QueueID, row.To, store.StatusDeferred); err != nil {
t.Fatalf("update status: %v", err)
}
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
for _, want := range []string{
"first after 10 minutes",
"up to about 1 hour 7 minutes",
"for up to 2 days",
} {
if !strings.Contains(out, want) {
t.Errorf("deferred history is missing %q:\n%s", want, out)
}
}
}
func TestDeliveryPageBouncedUsesRetryPolicy(t *testing.T) {
h, row := serverWithDelivery(t)
h.cfg.RetryPolicy = fixtureRetryPolicy()
if _, err := h.store.UpdateStatus(row.QueueID, row.To, store.StatusBounced); err != nil {
t.Fatalf("update status: %v", err)
}
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
if !strings.Contains(out, "gave up after 2 days in the queue") {
t.Errorf("bounced history does not use the fixture lifetime:\n%s", out)
}
}
@@ -14,8 +14,8 @@ import (
"golang.org/x/crypto/bcrypt"
)
// HandleAccount serves the signed-in user's account settings.
func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) {
// HandleSettings serves the signed-in user's panel settings.
func (h *Handlers) HandleSettings(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
p, ok := h.principal(r)
@@ -25,20 +25,20 @@ func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) {
}
u, err := h.store.GetUser(p.ID)
if err != nil {
logf("panel: account: get user failed: %v", err)
logf("panel: settings: get user failed: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.renderAccount(w, r, http.StatusOK, "", u.Username, u.DMARCReportEmail, p.IsGlobal())
h.renderSettings(w, r, http.StatusOK, "", u.Username, u.DMARCReportEmail, p.IsGlobal())
case http.MethodPost:
h.submitAccount(w, r)
h.submitSettings(w, r)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string, showDMARC bool) {
func (h *Handlers) renderSettings(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string, showDMARC bool) {
var reportAuth dnscheck.Result
if showDMARC && formDMARCEmail != "" {
if hub := dnscheck.EmailDomain(formDMARCEmail); hub != "" {
@@ -58,13 +58,13 @@ func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status
data["ReportAuthDNS"] = reportAuth
data["ReportAuthHub"] = dnscheck.EmailDomain(formDMARCEmail)
data["Error"] = formErr
data["Flash"] = accountFlash(r)
data["Flash"] = settingsFlash(r)
data["L1Messages"] = h.l1Messages()
data["L1Window"] = h.l1Window()
h.view.Render(w, status, "settings", data)
}
func accountFlash(r *http.Request) string {
func settingsFlash(r *http.Request) string {
switch r.URL.Query().Get("updated") {
case "username":
return "Username changed."
@@ -85,16 +85,16 @@ func accountFlash(r *http.Request) string {
}
}
func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) {
if !h.auth.AllowLoginAttempt(r) {
p, _ := h.principal(r)
h.renderAccount(w, r, http.StatusTooManyRequests,
h.renderSettings(w, r, http.StatusTooManyRequests,
"Too many attempts. Please wait and try again.", auth.CurrentUser(r), "", p.IsGlobal())
return
}
if err := r.ParseForm(); err != nil {
p, _ := h.principal(r)
h.renderAccount(w, r, http.StatusBadRequest, "Invalid form submission.", auth.CurrentUser(r), "", p.IsGlobal())
h.renderSettings(w, r, http.StatusBadRequest, "Invalid form submission.", auth.CurrentUser(r), "", p.IsGlobal())
return
}
@@ -105,7 +105,7 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
}
user, err := h.store.GetUser(p.ID)
if err != nil {
logf("panel: account: get user failed: %v", err)
logf("panel: settings: get user failed: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
@@ -123,21 +123,21 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(current)); err != nil {
h.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail, p.IsGlobal())
h.renderSettings(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail, p.IsGlobal())
return
}
renaming := username != user.Username
if renaming {
if err := validate.Username(username); err != nil {
h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, p.IsGlobal())
h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, p.IsGlobal())
return
}
}
if p.IsGlobal() {
if err := validate.Email(dmarcEmail); err != nil {
h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, true)
h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, true)
return
}
}
@@ -147,16 +147,16 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
repassword := password != "" || confirm != ""
if repassword {
if password != confirm {
h.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username, dmarcEmail, p.IsGlobal())
h.renderSettings(w, r, http.StatusBadRequest, "New passwords do not match.", username, dmarcEmail, p.IsGlobal())
return
}
if err := validate.AdminPassword(password); err != nil {
h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, p.IsGlobal())
h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, p.IsGlobal())
return
}
}
if !renaming && !repassword && !emailChanging {
h.renderAccount(w, r, http.StatusBadRequest,
h.renderSettings(w, r, http.StatusBadRequest,
"Nothing to change: enter a new username, password, or DMARC report address.", username, dmarcEmail, p.IsGlobal())
return
}
@@ -165,8 +165,8 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
if repassword {
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
logf("panel: account: hashing password failed: %v", err)
h.renderAccount(w, r, http.StatusInternalServerError,
logf("panel: settings: hashing password failed: %v", err)
h.renderSettings(w, r, http.StatusInternalServerError,
"Internal error. Please try again.", username, dmarcEmail, p.IsGlobal())
return
}
@@ -174,17 +174,17 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
}
if err := h.store.UpdateUser(user.ID, username, hash, dmarcEmail); err != nil {
logf("panel: account: update user failed: %v", err)
logf("panel: settings: update user failed: %v", err)
msg := "Could not save the changes. Please check the logs and try again."
if errors.Is(err, store.ErrUserNotFound) {
msg = "There is no user account to update."
}
if errors.Is(err, store.ErrUserExists) {
msg = "That username is already in use."
h.renderAccount(w, r, http.StatusConflict, msg, username, dmarcEmail, p.IsGlobal())
h.renderSettings(w, r, http.StatusConflict, msg, username, dmarcEmail, p.IsGlobal())
return
}
h.renderAccount(w, r, http.StatusInternalServerError, msg, username, dmarcEmail, p.IsGlobal())
h.renderSettings(w, r, http.StatusInternalServerError, msg, username, dmarcEmail, p.IsGlobal())
return
}
@@ -197,7 +197,7 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
}
}
logf("panel: user %d account updated (username: %t, password: %t, dmarc email: %t)", user.ID, renaming, repassword, emailChanging)
logf("panel: user %d settings updated (username: %t, password: %t, dmarc email: %t)", user.ID, renaming, repassword, emailChanging)
http.Redirect(w, r, "/settings?updated="+updatedFlag(renaming, repassword, emailChanging), http.StatusSeeOther)
}
+52 -4
View File
@@ -102,10 +102,6 @@ func (h *Handlers) HandleUserEdit(w http.ResponseWriter, r *http.Request) {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Invalid form submission.", FormUsername: u.Username, FormRole: string(u.Role)})
return
}
if r.PostFormValue("action") == "delete" {
h.submitUserDelete(w, r, u)
return
}
h.submitUserUpdate(w, r, u)
default:
w.Header().Set("Allow", "GET, POST")
@@ -291,6 +287,58 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
http.Redirect(w, r, "/users?done=updated", http.StatusSeeOther)
}
// HandleUserDeleteConfirm shows the cascade warning before a panel user is
// removed — the same pattern as HandleDeleteConfirm for domains, so a single
// mis-click on Delete cannot remove a user (P3, code-review.md).
func (h *Handlers) HandleUserDeleteConfirm(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
uid, ok := parseUserID(w, r)
if !ok {
return
}
u, err := h.store.GetUser(uid)
if err != nil {
if errors.Is(err, store.ErrUserNotFound) {
http.NotFound(w, r)
return
}
logf("panel: get user %d: %v", uid, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
data := h.pageBase(r)
data["Title"] = "SelfPost — delete " + u.Username
data["Active"] = "users"
data["TargetID"] = u.ID
data["TargetUsername"] = u.Username
h.view.Render(w, http.StatusOK, "user_delete", data)
}
// HandleUserDelete performs the deletion confirmed on HandleUserDeleteConfirm
// and returns to the user list.
func (h *Handlers) HandleUserDelete(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
uid, ok := parseUserID(w, r)
if !ok {
return
}
u, err := h.store.GetUser(uid)
if err != nil {
if errors.Is(err, store.ErrUserNotFound) {
http.NotFound(w, r)
return
}
logf("panel: get user %d: %v", uid, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.submitUserDelete(w, r, u)
}
func (h *Handlers) submitUserDelete(w http.ResponseWriter, r *http.Request, u store.User) {
p, ok := h.principal(r)
if !ok {
+93
View File
@@ -0,0 +1,93 @@
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+9
View File
@@ -245,6 +245,11 @@ form.inline { display: inline; margin: 0; }
main.page-login, main.page-setup { max-width: 24rem; }
.card + .card { margin-top: 1.2rem; }
.flash { background: var(--flash-bg); border: 1px solid var(--flash-border); color: var(--flash-fg); padding: 0.7rem 1rem; border-radius: 6px; margin-bottom: 1.2rem; }
/* RateLimitErr renders as .flash.error: a validation failure, not a success
notice. Without this rule it inherited the success surface (green) and only
.error's text colour, so a rejected rate limit read as good news in red
text. Same box, danger palette. */
.flash.error { background: var(--danger-bg); border-color: var(--danger-border); color: var(--danger-fg); }
table { width: 100%; border-collapse: collapse; }
/* A table column is at least as wide as the longest unbreakable run inside it,
and the panel's tables are full of runs with nothing to break on: email
@@ -658,6 +663,10 @@ h1.subject { overflow-wrap: anywhere; }
wider than the column it shares. */
.fact-value { display: block; margin-top: 0.1rem; overflow-wrap: anywhere; }
.fact-value.mono { font-family: var(--font-mono); font-size: 0.85rem; }
/* Retry-policy tiles carry a short phrase (`doubling, cap about 1 hour 7
minutes`) rather than a domain or a queue id, so they need a slightly
wider minimum than the delivery-page facts before wrapping to one column. */
.facts.retry-facts { grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); }
/* A message's history: the steps down a line, each with the time it happened,
the status it reached and what that means. The line is the list's own left
border and the dots sit on top of it, so nothing has to be positioned against
+64 -128
View File
@@ -46,145 +46,81 @@
}
});
// --- Address list shown only in list mode -----------------------------
// The "Addresses" field applies to list mode only; in wildcard mode the
// server ignores it, so hiding it removes a field that does nothing. The
// toggle runs on load too, because the edit form of an existing application
// may already be set to list mode.
function syncAddressField(select) {
var form = select.closest("form");
var field = form && form.querySelector("[data-addresses]");
if (!field) {
// --- Conditional field visibility ------------------------------------
// Several forms hide a block until a select, checkbox or file input says it
// applies. One rule table drives them all so the five near-identical helpers
// do not drift.
var showWhenRules = [
{
match: "select[data-list-mode]",
target: "[data-addresses]",
visible: function (el) { return el.value === el.dataset.listMode; }
},
{
match: "select[data-custom-mode]",
target: "[data-custom-address]",
visible: function (el) { return el.value === el.dataset.customMode; }
},
{
match: "select[data-global-role]",
target: "[data-domain-pick]",
visible: function (el) { return el.value !== el.dataset.globalRole; }
},
{
match: "input[data-encrypt-toggle]",
target: "[data-encrypt-fields]",
visible: function (el) { return el.checked; },
clearWhenHidden: true
},
{
match: "input[data-import-file]",
target: "[data-import-password-fields]",
visible: function (el) {
var name = (el.files && el.files[0] && el.files[0].name || "").toLowerCase();
return name !== "" && !/\.json$/.test(name);
},
clearWhenHidden: true
}
];
function syncShowWhen(control) {
var form = control.closest("form");
if (!form) {
return;
}
// The mode values come from the server (store.AddressModeList), so the
// select carries the one that means "list" rather than this script
// hard-coding it.
field.hidden = select.value !== select.dataset.listMode;
}
function initAddressFields(root) {
root.querySelectorAll("select[data-list-mode]").forEach(function (select) {
syncAddressField(select);
select.addEventListener("change", function () {
syncAddressField(select);
});
});
}
// --- Custom DMARC rua address shown only for "custom" mode ------------
// Same idea as the address list: the email field only applies when the
// operator picks Custom address. With JavaScript blocked the field stays
// visible and the server still ignores it for inherit/none.
function syncCustomAddressField(select) {
var form = select.closest("form");
var field = form && form.querySelector("[data-custom-address]");
if (!field) {
for (var i = 0; i < showWhenRules.length; i++) {
var rule = showWhenRules[i];
if (!control.matches(rule.match)) {
continue;
}
var target = form.querySelector(rule.target);
if (!target) {
return;
}
var show = rule.visible(control);
target.hidden = !show;
if (!show && rule.clearWhenHidden) {
target.querySelectorAll("input").forEach(function (input) {
input.value = "";
});
}
return;
}
field.hidden = select.value !== select.dataset.customMode;
}
function initCustomAddressFields(root) {
root.querySelectorAll("select[data-custom-mode]").forEach(function (select) {
syncCustomAddressField(select);
select.addEventListener("change", function () {
syncCustomAddressField(select);
});
});
}
// --- Domain pick shown only for domain administrators ------------------
// Global administrators manage every domain, so the assignment checkboxes
// are irrelevant for that role. The toggle runs on load too, because the
// edit form of an existing global user should not flash the fieldset.
function syncDomainPickField(select) {
var form = select.closest("form");
var field = form && form.querySelector("[data-domain-pick]");
if (!field) {
return;
}
field.hidden = select.value === select.dataset.globalRole;
}
function initDomainPickFields(root) {
root.querySelectorAll("select[data-global-role]").forEach(function (select) {
syncDomainPickField(select);
select.addEventListener("change", function () {
syncDomainPickField(select);
});
});
}
// --- Encryption password fields shown only when asked for --------------
// The backup, export and import forms carry an optional password block. It
// is hidden until the checkbox next to it is ticked, and cleared when it is
// unticked, so a password typed and then abandoned is never submitted. With
// JavaScript blocked the block stays visible and the forms behave exactly as
// the server reads them: the checkbox alone decides whether encryption
// happens.
function syncEncryptFields(box) {
var form = box.closest("form");
var fields = form && form.querySelector("[data-encrypt-fields]");
if (!fields) {
return;
}
fields.hidden = !box.checked;
if (!box.checked) {
fields.querySelectorAll("input").forEach(function (input) {
input.value = "";
});
}
}
function initEncryptFields(root) {
root.querySelectorAll("input[data-encrypt-toggle]").forEach(function (box) {
syncEncryptFields(box);
box.addEventListener("change", function () {
syncEncryptFields(box);
});
});
}
// --- Import password field shown based on the chosen file's extension ---
// The domain-import file decides for itself whether it is encrypted (the
// server checks the envelope magic, not a checkbox), so the panel offers
// the password field the same way: reveal it for a .spde file, hide and
// clear it for a plain .json one. With no file chosen yet there is nothing
// to ask a password for, so the field stays hidden until a file names it.
// An unrecognised name leaves the field visible rather than guessing wrong
// and hiding a password the file needs.
function syncImportPasswordField(input) {
var form = input.closest("form");
var fields = form && form.querySelector("[data-import-password-fields]");
if (!fields) {
return;
}
var name = (input.files && input.files[0] && input.files[0].name || "").toLowerCase();
var hide = name === "" || /\.json$/.test(name);
fields.hidden = hide;
if (hide) {
fields.querySelectorAll("input").forEach(function (pw) {
pw.value = "";
});
}
}
function initImportPasswordField(root) {
root.querySelectorAll("input[data-import-file]").forEach(function (input) {
syncImportPasswordField(input);
input.addEventListener("change", function () {
syncImportPasswordField(input);
function initShowWhen(root) {
showWhenRules.forEach(function (rule) {
root.querySelectorAll(rule.match).forEach(function (control) {
syncShowWhen(control);
control.addEventListener("change", function () {
syncShowWhen(control);
});
});
});
}
document.addEventListener("DOMContentLoaded", function () {
initAddressFields(document);
initCustomAddressFields(document);
initDomainPickFields(document);
initEncryptFields(document);
initImportPasswordField(document);
initShowWhen(document);
});
// --- Adaptive monitoring polling ---------------------------------------
+27
View File
@@ -1,8 +1,10 @@
package view
import (
"io/fs"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
@@ -26,6 +28,7 @@ func TestStaticAssetsCarryETag(t *testing.T) {
// The fonts are the assets this matters most for: they are the largest
// thing the panel serves and the ones a browser is most willing to keep.
"ibm-plex-sans.woff2", "ibm-plex-mono-400.woff2", "ibm-plex-mono-600.woff2",
"OFL.txt",
} {
rec := serveStatic("/static/"+name, nil)
if rec.Code != http.StatusOK {
@@ -77,3 +80,27 @@ func TestStaticETagsAreContentDerived(t *testing.T) {
t.Errorf("favicon.png and favicon.svg share the ETag %s", png)
}
}
// OFL condition 2: the licence text must travel with the Font Software. The
// WOFF2 files are embedded; OFL.txt sits next to them so a copy of the panel
// (source tree, image, or /static/OFL.txt) always has it.
func TestOFLTravelsWithFonts(t *testing.T) {
b, err := fs.ReadFile(assetsFS, "static/OFL.txt")
if err != nil {
t.Fatalf("OFL.txt is not embedded next to the Plex WOFF2 files: %v", err)
}
body := string(b)
if !strings.Contains(body, `Reserved Font Name "Plex"`) {
t.Error("OFL.txt is missing the IBM Plex reserved-font-name notice")
}
if !strings.Contains(body, "SIL OPEN FONT LICENSE Version 1.1") {
t.Error("OFL.txt is missing the SIL OFL 1.1 text")
}
rec := serveStatic("/static/OFL.txt", nil)
if rec.Code != http.StatusOK {
t.Fatalf("GET /static/OFL.txt: status %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "SIL OPEN FONT LICENSE Version 1.1") {
t.Error("GET /static/OFL.txt did not serve the OFL text")
}
}
+8 -6
View File
@@ -3,13 +3,15 @@
<div class="card">
<h2>Full backup</h2>
<p class="muted">Download a full backup of all persistent state — the database,
every domain's DKIM key and the application credentials. Use it to move the
whole server to a new machine: restore it into a container of the
<strong>same SelfPost version</strong>, with the same data mount, before first
start. TLS certificates and the mail queue are not included.</p>
<p class="muted">Download a self-contained backup of the whole instance —
<code>data/</code> (database, DKIM keys, application credentials, and the
Postfix queue), <code>docker-compose.yml</code>, <code>.env</code>, and
<code>certs/</code>. Extract it into an empty project directory on a new
machine, adjust hostname or proxy settings if needed, and start a container of
the <strong>same SelfPost version</strong> before first boot. The reverse-proxy
vhost is not included — set that up separately on the new host.</p>
<p class="muted"><strong>The backup file is a secret</strong> (it contains
private keys and credentials). Store and transfer it securely and delete it
private keys, TLS material, and credentials). Store and transfer it securely and delete it
once the restore succeeds. Encrypting it below is the simplest way to do that:
the download is then a <code>.spbk</code> file (SelfPost backup) that only
the password opens.</p>
@@ -1,5 +1,6 @@
{{define "deliveries_rows"}}
<div id="deliveries-rows" data-poll hx-get="/deliveries/rows?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.Page}}"
<div id="deliveries-rows" data-poll aria-live="polite"
hx-get="/deliveries/rows?domain={{.FilterDomain | urlquery}}&app={{.FilterApp | urlquery}}&p={{.Page}}"
hx-trigger="load" hx-swap="outerHTML">
{{if .Rows}}
<table>
@@ -14,15 +15,15 @@
<td>{{.To}}</td>
<td class="subject"><span title="{{.Subject}}">{{.Subject}}</span></td>
<td class="status"><span class="st st-{{.Level}}">{{.Status}}</span></td>
<td class="actions"><a href="/deliveries/{{.ID}}?domain={{$.FilterDomain}}&app={{$.FilterApp}}&p={{$.Page}}">Details</a></td>
<td class="actions"><a href="/deliveries/{{.ID}}?domain={{$.FilterDomain | urlquery}}&app={{$.FilterApp | urlquery}}&p={{$.Page}}">Details</a></td>
</tr>
{{end}}
</tbody>
</table>
<p class="muted">
Page {{.Page}}{{if .LastPage}} of {{.LastPage}}{{end}}
{{if .HasPrev}} &middot; <a href="/deliveries?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.PrevPage}}">&larr; Newer</a>{{end}}
{{if .HasNext}} &middot; <a href="/deliveries?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.NextPage}}">Older &rarr;</a>{{end}}
{{if .HasPrev}} &middot; <a href="/deliveries?domain={{.FilterDomain | urlquery}}&app={{.FilterApp | urlquery}}&p={{.PrevPage}}">&larr; Newer</a>{{end}}
{{if .HasNext}} &middot; <a href="/deliveries?domain={{.FilterDomain | urlquery}}&app={{.FilterApp | urlquery}}&p={{.NextPage}}">Older &rarr;</a>{{end}}
</p>
{{else}}
<p class="muted">No messages logged yet.</p>
+71 -132
View File
@@ -2,6 +2,61 @@
measure (same pattern as Status). */}}
{{define "wide"}}wide{{end}}
{{/* The Host/name ‖ Type field-pair repeats for every DNS record this page
shows (DKIM, SPF, DMARC, report authorization) in both the status card
and the publishable-record cards below it — only the host and whether it
carries a Copy button change. Two variants rather than one templated
Copy flag: the DNS status card never offers Copy (its host is derived,
not something to paste), the record cards always do. */}}
{{define "host_type"}}
<div class="field-pair host-type">
<div>
<label>Host / name</label>
<span class="code">{{.}}</span>
</div>
<div class="field-type">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
{{end}}
{{define "host_type_copy"}}
<div class="field-pair host-type">
<div>
<label>Host / name</label>
<div class="code-row">
<span class="code">{{.}}</span>
<button type="button" class="copy">Copy</button>
</div>
</div>
<div class="field-type">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
{{end}}
{{/* Value partials pair with the two host_type variants above: field_values
is the DNS status card's raw record dump (no Copy — those values are for
comparison, not for pasting), field_value is the single publishable
value on the record cards below (always has Copy). Callers still guard
the empty case, since "no records yet" and "one blank record" read
differently. */}}
{{define "field_values"}}
<label>Value</label>
<span class="code">{{range .}}{{.}}
{{end}}</span>
{{end}}
{{define "field_value"}}
<label>Value</label>
<div class="code-row">
<span class="code">{{.}}</span>
<button type="button" class="copy">Copy</button>
</div>
{{end}}
{{define "content"}}
<h1>{{.Domain.Name}}</h1>
@@ -37,21 +92,8 @@
<div class="check-cols">
<div class="check-col">
<label>DKIM <span class="st st-{{.DNS.DKIM.Status}}">{{.DNS.DKIM.Status}}</span></label>
<div class="field-pair host-type">
<div>
<label>Host / name</label>
<span class="code">{{.Record.Name}}</span>
</div>
<div class="field-type">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
{{if .DNS.DKIM.Records}}
<label>Value</label>
<span class="code">{{range .DNS.DKIM.Records}}{{.}}
{{end}}</span>
{{end}}
{{template "host_type" .Record.Name}}
{{if .DNS.DKIM.Records}}{{template "field_values" .DNS.DKIM.Records}}{{end}}
{{if ne .DNS.DKIM.Status "ok"}}
<p class="{{if eq .DNS.DKIM.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.DKIM.Detail}}</p>
{{end}}
@@ -59,21 +101,8 @@
<div class="check-col">
<label>SPF <span class="st st-{{.DNS.SPF.Status}}">{{.DNS.SPF.Status}}</span></label>
<div class="field-pair host-type">
<div>
<label>Host / name</label>
<span class="code">{{.Domain.Name}}</span>
</div>
<div class="field-type">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
{{if .DNS.SPF.Records}}
<label>Value</label>
<span class="code">{{range .DNS.SPF.Records}}{{.}}
{{end}}</span>
{{end}}
{{template "host_type" .Domain.Name}}
{{if .DNS.SPF.Records}}{{template "field_values" .DNS.SPF.Records}}{{end}}
{{if ne .DNS.SPF.Status "ok"}}
<p class="{{if eq .DNS.SPF.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.SPF.Detail}}</p>
{{end}}
@@ -83,21 +112,8 @@
<div class="check-col">
<label>DMARC <span class="st st-{{.DNS.DMARC.Status}}">{{.DNS.DMARC.Status}}</span></label>
<div class="field-pair host-type">
<div>
<label>Host / name</label>
<span class="code">{{.DMARCName}}</span>
</div>
<div class="field-type">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
{{if .DNS.DMARC.Records}}
<label>Value</label>
<span class="code">{{range .DNS.DMARC.Records}}{{.}}
{{end}}</span>
{{end}}
{{template "host_type" .DMARCName}}
{{if .DNS.DMARC.Records}}{{template "field_values" .DNS.DMARC.Records}}{{end}}
{{if ne .DNS.DMARC.Status "ok"}}
<p class="{{if eq .DNS.DMARC.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.DMARC.Detail}}</p>
{{else}}
@@ -108,21 +124,8 @@
<div class="check-col">
{{if .DNS.DMARCReportAuth.Status}}
<label>Report authorization <span class="st st-{{.DNS.DMARCReportAuth.Status}}">{{.DNS.DMARCReportAuth.Status}}</span></label>
<div class="field-pair host-type">
<div>
<label>Host / name</label>
<span class="code">{{.ReportAuthName}}</span>
</div>
<div class="field-type">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
{{if .DNS.DMARCReportAuth.Records}}
<label>Value</label>
<span class="code">{{range .DNS.DMARCReportAuth.Records}}{{.}}
{{end}}</span>
{{end}}
{{template "host_type" .ReportAuthName}}
{{if .DNS.DMARCReportAuth.Records}}{{template "field_values" .DNS.DMARCReportAuth.Records}}{{end}}
<p class="{{if eq .DNS.DMARCReportAuth.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARCReportAuth.Detail}}</p>
{{else}}
<label>Report authorization</label>
@@ -141,49 +144,15 @@
<h2>DKIM and SPF records</h2>
<p class="check-col-title">DKIM</p>
<div class="field-pair host-type">
<div>
<label>Host / name</label>
<div class="code-row">
<span class="code">{{.Record.Name}}</span>
<button type="button" class="copy">Copy</button>
</div>
</div>
<div class="field-type">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
<label>Value</label>
<div class="code-row">
<span class="code">{{.Record.Value}}</span>
<button type="button" class="copy">Copy</button>
</div>
{{template "host_type_copy" .Record.Name}}
{{template "field_value" .Record.Value}}
<p class="muted">Not a secret. Signed with selector
<strong>{{.Domain.DKIMSelector}}</strong>.</p>
<p class="check-col-title">SPF</p>
<div class="field-pair host-type">
<div>
<label>Host / name</label>
<div class="code-row">
<span class="code">{{.Domain.Name}}</span>
<button type="button" class="copy">Copy</button>
</div>
</div>
<div class="field-type">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
<label>Value</label>
<div class="code-row">
<span class="code">{{.SPFExample}}</span>
<button type="button" class="copy">Copy</button>
</div>
{{template "host_type_copy" .Domain.Name}}
{{template "field_value" .SPFExample}}
<p class="muted">Merge into an existing SPF if the domain already has one —
do not publish a second record.</p>
@@ -192,19 +161,7 @@
<div class="card" id="dmarc">
<h2>DMARC record</h2>
<div class="field-pair host-type">
<div>
<label>Host / name</label>
<div class="code-row">
<span class="code">{{.DMARCName}}</span>
<button type="button" class="copy">Copy</button>
</div>
</div>
<div class="field-type">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
{{template "host_type_copy" .DMARCName}}
<label>Value{{if eq .DMARCSource "settings"}} <span class="muted">(from Settings)</span>{{else if eq .DMARCSource "custom"}} <span class="muted">(custom)</span>{{else if eq .DMARCSource "none"}} <span class="muted">(no reports)</span>{{end}}</label>
<div class="code-row">
@@ -214,31 +171,13 @@
{{if .SameDomainRUA}}
<p class="error">The report address is on this sending domain. SelfPost does
not receive inbound mail — use a mailbox elsewhere or wait for in-panel report
reception in a future release.</p>
not receive inbound mail — use a mailbox on another domain.</p>
{{end}}
{{if .NeedsReportAuth}}
<p class="check-col-title">Report authorization</p>
<div class="field-pair host-type">
<div>
<label>Host / name</label>
<div class="code-row">
<span class="code">{{.ReportAuthName}}</span>
<button type="button" class="copy">Copy</button>
</div>
</div>
<div class="field-type">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
<label>Value</label>
<div class="code-row">
<span class="code">{{.ReportAuthValue}}</span>
<button type="button" class="copy">Copy</button>
</div>
{{template "host_type_copy" .ReportAuthName}}
{{template "field_value" .ReportAuthValue}}
{{end}}
<p class="muted"><code>p=none</code> does not affect delivery. Tighten to
+32 -2
View File
@@ -1,10 +1,40 @@
{{/* A table of queue ids, sizes, senders and recipients: the whole column
rather than the reading measure (see the "wide" block in layout.html). */}}
{{/* Mail queue is two cards in the whole column: the retry-policy snapshot
(static, outside the HTMX poll) and the live postqueue listing. */}}
{{define "wide"}}wide{{end}}
{{define "content"}}
<h1>Mail queue</h1>
<div class="card" id="retry-policy">
<h2>How delivery retries work</h2>
<p class="muted">This Postfix's policy, read once at panel start. There is
no maximum attempt count — only time. A deferred message stays in this
listing until it is delivered or the queue lifetime runs out.</p>
{{if .RetryFromDefaults}}
<p class="muted">Could not read the effective Postfix configuration;
showing compiled-in defaults. A live change is visible after the next
panel restart.</p>
{{end}}
<div class="facts retry-facts">
<div class="fact">
<span class="fact-label">First retry</span>
<span class="fact-value">{{.FirstRetry}}</span>
</div>
<div class="fact">
<span class="fact-label">Later retries</span>
<span class="fact-value">doubling, cap {{.BackoffCap}}</span>
</div>
<div class="fact">
<span class="fact-label">Kept in queue</span>
<span class="fact-value">{{.QueueLifetime}}</span>
</div>
<div class="fact">
<span class="fact-label">Then</span>
<span class="fact-value">bounced</span>
</div>
</div>
</div>
<div class="card">
<h2>Pending messages</h2>
{{template "mail_queue_body" .}}
@@ -1,5 +1,5 @@
{{define "mail_queue_body"}}
<div id="mail-queue-body" data-poll hx-get="/mail-queue/body" hx-trigger="load" hx-swap="outerHTML">
<div id="mail-queue-body" data-poll aria-live="polite" hx-get="/mail-queue/body" hx-trigger="load" hx-swap="outerHTML">
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<span class="code">{{if .Output}}{{.Output}}{{else}}Queue is empty.{{end}}</span>
</div>
+23 -28
View File
@@ -2,6 +2,25 @@
in panel.css, as on a delivery's page). */}}
{{define "wide"}}wide{{end}}
{{/* The username/password fields are identical for a global administrator
(split card, DMARC alongside) and a domain administrator (narrow card,
no DMARC card) — only the surrounding form and card differ. */}}
{{define "credentials_fields"}}
<label for="username">Username</label>
<input id="username" name="username" autocomplete="username"
autocapitalize="none" spellcheck="false" value="{{.FormUsername}}" required>
<label for="current_password">Current password</label>
<input id="current_password" name="current_password" type="password"
autocomplete="current-password" required>
<label for="new_password">New password</label>
<input id="new_password" name="new_password" type="password" autocomplete="new-password">
<label for="new_password_confirm">Confirm new password</label>
<input id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password">
{{end}}
{{define "content"}}
<h1>Settings</h1>
@@ -16,27 +35,15 @@
<p class="muted">These are the credentials for this control panel only.
Applications keep their own logins and passwords, which are not affected.</p>
<label for="username">Username</label>
<input id="username" name="username" autocomplete="username"
autocapitalize="none" spellcheck="false" value="{{.FormUsername}}" required>
<label for="current_password">Current password</label>
<input id="current_password" name="current_password" type="password"
autocomplete="current-password" required>
<label for="new_password">New password</label>
<input id="new_password" name="new_password" type="password" autocomplete="new-password">
<label for="new_password_confirm">Confirm new password</label>
<input id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password">
{{template "credentials_fields" .}}
</div>
<div class="card">
<h2>DMARC aggregate reports</h2>
<p class="muted">Optional default <code>rua=</code> address for every sending
domain (can be overridden per domain). Use a mailbox on a domain that
receives inbound mail. SelfPost is send-only today; a future release will
be able to receive reports in the panel itself.</p>
receives inbound mail. SelfPost does not receive inbound mail — point
<code>rua=</code> at a mailbox elsewhere.</p>
<label for="dmarc_report_email">Default report address</label>
<input id="dmarc_report_email" name="dmarc_report_email" type="email"
@@ -81,19 +88,7 @@ this one stays signed in.</p>
<p class="muted">These are the credentials for this control panel only.
Applications keep their own logins and passwords, which are not affected.</p>
<form method="post" action="/settings">
<label for="username">Username</label>
<input id="username" name="username" autocomplete="username"
autocapitalize="none" spellcheck="false" value="{{.FormUsername}}" required>
<label for="current_password">Current password</label>
<input id="current_password" name="current_password" type="password"
autocomplete="current-password" required>
<label for="new_password">New password</label>
<input id="new_password" name="new_password" type="password" autocomplete="new-password">
<label for="new_password_confirm">Confirm new password</label>
<input id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password">
{{template "credentials_fields" .}}
<button type="submit">Save changes</button>
</form>
+1 -1
View File
@@ -1,5 +1,5 @@
{{define "status_body"}}
<div id="status-body" data-poll hx-get="/status/fragment" hx-trigger="load" hx-swap="outerHTML">
<div id="status-body" data-poll aria-live="polite" hx-get="/status/fragment" hx-trigger="load" hx-swap="outerHTML">
<div class="card" id="overall">
<h2>Overall <span class="st st-{{.OverallStatus}}">{{.OverallStatus}}</span></h2>
<p class="muted">{{.OverallHeading}}</p>
@@ -1,5 +1,5 @@
{{define "system_log_body"}}
<div id="system-log-body" data-poll hx-get="/system-log/body" hx-trigger="load" hx-swap="outerHTML">
<div id="system-log-body" data-poll aria-live="polite" hx-get="/system-log/body" hx-trigger="load" hx-swap="outerHTML">
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<span class="code">{{if .Lines}}{{range .Lines}}{{.}}
{{end}}{{else}}No log lines yet.{{end}}</span>
@@ -0,0 +1,16 @@
{{define "content"}}
<h1>Delete {{.TargetUsername}}</h1>
{{template "back_link" (back (printf "/users/%d" .TargetID) (printf "Back to %s" .TargetUsername))}}
<div class="card narrow">
<h2>Confirm deletion</h2>
<p>You are about to delete the panel user <strong>{{.TargetUsername}}</strong>.
This cannot be undone; a signed-in session for this user stops working
immediately.</p>
<form method="post" action="/users/{{.TargetID}}/delete">
<button type="submit" class="danger">Delete {{.TargetUsername}}</button>
</form>
</div>
{{end}}
+7 -4
View File
@@ -33,10 +33,13 @@
</fieldset>
<button type="submit">{{if .IsEdit}}Save{{else}}Create{{end}}</button>
{{if .IsEdit}}
<button type="submit" name="action" value="delete" class="danger"{{if .LastGlobalLocked}} disabled{{end}}>Delete user</button>
{{if .LastGlobalLocked}}<p class="muted">The only global administrator cannot be deleted.</p>{{end}}
{{end}}
</form>
{{if .IsEdit}}
{{if .LastGlobalLocked}}
<p class="muted">The only global administrator cannot be deleted.</p>
{{else}}
<a class="danger" href="/users/{{.UserID}}/delete">Delete user</a>
{{end}}
{{end}}
</div>
{{end}}
+37
View File
@@ -282,6 +282,7 @@ func TestSettingsPageDocumentsRateLimits(t *testing.T) {
func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) {
drillDown := map[string]bool{
"user_form.html": true,
"user_delete.html": true,
"domain_detail.html": true,
"domain_delete.html": true,
"delivery.html": true,
@@ -526,3 +527,39 @@ func forEachTemplate(t *testing.T, fn func(name, body string)) {
fn(e.Name(), string(body))
}
}
// The retry-policy card is static HTML on mail_queue (outside the HTMX
// fragment), so rendering the page with a fixture must print those strings
// rather than falling back to empty template fields.
func TestMailQueuePageRendersRetryPolicy(t *testing.T) {
engine, err := New("test")
if err != nil {
t.Fatalf("New: %v", err)
}
var buf bytes.Buffer
if err := engine.Page("mail_queue").ExecuteTemplate(&buf, "layout.html", map[string]any{
"Title": "t", "User": "admin", "Active": "mail_queue", "Version": "test",
"Copyright": "Copyright © 2026 Mikhail Yenuchenko",
"SourceURL": "https://github.com/mixeme/selfpost",
"FirstRetry": "10 minutes", "BackoffCap": "about 1 hour 7 minutes",
"QueueLifetime": "2 days",
}); err != nil {
t.Fatalf("execute mail_queue: %v", err)
}
out := buf.String()
for _, want := range []string{
"How delivery retries work",
"10 minutes",
"about 1 hour 7 minutes",
"2 days",
`id="retry-policy"`,
`hx-get="/mail-queue/body"`,
} {
if !strings.Contains(out, want) {
t.Errorf("mail_queue is missing %q:\n%s", want, out)
}
}
if strings.Contains(out, "compiled-in defaults") {
t.Error("RetryFromDefaults was unset; the fallback note should stay off")
}
}
+1
View File
@@ -37,6 +37,7 @@ var pageFiles = map[string][]string{
"settings": {"templates/settings.html"},
"users": {"templates/users.html"},
"user_form": {"templates/user_form.html"},
"user_delete": {"templates/user_delete.html"},
"backup": {"templates/backup.html", "templates/encrypt_fields.html"},
"domain_detail": {"templates/domain_detail.html", "templates/encrypt_fields.html"},
"domain_delete": {"templates/domain_delete.html"},
+18 -7
View File
@@ -1,4 +1,4 @@
// Package web implements the SelfPost control panel's HTTP surface: the
// Package web implements the SelfPost control panel's HTTP surface: the
// one-time administrator setup flow (security.md), login/session handling
// (security.md) and the authenticated shell the later phases build on.
package web
@@ -13,6 +13,7 @@ import (
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/legal"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
"github.com/mixeme/selfpost/internal/web/handlers"
@@ -40,11 +41,13 @@ type Config struct {
// the log-tailer role follows in cmd/panel.
MailLogPath string
// DataDir and DBPath locate the persistent state a full backup archives
// (architecture.md § Persistence); Version is stamped into the backup
// manifest. They mirror the panel's own configuration.
DataDir string
DBPath string
Version string
// (architecture.md § Persistence); DeployRoot is the operator project
// directory (docker-compose.yml, .env, certs/); Version is stamped into
// the backup manifest. They mirror the panel's own configuration.
DataDir string
DBPath string
DeployRoot string
Version string
// TrustedProxyCIDRs are the reverse-proxy addresses allowed to supply
// X-Forwarded-For (env TRUSTED_PROXY_CIDR). A request whose
// direct peer (RemoteAddr) is not in this list never has its XFF header
@@ -75,6 +78,10 @@ type Config struct {
// display and to cap domain/app level-2 ceilings (guide § Rate limiting).
RateLimitMessagesPerIP int
RateLimitWindowSeconds int
// RetryPolicy is this Postfix's deferred-mail timings, snapshotted once
// when the HTTP role starts. Handlers read the cache; they never call
// postconf (architecture.md).
RetryPolicy postfix.RetryPolicy
}
// Server is the panel HTTP application.
@@ -106,12 +113,14 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config
MailLogPath: cfg.MailLogPath,
DataDir: cfg.DataDir,
DBPath: cfg.DBPath,
DeployRoot: cfg.DeployRoot,
Version: cfg.Version,
TLSCertFile: cfg.TLSCertFile,
OpenDKIMSocket: cfg.OpenDKIMSocket,
JournalSocket: cfg.JournalSocket,
RateLimitMessagesPerIP: cfg.RateLimitMessagesPerIP,
RateLimitWindowSeconds: cfg.RateLimitWindowSeconds,
RetryPolicy: cfg.RetryPolicy,
}, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a)
return &Server{cfg: cfg, auth: a, handlers: h}, nil
}
@@ -158,7 +167,7 @@ func (s *Server) Handler() http.Handler {
authed.HandleFunc("POST /applications/{aid}/delete", h.HandleDeleteApplication)
authed.HandleFunc("POST /reload", h.HandleReload)
authed.HandleFunc("/settings", h.HandleAccount)
authed.HandleFunc("/settings", h.HandleSettings)
authed.HandleFunc("/account", redirectSettings)
authed.HandleFunc("GET /users", h.HandleUsers)
@@ -166,6 +175,8 @@ func (s *Server) Handler() http.Handler {
authed.HandleFunc("POST /users/new", h.HandleUserNew)
authed.HandleFunc("GET /users/{uid}", h.HandleUserEdit)
authed.HandleFunc("POST /users/{uid}", h.HandleUserEdit)
authed.HandleFunc("GET /users/{uid}/delete", h.HandleUserDeleteConfirm)
authed.HandleFunc("POST /users/{uid}/delete", h.HandleUserDelete)
authed.HandleFunc("GET /backup", h.HandleBackupPage)
authed.HandleFunc("POST /backup", h.HandleBackup)