release: 0.5.0: Pre-1.0 security review, domain-admin role, doc consolidation

A bounce could be recorded as a successful delivery. The log-tailer's
Fixed:
  - A bounce could be recorded as a successful delivery. The log-tailer's
  - Log-tailer resumes where it stopped instead of jumping to end-of-file on
  - Level-2 rate limit no longer overshoots under concurrency: messages that
Changed:
  - The project has a single public home: `github.com/mixeme/selfpost`. Codeberg
  - Code comments no longer cite the archived specification. References like
  - `docs/code-review.md` is gone. Its plan is finished — phases 0 (bar the
  - `docs/architecture.md` gained a *Code layers* section: a diagram of
  - Phase 1 doc/code hygiene (`docs/code-review.md`): removed ~30 stale
  … (1 more in CHANGELOG)
Security:
  - Pre-release security review (plan § D, model Fable, 2026-08-06): full pass
  - `saslpasswd2` argv: the application login is now passed after a `--`
Added:
  - Optional password encryption for the two secret-bearing downloads (plan
  - Domain import accepts an encrypted export: the envelope is detected by its
  - `selfpost-backup` writes encrypted archives and reads them back:
  - `TestParseDelivery` covers the exotic mail.log shapes the review asked for
  - docs: README *Encrypting a backup or export*; `docs/security.md` §
  … (14 more in CHANGELOG)
Removed:
  - docs: `security.md` — accepted risk «restore old backup revives session rows»
Changed:
  - docs: `implementation-plan.md` trimmed to the sole open v1.x gate — pre-release
  - docs: `architecture.md` — sessions (SQLite, idle renew, password change),
  - docs: `development.md` — expanded e2e stack and `release.yml` matrix workflow.
  - docs: `security.md` — accepted risk for send-log rows stuck at `queued` after
  - docs: `roadmap.md` — optional send-log / `mail.log` follow-ups under v1.x tail.
  … (10 more in CHANGELOG)

Full detail: CHANGELOG.md (0.5.0).
This commit is contained in:
mix
2026-09-22 15:24:51 +03:00
parent 72b09d60a6
commit 5e5d157496
113 changed files with 4284 additions and 1140 deletions
+2 -2
View File
@@ -8,12 +8,12 @@ import (
// passwordBytes is the number of random bytes behind a generated application
// password. 24 bytes = 192 bits of entropy, well above any brute-force concern
// for a SASL credential the panel shows exactly once (spec 7.6.1).
// for a SASL credential the panel shows exactly once (security.md).
const passwordBytes = 24
// generatePassword returns a strong, URL-safe random password for an
// application's SASL account. The panel generates it, shows it once and never
// stores the plaintext (spec 7.6.1); sasldb2 keeps only the hashed form.
// stores the plaintext (security.md); sasldb2 keeps only the hashed form.
//
// base64url output keeps the password to a safe ASCII alphabet with no shell or
// SMTP-special characters, so it survives being typed into client configuration
+27 -22
View File
@@ -10,11 +10,12 @@ import (
)
// SASLDB manages the Cyrus SASL account database (sasldb2) the panel maintains
// for application credentials (spec 5.1). The panel is the only writer; Postfix
// reads it to authenticate SMTP clients. Accounts are created and removed with
// the standard saslpasswd2 tool ("эквивалент saslpasswd2", per the plan).
// for application credentials (architecture.md § Mail path). The panel is the
// only writer; Postfix reads it to authenticate SMTP clients. Accounts are
// created and removed with the standard saslpasswd2 tool ("эквивалент
// saslpasswd2", per the plan).
type SASLDB struct {
path string // sasldb2 file, under /data so it survives restarts (spec 9)
path string // sasldb2 file, under /data so it survives restarts
realm string // SASL realm, so lookups match what Postfix's SASL uses
// run executes saslpasswd2. It is a field so tests can substitute a fake;
@@ -28,20 +29,20 @@ type SASLDB struct {
// NewSASLDB builds a manager for the sasldb2 at path with the given realm. The
// realm should match SELFPOST_HOSTNAME so the account identity lines up with
// Postfix's SASL configuration in Phase 5.
// Postfix's SASL configuration.
func NewSASLDB(path, realm string) *SASLDB {
return &SASLDB{path: path, realm: realm, run: runSaslpasswd2, dump: dumpSASLDB}
}
// Set creates or updates an application's SASL account with the given password
// (spec 5.1, 7.2.9). Used both at creation and when a password is regenerated;
// saslpasswd2 overwrites an existing entry in place.
// (architecture.md § Mail path). Used both at creation and when a password is
// regenerated; saslpasswd2 overwrites an existing entry in place.
//
// The password is passed to saslpasswd2 on stdin (never as an argument, so it
// cannot leak through the process table or logs). The login is passed as a
// separate argv element after being whitelisted by validateLogin — it never
// goes through a shell and is never interpolated into a command string (spec
// 7.6.3).
// goes through a shell and is never interpolated into a command string
// (security.md).
func (s *SASLDB) Set(login, password string) error {
if err := validateLogin(login); err != nil {
return err
@@ -50,21 +51,24 @@ func (s *SASLDB) Set(login, password string) error {
// -c: create the account / set the password.
// -f: operate on our sasldb2 rather than the system default path.
// -u: the realm the account lives under.
args := []string{"-p", "-c", "-f", s.path, "-u", s.realm, login}
// --: end of options, so a login can never be parsed as a flag (the
// whitelist already forbids nothing that getopt would eat, but a login
// starting with '-' is legal there — this keeps it an operand).
args := []string{"-p", "-c", "-f", s.path, "-u", s.realm, "--", login}
if err := s.run(args, []byte(password)); err != nil {
return fmt.Errorf("saslpasswd2 set %q: %w", login, err)
}
return nil
}
// Delete removes an application's SASL account (spec 7.2.8). A missing account
// Delete removes an application's SASL account (product.md). A missing account
// is not treated as an error, so deletion is idempotent and safe to retry.
func (s *SASLDB) Delete(login string) error {
if err := validateLogin(login); err != nil {
return err
}
// -d: delete the account.
args := []string{"-d", "-f", s.path, "-u", s.realm, login}
// -d: delete the account. "--" as in Set: the login is always an operand.
args := []string{"-d", "-f", s.path, "-u", s.realm, "--", login}
if err := s.run(args, nil); err != nil {
return fmt.Errorf("saslpasswd2 delete %q: %w", login, err)
}
@@ -76,15 +80,16 @@ func (s *SASLDB) Delete(login string) error {
var ErrSecretNotFound = fmt.Errorf("sasl secret not found")
// Secret returns an application's stored password so it can be carried in a
// domain export and re-created verbatim on another instance (spec 7.5.B). This
// is possible because sasldb2 keeps the SASL secret in a password-equivalent
// form (the plaintext userPassword property, to serve challenge-response
// mechanisms) — unlike the admin's one-way bcrypt hash (spec 7.6). The value is
// realm-independent, so the importer can re-key it under its own realm.
// domain export and re-created verbatim on another instance (architecture.md §
// Persistence). This is possible because sasldb2 keeps the SASL secret in a
// password-equivalent form (the plaintext userPassword property, to serve
// challenge-response mechanisms) — unlike the admin's one-way bcrypt hash
// (security.md). The value is realm-independent, so the importer can re-key it
// under its own realm.
//
// It reads the database with db_dump (Berkeley DB), passing only our own file
// path as a fixed argument (no shell, no user input — spec 7.6.3), and returns
// ErrSecretNotFound if the login has no entry.
// path as a fixed argument (no shell, no user input — security.md), and
// returns ErrSecretNotFound if the login has no entry.
func (s *SASLDB) Secret(login string) (string, error) {
if err := validateLogin(login); err != nil {
return "", err
@@ -155,7 +160,7 @@ func parseSASLSecret(dump []byte, login, realm string) (string, bool, error) {
// runSaslpasswd2 executes the real saslpasswd2 with the given arguments and
// stdin. Arguments are passed as a fixed argv (no shell), so no user input is
// ever interpreted as a command (spec 7.6.3).
// ever interpreted as a command (security.md).
func runSaslpasswd2(args []string, stdin []byte) error {
cmd := exec.Command("saslpasswd2", args...)
if stdin != nil {
@@ -170,7 +175,7 @@ func runSaslpasswd2(args []string, stdin []byte) error {
// dumpSASLDB runs db_dump to export the sasldb2 as key/value hex pairs. The path
// is our own sasldb2 file (never user input) and is passed as a fixed argument
// with no shell (spec 7.6.3).
// with no shell (security.md).
func dumpSASLDB(path string) ([]byte, error) {
cmd := exec.Command("db_dump", path)
out, err := cmd.CombinedOutput()
+4 -3
View File
@@ -39,8 +39,9 @@ func TestSASLSetPassesPasswordOnStdinNotArgv(t *testing.T) {
if strings.Contains(joined, secret) {
t.Errorf("password leaked into argv: %q", joined)
}
// Expected fixed flags and the login as its own trailing argument.
want := []string{"-p", "-c", "-f", "/data/sasl/sasldb2", "-u", "mail.example.com", "alerts"}
// Expected fixed flags and the login as its own trailing argument, behind
// "--" so it can never be parsed as an option.
want := []string{"-p", "-c", "-f", "/data/sasl/sasldb2", "-u", "mail.example.com", "--", "alerts"}
if len(fr.args) != len(want) {
t.Fatalf("args = %v, want %v", fr.args, want)
}
@@ -56,7 +57,7 @@ func TestSASLDeleteArgs(t *testing.T) {
if err := s.Delete("alerts"); err != nil {
t.Fatalf("Delete: %v", err)
}
want := []string{"-d", "-f", "/data/sasl/sasldb2", "-u", "mail.example.com", "alerts"}
want := []string{"-d", "-f", "/data/sasl/sasldb2", "-u", "mail.example.com", "--", "alerts"}
if strings.Join(fr.args, " ") != strings.Join(want, " ") {
t.Errorf("delete args = %v, want %v", fr.args, want)
}
+37 -33
View File
@@ -1,13 +1,13 @@
// Package app owns application accounts (spec 4.1, 5.1): the SASL credentials in
// sasldb2, the per-application sender address mode, and the
// smtpd_sender_login_maps bindings that tie each login to the addresses it may
// send from. It keeps those three stores — the SQLite registry, sasldb2 and the
// Postfix map — in agreement and drives the Postfix reload.
// Package app owns application accounts (product.md § Multi-domain model): the
// SASL credentials in sasldb2, the per-application sender address mode, and
// the smtpd_sender_login_maps bindings that tie each login to the addresses it
// may send from. It keeps those three stores — the SQLite registry, sasldb2
// and the Postfix map — in agreement and drives the Postfix reload.
package app
import (
"codeberg.org/mix/selfpost/internal/postfix"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
)
// SenderMaps is the slice of the Postfix manager the application service needs:
@@ -19,7 +19,7 @@ type SenderMaps interface {
// Service coordinates application state across SQLite, sasldb2 and the Postfix
// sender_login_maps. Web handlers validate raw input first; the Service performs
// the domain-ownership checks that must not be skipped (spec 7.6.2) and keeps
// the domain-ownership checks that must not be skipped (security.md) and keeps
// the stores consistent.
type Service struct {
store *store.Store
@@ -33,7 +33,7 @@ func NewService(st *store.Store, sasl *SASLDB, pf SenderMaps) *Service {
return &Service{store: st, sasl: sasl, pf: pf}
}
// List returns a domain's applications with their address lists (spec 7.2.6).
// List returns a domain's applications with their address lists (product.md).
func (s *Service) List(domainID int64) ([]store.Application, error) {
return s.store.ListApplicationsByDomain(domainID)
}
@@ -44,10 +44,10 @@ func (s *Service) Get(id int64) (store.Application, error) {
}
// Create adds an application to a domain: it validates the login and (in list
// mode) that every address belongs to the domain (spec 7.6.2), generates a
// mode) that every address belongs to the domain (security.md), generates a
// strong password, writes the SASL account and rebuilds the sender map (spec
// 7.2.5). The generated password is returned so the caller can show it exactly
// once (spec 7.6.1) — it is never persisted in plaintext.
// once (security.md) — it is never persisted in plaintext.
//
// The registry row is written first so its UNIQUE constraint is the sole arbiter
// of a duplicate login (avoiding a check-then-act race and, crucially, avoiding
@@ -92,13 +92,14 @@ func (s *Service) rollbackCreate(id int64, login string) {
_, _ = s.store.DeleteApplication(id)
}
// ImportApplication re-creates an application from a domain-export file (spec
// 7.5.B): it validates the login and (in list mode) that every address belongs
// to the domain, inserts the registry row and writes the SASL account with the
// imported password verbatim, re-keyed under this instance's realm so the
// credential keeps working without regeneration. It deliberately does not
// rebuild the sender map — the caller (domain import) does that once after all
// applications are in — and returns store.ErrLoginExists if the login collides.
// ImportApplication re-creates an application from a domain-export file
// (architecture.md § Persistence): it validates the login and (in list mode)
// that every address belongs to the domain, inserts the registry row and
// writes the SASL account with the imported password verbatim, re-keyed under
// this instance's realm so the credential keeps working without regeneration.
// It deliberately does not rebuild the sender map — the caller (domain import)
// does that once after all applications are in — and returns
// store.ErrLoginExists if the login collides.
func (s *Service) ImportApplication(domainID int64, login, mode string, rawAddresses []string, password string) error {
addresses, err := s.validateForDomain(domainID, login, mode, rawAddresses)
if err != nil {
@@ -118,14 +119,15 @@ func (s *Service) ImportApplication(domainID int64, login, mode string, rawAddre
return nil
}
// Secret returns an application's stored password for a domain export (spec
// 7.5.B). See SASLDB.Secret for why this is possible and safe.
// Secret returns an application's stored password for a domain export
// (architecture.md § Persistence). See SASLDB.Secret for why this is possible
// and safe.
func (s *Service) Secret(login string) (string, error) {
return s.sasl.Secret(login)
}
// UpdateMode switches an application's address mode / list and rebuilds the
// sender map (spec 7.2.7). The login and password are untouched. Addresses are
// sender map (product.md). The login and password are untouched. Addresses are
// re-validated against the application's domain.
func (s *Service) UpdateMode(id int64, mode string, rawAddresses []string) error {
a, err := s.store.GetApplication(id)
@@ -172,22 +174,23 @@ func (s *Service) Delete(id int64) error {
if err := s.sasl.Delete(a.Login); err != nil {
return err
}
// Drop the application's level-2 limit, if any (spec 7.4); rate_limits has no
// cascade of its own.
// Drop the application's level-2 limit, if any (README § Rate limiting);
// rate_limits has no cascade of its own.
if err := s.store.DeleteRateLimit(store.RateLimitScopeApp, id); err != nil {
return err
}
return s.Resync()
}
// RateLimit returns the application-level differentiated rate limit (spec 7.4),
// and whether one is configured, for the application's edit form.
// RateLimit returns the application-level differentiated rate limit (README §
// Rate limiting), and whether one is configured, for the application's edit
// form.
func (s *Service) RateLimit(appID int64) (store.RateLimit, bool, error) {
return s.store.GetRateLimit(store.RateLimitScopeApp, appID)
}
// SaveRateLimit stores the application-level rate limit. The caller has validated
// the IPs and numbers (spec 7.6.2); the milter reads the row live, so no reload
// the IPs and numbers (security.md); the milter reads the row live, so no reload
// is needed.
func (s *Service) SaveRateLimit(appID int64, ips []string, maxMessages, windowSeconds int) error {
return s.store.SetRateLimit(store.RateLimit{
@@ -199,14 +202,15 @@ func (s *Service) SaveRateLimit(appID int64, ips []string, maxMessages, windowSe
})
}
// ClearRateLimit removes the application-level rate limit (spec 7.4).
// ClearRateLimit removes the application-level rate limit (README § Rate
// limiting).
func (s *Service) ClearRateLimit(appID int64) error {
return s.store.DeleteRateLimit(store.RateLimitScopeApp, appID)
}
// PurgeDomainSASL removes the SASL accounts of every application bound to a
// domain. It must be called before the domain's registry rows are cascade-
// deleted, while the logins are still known (spec 7.2.4). The registry rows and
// deleted, while the logins are still known (product.md). The registry rows and
// the sender map are handled by the domain deletion path; this only clears
// sasldb2, which has no cascade of its own.
func (s *Service) PurgeDomainSASL(domainID int64) error {
@@ -223,10 +227,10 @@ func (s *Service) PurgeDomainSASL(domainID int64) error {
}
// Resync rebuilds smtpd_sender_login_maps from the full set of application
// bindings and reloads Postfix (spec 5.1). It is the single idempotent apply
// path shared by create/edit/delete and is also reachable from the manual
// reload button; it doubles as recovery if the map ever drifts from the
// database.
// bindings and reloads Postfix (architecture.md § Mail path). It is the single
// idempotent apply path shared by create/edit/delete and is also reachable
// from the manual reload button; it doubles as recovery if the map ever drifts
// from the database.
func (s *Service) Resync() error {
bindings, err := s.store.ListBindings()
if err != nil {
@@ -241,7 +245,7 @@ func (s *Service) Resync() error {
// validateForDomain resolves the domain, validates the login and address mode,
// and — in list mode — validates that every address belongs to the domain
// (spec 7.6.2). It returns the cleaned address list, which is empty in wildcard
// (security.md). It returns the cleaned address list, which is empty in wildcard
// mode. Resolving the domain here also confirms it exists before any write.
func (s *Service) validateForDomain(domainID int64, login, mode string, rawAddresses []string) ([]string, error) {
d, err := s.store.GetDomain(domainID)
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"path/filepath"
"testing"
"codeberg.org/mix/selfpost/internal/postfix"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
)
// fakeMaps records the last set of bindings passed to a rebuild and can be told
+10 -9
View File
@@ -4,7 +4,7 @@ import (
"fmt"
"strings"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/store"
)
const (
@@ -13,12 +13,12 @@ const (
)
// validateLogin enforces a strict server-side whitelist for the SASL login
// (spec 7.6.2). It intentionally excludes '@': the login is stored in sasldb2,
// (security.md). It intentionally excludes '@': the login is stored in sasldb2,
// where '@' separates the user from the realm, so allowing it would change the
// account's identity. Client validation is never trusted.
//
// The login is the one piece of user input that is passed to saslpasswd2 as a
// command argument (never through a shell, spec 7.6.3); this whitelist is what
// command argument (never through a shell, security.md); this whitelist is what
// makes that safe.
func validateLogin(login string) error {
if len(login) < minLoginLen || len(login) > maxLoginLen {
@@ -36,10 +36,11 @@ func validateLogin(login string) error {
}
// validateImportedPassword guards a password taken from a domain-export file
// (spec 7.5.B) before it is written to sasldb2. Our own exports carry base64url
// passwords, but the file is untrusted input, so we reject an empty value or one
// containing control characters — saslpasswd2 reads the passphrase from stdin
// and a newline would silently truncate it (spec 7.6.2).
// (architecture.md § Persistence) before it is written to sasldb2. Our own
// exports carry base64url passwords, but the file is untrusted input, so we
// reject an empty value or one containing control characters — saslpasswd2
// reads the passphrase from stdin and a newline would silently truncate it
// (security.md).
func validateImportedPassword(password string) error {
if password == "" {
return fmt.Errorf("imported application password is empty")
@@ -71,7 +72,7 @@ func normalizeAddress(addr string) string {
}
// validateSenderAddress enforces that a list-mode address is well-formed and,
// critically, belongs to the application's own domain (spec 7.6.2). The domain
// critically, belongs to the application's own domain (security.md). The domain
// check is done here, before anything is written to a config file — not left to
// smtpd_sender_login_maps to catch at delivery time. domain must already be a
// validated, normalised domain name.
@@ -92,7 +93,7 @@ func validateSenderAddress(addr, domain string) error {
// validateLocalPart applies a conservative whitelist to the part before '@'.
// This is deliberately stricter than RFC 5321 (no quoted local parts) so the
// value is always safe to write verbatim into the Postfix map (spec 7.6.4).
// value is always safe to write verbatim into the Postfix map (security.md).
func validateLocalPart(local string) error {
if local == "" {
return fmt.Errorf("missing the part before '@'")
+1 -1
View File
@@ -25,7 +25,7 @@ func TestValidateLogin(t *testing.T) {
}
func TestValidateSenderAddressDomainOwnership(t *testing.T) {
// The critical check (spec 7.6.2): an address must belong to the app's domain.
// The critical check (security.md): an address must belong to the app's domain.
if err := validateSenderAddress("alerts@example.com", "example.com"); err != nil {
t.Errorf("same-domain address rejected: %v", err)
}
+30 -27
View File
@@ -1,17 +1,18 @@
// Package backup implements SelfPost's full-server backup and the restore
// version guard (spec 7.5.A). 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 (spec 7.5.A).
// 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).
//
// 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 schema/format skew
// between versions cannot silently corrupt state (spec 7.5.A).
// 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
// schema/format skew between versions cannot silently corrupt state
// (architecture.md § Persistence).
package backup
import (
@@ -39,9 +40,9 @@ const FormatFull = "selfpost-full-backup"
const ManifestName = "manifest.json"
// 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 (spec
// 7.5.A).
// 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
// (architecture.md § Persistence).
type Manifest struct {
Format string `json:"format"`
Version string `json:"version"`
@@ -63,9 +64,9 @@ type Params struct {
// 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); and a "tls" directory holds the reverse
// proxy's certificates, which are explicitly out of scope for a SelfPost backup
// (spec 7.5.A) — excluding it keeps that guarantee even when an operator points
// TLS_CERT_FILE inside /data.
// 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.
var excludedFromArchive = map[string]bool{
"selfpost.db": true,
"selfpost.db-wal": true,
@@ -78,9 +79,10 @@ var excludedFromArchive = map[string]bool{
// 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 (spec 7.5.A). 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.
// 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.
func Create(w io.Writer, p Params) error {
if p.DataDir == "" || p.DBPath == "" {
return fmt.Errorf("backup: DataDir and DBPath are required")
@@ -254,12 +256,13 @@ func snapshotDB(dbPath string) (path string, cleanup func(), err error) {
return target, cleanup, nil
}
// CheckRestore enforces the backup version guard (spec 7.5.A). If manifestPath
// exists (a backup was extracted into the data 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.
// CheckRestore enforces the backup version guard (architecture.md §
// Persistence). If manifestPath exists (a backup was extracted into the data
// 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 {
data, err := os.ReadFile(manifestPath)
if os.IsNotExist(err) {
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"strings"
"testing"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/store"
)
// seedDataDir builds a realistic /data tree: a migrated SQLite database plus the
+2 -2
View File
@@ -3,8 +3,8 @@ package buildinfo
// Version is the SelfPost release. It is set at build time with
//
// -ldflags "-X codeberg.org/mix/selfpost/internal/buildinfo.Version=<tag>"
// -ldflags "-X github.com/mixeme/selfpost/internal/buildinfo.Version=<tag>"
//
// and must match the Docker image tag; it is used for the backup/restore
// compatibility check (spec 7.5.A). Defaults to "dev" for local/unstamped builds.
// compatibility check (architecture.md § Persistence). Defaults to "dev" for local/unstamped builds.
var Version = "dev"
+1 -1
View File
@@ -18,7 +18,7 @@ import (
"sync"
"time"
"codeberg.org/mix/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/health"
)
const (
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"testing"
"time"
"codeberg.org/mix/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/health"
)
// fakeResolver serves a fixed zone, so the checks can be driven through every
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
"codeberg.org/mix/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/health"
)
func TestSPFExample(t *testing.T) {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"fmt"
"strings"
"codeberg.org/mix/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/health"
)
// checkDKIM compares the TXT record published at <selector>._domainkey.<domain>
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"fmt"
"time"
"codeberg.org/mix/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/health"
)
// checkServer resolves the panel's own hostname and confirms the reverse
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"net"
"strings"
"codeberg.org/mix/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/health"
)
// spfLookupBudget caps how many DNS lookups one SPF evaluation may spend on
+5 -5
View File
@@ -1,8 +1,8 @@
// Package domain owns SelfPost's sending-domain model: per-domain DKIM key
// generation, the OpenDKIM KeyTable/SigningTable that drive signing, and the
// orchestration that keeps the SQLite registry, the on-disk keys and OpenDKIM
// in agreement (spec 4.1, 6). Key material lives under /data so it survives
// container restarts (spec 6.1, 9).
// in agreement (architecture.md § OpenDKIM). Key material lives under /data so
// it survives container restarts (architecture.md § OpenDKIM).
package domain
import (
@@ -46,7 +46,7 @@ func writePrivateKeyPEM(path string, key *rsa.PrivateKey) error {
// loadPrivateKeyPEM reads and parses a PKCS#1 RSA private key written by
// writePrivateKeyPEM. It is used to recompute the public DNS record on demand,
// keeping the private key file the single source of truth (spec 7.2.10).
// keeping the private key file the single source of truth (product.md).
func loadPrivateKeyPEM(path string) (*rsa.PrivateKey, error) {
data, err := os.ReadFile(path)
if err != nil {
@@ -63,7 +63,7 @@ func loadPrivateKeyPEM(path string) (*rsa.PrivateKey, error) {
return key, nil
}
// DKIMRecord is the DNS TXT record a user must publish for a domain (spec 7.2.10).
// DKIMRecord is the DNS TXT record a user must publish for a domain (product.md).
type DKIMRecord struct {
// Name is the record's host, e.g. "selfpost._domainkey.example.com".
Name string
@@ -89,7 +89,7 @@ func dkimRecord(selector, domainName string, pub *rsa.PublicKey) (DKIMRecord, er
// writeFileAtomic writes data to path via a temp file in the same directory
// followed by a rename, so readers only ever see the complete old or new file.
// It is the single safe-write primitive for DKIM keys and OpenDKIM tables
// (spec 7.6.4).
// (security.md).
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".tmp-*")
+33 -30
View File
@@ -12,9 +12,9 @@ import (
)
// OpenDKIM manages the on-disk OpenDKIM state the panel is responsible for
// (spec 6): per-domain signing keys under keysDir and the KeyTable/SigningTable
// that map domains to those keys. After rewriting the tables it asks OpenDKIM to
// reload them.
// (architecture.md § OpenDKIM): per-domain signing keys under keysDir and the
// KeyTable/SigningTable that map domains to those keys. After rewriting the
// tables it asks OpenDKIM to reload them.
type OpenDKIM struct {
keysDir string
keyTablePath string
@@ -50,8 +50,8 @@ func (o *OpenDKIM) keyPath(domainName, selector string) string {
// EnsureKey makes sure a signing key exists for the domain. An existing key is
// reused untouched — critical because overwriting it would silently invalidate
// the DKIM record already published in DNS (spec 6.1). Returns whether a new key
// was generated.
// the DKIM record already published in DNS (architecture.md § OpenDKIM).
// Returns whether a new key was generated.
func (o *OpenDKIM) EnsureKey(domainName, selector string) (bool, error) {
if err := assertConfigSafe(domainName, selector); err != nil {
return false, err
@@ -77,8 +77,8 @@ func (o *OpenDKIM) EnsureKey(domainName, selector string) (bool, error) {
return true, nil
}
// RemoveKey deletes a domain's key directory (spec 6.5). A missing directory is
// not an error.
// RemoveKey deletes a domain's key directory (architecture.md § OpenDKIM). A
// missing directory is not an error.
func (o *OpenDKIM) RemoveKey(domainName string) error {
if err := assertConfigSafe(domainName, "x"); err != nil {
return err
@@ -89,10 +89,11 @@ func (o *OpenDKIM) RemoveKey(domainName string) error {
return nil
}
// ExportKey returns a domain's DKIM private key as PKCS#1 PEM, for carrying in a
// domain export so the receiving instance signs with the same key and the DNS
// TXT record never has to change (spec 7.5.B). It re-marshals the parsed key
// rather than returning the raw file, so a malformed on-disk key is caught here.
// ExportKey returns a domain's DKIM private key as PKCS#1 PEM, for carrying in
// a domain export so the receiving instance signs with the same key and the
// DNS TXT record never has to change (architecture.md § Persistence). It
// re-marshals the parsed key rather than returning the raw file, so a
// malformed on-disk key is caught here.
func (o *OpenDKIM) ExportKey(domainName, selector string) ([]byte, error) {
if err := assertConfigSafe(domainName, selector); err != nil {
return nil, err
@@ -105,11 +106,12 @@ func (o *OpenDKIM) ExportKey(domainName, selector string) ([]byte, error) {
return pem.EncodeToMemory(block), nil
}
// ImportKey writes an imported DKIM private key to disk for a domain (spec
// 7.5.B). The PEM is parsed and re-marshalled through the same writer used for
// generated keys, so only a well-formed PKCS#1 RSA key is ever stored. Unlike
// EnsureKey it overwrites: an import (re-)creates the domain with exactly this
// key, which is the whole point of keeping the published DNS record valid.
// ImportKey writes an imported DKIM private key to disk for a domain
// (architecture.md § Persistence). The PEM is parsed and re-marshalled through
// the same writer used for generated keys, so only a well-formed PKCS#1 RSA
// key is ever stored. Unlike EnsureKey it overwrites: an import (re-)creates
// the domain with exactly this key, which is the whole point of keeping the
// published DNS record valid.
func (o *OpenDKIM) ImportKey(domainName, selector string, pemKey []byte) error {
if err := assertConfigSafe(domainName, selector); err != nil {
return err
@@ -130,7 +132,7 @@ func (o *OpenDKIM) ImportKey(domainName, selector string, pemKey []byte) error {
}
// Record returns the published DKIM DNS record for a domain, recomputed from the
// private key on disk (spec 7.2.10).
// private key on disk (product.md).
func (o *OpenDKIM) Record(domainName, selector string) (DKIMRecord, error) {
key, err := loadPrivateKeyPEM(o.keyPath(domainName, selector))
if err != nil {
@@ -140,10 +142,10 @@ func (o *OpenDKIM) Record(domainName, selector string) (DKIMRecord, error) {
}
// Rebuild regenerates KeyTable and SigningTable from the full domain set and
// reloads OpenDKIM (spec 6.2). Full regeneration (rather than incremental
// edits) keeps the files a pure function of the registry, so add and delete
// share one idempotent path. Both files are written atomically before the
// reload signal is sent.
// reloads OpenDKIM (architecture.md § OpenDKIM). Full regeneration (rather
// than incremental edits) keeps the files a pure function of the registry, so
// add and delete share one idempotent path. Both files are written atomically
// before the reload signal is sent.
func (o *OpenDKIM) Rebuild(domains []SigningDomain) error {
keyTable, signingTable, err := renderTables(o.keysDir, domains)
if err != nil {
@@ -158,17 +160,18 @@ func (o *OpenDKIM) Rebuild(domains []SigningDomain) error {
return o.reload()
}
// Reload asks OpenDKIM to re-read its tables without regenerating them. It backs
// the panel's manual reload button (spec 7.2.12).
// Reload asks OpenDKIM to re-read its tables without regenerating them. It
// backs the panel's manual reload button (architecture.md § Panel HTTP
// surface).
func (o *OpenDKIM) Reload() error {
return o.reload()
}
// 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 (spec
// 7.6.4) — validation upstream already guarantees this, but the table writer
// refuses to emit anything unsafe as a hard backstop.
// re-checked for shell/config-injection safety before being written
// (security.md) — validation upstream already guarantees this, but the table
// writer refuses to emit anything unsafe as a hard backstop.
func renderTables(keysDir string, domains []SigningDomain) (keyTable, signingTable []byte, err error) {
sorted := append([]SigningDomain(nil), domains...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
@@ -191,9 +194,9 @@ func renderTables(keysDir string, domains []SigningDomain) (keyTable, signingTab
// assertConfigSafe rejects any domain/selector value that could break out of a
// single table line. Domains are already whitelisted to [a-z0-9.-] and selectors
// to a similar set before they reach here (spec 7.6.2); this is defence in depth
// to a similar set before they reach here (security.md); this is defence in depth
// against a validation gap ever letting whitespace, a newline or a field
// separator through into a config file (spec 7.6.4).
// separator through into a config file (security.md).
func assertConfigSafe(domainName, selector string) error {
for _, v := range []string{domainName, selector} {
if v == "" {
@@ -210,10 +213,10 @@ func assertConfigSafe(domainName, selector string) error {
// OpenDKIM process SIGUSR1, which makes it re-read KeyTable/SigningTable
// (opendkim's documented reload signal). The panel runs unprivileged and cannot
// signal another user's process directly, so it goes through the supervisor
// control socket, reachable via the shared `selfpost` group (spec 7.6.3, 7.6.8).
// control socket, reachable via the shared `selfpost` group (security.md).
//
// Arguments are fixed literals — no user input is interpolated into the command,
// and it never goes through a shell (spec 7.6.3).
// and it never goes through a shell (security.md).
func reloadViaSupervisor() error {
cmd := exec.Command("supervisorctl",
"-c", "/etc/supervisor/supervisord.conf",
+26 -21
View File
@@ -3,7 +3,7 @@ package domain
import (
"fmt"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/store"
)
// Applications is the slice of the application service the domain service needs
@@ -19,18 +19,20 @@ type Applications interface {
// and reloads Postfix.
Resync() error
// Secret returns an application's stored password, for a domain export
// (spec 7.5.B).
// (architecture.md § Persistence).
Secret(login string) (string, error)
// ImportApplication re-creates an application (registry row + SASL account)
// from a domain-export file, without rebuilding the sender map (spec 7.5.B).
// from a domain-export file, without rebuilding the sender map
// (architecture.md § Persistence).
ImportApplication(domainID int64, login, mode string, addresses []string, password string) error
}
// Service coordinates the places a sending domain lives: the SQLite registry,
// the on-disk DKIM keys and OpenDKIM's tables, plus — on deletion — the SASL
// database and Postfix sender map its applications touch. Callers (the web
// handlers) validate user input first; Service keeps the stores in agreement and
// drives the OpenDKIM/Postfix reloads (spec 6, 7.2.2-4, 7.2.10).
// handlers) validate user input first; Service keeps the stores in agreement
// and drives the OpenDKIM/Postfix reloads (architecture.md § OpenDKIM,
// product.md).
type Service struct {
store *store.Store
odk *OpenDKIM
@@ -39,14 +41,15 @@ type Service struct {
}
// NewService builds the domain service. selectorDefault is the DKIM selector
// assigned to new domains (spec 8: DKIM_SELECTOR_DEFAULT); it is
// operator-configured, not user input. apps is used only on deletion, to clear
// the SASL accounts and sender-map bindings of the domain's applications.
// assigned to new domains (README § Environment variables:
// DKIM_SELECTOR_DEFAULT); it is operator-configured, not user input. apps is
// used only on deletion, to clear the SASL accounts and sender-map bindings of
// the domain's applications.
func NewService(st *store.Store, odk *OpenDKIM, apps Applications, selectorDefault string) *Service {
return &Service{store: st, odk: odk, apps: apps, selector: selectorDefault}
}
// List returns all domains with application counts (spec 7.2.2).
// List returns all domains with application counts (product.md).
func (s *Service) List() ([]store.Domain, error) {
return s.store.ListDomains()
}
@@ -57,7 +60,7 @@ func (s *Service) Get(id int64) (store.Domain, error) {
}
// Add registers a new sending domain: it records the row, ensures a DKIM key
// exists on disk, and regenerates + reloads the OpenDKIM tables (spec 7.2.3).
// exists on disk, and regenerates + reloads the OpenDKIM tables (product.md).
// name must already be normalised and validated by the caller. A duplicate
// returns store.ErrDomainExists.
//
@@ -91,7 +94,7 @@ func (s *Service) rollbackAdd(id int64) {
_ = s.store.DeleteDomain(id)
}
// Delete removes a domain and everything bound to it (spec 7.2.4, 6.5). The
// Delete removes a domain and everything bound to it (product.md). The
// order matters: the applications' SASL accounts are cleared first, while their
// logins are still in the registry; then the registry rows (applications and
// their addresses) go via the DB cascade; then the OpenDKIM tables and the
@@ -107,8 +110,9 @@ func (s *Service) Delete(id int64) error {
return fmt.Errorf("clear SASL accounts for %s: %w", d.Name, err)
}
// Drop the domain's own level-2 limit and those of its applications while the
// application rows still exist (the cleanup query joins them). rate_limits has
// no cascade of its own (ref_id is a plain integer, spec 7.4/9).
// application rows still exist (the cleanup query joins them). rate_limits
// has no cascade of its own (ref_id is a plain integer, README § Rate
// limiting; architecture.md § Persistence).
if err := s.store.DeleteRateLimitsForDomain(id); err != nil {
return fmt.Errorf("clear rate limits for %s: %w", d.Name, err)
}
@@ -129,19 +133,19 @@ func (s *Service) Delete(id int64) error {
return nil
}
// DKIMRecord returns the DNS TXT record to publish for a domain (spec 7.2.10).
// DKIMRecord returns the DNS TXT record to publish for a domain (product.md).
func (s *Service) DKIMRecord(d store.Domain) (DKIMRecord, error) {
return s.odk.Record(d.Name, d.DKIMSelector)
}
// RateLimit returns the domain-level differentiated rate limit (spec 7.4), and
// whether one is configured, for the domain's edit form.
// RateLimit returns the domain-level differentiated rate limit (README § Rate
// limiting), and whether one is configured, for the domain's edit form.
func (s *Service) RateLimit(domainID int64) (store.RateLimit, bool, error) {
return s.store.GetRateLimit(store.RateLimitScopeDomain, domainID)
}
// SaveRateLimit stores the domain-level rate limit. The caller has validated the
// IPs and numbers (spec 7.6.2); the milter reads the row live, so no reload is
// IPs and numbers (security.md); the milter reads the row live, so no reload is
// needed.
func (s *Service) SaveRateLimit(domainID int64, ips []string, maxMessages, windowSeconds int) error {
return s.store.SetRateLimit(store.RateLimit{
@@ -154,14 +158,15 @@ func (s *Service) SaveRateLimit(domainID int64, ips []string, maxMessages, windo
}
// ClearRateLimit removes the domain-level rate limit, falling back to level 1
// only (spec 7.4).
// only (README § Rate limiting).
func (s *Service) ClearRateLimit(domainID int64) error {
return s.store.DeleteRateLimit(store.RateLimitScopeDomain, domainID)
}
// Resync regenerates the OpenDKIM tables from the registry and reloads OpenDKIM.
// It backs the manual reload button (spec 7.2.12) and doubles as a recovery path
// if the tables ever drift from the database.
// Resync regenerates the OpenDKIM tables from the registry and reloads
// OpenDKIM. It backs the manual reload button (architecture.md § Panel HTTP
// surface) and doubles as a recovery path if the tables ever drift from the
// database.
func (s *Service) Resync() error {
return s.resync()
}
+22 -20
View File
@@ -3,19 +3,20 @@ package domain
import (
"fmt"
"codeberg.org/mix/selfpost/internal/buildinfo"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/buildinfo"
"github.com/mixeme/selfpost/internal/store"
)
// FormatDomainExport identifies a single-domain export file (spec 7.5.B).
// FormatDomainExport identifies a single-domain export file (architecture.md §
// Persistence).
const FormatDomainExport = "selfpost-domain-export"
// DomainExport is the serialisable form of one sending domain, for moving it
// between two independently running SelfPost instances (spec 7.5.B). It carries
// the DKIM private key (so the published DNS record stays valid) and each
// application's working password (so credentials transfer without regeneration).
// The file is therefore as sensitive as a full backup and must be handled as a
// secret.
// between two independently running SelfPost instances (architecture.md §
// Persistence). It carries the DKIM private key (so the published DNS record
// stays valid) and each application's working password (so credentials
// transfer without regeneration). The file is therefore as sensitive as a full
// backup and must be handled as a secret.
type DomainExport struct {
Format string `json:"format"`
Version string `json:"version"`
@@ -35,8 +36,8 @@ type AppExport struct {
// Export builds the transferable representation of a domain: its DKIM key, its
// selector and every application with its address mode and working password
// (spec 7.5.B). The returned struct is marshalled to JSON by the caller and
// offered as a secret download.
// (architecture.md § Persistence). The returned struct is marshalled to JSON
// by the caller and offered as a secret download.
func (s *Service) Export(id int64) (DomainExport, error) {
d, err := s.store.GetDomain(id)
if err != nil {
@@ -73,17 +74,18 @@ func (s *Service) Export(id int64) (DomainExport, error) {
return exp, nil
}
// Import re-creates a domain from an export file on this instance (spec 7.5.B):
// it stores the imported DKIM key (so the published DNS record needs no change),
// registers the domain and rebuilds the OpenDKIM tables, then re-creates each
// application with its working password and rebuilds the Postfix sender map.
// Import re-creates a domain from an export file on this instance
// (architecture.md § Persistence): it stores the imported DKIM key (so the
// published DNS record needs no change), registers the domain and rebuilds the
// OpenDKIM tables, then re-creates each application with its working password
// and rebuilds the Postfix sender map.
//
// exp.Domain must already be normalised and validated by the caller (spec
// 7.6.2); the selector is checked for config-injection safety here. A domain or
// login that already exists is rejected (store.ErrDomainExists /
// store.ErrLoginExists) rather than merged. If any step fails, everything the
// import created is rolled back, so a partial import never leaves the instance
// in an inconsistent state.
// exp.Domain must already be normalised and validated by the caller
// (security.md); the selector is checked for
// config-injection safety here. A domain or login that already exists is
// rejected (store.ErrDomainExists / store.ErrLoginExists) rather than merged.
// If any step fails, everything the import created is rolled back, so a
// partial import never leaves the instance in an inconsistent state.
func (s *Service) Import(exp DomainExport) (store.Domain, error) {
if exp.Format != FormatDomainExport {
return store.Domain{}, fmt.Errorf("not a SelfPost domain export (format %q)", exp.Format)
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"fmt"
"testing"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/store"
)
// fakeApps stands in for *app.Service in the domain-transfer tests: it records
+2 -2
View File
@@ -14,8 +14,8 @@ import (
const certWarnDays = 14
// Certificate is the state of the TLS certificate Postfix serves on 465/587
// (spec 8: TLS_CERT_FILE). The panel only reads it — the file is supplied by
// the reverse proxy through a read-only mount.
// (README § Environment variables: TLS_CERT_FILE). The panel only reads it —
// the file is supplied by the reverse proxy through a read-only mount.
type Certificate struct {
Path string
Subject string
+25
View File
@@ -163,3 +163,28 @@ func writeCert(t *testing.T, path, cn string, validFor time.Duration) {
t.Fatal(err)
}
}
func TestLivenessFromParsedProcesses(t *testing.T) {
allRunning := `opendkim RUNNING pid 21, uptime 0:04:10
panel RUNNING pid 22, uptime 0:04:09
postfix RUNNING pid 23, uptime 0:04:08
postfix-reload STOPPED Not started
`
procs := parseProcesses(allRunning)
for _, p := range procs {
if mailPathPrograms[p.Name] && p.Status != StatusOK {
t.Fatalf("%s should be ok for liveness, got %q", p.Name, p.Status)
}
}
postfixDead := `opendkim RUNNING pid 21, uptime 0:04:10
panel RUNNING pid 22, uptime 0:04:09
postfix FATAL Exited too quickly
`
procs = parseProcesses(postfixDead)
for _, p := range procs {
if p.Name == "postfix" && p.Status == StatusOK {
t.Fatal("postfix FATAL should not grade as OK")
}
}
}
+46
View File
@@ -0,0 +1,46 @@
package health
import (
"fmt"
"strings"
)
// mailPathPrograms are the supervised processes whose absence means the
// container should not report healthy to orchestrators.
var mailPathPrograms = map[string]bool{
"opendkim": true,
"panel": true,
"postfix": true,
}
// Liveness reports whether the mail path is healthy enough for container
// probes. It requires opendkim, panel, and postfix to be RUNNING under
// supervisord.
func Liveness() error {
procs, err := Processes()
if err != nil {
return err
}
seen := make(map[string]Status, len(mailPathPrograms))
for _, p := range procs {
if mailPathPrograms[p.Name] {
seen[p.Name] = p.Status
}
}
var unhealthy []string
for name := range mailPathPrograms {
switch seen[name] {
case StatusOK:
case StatusUnknown:
unhealthy = append(unhealthy, name+": missing")
default:
unhealthy = append(unhealthy, fmt.Sprintf("%s: %s", name, seen[name]))
}
}
if len(unhealthy) > 0 {
return fmt.Errorf("unhealthy: %s", strings.Join(unhealthy, ", "))
}
return nil
}
+4 -3
View File
@@ -25,11 +25,12 @@ type Process struct {
Status Status
}
// Processes returns the state of every supervised program (spec 4's three
// processes plus the reload/cert/logrotate helpers).
// Processes returns the state of every supervised program (architecture.md §
// Image and processes — the three processes plus the reload/cert/logrotate
// helpers).
//
// The command takes fixed arguments and no user input, so it never goes through
// a shell (spec 7.6.3). `supervisorctl status` deliberately exits non-zero when
// a shell (security.md). `supervisorctl status` deliberately exits non-zero when
// some program is not running, so the output is parsed first and the exit status
// only matters when nothing could be parsed from it.
func Processes() ([]Process, error) {
+50 -21
View File
@@ -1,9 +1,10 @@
// Package logtail follows Postfix's mail.log and reconciles the send-log
// delivery statuses the journal-milter could not know at receive time (spec
// 7.3). A milter row starts life as "queued"; Postfix only decides sent /
// deferred / bounced later, per recipient, and reports it in mail.log. This
// package parses those lines by queue-id + recipient and advances the matching
// rows, and prunes rows past the retention window.
// delivery statuses the journal-milter could not know at receive time
// (architecture.md § Persistence). A milter row starts life as "queued";
// Postfix only decides sent / deferred / bounced later, per recipient, and
// reports it in mail.log. This package parses those lines by queue-id +
// recipient and advances the matching rows, and prunes rows past the retention
// window.
package logtail
import (
@@ -17,14 +18,17 @@ import (
"strings"
"time"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/store"
)
// StatusStore is the slice of the store the log-tailer needs. *store.Store
// satisfies it.
// StatusStore is the slice of the store the log-tailer needs: advancing
// delivery statuses, pruning the retention window, and remembering how far into
// mail.log it has read. *store.Store satisfies it.
type StatusStore interface {
UpdateStatus(queueID, recipient, status string) (int64, error)
DeleteSendLogBefore(cutoff time.Time) (int64, error)
LogtailState(path string) (store.LogtailState, bool, error)
SaveLogtailState(path string, st store.LogtailState) error
}
// pollInterval is how often the tail loop checks for new bytes / rotation. It
@@ -36,7 +40,7 @@ const (
// startup). The window itself is configurable; the cadence need not be.
retentionInterval = 6 * time.Hour
// defaultRetentionDays applies when the configured value is unset/invalid
// (spec 7.3).
// (README § Environment variables: SEND_LOG_RETENTION_DAYS).
defaultRetentionDays = 90
)
@@ -47,7 +51,13 @@ const (
//
// The "<queue-id>: to=<addr>, …, status=<word>" shape is specific to the
// delivery agents; qmgr/smtpd/cleanup lines do not match.
var deliveryRe = regexp.MustCompile(`\b([0-9A-Za-z]+): to=<([^>]*)>,.*\bstatus=(\w+)`)
//
// The run before status= is lazy on purpose. Postfix appends the remote
// server's reply verbatim, so a greedy match would take the *last* status= on
// the line — and that one can come from the reply text, which the far end
// controls. A bounce whose reply quoted "status=sent" would then be filed as a
// success. The real field is always the first one after to=<…>.
var deliveryRe = regexp.MustCompile(`\b([0-9A-Za-z]+): to=<([^>]*)>,.*?\bstatus=(\w+)`)
// parseDelivery extracts (queue-id, recipient, status) from a mail.log line.
// ok is false for lines that are not recognised delivery results.
@@ -73,12 +83,13 @@ func parseDelivery(line string) (queueID, recipient, status string, ok bool) {
}
// Run follows path and updates send-log statuses until ctx is cancelled, while
// a background sweep prunes rows older than retentionDays. It returns nil on a
// clean shutdown.
// a background sweep prunes rows older than retentionDays. Reading resumes at
// the offset the previous run persisted, so a restart parses the delivery lines
// written while the panel was down. It returns nil on a clean shutdown.
func Run(ctx context.Context, path string, st StatusStore, retentionDays int) error {
go retentionLoop(ctx, st, retentionDays)
return follow(ctx, path, func(line string) {
return follow(ctx, path, &tracker{st: st, path: path}, func(line string) {
queueID, recipient, status, ok := parseDelivery(line)
if !ok {
return
@@ -120,10 +131,10 @@ func retentionLoop(ctx context.Context, st StatusStore, retentionDays int) {
}
// TailLines returns up to n of the most recent lines from path, for the
// panel's mail.log monitoring view (spec 7.2.13). It is a one-shot,
// point-in-time read on request — unrelated to the background follow loop
// above — that reads backwards in chunks so it stays cheap against a
// multi-megabyte log rather than reading the whole file every poll.
// panel's mail.log monitoring view (architecture.md § Panel HTTP surface). It
// is a one-shot, point-in-time read on request — unrelated to the background
// follow loop above — that reads backwards in chunks so it stays cheap against
// a multi-megabyte log rather than reading the whole file every poll.
func TailLines(path string, n int) ([]string, error) {
f, err := os.Open(path)
if err != nil {
@@ -166,10 +177,11 @@ func TailLines(path string, n int) ([]string, error) {
}
// follow tails path line by line, calling handle for each complete line, until
// ctx is cancelled. It starts at end-of-file (so a restart does not reprocess
// history) and reopens the file when it is rotated (inode change from
// ctx is cancelled. Where it starts is tr's decision (a persisted offset, the
// start of a file that changed while the panel was down, or end-of-file on a
// first ever run); it reopens the file when it is rotated (inode change from
// logrotate's create, or truncation from copytruncate) so nothing is missed.
func follow(ctx context.Context, path string, handle func(string)) error {
func follow(ctx context.Context, path string, tr *tracker, handle func(string)) error {
var (
f *os.File
r *bufio.Reader
@@ -199,7 +211,7 @@ func follow(ctx context.Context, path string, handle func(string)) error {
// The container may start before Postfix has created mail.log; wait for it.
for {
if err := openAt(0, io.SeekEnd); err == nil {
if err := openAt(0, io.SeekStart); err == nil {
break
}
select {
@@ -208,6 +220,10 @@ func follow(ctx context.Context, path string, handle func(string)) error {
case <-time.After(pollInterval):
}
}
if _, err := f.Seek(tr.resume(f), io.SeekStart); err != nil {
log.Printf("log-tailer: seek %s: %v", path, err)
}
r.Reset(f) // the reader buffered from the pre-seek position
defer func() {
if f != nil {
f.Close()
@@ -231,11 +247,20 @@ func follow(ctx context.Context, path string, handle func(string)) error {
}
}
// read returns how many bytes of the open file have actually been consumed:
// the descriptor position less the partial line bufio handed back at EOF,
// which is re-read (and completed) on the next drain or the next start.
read := func() int64 {
pos, _ := f.Seek(0, io.SeekCurrent)
return pos - int64(len(pending))
}
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
tr.record(f, read(), true) // shutdown: the next start resumes here
return nil
case <-ticker.C:
drain()
@@ -252,8 +277,12 @@ func follow(ctx context.Context, path string, handle func(string)) error {
drain()
if err := openAt(0, io.SeekStart); err != nil {
log.Printf("log-tailer: reopen %s: %v", path, err)
continue
}
tr.adopt(f)
continue
}
tr.record(f, read(), false)
}
}
}
+161 -2
View File
@@ -4,11 +4,12 @@ import (
"context"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/store"
)
func TestParseDelivery(t *testing.T) {
@@ -60,6 +61,59 @@ func TestParseDelivery(t *testing.T) {
line: "host postfix/smtpd[10]: 41E862C00D9E: client=unknown[203.0.113.7]",
wantOK: false,
},
{
// The remote server's reply is quoted verbatim at the end of the
// line and is entirely attacker-influenced text. A "status=" that
// appears in there must not win over the real field, or a bounce
// would be filed as a success.
name: "status= quoted in the remote reply does not win",
line: "host postfix/smtp[26]: 9F1A2C00D9E: to=<a@example.net>, relay=mx.example.net[203.0.113.9]:25, dsn=5.1.1, status=bounced (host mx.example.net said: 550 5.1.1 unknown status=sent (in reply to RCPT TO command))",
wantOK: true,
queueID: "9F1A2C00D9E",
recipient: "a@example.net",
status: store.StatusBounced,
},
{
// Postfix logs the null sender's own delivery (double bounce) with
// an empty recipient. It parses, and the empty recipient simply
// matches no send-log row — the panel only ever records mail it
// accepted from an authenticated client.
name: "null recipient parses with an empty address",
line: "host postfix/smtp[26]: A1B2C3: to=<>, relay=none, delay=0.1, dsn=2.0.0, status=sent (250 OK)",
wantOK: true,
queueID: "A1B2C3",
recipient: "",
status: store.StatusSent,
},
{
// An alias/virtual expansion carries orig_to= as well; the address
// the message was actually delivered to is the one in to=.
name: "orig_to is ignored in favour of to",
line: "host postfix/lmtp[26]: 4Xk9tS1abcz: to=<real@example.net>, orig_to=<alias@example.net>, relay=x, dsn=2.0.0, status=sent (ok)",
wantOK: true,
queueID: "4Xk9tS1abcz",
recipient: "real@example.net",
status: store.StatusSent,
},
{
// Postfix's own delivery agents write these two, but neither is a
// final result we model: "deliverable" comes from address
// verification probes, and anything unrecognised is dropped rather
// than guessed at, leaving the row in its previous state.
name: "unknown status word is not a delivery result",
line: "host postfix/smtp[26]: BEEF01: to=<a@example.net>, relay=x, status=deliverable (ok)",
wantOK: false,
},
{
name: "status matching is case-sensitive, as Postfix writes it",
line: "host postfix/smtp[26]: BEEF02: to=<a@example.net>, relay=x, dsn=4.0.0, status=Deferred (connect timed out)",
wantOK: false,
},
{
name: "cleanup message-id line ignored",
line: "host postfix/cleanup[12]: BEEF03: message-id=<x@example.com>",
wantOK: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -77,10 +131,16 @@ func TestParseDelivery(t *testing.T) {
}
}
// captureStore records UpdateStatus calls for the follow integration test.
// captureStore records UpdateStatus calls for the follow integration test and
// keeps the persisted read offset in memory, so a "restart" in a test is a
// second Run against the same captureStore.
type captureStore struct {
mu sync.Mutex
calls []string
state store.LogtailState
haveState bool
stateErr error
}
func (c *captureStore) UpdateStatus(queueID, recipient, status string) (int64, error) {
@@ -92,12 +152,37 @@ func (c *captureStore) UpdateStatus(queueID, recipient, status string) (int64, e
func (c *captureStore) DeleteSendLogBefore(time.Time) (int64, error) { return 0, nil }
func (c *captureStore) LogtailState(string) (store.LogtailState, bool, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.stateErr != nil {
return store.LogtailState{}, false, c.stateErr
}
return c.state, c.haveState, nil
}
func (c *captureStore) SaveLogtailState(_ string, st store.LogtailState) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.stateErr != nil {
return c.stateErr
}
c.state, c.haveState = st, true
return nil
}
func (c *captureStore) snapshot() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.calls...)
}
func (c *captureStore) reset() {
c.mu.Lock()
defer c.mu.Unlock()
c.calls = nil
}
// TestFollowTailsAndRotates writes delivery lines to a log file, then rotates
// it (rename + fresh create, as logrotate does) and writes more, asserting the
// tailer picks up lines from both the original and rotated file.
@@ -142,6 +227,80 @@ func TestFollowTailsAndRotates(t *testing.T) {
}
}
// TestFollowResumesAfterRestart covers the persisted read offset: a restart
// must parse the delivery lines written while the tailer was down (rows that
// would otherwise stay "queued" forever), without re-parsing what it already
// read, and must fall back to reading the whole file when the log was rotated
// or recreated in the meantime.
func TestFollowResumesAfterRestart(t *testing.T) {
old := pollInterval
pollInterval = 10 * time.Millisecond
t.Cleanup(func() { pollInterval = old })
dir := t.TempDir()
path := filepath.Join(dir, "mail.log")
// A head longer than fingerprintSize, so the file stays identifiable across
// the restart; the lines themselves predate the first start and are ignored.
seed := strings.Repeat("host postfix/qmgr[1]: seed line, not a delivery\n", 20)
if err := os.WriteFile(path, []byte(seed), 0o644); err != nil {
t.Fatalf("seed log: %v", err)
}
cs := &captureStore{}
stop := startRun(t, path, cs)
appendLine(t, path, "host postfix/smtp[1]: Q1: to=<a@example.net>, dsn=2.0.0, status=sent (ok)")
waitFor(t, func() bool { return contains(cs.snapshot(), "Q1|a@example.net|sent") })
stop() // persists the offset past Q1
// Down: Postfix keeps delivering.
appendLine(t, path, "host postfix/smtp[1]: Q2: to=<b@example.net>, dsn=2.0.0, status=sent (ok)")
cs.reset()
stop = startRun(t, path, cs)
waitFor(t, func() bool { return contains(cs.snapshot(), "Q2|b@example.net|sent") })
if contains(cs.snapshot(), "Q1|a@example.net|sent") {
t.Fatal("resumed run re-parsed Q1: offset was not honoured")
}
stop()
// Down again, and this time the log is replaced (logrotate + fresh create).
// The stored offset belongs to a file that no longer exists, so the new one
// must be read from the start.
if err := os.WriteFile(path, []byte(strings.Repeat("host postfix/qmgr[1]: fresh log after rotation\n", 20)+
"host postfix/smtp[1]: Q3: to=<c@example.net>, dsn=5.1.1, status=bounced (nope)\n"), 0o644); err != nil {
t.Fatalf("recreate log: %v", err)
}
cs.reset()
stop = startRun(t, path, cs)
waitFor(t, func() bool { return contains(cs.snapshot(), "Q3|c@example.net|bounced") })
stop()
}
// startRun launches the tailer and returns a function that cancels it and waits
// for a clean return, the way a panel restart bookends a run.
func startRun(t *testing.T, path string, cs *captureStore) func() {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- Run(ctx, path, cs, 90) }()
// follow() opens and seeks on start; give it a moment before the caller
// appends, so the append is not raced by the initial open.
time.Sleep(50 * time.Millisecond)
return func() {
t.Helper()
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("Run: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("Run did not return after cancel")
}
}
}
func appendLine(t *testing.T, path, line string) {
t.Helper()
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
+136
View File
@@ -0,0 +1,136 @@
package logtail
import (
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"log"
"os"
"time"
"github.com/mixeme/selfpost/internal/store"
)
const (
// fingerprintSize is how many bytes from the head of the log identify it.
// Postfix writes a timestamped line per event, so the first 512 bytes are
// effectively unique per log generation — enough to tell "the file we were
// reading" from "a fresh one created by logrotate while we were down",
// which os.SameFile cannot answer across a restart.
fingerprintSize = 512
// persistInterval throttles the offset write. Losing up to this much
// progress on a crash only means re-parsing a few lines (UpdateStatus is
// idempotent), which is much cheaper than a database write per poll tick.
persistInterval = 5 * time.Second
)
// tracker persists the tailer's read position so a restart resumes where the
// previous run stopped instead of jumping to end-of-file — the "send-log rows
// stay queued forever" gap (architecture.md § Log tailer). It is used from the
// follow loop only, so it needs no locking.
type tracker struct {
st StatusStore
path string
fp string // fingerprint of the file currently open ("" if too short)
saved int64 // last offset written to the store
lastSave time.Time
}
// resume returns the byte offset the tailer should start reading f at, having
// recorded f's fingerprint for later saves.
//
// The rules, in order: no stored state at all (first ever start) means start at
// the end, so installing the panel does not replay a pre-existing log; a stored
// state whose fingerprint still matches means continue from it, parsing the
// tail written while the panel was down; anything else means the file is not
// the one the offset referred to (rotated, recreated or truncated in the
// meantime), so read it from the start. Re-parsing lines already seen is
// harmless: UpdateStatus writes the same status onto the same row.
func (t *tracker) resume(f *os.File) int64 {
size, err := fileSize(f)
if err != nil {
log.Printf("log-tailer: stat %s: %v (reading from the start)", t.path, err)
return 0
}
t.fp = fingerprintOf(f)
prev, ok, err := t.st.LogtailState(t.path)
if err != nil {
log.Printf("log-tailer: read stored offset: %v (starting at end)", err)
return size
}
switch {
case !ok:
t.saved = size
return size
case prev.Fingerprint != "" && prev.Fingerprint == t.fp && prev.Offset <= size:
t.saved = prev.Offset
if prev.Offset < size {
log.Printf("log-tailer: resuming %s at offset %d (%d bytes to catch up)",
t.path, prev.Offset, size-prev.Offset)
}
return prev.Offset
default:
log.Printf("log-tailer: %s changed while the panel was down; reading from the start", t.path)
t.saved = 0
return 0
}
}
// adopt re-fingerprints after the follow loop switched to a rotated-in file and
// persists the fresh start immediately, so a restart right after a rotation
// does not resume at the old file's offset.
func (t *tracker) adopt(f *os.File) {
t.fp = fingerprintOf(f)
t.saved = -1 // force the write below even if the old offset happened to be 0
t.record(f, 0, true)
}
// record persists offset, at most once per persistInterval unless force is set
// (rotation and shutdown, where the write must not be skipped).
func (t *tracker) record(f *os.File, offset int64, force bool) {
if offset == t.saved {
return
}
if !force && time.Since(t.lastSave) < persistInterval {
return
}
if t.fp == "" {
// The log was shorter than a fingerprint when we opened it; now that it
// has grown, an identifiable one may be available.
t.fp = fingerprintOf(f)
}
if err := t.st.SaveLogtailState(t.path, store.LogtailState{Fingerprint: t.fp, Offset: offset}); err != nil {
log.Printf("log-tailer: save offset: %v", err)
return
}
t.saved = offset
t.lastSave = time.Now()
}
// fingerprintOf hashes the head of the file. It returns "" for a file too short
// to identify — the head would still change as Postfix appends, so such a
// fingerprint could not be compared meaningfully on the next start.
func fingerprintOf(f *os.File) string {
buf := make([]byte, fingerprintSize)
n, err := f.ReadAt(buf, 0)
if err != nil && !errors.Is(err, io.EOF) {
log.Printf("log-tailer: fingerprint read: %v", err)
return ""
}
if n < fingerprintSize {
return ""
}
sum := sha256.Sum256(buf)
return hex.EncodeToString(sum[:])
}
func fileSize(f *os.File) (int64, error) {
info, err := f.Stat()
if err != nil {
return 0, err
}
return info.Size(), nil
}
+99
View File
@@ -0,0 +1,99 @@
package milter
import (
"sync"
"time"
)
// reservationTTL bounds how long a message may stay reserved. A reservation is
// released at end-of-message or on ABORT, but a client that simply drops the
// connection after MAIL FROM produces neither callback (go-milter has no
// connection-close hook), and a reservation that never expired would count
// against the limit forever — a fail-closed drift this milter must not have.
// The TTL is generously longer than any realistic DATA transfer, so a message
// still being received is never dropped from the count.
const reservationTTL = 10 * time.Minute
// reservation is one message that passed the level-2 check and has not been
// written to the send log yet.
type reservation struct {
key string
at time.Time
}
// inflight counts messages that are between the limit check (MAIL FROM) and the
// send-log insert (end-of-message). The stored count alone cannot see them, so
// without this several concurrent SMTP sessions each read the same pre-insert
// count, each conclude they are under the ceiling, and the limit is overshot by
// however many were in flight. Counting reservations closes that window without
// writing placeholder rows the operator would see in the UI.
//
// One instance is shared by every session of the process, hence the mutex.
// Methods tolerate a nil receiver so a session built without one (tests) simply
// behaves as it did before.
type inflight struct {
mu sync.Mutex
m map[string]map[*reservation]struct{}
}
// 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 {
if f == nil {
return 0
}
f.mu.Lock()
defer f.mu.Unlock()
set := f.m[key]
cutoff := time.Now().Add(-reservationTTL)
var n int64
for r := range set {
if r.at.Before(cutoff) {
delete(set, r)
continue
}
if !r.at.Before(since) {
n++
}
}
if len(set) == 0 {
delete(f.m, key)
}
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()
if f.m == nil {
f.m = make(map[string]map[*reservation]struct{})
}
if f.m[key] == nil {
f.m[key] = make(map[*reservation]struct{})
}
r := &reservation{key: key, at: time.Now()}
f.m[key][r] = struct{}{}
return r
}
// release drops a reservation, either because the message reached the send log
// (where the stored count takes over) or because it never will.
func (f *inflight) release(r *reservation) {
if f == nil || r == nil {
return
}
f.mu.Lock()
defer f.mu.Unlock()
set := f.m[r.key]
delete(set, r)
if len(set) == 0 {
delete(f.m, r.key)
}
}
+51 -28
View File
@@ -1,13 +1,14 @@
// Package milter implements the SelfPost journal-milter: a lightweight milter
// (spec 7.3) attached to Postfix's smtpd_milters alongside OpenDKIM. On the
// receive path it reads the SASL login, From, recipients and Subject of each
// accepted message and records one send-log row per (queue-id, recipient),
// giving the panel a structured, filterable history that raw mail.log cannot.
// (architecture.md § Mail path) attached to Postfix's smtpd_milters alongside
// OpenDKIM. On the receive path it reads the SASL login, From, recipients and
// Subject of each accepted message and records one send-log row per (queue-id,
// recipient), giving the panel a structured, filterable history that raw
// mail.log cannot.
//
// It is monitoring only: it never rejects, and every callback returns Continue
// or Accept so a failure of this milter can never block the relay. Postfix is
// configured with default_action=accept for this milter's socket, so even a
// crash or hang fails open (spec 7.3).
// crash or hang fails open (architecture.md § Mail path).
package milter
import (
@@ -21,13 +22,14 @@ import (
"github.com/emersion/go-milter"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/store"
)
// Store is the persistence the milter needs on the receive path: recording
// accepted messages (spec 7.3) and, for level-2 rate limiting (spec 7.4),
// looking up the configured limits and counting recent messages. *store.Store
// satisfies it; tests substitute a fake.
// accepted messages (architecture.md § Mail path) and, for level-2 rate
// limiting (README § Rate limiting), looking up the configured limits and
// counting recent messages. *store.Store satisfies it; tests substitute a
// fake.
type Store interface {
InsertQueued(e store.SendLogEntry) error
InsertRejected(e store.SendLogEntry) error
@@ -37,26 +39,31 @@ type Store interface {
// session accumulates the fields of one message as the milter callbacks fire.
// Milter macros arrive per-stage and do not accumulate, so each value is
// captured at the stage that carries it (spec 7.3 / Phase 0 spike): SASL login
// and From at MAIL, each recipient at RCPT, Subject in the headers, and the
// queue-id at end-of-message. go-milter creates one session per connection; a
// connection may carry several messages, so per-message fields are reset at
// MailFrom (the start of every transaction).
// captured at the stage that carries it (architecture.md § Mail path): SASL
// login and From at MAIL, each recipient at RCPT, Subject in the headers, and
// the queue-id at end-of-message. go-milter creates one session per
// connection; a connection may carry several messages, so per-message fields
// are reset at MailFrom (the start of every transaction).
type session struct {
milter.NoOpMilter
rec Store
// flight is shared by every session of the process; it holds the messages
// that passed the level-2 check but are not in the send log yet. Nil is a
// valid zero value (no in-flight accounting).
flight *inflight
clientIP string // captured once per connection
login string
from string
rcpts []string
subject string
login string
from string
rcpts []string
subject string
reserved []*reservation // level-2 slots held by the current message
}
// Connect captures the client IP, which comes from the addr parameter rather
// than a macro (the {client_addr} macro was empty in the spike). It is the
// rate-limit key for Phase 8; here it is recorded for completeness.
// than a macro (the {client_addr} macro was empty in testing). It is the
// rate-limit key; here it is recorded for completeness.
func (s *session) Connect(host, family string, port uint16, addr net.IP, m *milter.Modifier) (milter.Response, error) {
if addr != nil {
s.clientIP = addr.String()
@@ -69,9 +76,10 @@ func (s *session) Connect(host, family string, port uint16, addr net.IP, m *milt
// macros). This is also the earliest stage where both the sending domain (from
// the sender) and the application (the login) are known, so the level-2 rate
// limit is enforced here: over the limit, the message is refused with a 4xx
// tempfail before recipients are even offered (spec 7.4). Enforcement is
// fail-open — see overLimit.
// tempfail before recipients are even offered (README § Rate limiting).
// Enforcement is fail-open — see overLimit.
func (s *session) MailFrom(from string, m *milter.Modifier) (milter.Response, error) {
s.releaseReservations() // a previous transaction that ended without EOM/ABORT
s.from = cleanAddress(from)
s.login = macro(m, "auth_authen")
s.rcpts = nil
@@ -127,14 +135,26 @@ func decodeSubject(v string) string {
// rows are written. We accept (this milter is done) without ever rejecting.
func (s *session) Body(m *milter.Modifier) (milter.Response, error) {
s.record(macro(m, "i"))
// The rows are in the send log now, so the stored count sees this message
// and its level-2 slots are no longer needed.
s.releaseReservations()
return milter.RespAccept, nil
}
// Abort ends the current transaction without an end-of-message (client RSET, or
// Postfix rejecting the message for its own reasons). No send-log row will be
// written, so the level-2 slots this message held must go back.
func (s *session) Abort(m *milter.Modifier) error {
s.releaseReservations()
s.rcpts = nil
s.subject = ""
return nil
}
// macro reads a milter macro, tolerating Postfix's convention of wrapping
// multi-character macro names in curly braces (e.g. {auth_authen}) while
// single-character names (e.g. i) arrive bare. go-milter stores whatever name
// Postfix sends verbatim, so a lookup must try both forms — this is exactly the
// distinction the SASL-less Phase 0 spike could not observe.
// Postfix sends verbatim, so a lookup must try both forms.
func macro(m *milter.Modifier, name string) string {
if v, ok := m.Macros[name]; ok {
return v
@@ -143,7 +163,8 @@ func macro(m *milter.Modifier, name string) string {
}
// record writes one send-log row per recipient. Failures are logged, never
// propagated: journalling must not affect mail acceptance (spec 7.3).
// propagated: journalling must not affect mail acceptance (architecture.md §
// Mail path).
func (s *session) record(queueID string) {
domain := domainOf(s.from)
rcpts := s.rcpts
@@ -180,8 +201,9 @@ func cleanAddress(a string) string {
}
// domainOf returns the lower-cased domain of an email address, or "" if there
// is no domain part. Sender binding (Phase 4) guarantees the From domain equals
// the application's domain, so this is the sending domain (spec 7.3).
// is no domain part. Sender binding guarantees the From domain equals the
// application's domain, so this is the sending domain (architecture.md § Mail
// path).
func domainOf(addr string) string {
if i := strings.LastIndexByte(addr, '@'); i >= 0 {
return strings.ToLower(addr[i+1:])
@@ -192,8 +214,9 @@ func domainOf(addr string) string {
// Serve runs the journal-milter on ln until ctx is cancelled. Each connection
// gets a fresh session bound to rec. It returns nil on a clean shutdown.
func Serve(ctx context.Context, ln net.Listener, rec Store) error {
flight := &inflight{} // shared: the level-2 window spans all connections
srv := &milter.Server{
NewMilter: func() milter.Milter { return &session{rec: rec} },
NewMilter: func() milter.Milter { return &session{rec: rec, flight: flight} },
Actions: 0, // read-only: we make no message modifications
Protocol: milter.OptNoBody, // the journal needs headers/EOM, not the body
}
+125 -1
View File
@@ -9,7 +9,7 @@ import (
"github.com/emersion/go-milter"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/store"
)
// fakeRecorder captures inserts and can be made to fail, to prove the milter
@@ -331,6 +331,130 @@ func TestRateLimitNoIPKeyDoesNotApply(t *testing.T) {
}
}
// mailFromIn is mailFrom with an explicit shared in-flight registry, so a test
// can play several concurrent SMTP sessions of one process against each other.
func mailFromIn(t *testing.T, rec Store, fl *inflight, ip, from, login string) (*session, milter.Response) {
t.Helper()
s := &session{rec: rec, flight: fl}
if _, err := s.Connect("h", "tcp4", 0, net.ParseIP(ip), mods(nil)); err != nil {
t.Fatalf("Connect: %v", err)
}
resp, err := s.MailFrom(from, mods(map[string]string{"auth_authen": login}))
if err != nil {
t.Fatalf("MailFrom: %v", err)
}
return s, resp
}
func limitedRecorder(count int64) *fakeRecorder {
return &fakeRecorder{
limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
},
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": count},
}
}
// Messages between MAIL FROM and end-of-message are not in the send log yet, so
// counting the stored rows alone lets concurrent sessions each pass the same
// check and overshoot the ceiling. The last free slot may only be taken once.
func TestRateLimitCountsInFlightMessages(t *testing.T) {
rec := limitedRecorder(4) // one below the ceiling of 5
fl := &inflight{}
if _, resp := mailFromIn(t, rec, fl, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
t.Fatalf("first message = %v, want Continue (4/5 stored)", resp)
}
// Same window, nothing written yet: the first message holds the fifth slot.
if _, resp := mailFromIn(t, rec, fl, limitIP, "b@example.com", "app1"); resp != milter.RespTempFail {
t.Fatalf("concurrent message = %v, want TempFail (would overshoot)", resp)
}
if len(rec.rejected) != 1 {
t.Fatalf("want one rejected send-log row, got %+v", rec.rejected)
}
}
// 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.
func TestReservationReleasedAtEndOfMessage(t *testing.T) {
rec := limitedRecorder(4)
fl := &inflight{}
s, resp := mailFromIn(t, rec, fl, limitIP, "a@example.com", "app1")
if resp != milter.RespContinue {
t.Fatalf("first message = %v, want Continue", resp)
}
if _, err := s.Body(mods(map[string]string{"i": "Q1"})); err != nil {
t.Fatalf("Body: %v", err)
}
if n := fl.count(store.RateLimitScopeDomain+"|example.com", time.Now().Add(-time.Hour)); n != 0 {
t.Fatalf("in-flight count after EOM = %d, want 0", n)
}
}
// A transaction the client abandons (RSET, or a Postfix-side rejection) never
// reaches the send log, so its slot must not stay claimed.
func TestReservationReleasedOnAbort(t *testing.T) {
rec := limitedRecorder(4)
fl := &inflight{}
s, resp := mailFromIn(t, rec, fl, limitIP, "a@example.com", "app1")
if resp != milter.RespContinue {
t.Fatalf("first message = %v, want Continue", resp)
}
if err := s.Abort(mods(nil)); err != nil {
t.Fatalf("Abort: %v", err)
}
if _, resp := mailFromIn(t, rec, fl, limitIP, "b@example.com", "app1"); resp != milter.RespContinue {
t.Fatalf("after abort = %v, want Continue (slot released)", resp)
}
}
// A refused message must not leave the slots it claimed for the limits checked
// before the one that tripped, or every refusal would tighten the ceiling.
func TestRefusalReleasesEarlierReservation(t *testing.T) {
rec := &fakeRecorder{
limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
store.RateLimitScopeApp + "|app1": activeLimit(limitIP),
},
counts: map[string]int64{
store.RateLimitScopeDomain + "|example.com": 0, // domain: plenty of room
store.RateLimitScopeApp + "|app1": 5, // app: at the ceiling
},
}
fl := &inflight{}
if _, resp := mailFromIn(t, rec, fl, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
t.Fatalf("app over limit = %v, want TempFail", resp)
}
if n := fl.count(store.RateLimitScopeDomain+"|example.com", time.Now().Add(-time.Hour)); n != 0 {
t.Fatalf("domain reservation left behind after refusal: %d", n)
}
}
// 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.at = time.Now().Add(-time.Minute)
if n := fl.count("domain|example.com", time.Now().Add(-time.Hour)); n != 1 {
t.Fatalf("count inside window = %d, want 1", n)
}
if n := fl.count("domain|example.com", time.Now().Add(-time.Second)); n != 0 {
t.Fatalf("count outside window = %d, want 0", n)
}
// Past the TTL the reservation is dropped even for a wide window, so a
// client that vanished after MAIL FROM cannot hold a slot forever.
r.at = time.Now().Add(-2 * reservationTTL)
if n := fl.count("domain|example.com", time.Now().Add(-3*reservationTTL)); n != 0 {
t.Fatalf("expired reservation still counted: %d", n)
}
}
func TestDomainOf(t *testing.T) {
cases := map[string]string{
"user@Example.COM": "example.com",
+37 -12
View File
@@ -4,19 +4,24 @@ import (
"log"
"time"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/store"
)
// overLimit reports whether the message currently being received should be
// refused under a level-2 differentiated limit (spec 7.4). It checks the
// domain-level and application-level limits in turn; either being exceeded is
// enough to refuse.
// refused under a level-2 differentiated limit (README § Rate limiting). It
// checks the domain-level and application-level limits in turn; either being
// exceeded is enough to refuse.
//
// It is deliberately fail-open: any store error, or the absence of a usable
// limit, is treated as "not over limit" so a malfunction of the level-2 limiter
// can never block mail — Postfix's level-1 anvil limit (spec 5) remains the
// backstop, and it does not depend on this milter at all. Only a clean count at
// or above a configured ceiling returns true.
// limit, is treated as "not over limit" so a malfunction of the level-2
// limiter can never block mail — Postfix's level-1 anvil limit
// (architecture.md § Mail path) remains the backstop, and it does not depend
// on this milter at all. Only a clean count at or above a configured ceiling
// returns true.
//
// A message that passes reserves a slot per applicable limit, released once it
// reaches the send log (or is abandoned) — see inflight for why the stored
// count alone is not enough.
func (s *session) overLimit() bool {
if s.clientIP == "" {
return false // no client IP to key on; level-2 does not apply
@@ -25,6 +30,7 @@ func (s *session) overLimit() bool {
{store.RateLimitScopeDomain, domainOf(s.from)},
{store.RateLimitScopeApp, s.login},
}
var taken []*reservation
for _, c := range checks {
if c.ref == "" {
continue
@@ -45,19 +51,38 @@ func (s *session) overLimit() bool {
log.Printf("journal-milter: rate-limit count %s %q: %v (fail-open)", c.scope, c.ref, err)
continue
}
key := c.scope + "|" + c.ref
n += s.flight.count(key, since)
if n >= int64(rl.MaxMessages) {
log.Printf("journal-milter: %s %q over limit: %d/%d in %ds from %s — refusing 4xx",
c.scope, c.ref, n, rl.MaxMessages, rl.WindowSeconds, s.clientIP)
// The message is refused, so the slots claimed for the limits
// checked before this one must not stay claimed.
for _, r := range taken {
s.flight.release(r)
}
return true
}
taken = append(taken, s.flight.reserve(key))
}
s.reserved = append(s.reserved, taken...)
return false
}
// recordRejected writes a send-log row for a message refused by a level-2 limit
// (spec 7.4, "опционально фиксирует ... для видимости в UI"), so the rejection
// shows up in the monitoring screen. Only MAIL-stage fields are known; the write
// is best-effort and never affects the response.
// releaseReservations gives back every slot this message holds. It runs once
// the message is in the send log (where the stored count sees it), and whenever
// the transaction ends without getting there.
func (s *session) releaseReservations() {
for _, r := range s.reserved {
s.flight.release(r)
}
s.reserved = nil
}
// recordRejected writes a send-log row for a message refused by a level-2
// limit (README § Rate limiting — refusals are recorded too), so the rejection
// shows up in the monitoring screen. Only MAIL-stage fields are known; the
// write is best-effort and never affects the response.
func (s *session) recordRejected() {
err := s.rec.InsertRejected(store.SendLogEntry{
Domain: domainOf(s.from),
+30 -27
View File
@@ -1,8 +1,8 @@
// Package postfix owns the Postfix configuration files the panel edits at
// runtime and the privileged reload that applies them (spec 5.1, 7.6.3-4). In
// Phase 4 that is the smtpd_sender_login_maps table binding each application's
// SASL login to the sender addresses it may use; the full relay configuration
// lands in Phase 5.
// runtime and the privileged reload that applies them (architecture.md § Mail
// path, security.md): the smtpd_sender_login_maps table binding each
// application's SASL login to the sender addresses it may use, plus the relay
// configuration in main.cf.
package postfix
import (
@@ -33,28 +33,30 @@ func New(dir string) *Postfix {
}
}
// SenderLoginMapsPath is the absolute path of the generated map, so the Postfix
// main.cf written in Phase 5 can point smtpd_sender_login_maps at it.
// SenderLoginMapsPath is the absolute path of the generated map, so main.cf
// can point smtpd_sender_login_maps at it.
func (p *Postfix) SenderLoginMapsPath() string {
return p.senderLoginMapsPath
}
// Binding is one sender-address → login pair (spec 5.1). Address is either a
// domain wildcard "@example.com" or a specific address "alerts@example.com".
// Binding is one sender-address → login pair (architecture.md § Mail path).
// Address is either a domain wildcard "@example.com" or a specific address
// "alerts@example.com".
type Binding struct {
Address string
Login string
}
// RebuildSenderLoginMaps regenerates the sender_login_maps file from the full
// set of bindings and reloads Postfix (spec 5.1). Full regeneration (rather than
// incremental edits) keeps the file a pure function of the registry, so add,
// edit and delete share one idempotent path. The file is written atomically
// before the reload.
// set of bindings and reloads Postfix (architecture.md § Mail path). Full
// regeneration (rather than incremental edits) keeps the file a pure function
// of the registry, so add, edit and delete share one idempotent path. The file
// is written atomically before the reload.
//
// Several applications may be authorised for the same address (many-to-one,
// spec 5.1 §4) — their logins are merged onto a single line as a comma-separated
// list, which is how Postfix expects multiple owners of one sender.
// architecture.md § Mail path) — their logins are merged onto a single line as
// a comma-separated list, which is how Postfix expects multiple owners of one
// sender.
func (p *Postfix) RebuildSenderLoginMaps(bindings []Binding) error {
content, err := renderSenderLoginMaps(bindings)
if err != nil {
@@ -67,7 +69,8 @@ func (p *Postfix) RebuildSenderLoginMaps(bindings []Binding) error {
}
// Reload asks Postfix to re-read its configuration without regenerating any
// file. It backs the panel's manual reload button (spec 7.2.12).
// file. It backs the panel's manual reload button (architecture.md § Panel
// HTTP surface).
func (p *Postfix) Reload() error {
return p.reload()
}
@@ -75,7 +78,7 @@ func (p *Postfix) Reload() error {
// 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
// before being written (spec 7.6.4) — upstream validation already guarantees
// before being written (security.md) — upstream validation already guarantees
// this, but the writer refuses to emit anything unsafe as a hard backstop.
func renderSenderLoginMaps(bindings []Binding) ([]byte, error) {
byAddr := make(map[string][]string)
@@ -95,8 +98,8 @@ func renderSenderLoginMaps(bindings []Binding) ([]byte, error) {
for _, addr := range order {
logins := byAddr[addr]
sort.Strings(logins)
// texthash format: <key><whitespace><value>. A comma-separated value
// lists every login permitted to use this sender (spec 5.1 §4).
// texthash format: <key><whitespace><value>. A comma-separated value lists
// every login permitted to use this sender (architecture.md § Mail path).
fmt.Fprintf(&sb, "%s %s\n", addr, strings.Join(logins, ","))
}
return []byte(sb.String()), nil
@@ -114,9 +117,9 @@ func appendUnique(list []string, v string) []string {
// assertMapSafe rejects any address/login value that could break out of a single
// map line or inject a directive. Addresses are validated to a strict whitelist
// (letters, digits, '@', '.', '-', '_', '+') and logins to an even stricter one
// upstream (spec 7.6.2); this is defence in depth against a validation gap ever
// upstream (security.md); this is defence in depth against a validation gap ever
// letting whitespace, a newline or a comma (the value separator) through into
// the file (spec 7.6.4).
// the file (security.md).
func assertMapSafe(address, login string) error {
if address == "" || login == "" {
return fmt.Errorf("postfix: empty address or login")
@@ -131,17 +134,17 @@ func assertMapSafe(address, login string) error {
}
// reloadViaSupervisor asks supervisord (PID 1, running as root) to run the
// one-shot `postfix-reload` program, which executes the canonical
// `postfix reload` and re-reads main.cf/master.cf and the lookup tables they
// reference. The panel runs unprivileged: it cannot run `postfix reload` itself,
// and it cannot signal the Postfix master directly because `postfix start-fg`
// forks a separate master whose PID supervisord does not track (a SIGHUP to the
// one-shot `postfix-reload` program, which executes the canonical `postfix
// reload` and re-reads main.cf/master.cf and the lookup tables they reference.
// The panel runs unprivileged: it cannot run `postfix reload` itself, and it
// cannot signal the Postfix master directly because `postfix start-fg` forks a
// separate master whose PID supervisord does not track (a SIGHUP to the
// supervised process would never reach it). Going through supervisord's
// group-accessible control socket runs the reload as root without any panel
// privilege (spec 5.2, 7.2.12, 7.6.3, 7.6.8).
// privilege (architecture.md § Mail path, security.md).
//
// Arguments are fixed literals — no user input is interpolated into the command,
// and it never goes through a shell (spec 7.6.3).
// and it never goes through a shell (security.md).
func reloadViaSupervisor() error {
cmd := exec.Command("supervisorctl",
"-c", "/etc/supervisor/supervisord.conf",
+1 -1
View File
@@ -7,7 +7,7 @@ import (
func TestRenderSenderLoginMaps(t *testing.T) {
// Deliberately unsorted, with two logins sharing one wildcard key
// (many-to-one, spec 5.1 §4) to exercise merge + sort.
// (many-to-one, architecture.md § Mail path) to exercise merge + sort.
bindings := []Binding{
{"@zeta.example", "z1"},
{"alerts@alpha.example", "a-listed"},
+6 -6
View File
@@ -6,12 +6,12 @@ import (
"strings"
)
// Queue returns Postfix's own human-readable mail-queue listing (spec 7.2.11):
// active, deferred and held messages, exactly as an administrator would see
// via the CLI. The command takes a single fixed flag and no user input, so it
// never goes through a shell (spec 7.6.3). The panel is responsible for
// escaping the output before display (spec 7.6.7); this function returns it
// as-is.
// Queue returns Postfix's own human-readable mail-queue listing
// (architecture.md § Panel HTTP surface): active, deferred and held messages,
// exactly as an administrator would see via the CLI. The command takes a
// single fixed flag and no user input, so it never goes through a shell
// (security.md). The panel is responsible for escaping the output before
// display (security.md); this function returns it as-is.
func Queue() (string, error) {
cmd := exec.Command("postqueue", "-p")
out, err := cmd.CombinedOutput()
+1 -1
View File
@@ -8,7 +8,7 @@ import (
// writeFileAtomic writes data to path via a temp file in the same directory
// followed by a rename, so a concurrent Postfix reload only ever sees the
// complete old or new map, never a partial write (spec 7.6.4). It mirrors the
// complete old or new map, never a partial write (security.md). It mirrors the
// same primitive used for the OpenDKIM tables.
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
+458
View File
@@ -0,0 +1,458 @@
// Package secretfile wraps SelfPost's secret-bearing downloads — the full
// server backup and the single-domain export — in a password-encrypted
// envelope. Both artefacts carry DKIM private keys, SASL credentials and
// application passwords in the clear, so an operator who stores them outside
// the server (the whole point of a backup) has to protect them by hand. The
// envelope makes that optional-but-easy: tick a box, give a password, and the
// file that leaves the panel is useless without it.
//
// Layout — a fixed header followed by a sequence of independently
// authenticated chunks, so both writing and reading stream and a multi-megabyte
// backup never has to sit in memory:
//
// magic 9 "SELFPOST1" (the trailing digit is the envelope version)
// type 1 PayloadType: what the plaintext is
// kdf 1 KDF identifier (1 = scrypt)
// logN 1 scrypt cost, log2(N)
// r 4 scrypt block size (big-endian)
// p 4 scrypt parallelisation (big-endian)
// salt 16 scrypt salt
// prefix 8 nonce prefix
// chunks … repeated: length (4, big-endian) + AES-256-GCM ciphertext
//
// The KDF parameters travel in the header so a file encrypted today still opens
// after the cost is raised. Every chunk is sealed with the nonce prefix plus its
// own counter, and takes the whole header, that counter and an end-of-stream
// flag as additional data: a chunk cannot be reordered, swapped between files,
// or dropped from the end without decryption failing.
package secretfile
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"io"
"golang.org/x/crypto/scrypt"
)
// PayloadType identifies what a decrypted envelope contains, so opening a file
// can report "that is a domain export, not a backup" instead of handing an
// unexpected payload to a parser.
type PayloadType byte
const (
// TypeFullBackup is the gzip-compressed tar of a full server backup.
TypeFullBackup PayloadType = 1
// TypeDomainExport is the JSON document of a single-domain export.
TypeDomainExport PayloadType = 2
)
// String names a payload type for operator-facing messages.
func (t PayloadType) String() string {
switch t {
case TypeFullBackup:
return "full backup"
case TypeDomainExport:
return "domain export"
default:
return fmt.Sprintf("unknown type %d", byte(t))
}
}
// File name suffixes for the two encrypted artefacts: SelfPost BacKup and
// SelfPost Domain Export. They exist so an operator can tell an encrypted file
// from a plain .tar.gz/.json at a glance; nothing reads them back — the magic
// bytes decide.
const (
ExtBackup = ".spbk"
ExtDomainExport = ".spde"
)
// magic prefixes every envelope. MagicLen bytes are enough to tell an encrypted
// file from a plain one, which is what an import handler needs to decide whether
// to ask for a password.
var magic = []byte("SELFPOST1")
// MagicLen is the number of leading bytes HasMagic inspects.
const MagicLen = 9
const (
kdfScrypt = 1
saltLen = 16
noncePrefixLen = 8
counterLen = 4
headerLen = MagicLen + 1 + 1 + 1 + 4 + 4 + saltLen + noncePrefixLen
// chunkSize is the plaintext carried by one sealed chunk. 64 KiB keeps the
// per-chunk overhead negligible while bounding the buffer a reader must
// allocate for a hostile length field.
chunkSize = 64 * 1024
tagLen = 16
keyLen = 32 // AES-256
)
// Default scrypt parameters: 32 MiB and roughly a tenth of a second per attempt
// on ordinary hardware. The panel derives a key at most once per download, so
// the cost is invisible to the operator and expensive for anyone brute-forcing a
// stolen backup.
const (
defaultLogN = 15
defaultR = 8
defaultP = 1
// maxLogN caps what a file may ask for, so a hostile header cannot make the
// reader allocate its way to an out-of-memory kill before it ever fails
// authentication.
maxLogN = 20
maxR = 32
maxP = 16
)
var (
// ErrNotEncrypted reports a file that is not a SelfPost envelope at all —
// most often a plain .json export handed to the encrypted path.
ErrNotEncrypted = errors.New("secretfile: not an encrypted SelfPost file")
// ErrWrongPassword reports a failed authentication: the password is wrong,
// or the file has been altered. The two are indistinguishable by design.
ErrWrongPassword = errors.New("secretfile: wrong password or corrupted file")
// ErrCorrupt reports structural damage — a truncated or malformed envelope.
ErrCorrupt = errors.New("secretfile: corrupted file")
)
// HasMagic reports whether b begins with the envelope magic. b may be shorter
// than MagicLen (a short file simply is not an envelope).
func HasMagic(b []byte) bool {
if len(b) < MagicLen {
return false
}
for i, c := range magic {
if b[i] != c {
return false
}
}
return true
}
// header is the parsed fixed prefix, kept alongside its raw bytes because those
// bytes are the additional data every chunk is authenticated with.
type header struct {
raw []byte
typ PayloadType
logN uint8
r, p uint32
salt []byte
noncePrefix []byte
}
func (h *header) marshal() []byte {
buf := make([]byte, 0, headerLen)
buf = append(buf, magic...)
buf = append(buf, byte(h.typ), kdfScrypt, h.logN)
buf = binary.BigEndian.AppendUint32(buf, h.r)
buf = binary.BigEndian.AppendUint32(buf, h.p)
buf = append(buf, h.salt...)
buf = append(buf, h.noncePrefix...)
return buf
}
func parseHeader(buf []byte) (*header, error) {
if !HasMagic(buf) {
return nil, ErrNotEncrypted
}
if len(buf) != headerLen {
return nil, ErrCorrupt
}
h := &header{raw: buf}
i := MagicLen
h.typ = PayloadType(buf[i])
if buf[i+1] != kdfScrypt {
return nil, fmt.Errorf("%w: unsupported key derivation %d", ErrCorrupt, buf[i+1])
}
h.logN = buf[i+2]
h.r = binary.BigEndian.Uint32(buf[i+3 : i+7])
h.p = binary.BigEndian.Uint32(buf[i+7 : i+11])
i += 11
h.salt = buf[i : i+saltLen]
h.noncePrefix = buf[i+saltLen : i+saltLen+noncePrefixLen]
// Reject absurd work factors before spending any memory on them.
if h.logN < 1 || h.logN > maxLogN || h.r < 1 || h.r > maxR || h.p < 1 || h.p > maxP {
return nil, fmt.Errorf("%w: unreasonable key-derivation parameters", ErrCorrupt)
}
return h, nil
}
// deriveKey runs scrypt with the header's parameters.
func (h *header) deriveKey(password string) ([]byte, error) {
key, err := scrypt.Key([]byte(password), h.salt, 1<<h.logN, int(h.r), int(h.p), keyLen)
if err != nil {
return nil, fmt.Errorf("secretfile: derive key: %w", err)
}
return key, nil
}
func newAEAD(key []byte) (cipher.AEAD, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("secretfile: cipher: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("secretfile: gcm: %w", err)
}
return aead, nil
}
// nonce composes the per-chunk nonce: the file's random prefix followed by the
// chunk counter, so no two chunks in a file — and, with overwhelming
// probability, no two chunks across files — share one.
func nonce(prefix []byte, counter uint32) []byte {
n := make([]byte, 0, noncePrefixLen+counterLen)
n = append(n, prefix...)
return binary.BigEndian.AppendUint32(n, counter)
}
// chunkAAD binds a chunk to its file, its position and its end-of-stream flag.
func chunkAAD(hdr []byte, counter uint32, last bool) []byte {
aad := make([]byte, 0, len(hdr)+counterLen+1)
aad = append(aad, hdr...)
aad = binary.BigEndian.AppendUint32(aad, counter)
if last {
return append(aad, 1)
}
return append(aad, 0)
}
// Writer encrypts a stream into w. Callers must Close it: the final chunk (and
// with it the end-of-stream marker that makes truncation detectable) is only
// written then.
type Writer struct {
w io.Writer
aead cipher.AEAD
hdr *header
buf []byte // pending plaintext, at most chunkSize
sealed []byte // reusable ciphertext scratch
counter uint32
closed bool
err error
}
// NewWriter derives a key from password and writes the envelope header to w.
// Deriving the key is deliberately slow (scrypt), so call this once per file.
func NewWriter(w io.Writer, typ PayloadType, password string) (*Writer, error) {
if password == "" {
return nil, errors.New("secretfile: empty password")
}
h := &header{
typ: typ,
logN: defaultLogN,
r: defaultR,
p: defaultP,
salt: make([]byte, saltLen),
noncePrefix: make([]byte, noncePrefixLen),
}
if _, err := rand.Read(h.salt); err != nil {
return nil, fmt.Errorf("secretfile: salt: %w", err)
}
if _, err := rand.Read(h.noncePrefix); err != nil {
return nil, fmt.Errorf("secretfile: nonce: %w", err)
}
h.raw = h.marshal()
key, err := h.deriveKey(password)
if err != nil {
return nil, err
}
aead, err := newAEAD(key)
if err != nil {
return nil, err
}
if _, err := w.Write(h.raw); err != nil {
return nil, fmt.Errorf("secretfile: write header: %w", err)
}
return &Writer{
w: w,
aead: aead,
hdr: h,
buf: make([]byte, 0, chunkSize),
sealed: make([]byte, 0, chunkSize+tagLen),
}, nil
}
// Write buffers plaintext, sealing and emitting a chunk whenever a full one has
// accumulated.
func (e *Writer) Write(p []byte) (int, error) {
if e.err != nil {
return 0, e.err
}
if e.closed {
return 0, errors.New("secretfile: write after close")
}
written := 0
for len(p) > 0 {
n := chunkSize - len(e.buf)
if n > len(p) {
n = len(p)
}
e.buf = append(e.buf, p[:n]...)
p = p[n:]
written += n
if len(e.buf) == chunkSize {
if err := e.flush(false); err != nil {
return written, err
}
}
}
return written, nil
}
// flush seals the buffered plaintext as one chunk. last marks the end of the
// stream inside the authenticated data.
func (e *Writer) flush(last bool) error {
e.sealed = e.aead.Seal(e.sealed[:0], nonce(e.hdr.noncePrefix, e.counter), e.buf, chunkAAD(e.hdr.raw, e.counter, last))
var length [4]byte
binary.BigEndian.PutUint32(length[:], uint32(len(e.sealed)))
if _, err := e.w.Write(length[:]); err != nil {
e.err = fmt.Errorf("secretfile: write chunk: %w", err)
return e.err
}
if _, err := e.w.Write(e.sealed); err != nil {
e.err = fmt.Errorf("secretfile: write chunk: %w", err)
return e.err
}
e.buf = e.buf[:0]
e.counter++
return nil
}
// Close seals whatever is buffered as the final chunk. It does not close the
// underlying writer.
func (e *Writer) Close() error {
if e.err != nil {
return e.err
}
if e.closed {
return nil
}
e.closed = true
// Always emitted, even for empty input: the end-of-stream chunk is what
// proves the file was not truncated.
return e.flush(true)
}
// Reader decrypts an envelope. Chunks are verified as they are read, so a
// truncated or tampered file surfaces as a read error rather than as short but
// plausible plaintext.
type Reader struct {
r io.Reader
aead cipher.AEAD
hdr *header
plain []byte // decrypted, not yet handed to the caller
sealed []byte // reusable ciphertext scratch
counter uint32
done bool
err error
}
// NewReader reads and verifies the envelope header, derives the key and returns
// a Reader over the plaintext. It fails with ErrNotEncrypted for a file that is
// not an envelope; a wrong password is only detected on the first Read, when
// the first chunk fails authentication.
func NewReader(r io.Reader, password string) (*Reader, error) {
raw := make([]byte, headerLen)
if _, err := io.ReadFull(r, raw); err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
// Too short to be an envelope: if what we did read is not even the
// magic, say so — that is the common "plain file" case.
if !HasMagic(raw) {
return nil, ErrNotEncrypted
}
return nil, ErrCorrupt
}
return nil, fmt.Errorf("secretfile: read header: %w", err)
}
h, err := parseHeader(raw)
if err != nil {
return nil, err
}
key, err := h.deriveKey(password)
if err != nil {
return nil, err
}
aead, err := newAEAD(key)
if err != nil {
return nil, err
}
return &Reader{r: r, aead: aead, hdr: h}, nil
}
// Type reports what the envelope claims to contain. The claim is authenticated
// (the header is additional data for every chunk), so it is trustworthy as soon
// as the first Read succeeds.
func (d *Reader) Type() PayloadType { return d.hdr.typ }
// Read returns decrypted plaintext, verifying one chunk at a time.
func (d *Reader) Read(p []byte) (int, error) {
for len(d.plain) == 0 {
if d.err != nil {
return 0, d.err
}
if d.done {
return 0, io.EOF
}
if err := d.next(); err != nil {
d.err = err
return 0, err
}
}
n := copy(p, d.plain)
d.plain = d.plain[n:]
return n, nil
}
// next reads, verifies and decrypts the following chunk, setting done when the
// chunk carries the end-of-stream flag.
func (d *Reader) next() error {
var length [4]byte
if _, err := io.ReadFull(d.r, length[:]); err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
// The stream ended without a chunk marked last: truncated.
return ErrCorrupt
}
return fmt.Errorf("secretfile: read chunk: %w", err)
}
n := binary.BigEndian.Uint32(length[:])
if n < tagLen || n > chunkSize+tagLen {
return ErrCorrupt
}
if cap(d.sealed) < int(n) {
d.sealed = make([]byte, n)
}
d.sealed = d.sealed[:n]
if _, err := io.ReadFull(d.r, d.sealed); err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return ErrCorrupt
}
return fmt.Errorf("secretfile: read chunk: %w", err)
}
// A chunk authenticates under exactly one of the two end-of-stream flags;
// trying the ordinary one first keeps the common case to a single open.
nc := nonce(d.hdr.noncePrefix, d.counter)
plain, err := d.aead.Open(nil, nc, d.sealed, chunkAAD(d.hdr.raw, d.counter, false))
if err != nil {
plain, err = d.aead.Open(nil, nc, d.sealed, chunkAAD(d.hdr.raw, d.counter, true))
if err != nil {
return ErrWrongPassword
}
d.done = true
}
d.plain = plain
d.counter++
return nil
}
+162
View File
@@ -0,0 +1,162 @@
package secretfile
import (
"bytes"
"errors"
"io"
"testing"
)
// encrypt is the round-trip helper: seal data and return the envelope bytes.
func encrypt(t *testing.T, typ PayloadType, password string, data []byte) []byte {
t.Helper()
var buf bytes.Buffer
w, err := NewWriter(&buf, typ, password)
if err != nil {
t.Fatalf("NewWriter: %v", err)
}
if _, err := w.Write(data); err != nil {
t.Fatalf("Write: %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
return buf.Bytes()
}
func decrypt(t *testing.T, enc []byte, password string) ([]byte, PayloadType, error) {
t.Helper()
r, err := NewReader(bytes.NewReader(enc), password)
if err != nil {
return nil, 0, err
}
out, err := io.ReadAll(r)
return out, r.Type(), err
}
func TestRoundTripSizes(t *testing.T) {
// Empty, sub-chunk, exactly one chunk, and several chunks with a partial
// tail: the boundaries where chunk framing tends to break.
sizes := []int{0, 1, 1000, chunkSize - 1, chunkSize, chunkSize + 1, 3*chunkSize + 77}
for _, n := range sizes {
data := make([]byte, n)
for i := range data {
data[i] = byte(i * 7)
}
enc := encrypt(t, TypeFullBackup, "correct horse battery staple", data)
got, typ, err := decrypt(t, enc, "correct horse battery staple")
if err != nil {
t.Fatalf("size %d: decrypt: %v", n, err)
}
if typ != TypeFullBackup {
t.Errorf("size %d: type = %v, want full backup", n, typ)
}
if !bytes.Equal(got, data) {
t.Errorf("size %d: plaintext mismatch (%d bytes back)", n, len(got))
}
}
}
func TestCiphertextDoesNotLeakPlaintext(t *testing.T) {
secret := []byte("-----BEGIN RSA PRIVATE KEY-----\nMIIEow...\n")
enc := encrypt(t, TypeDomainExport, "a very long password", secret)
if bytes.Contains(enc, secret) {
t.Fatal("plaintext found verbatim in the envelope")
}
if !HasMagic(enc) {
t.Fatal("envelope does not start with the magic")
}
}
func TestWrongPassword(t *testing.T) {
enc := encrypt(t, TypeDomainExport, "the right password", []byte("secret payload"))
_, _, err := decrypt(t, enc, "the wrong password")
if !errors.Is(err, ErrWrongPassword) {
t.Fatalf("err = %v, want ErrWrongPassword", err)
}
}
func TestNotEncrypted(t *testing.T) {
for _, plain := range [][]byte{[]byte(`{"format":"selfpost-domain-export"}`), {}, []byte("SELF")} {
if _, err := NewReader(bytes.NewReader(plain), "pw"); !errors.Is(err, ErrNotEncrypted) {
t.Errorf("NewReader(%q) = %v, want ErrNotEncrypted", plain, err)
}
if HasMagic(plain) {
t.Errorf("HasMagic(%q) = true", plain)
}
}
}
func TestTruncationDetected(t *testing.T) {
// A backup cut short mid-transfer must fail loudly rather than restore a
// plausible-looking prefix.
data := bytes.Repeat([]byte("payload"), 20000) // spans several chunks
enc := encrypt(t, TypeFullBackup, "password password", data)
// Drop the final chunk entirely: what remains is a sequence of valid,
// correctly authenticated chunks, none of which is marked last.
var chunks []int
for off := headerLen; off < len(enc); {
n := int(enc[off])<<24 | int(enc[off+1])<<16 | int(enc[off+2])<<8 | int(enc[off+3])
chunks = append(chunks, off)
off += 4 + n
}
if len(chunks) < 2 {
t.Fatalf("expected several chunks, got %d", len(chunks))
}
truncated := enc[:chunks[len(chunks)-1]]
r, err := NewReader(bytes.NewReader(truncated), "password password")
if err != nil {
t.Fatalf("NewReader (truncated): %v", err)
}
if _, err := io.ReadAll(r); !errors.Is(err, ErrCorrupt) {
t.Fatalf("read truncated = %v, want ErrCorrupt", err)
}
}
func TestTamperDetected(t *testing.T) {
enc := encrypt(t, TypeFullBackup, "password password", []byte("some archive bytes"))
for _, off := range []int{MagicLen /* type byte */, headerLen + 6 /* ciphertext */} {
bad := bytes.Clone(enc)
bad[off] ^= 0xff
if _, _, err := decrypt(t, bad, "password password"); err == nil {
t.Errorf("flipping byte %d was accepted", off)
}
}
}
func TestChunkReorderRejected(t *testing.T) {
// Two full chunks plus a tail, so swapping the first two is possible without
// changing any length.
data := make([]byte, 2*chunkSize+10)
for i := range data {
data[i] = byte(i)
}
enc := encrypt(t, TypeFullBackup, "password password", data)
const framed = 4 + chunkSize + tagLen
first := headerLen
second := headerLen + framed
swapped := bytes.Clone(enc)
copy(swapped[first:first+framed], enc[second:second+framed])
copy(swapped[second:second+framed], enc[first:first+framed])
if _, _, err := decrypt(t, swapped, "password password"); !errors.Is(err, ErrWrongPassword) {
t.Fatalf("reordered chunks = %v, want authentication failure", err)
}
}
func TestEmptyPasswordRejected(t *testing.T) {
if _, err := NewWriter(io.Discard, TypeFullBackup, ""); err == nil {
t.Fatal("empty password accepted")
}
}
func TestUnreasonableKDFParamsRejected(t *testing.T) {
enc := encrypt(t, TypeFullBackup, "password password", []byte("x"))
bad := bytes.Clone(enc)
bad[MagicLen+2] = 40 // logN far beyond maxLogN
if _, err := NewReader(bytes.NewReader(bad), "password password"); !errors.Is(err, ErrCorrupt) {
t.Fatalf("err = %v, want ErrCorrupt", err)
}
}
+2 -2
View File
@@ -10,7 +10,7 @@ import (
// ErrNoAdmin is returned by GetAdmin when primary setup has not happened yet.
var ErrNoAdmin = errors.New("no administrator account")
// Admin is the single panel administrator (spec 7.6.1).
// Admin is the single panel administrator (security.md).
type Admin struct {
Username string
PasswordHash string
@@ -19,7 +19,7 @@ type Admin struct {
// AdminExists reports whether the administrator account has been created. This
// doubles as the "primary setup complete" flag: once true, the /setup route is
// permanently gone (spec 7.6.1).
// permanently gone (security.md).
func (s *Store) AdminExists() (bool, error) {
var n int
if err := s.db.QueryRow("SELECT COUNT(*) FROM admin").Scan(&n); err != nil {
+1 -1
View File
@@ -28,7 +28,7 @@ func TestUpdateAdmin(t *testing.T) {
}
// An update before setup must not create the account: only the one-time setup
// flow may do that (spec 7.6.1).
// flow may do that (security.md).
func TestUpdateAdminWithoutAdmin(t *testing.T) {
st := openTestStore(t)
+24 -21
View File
@@ -8,23 +8,24 @@ import (
)
// ErrLoginExists is returned when an application login is already taken. The
// login is globally unique because it is the SASL identity Postfix authenticates
// (one sasldb2 across all domains, spec 5.1).
// login is globally unique because it is the SASL identity Postfix
// authenticates (one sasldb2 across all domains, architecture.md § Mail path).
var ErrLoginExists = errors.New("application login already exists")
// ErrApplicationNotFound is returned when an application id does not exist.
var ErrApplicationNotFound = errors.New("application not found")
// Address modes (spec 4.1). Kept in sync with the CHECK constraint in the schema.
// Address modes (product.md § Multi-domain model). Kept in sync with the CHECK
// constraint in the schema.
const (
AddressModeWildcard = "wildcard" // any address within the application's domain
AddressModeList = "list" // only the explicitly listed addresses
)
// Application is a SASL account bound to a single domain (spec 4.1, 5.1). The
// password is never stored here — only in sasldb2, hashed — so it can be shown
// exactly once at creation/regeneration (spec 7.6.1). Addresses is populated only
// in 'list' mode.
// Application is a SASL account bound to a single domain (product.md §
// Multi-domain model). The password is never stored here — only in sasldb2,
// hashed — so it can be shown exactly once at creation/regeneration
// (security.md). Addresses is populated only in 'list' mode.
type Application struct {
ID int64
DomainID int64
@@ -35,9 +36,9 @@ type Application struct {
}
// Binding is one sender-address → login pair, as consumed by the
// smtpd_sender_login_maps generator (spec 5.1). For a wildcard application the
// Address is the domain wildcard "@example.com"; for a list application there is
// one Binding per listed address.
// smtpd_sender_login_maps generator (architecture.md § Mail path). For a
// wildcard application the Address is the domain wildcard "@example.com"; for
// a list application there is one Binding per listed address.
type Binding struct {
Address string
Login string
@@ -45,7 +46,7 @@ type Binding struct {
// AddApplication inserts an application and, in list mode, its addresses, in a
// single transaction. The caller must have validated login and every address
// (spec 7.6.2) beforehand; the query is parameterised regardless. A duplicate
// (security.md) beforehand; the query is parameterised regardless. A duplicate
// login maps to ErrLoginExists.
func (s *Store) AddApplication(domainID int64, login, mode string, addresses []string) (Application, error) {
now := time.Now().UTC()
@@ -82,7 +83,7 @@ func (s *Store) AddApplication(domainID int64, login, mode string, addresses []s
}
// UpdateApplicationMode switches an application's address mode and replaces its
// address list atomically (spec 7.2.7). The login and password are untouched.
// address list atomically (product.md). The login and password are untouched.
// Returns ErrApplicationNotFound if the id does not exist.
func (s *Store) UpdateApplicationMode(id int64, mode string, addresses []string) error {
tx, err := s.db.Begin()
@@ -163,7 +164,7 @@ func (s *Store) GetApplication(id int64) (Application, error) {
}
// ListApplicationsByDomain returns a domain's applications ordered by login,
// each with its address list populated (spec 7.2.6).
// each with its address list populated (product.md).
func (s *Store) ListApplicationsByDomain(domainID int64) ([]Application, error) {
rows, err := s.db.Query(
"SELECT id, domain_id, login, address_mode, created_at FROM applications WHERE domain_id = ? ORDER BY login",
@@ -196,8 +197,9 @@ func (s *Store) ListApplicationsByDomain(domainID int64) ([]Application, error)
}
// ListApplicationLogins returns every application login across all domains,
// ordered, for the send-log monitoring screen's filter dropdown (spec 7.2).
// Logins are globally unique (spec 5.1), so no domain qualifier is needed.
// ordered, for the send-log monitoring screen's filter dropdown (product.md).
// Logins are globally unique (architecture.md § Mail path), so no domain
// qualifier is needed.
func (s *Store) ListApplicationLogins() ([]string, error) {
rows, err := s.db.Query("SELECT login FROM applications ORDER BY login")
if err != nil {
@@ -218,7 +220,7 @@ func (s *Store) ListApplicationLogins() ([]string, error) {
// ListLoginsByDomain returns the SASL logins of a domain's applications. Used to
// purge sasldb2 entries before a domain (and its applications via cascade) is
// deleted, while the logins are still known (spec 7.2.4).
// deleted, while the logins are still known (product.md).
func (s *Store) ListLoginsByDomain(domainID int64) ([]string, error) {
rows, err := s.db.Query("SELECT login FROM applications WHERE domain_id = ? ORDER BY login", domainID)
if err != nil {
@@ -237,10 +239,11 @@ func (s *Store) ListLoginsByDomain(domainID int64) ([]string, error) {
return out, rows.Err()
}
// ListBindings returns every sender-address → login pair across all domains, the
// raw material for the smtpd_sender_login_maps file (spec 5.1). Wildcard
// applications yield a single "@domain" binding; list applications yield one
// binding per address. Ordered deterministically so the generated map is stable.
// ListBindings returns every sender-address → login pair across all domains,
// the raw material for the smtpd_sender_login_maps file (architecture.md §
// Mail path). Wildcard applications yield a single "@domain" binding; list
// applications yield one binding per address. Ordered deterministically so the
// generated map is stable.
func (s *Store) ListBindings() ([]Binding, error) {
rows, err := s.db.Query(`
SELECT '@' || d.name, a.login
@@ -271,7 +274,7 @@ func (s *Store) ListBindings() ([]Binding, error) {
// DeleteApplication removes an application and its addresses (via cascade),
// returning the deleted application so the caller can drop its sasldb2 entry
// (spec 7.2.8). Returns ErrApplicationNotFound if no such row existed.
// (product.md). Returns ErrApplicationNotFound if no such row existed.
func (s *Store) DeleteApplication(id int64) (Application, error) {
a, err := s.GetApplication(id)
if err != nil {
+7 -6
View File
@@ -16,9 +16,10 @@ var ErrDomainExists = errors.New("domain already exists")
// ErrDomainNotFound is returned when a domain id/name does not exist.
var ErrDomainNotFound = errors.New("domain not found")
// Domain is a sending domain managed through the panel (spec 4.1). The DKIM key
// material itself lives on disk under /data; this row records the selector and
// metadata. AppCount is populated by the listing queries, not stored.
// Domain is a sending domain managed through the panel (product.md §
// Multi-domain model). The DKIM key material itself lives on disk under /data;
// this row records the selector and metadata. AppCount is populated by the
// listing queries, not stored.
type Domain struct {
ID int64
Name string
@@ -28,7 +29,7 @@ type Domain struct {
}
// AddDomain inserts a new sending domain. The caller is responsible for having
// validated name (spec 7.6.2) before it reaches SQL; the query is parameterised
// validated name (security.md) before it reaches SQL; the query is parameterised
// regardless. A duplicate name maps to ErrDomainExists.
func (s *Store) AddDomain(name, selector string) (Domain, error) {
now := time.Now().UTC()
@@ -49,7 +50,7 @@ func (s *Store) AddDomain(name, selector string) (Domain, error) {
return Domain{ID: id, Name: name, DKIMSelector: selector, CreatedAt: now}, nil
}
// ListDomains returns every domain with its bound-application count (spec 7.2.2),
// ListDomains returns every domain with its bound-application count (product.md),
// ordered by name.
func (s *Store) ListDomains() ([]Domain, error) {
rows, err := s.db.Query(`
@@ -92,7 +93,7 @@ func (s *Store) GetDomain(id int64) (Domain, error) {
}
// DeleteDomain removes a domain. Its applications and their address/binding rows
// go with it via ON DELETE CASCADE (spec 7.2.4). Returns ErrDomainNotFound if no
// go with it via ON DELETE CASCADE (product.md). Returns ErrDomainNotFound if no
// such row existed.
func (s *Store) DeleteDomain(id int64) error {
res, err := s.db.Exec("DELETE FROM domains WHERE id = ?", id)
+2 -2
View File
@@ -62,8 +62,8 @@ func TestDeleteDomainCascadesApplications(t *testing.T) {
t.Fatal(err)
}
// Insert an application + address directly (the AddApplication API lands in
// Phase 4); this verifies the ON DELETE CASCADE wiring now.
// Insert an application + address directly (bypassing the AddApplication
// API) to verify the ON DELETE CASCADE wiring.
now := time.Now().UTC().Format(time.RFC3339)
res, err := st.db.Exec(
"INSERT INTO applications (domain_id, login, address_mode, created_at) VALUES (?, ?, 'wildcard', ?)",
+54
View File
@@ -0,0 +1,54 @@
package store
import (
"database/sql"
"errors"
"fmt"
"time"
)
// LogtailState is the persisted read position of the mail.log tailer: how far
// into the log it had got, and a fingerprint of the file that offset refers to.
// It lets the tailer resume after a panel restart instead of jumping to
// end-of-file and losing the delivery lines written while it was down.
type LogtailState struct {
Fingerprint string
Offset int64
}
// LogtailState returns the stored read position for path. ok is false the first
// time a path is followed (nothing persisted yet), which the tailer treats as
// "start at the end".
func (s *Store) LogtailState(path string) (LogtailState, bool, error) {
var st LogtailState
err := s.db.QueryRow(
`SELECT fingerprint, read_offset FROM logtail_state WHERE path = ?`,
path,
).Scan(&st.Fingerprint, &st.Offset)
if errors.Is(err, sql.ErrNoRows) {
return LogtailState{}, false, nil
}
if err != nil {
return LogtailState{}, false, fmt.Errorf("read logtail state for %q: %w", path, err)
}
return st, true, nil
}
// SaveLogtailState records the tailer's read position for path, replacing any
// previous one. It is called on a timer while tailing, so it is a single small
// upsert rather than a transaction.
func (s *Store) SaveLogtailState(path string, st LogtailState) error {
_, err := s.db.Exec(
`INSERT INTO logtail_state (path, fingerprint, read_offset, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(path) DO UPDATE SET
fingerprint = excluded.fingerprint,
read_offset = excluded.read_offset,
updated_at = excluded.updated_at`,
path, st.Fingerprint, st.Offset, time.Now().UTC().Format(time.RFC3339),
)
if err != nil {
return fmt.Errorf("save logtail state for %q: %w", path, err)
}
return nil
}
@@ -0,0 +1,12 @@
-- Log-tailer read position. Without it the tailer starts at end-of-file on
-- every start, so delivery lines written while the panel was down are never
-- parsed and their send-log rows stay "queued" forever. One row per followed
-- path; fingerprint identifies the file the offset belongs to (the head bytes
-- of the log), so a rotated or recreated mail.log is detected across a restart,
-- where os.SameFile cannot help.
CREATE TABLE logtail_state (
path TEXT PRIMARY KEY,
fingerprint TEXT NOT NULL,
read_offset INTEGER NOT NULL,
updated_at TEXT NOT NULL
);
+24 -20
View File
@@ -9,23 +9,25 @@ import (
"time"
)
// Rate-limit scopes (spec 7.4). A level-2 limit is attached either to a domain
// (counted across all its applications and IPs) or to a single application.
// Rate-limit scopes (README § Rate limiting). A level-2 limit is attached
// either to a domain (counted across all its applications and IPs) or to a
// single application.
const (
RateLimitScopeDomain = "domain"
RateLimitScopeApp = "application"
)
// RateLimit is a differentiated level-2 rate limit (spec 7.4): an optional set
// of expected client IPs plus a message ceiling over a sliding window, attached
// to a domain or an application. It is enforced in the journal-milter; level 1
// (Postfix anvil, spec 5) is the IP backstop that always applies even when this
// is absent or the milter is down.
// RateLimit is a differentiated level-2 rate limit (README § Rate limiting):
// an optional set of expected client IPs plus a message ceiling over a sliding
// window, attached to a domain or an application. It is enforced in the
// journal-milter; level 1 (Postfix anvil, architecture.md § Mail path) is the
// IP backstop that always applies even when this is absent or the milter is
// down.
//
// Both the IP binding and the ceiling are optional in the schema, but a limit is
// only enforced when it is Active(): the design deliberately allows an admin to
// leave the IP binding empty for apps that send from changing IPs, in which case
// only level 1 protects them (spec 7.4's caveat).
// Both the IP binding and the ceiling are optional in the schema, but a limit
// is only enforced when it is Active(): the design deliberately allows an
// admin to leave the IP binding empty for apps that send from changing IPs, in
// which case only level 1 protects them (README § Rate limiting).
type RateLimit struct {
Scope string
RefID int64
@@ -35,8 +37,9 @@ type RateLimit struct {
}
// Active reports whether the limit is fully configured and should be enforced.
// A missing IP binding, ceiling or window leaves the differentiated limit inert
// (spec 7.4): the IP binding is what scopes the limit to a known sender.
// A missing IP binding, ceiling or window leaves the differentiated limit
// inert (README § Rate limiting): the IP binding is what scopes the limit to a
// known sender.
func (r RateLimit) Active() bool {
return len(r.AllowedIPs) > 0 && r.MaxMessages > 0 && r.WindowSeconds > 0
}
@@ -76,7 +79,7 @@ func (s *Store) GetRateLimit(scope string, refID int64) (RateLimit, bool, error)
}
// SetRateLimit upserts the level-2 limit for a domain or application. The caller
// (panel) has already validated the IPs and numbers (spec 7.6.2); values are
// (panel) has already validated the IPs and numbers (security.md); values are
// stored via bound parameters and read back live by the milter.
func (s *Store) SetRateLimit(rl RateLimit) error {
_, err := s.db.Exec(
@@ -148,12 +151,13 @@ func (s *Store) RateLimit(scope, ref string) (RateLimit, bool, error) {
return rl, true, nil
}
// CountMessages returns how many distinct messages the reference (a domain name
// or an application login) has queued since t, for the level-2 sliding window
// (spec 7.4). It counts distinct queue-ids — one message with many recipients is
// one message, matching level 1's per-message semantics — and excludes rows that
// were themselves rejected by a limit (they were never sent). It reuses the send
// log the journal already writes (spec 7.4: "переиспользует данные журнала").
// CountMessages returns how many distinct messages the reference (a domain
// name or an application login) has queued since t, for the level-2 sliding
// window (README § Rate limiting). It counts distinct queue-ids — one message
// with many recipients is one message, matching level 1's per-message
// semantics — and excludes rows that were themselves rejected by a limit (they
// were never sent). It reuses the send log the journal already writes (README
// § Rate limiting — the limiter reuses the send log).
func (s *Store) CountMessages(scope, ref string, since time.Time) (int64, error) {
var column string
switch scope {
+24 -20
View File
@@ -6,23 +6,25 @@ import (
"time"
)
// Send-log status values (spec 7.3). "queued" is written by the journal-milter
// when a message is accepted; the log-tailer advances it to one of the final
// states as Postfix reports delivery per recipient.
// Send-log status values (architecture.md § Persistence). "queued" is written
// by the journal-milter when a message is accepted; the log-tailer advances it
// to one of the final states as Postfix reports delivery per recipient.
const (
StatusQueued = "queued"
StatusSent = "sent"
StatusDeferred = "deferred"
StatusBounced = "bounced"
// StatusRejected marks a message the journal-milter refused with a 4xx under
// a level-2 rate limit (spec 7.4). Such a row never gets a queue-id and is
// excluded from the level-2 message count (it was never sent).
// a level-2 rate limit (README § Rate limiting). Such a row never gets a
// queue-id and is excluded from the level-2 message count (it was never
// sent).
StatusRejected = "rejected"
)
// SendLogEntry is a single queued send-log row. The journal-milter creates one
// per (queue-id, recipient) pair at end-of-message (spec 7.3.3); every field
// except the status/timestamps comes from the accepted message.
// per (queue-id, recipient) pair at end-of-message (architecture.md §
// Persistence); every field except the status/timestamps comes from the
// accepted message.
type SendLogEntry struct {
QueueID string
Domain string
@@ -34,8 +36,8 @@ type SendLogEntry struct {
// InsertQueued records an accepted message in the send log with status
// "queued". It is called from the journal-milter hot path, so it returns any
// error for the caller to log rather than deciding policy here; the milter must
// stay fail-open regardless (spec 7.3).
// error for the caller to log rather than deciding policy here; the milter
// must stay fail-open regardless (architecture.md § Persistence).
func (s *Store) InsertQueued(e SendLogEntry) error {
now := time.Now().UTC().Format(time.RFC3339)
_, err := s.db.Exec(
@@ -51,9 +53,10 @@ func (s *Store) InsertQueued(e SendLogEntry) error {
}
// InsertRejected records a message the journal-milter refused under a level-2
// rate limit (spec 7.4), so the rejection is visible in the send-log UI. Only
// the fields known at MAIL FROM are set (domain, sender, app login); there is no
// queue-id or recipient because the message was rejected before it was queued.
// rate limit (README § Rate limiting), so the rejection is visible in the
// send-log UI. Only the fields known at MAIL FROM are set (domain, sender, app
// login); there is no queue-id or recipient because the message was rejected
// before it was queued.
func (s *Store) InsertRejected(e SendLogEntry) error {
now := time.Now().UTC().Format(time.RFC3339)
_, err := s.db.Exec(
@@ -88,9 +91,9 @@ func (s *Store) UpdateStatus(queueID, recipient, status string) (int64, error) {
return n, nil
}
// SendLogRow is one row as returned to the monitoring UI (spec 7.2, 7.3.3): a
// SendLogEntry plus the fields that only exist once a row has been written
// (id, current status, timestamps).
// SendLogRow is one row as returned to the monitoring UI (architecture.md §
// Persistence): a SendLogEntry plus the fields that only exist once a row has
// been written (id, current status, timestamps).
type SendLogRow struct {
ID int64
QueueID string
@@ -111,7 +114,7 @@ type SendLogFilter struct {
}
// QuerySendLog returns send-log rows matching filter, newest first, for the
// monitoring screen's server-side pagination (spec 7.2's send-log view).
// monitoring screen's server-side pagination (product.md's send-log view).
func (s *Store) QuerySendLog(filter SendLogFilter, limit, offset int) ([]SendLogRow, error) {
where, args := sendLogWhere(filter)
args = append(args, limit, offset)
@@ -171,10 +174,11 @@ func sendLogWhere(f SendLogFilter) (string, []any) {
return " WHERE " + strings.Join(clauses, " AND "), args
}
// DeleteSendLogBefore removes send-log rows created before cutoff, implementing
// the configurable retention window (spec 7.3, SEND_LOG_RETENTION_DAYS). It
// returns the number of rows pruned. created_at is stored as RFC3339 UTC, so a
// lexical comparison against the same format is chronologically correct.
// DeleteSendLogBefore removes send-log rows created before cutoff,
// implementing the configurable retention window (architecture.md §
// Persistence, SEND_LOG_RETENTION_DAYS). It returns the number of rows pruned.
// created_at is stored as RFC3339 UTC, so a lexical comparison against the
// same format is chronologically correct.
func (s *Store) DeleteSendLogBefore(cutoff time.Time) (int64, error) {
res, err := s.db.Exec(
`DELETE FROM send_log WHERE created_at < ?`,
+4 -3
View File
@@ -5,8 +5,8 @@ import (
"time"
)
// readSendLog returns every send_log row ordered by id. Phase 6 has no read
// query yet (the monitoring UI is Phase 7), so tests read the table directly.
// readSendLog returns every send_log row ordered by id. The monitoring UI has
// no equivalent read query, so tests read the table directly.
type sendLogRow struct {
QueueID string
Domain string
@@ -40,7 +40,8 @@ func readSendLog(t *testing.T, s *Store) []sendLogRow {
func TestInsertQueuedAndUpdateStatus(t *testing.T) {
st := openTestStore(t)
// Two recipients on the same queue-id → two independent rows (spec 7.3.3).
// Two recipients on the same queue-id → two independent rows (architecture.md
// § Persistence).
for _, to := range []string{"a@example.net", "b@example.net"} {
if err := st.InsertQueued(SendLogEntry{
QueueID: "ABC123",
+2 -2
View File
@@ -1,7 +1,7 @@
// Package store owns the SelfPost SQLite database: the single file under /data
// that persists the administrator account, sending domains and applications,
// the send log and rate-limit settings (spec 9). It exposes typed queries so
// the rest of the panel never builds SQL by hand.
// 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 (
+6 -6
View File
@@ -5,15 +5,15 @@ import (
"net/http"
"strings"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/store"
"golang.org/x/crypto/bcrypt"
)
// handleAccount serves the administrator's own account settings: the username
// and password chosen during setup are the only panel credentials (spec 7.6.1),
// and until now they could be changed only by recreating the state. Changing
// them here never touches application SASL logins, which are a separate
// identity system (spec 5.1).
// and password chosen during setup are the only panel credentials
// (security.md), and until now they could be changed only by recreating the
// state. Changing them here never touches application SASL logins, which are a
// separate identity system (architecture.md § Mail path).
func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
@@ -58,7 +58,7 @@ func accountFlash(r *http.Request) string {
// is always required, so a stolen session alone cannot lock the administrator
// out of their own panel, and the attempt is throttled on the same limiter as
// the login form so this route cannot be used to brute-force the password past
// that limit (spec 7.6.5).
// that limit (security.md).
func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) {
if !s.loginLimiter.Allow(clientIP(r, s.trustedProxies)) {
s.renderAccount(w, r, http.StatusTooManyRequests,
+21 -16
View File
@@ -7,13 +7,13 @@ import (
"strconv"
"strings"
"codeberg.org/mix/selfpost/internal/dnscheck"
"codeberg.org/mix/selfpost/internal/domain"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/store"
)
// newCred carries a freshly generated login/password to the template so it can
// be shown exactly once (spec 7.6.1). It is never read back from storage.
// be shown exactly once (security.md). It is never read back from storage.
type newCred struct {
Login string
Password string
@@ -29,9 +29,12 @@ type detailView struct {
FormMode string
FormAddrs string
NewCred *newCred
// RateLimitErr surfaces a validation error from a domain- or application-level
// rate-limit form (spec 7.4) as a page banner.
// RateLimitErr surfaces a validation error from a domain- or
// application-level rate-limit form (README § Rate limiting) as a page
// banner.
RateLimitErr string
// ExportErr surfaces a rejected encryption password from the export card.
ExportErr string
}
// appRateLimitView pairs an application with its differentiated rate-limit
@@ -45,9 +48,9 @@ type appRateLimitView struct {
WindowVal string // window seconds, defaulted when unset
}
// handleDomainDetail shows a single domain: its DKIM DNS record (spec 7.2.10)
// handleDomainDetail shows a single domain: its DKIM DNS record (product.md)
// and its applications with the controls to add, edit, delete and re-issue
// credentials (spec 7.2.5-9).
// credentials (product.md).
func (s *Server) handleDomainDetail(w http.ResponseWriter, r *http.Request) {
d, ok := s.lookupDomain(w, r)
if !ok {
@@ -97,8 +100,8 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
}
// What DNS actually publishes for the domain today, checked against the key
// this server signs with (phase 13.B). Cached by the checker, so
// re-rendering the page after a form post costs nothing.
// this server signs with. Cached by the checker, so re-rendering the page
// after a form post costs nothing.
dns, srv := s.domainDNS(d, record, false)
s.render(w, status, "domain_detail", map[string]any{
@@ -131,6 +134,8 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
"Wildcard": store.AddressModeWildcard,
"List": store.AddressModeList,
"RateLimitErr": view.RateLimitErr,
"ExportErr": view.ExportErr,
"MinPwLen": minSecretFilePasswordLen,
"DomainHasRL": domainRLok && domainRL.Active(),
"DomainRLIPs": strings.Join(domainRL.AllowedIPs, "\n"),
"DomainRLMax": intOrBlank(domainRL.MaxMessages),
@@ -139,7 +144,7 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
}
// domainDNS resolves what the world sees for a domain: its DKIM, SPF and DMARC
// records (phase 13.B). The server's own address comes from the (separately
// records. The server's own address comes from the (separately
// cached) hostname check, so the SPF heuristic knows which IP it is looking for
// and no extra environment variable is needed. That server result is returned
// alongside, because the page's suggested SPF record is built from the same
@@ -210,7 +215,7 @@ func detailFlash(r *http.Request) string {
}
// handleAddApplication creates an application on a domain and renders the page
// back with the generated password shown once (spec 7.2.5, 7.6.1). Because the
// back with the generated password shown once (product.md, security.md). Because the
// password cannot be recovered later, this deliberately renders inline rather
// than redirecting.
func (s *Server) handleAddApplication(w http.ResponseWriter, r *http.Request) {
@@ -249,7 +254,7 @@ func (s *Server) handleAddApplication(w http.ResponseWriter, r *http.Request) {
})
}
// handleUpdateAppMode switches an application's address mode / list (spec 7.2.7).
// handleUpdateAppMode switches an application's address mode / list (product.md).
func (s *Server) handleUpdateAppMode(w http.ResponseWriter, r *http.Request) {
a, ok := s.lookupApplication(w, r)
if !ok {
@@ -278,7 +283,7 @@ func (s *Server) handleUpdateAppMode(w http.ResponseWriter, r *http.Request) {
}
// handleRegenPassword issues a new password for an application and shows it once
// (spec 7.2.9, 7.6.1). Rendered inline, like creation, so the password is visible.
// (product.md, security.md). Rendered inline, like creation, so the password is visible.
func (s *Server) handleRegenPassword(w http.ResponseWriter, r *http.Request) {
a, ok := s.lookupApplication(w, r)
if !ok {
@@ -302,7 +307,7 @@ func (s *Server) handleRegenPassword(w http.ResponseWriter, r *http.Request) {
}
// handleDeleteApplication removes an application and returns to its domain page
// (spec 7.2.8).
// (product.md).
func (s *Server) handleDeleteApplication(w http.ResponseWriter, r *http.Request) {
a, ok := s.lookupApplication(w, r)
if !ok {
@@ -339,7 +344,7 @@ func (s *Server) lookupApplication(w http.ResponseWriter, r *http.Request) (stor
// splitAddresses turns the textarea/field input (addresses separated by
// newlines, commas or whitespace) into a raw slice. Normalisation and
// validation happen in the app service (spec 7.6.2).
// validation happen in the app service (security.md).
func splitAddresses(s string) []string {
return strings.FieldsFunc(s, func(r rune) bool {
return r == '\n' || r == '\r' || r == ',' || r == ' ' || r == '\t' || r == ';'
+4 -4
View File
@@ -5,7 +5,7 @@ import (
"net/http"
"strings"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/store"
"golang.org/x/crypto/bcrypt"
)
@@ -13,7 +13,7 @@ import (
// TLS in front, so CookieSecure — it carries the __Host- prefix, which turns
// what the cookie's attributes merely promise into something the browser
// enforces: Secure, Path=/ and, the point of the exercise, no Domain
// attribute, so no other host may set a cookie by this name (phase 14.B).
// attribute, so no other host may set a cookie by this name.
// The prefix is only valid on a Secure cookie, so a development instance on
// plain HTTP has to keep the bare name: with the prefix the browser would
// discard the Set-Cookie outright and logging in would silently never stick.
@@ -42,7 +42,7 @@ func (s *Server) sessionCookie() string {
// is denial of service, not compromise; refusing the request and saying so in
// the log is what makes it diagnosable instead of an endless login loop. The
// __Host- prefix prevents this outright, but only where it applies — this
// check also covers the plain-HTTP development shape (phase 14.B).
// check also covers the plain-HTTP development shape.
func (s *Server) sessionToken(r *http.Request) (string, bool) {
name := s.sessionCookie()
var token string
@@ -127,7 +127,7 @@ func (s *Server) renderLogin(w http.ResponseWriter, status int, formErr string)
}
func (s *Server) submitLogin(w http.ResponseWriter, r *http.Request) {
// Brute-force throttle by client IP (spec 7.6.5).
// Brute-force throttle by client IP (security.md).
if !s.loginLimiter.Allow(clientIP(r, s.trustedProxies)) {
s.renderLogin(w, http.StatusTooManyRequests, "Too many attempts. Please wait and try again.")
return
+195 -28
View File
@@ -1,20 +1,25 @@
package web
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
"codeberg.org/mix/selfpost/internal/backup"
"codeberg.org/mix/selfpost/internal/domain"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/backup"
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/secretfile"
"github.com/mixeme/selfpost/internal/store"
)
// maxImportBytes caps a domain-import upload. A domain export is a small JSON
// document (a DKIM key and a handful of credentials); this leaves generous head
// room while refusing anything large enough to be an abuse attempt.
// room while refusing anything large enough to be an abuse attempt. An
// encrypted export adds only a header and per-chunk tags, so the same ceiling
// covers both forms.
const maxImportBytes = 1 << 20 // 1 MiB
// handleBackupPage renders the backup/migration screen: the full-server backup
@@ -25,50 +30,108 @@ func (s *Server) handleBackupPage(w http.ResponseWriter, r *http.Request) {
}
// renderBackupPage draws the page; importErr surfaces a failed domain import
// (spec 7.5.B) next to the form that produced it.
// (architecture.md § Persistence) next to the form that produced it.
func (s *Server) renderBackupPage(w http.ResponseWriter, r *http.Request, status int, importErr string) {
s.renderBackupPageWith(w, r, status, importErr, "")
}
// renderBackupPageWith is renderBackupPage with the second of the page's two
// error slots: backupErr belongs to the full-backup card (a rejected encryption
// password), importErr to the import card, so neither message appears under the
// wrong form.
func (s *Server) renderBackupPageWith(w http.ResponseWriter, r *http.Request, status int, importErr, backupErr string) {
s.render(w, status, "backup", map[string]any{
"Title": "SelfPost — backup",
"User": currentUser(r),
"Active": "backup",
"ImportErr": importErr,
"BackupErr": backupErr,
"MinPwLen": minSecretFilePasswordLen,
})
}
// handleBackup streams a full-server backup as a download (spec 7.5.A). It is an
// authenticated admin action (this handler sits behind the auth middleware). The
// archive carries DKIM private keys, the admin password hash and SASL
// credentials, so it is served with no-store and as an attachment to discourage
// caching of secret material.
// handleBackup streams a full-server backup as a download (architecture.md §
// Persistence). It is an authenticated admin action (this handler sits behind
// the auth middleware). The archive carries DKIM private keys, the admin
// password hash and SASL credentials, so it is served with no-store and as an
// attachment to discourage caching of secret material. When the operator ticks
// "encrypt with a password", the archive is wrapped in a .spbk envelope on the
// way out, so the file that lands on their disk — wherever it is copied
// afterwards — is useless without the password.
func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) {
filename := fmt.Sprintf("selfpost-backup-%s.tar.gz", time.Now().UTC().Format("20060102-150405"))
password, pwErr := secretFilePassword(r)
if pwErr != "" {
s.renderBackupPageWith(w, r, http.StatusBadRequest, "", pwErr)
return
}
w.Header().Set("Content-Type", "application/gzip")
stamp := time.Now().UTC().Format("20060102-150405")
filename := fmt.Sprintf("selfpost-backup-%s.tar.gz", stamp)
contentType := "application/gzip"
if password != "" {
filename = fmt.Sprintf("selfpost-backup-%s%s", stamp, secretfile.ExtBackup)
contentType = "application/octet-stream"
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
w.Header().Set("Cache-Control", "no-store")
if err := backup.Create(w, backup.Params{
// Everything below streams: past this point headers (and possibly some
// bytes) are already on the wire, so a failure cannot switch to a clean
// error page. Log it and let the truncated download fail loudly on the
// client side — for an encrypted archive that is a missing end-of-stream
// chunk, which decryption refuses outright.
sink := io.Writer(w)
var env *secretfile.Writer
if password != "" {
var err error
// The only failures here are key derivation (which happens before
// anything is written) and writing the envelope header, which fails only
// if the client is already gone.
env, err = secretfile.NewWriter(w, secretfile.TypeFullBackup, password)
if err != nil {
logf("panel: full backup: encrypt: %v", err)
http.Error(w, "backup failed", http.StatusInternalServerError)
return
}
sink = env
}
if err := backup.Create(sink, backup.Params{
DataDir: s.cfg.DataDir,
DBPath: s.cfg.DBPath,
Version: s.cfg.Version,
}); err != nil {
// Headers (and possibly some bytes) may already be on the wire, so we
// cannot switch to a clean error page; log it and let the truncated
// download fail loudly on the client side.
logf("panel: full backup failed: %v", err)
return
}
if env != nil {
if err := env.Close(); err != nil {
logf("panel: full backup failed: %v", err)
}
}
}
// handleExportDomain streams a single-domain export as a secret download (spec
// 7.5.B). Like the full backup it is POST-only (state is not changed, but the
// response contains the domain's DKIM private key and application passwords, so
// it must not be prefetchable or cached).
// handleExportDomain streams a single-domain export as a secret download
// (architecture.md § Persistence). Like the full backup it is POST-only (state
// is not changed, but the response contains the domain's DKIM private key and
// application passwords, so it must not be prefetchable or cached). Like the
// full backup it can be encrypted with a password, in which case the download
// is a .spde envelope instead of plain JSON.
func (s *Server) handleExportDomain(w http.ResponseWriter, r *http.Request) {
d, ok := s.lookupDomain(w, r)
if !ok {
return
}
password, pwErr := secretFilePassword(r)
if pwErr != "" {
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormMode: store.AddressModeWildcard,
ExportErr: pwErr,
})
return
}
exp, err := s.domains.Export(d.ID)
if err != nil {
logf("panel: export domain %d: %v", d.ID, err)
@@ -83,18 +146,41 @@ func (s *Server) handleExportDomain(w http.ResponseWriter, r *http.Request) {
}
filename := fmt.Sprintf("selfpost-domain-%s.json", d.Name)
w.Header().Set("Content-Type", "application/json")
contentType := "application/json"
if password != "" {
// An export is small, so it is sealed in memory: the response is only
// started once the ciphertext is complete and nothing can half-fail.
var buf bytes.Buffer
env, err := secretfile.NewWriter(&buf, secretfile.TypeDomainExport, password)
if err == nil {
_, err = env.Write(body)
}
if err == nil {
err = env.Close()
}
if err != nil {
logf("panel: export domain %d: encrypt: %v", d.ID, err)
http.Error(w, "export failed", http.StatusInternalServerError)
return
}
body = buf.Bytes()
filename = fmt.Sprintf("selfpost-domain-%s%s", d.Name, secretfile.ExtDomainExport)
contentType = "application/octet-stream"
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body)
}
// handleImportDomain accepts an uploaded domain-export file and re-creates the
// domain on this instance (spec 7.5.B). The domain name is normalised and
// validated here (spec 7.6.2); the domain service validates the selector, each
// login and address, and the DKIM key before writing anything. On success it
// redirects to the new domain's page; on failure it re-renders the backup page,
// where the import form lives, with a friendly message.
// domain on this instance (architecture.md § Persistence). The domain name is
// normalised and validated here (security.md); the domain service validates
// the selector, each login and address, and the DKIM key before writing
// anything. On success it redirects to the new domain's page; on failure it
// re-renders the backup page, where the import form lives, with a friendly
// message.
func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxImportBytes)
if err := r.ParseMultipartForm(maxImportBytes); err != nil {
@@ -108,8 +194,49 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
}
defer file.Close()
// An encrypted export announces itself with the envelope magic, so the file
// decides which path it takes; the password field is only consulted when the
// file actually needs it, and a password typed for a plain file is a plain
// mistake worth reporting.
head := make([]byte, secretfile.MagicLen)
n, err := io.ReadFull(file, head)
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
s.renderBackupPage(w, r, http.StatusBadRequest, "Could not read the uploaded file.")
return
}
source := io.MultiReader(bytes.NewReader(head[:n]), file)
password := r.PostFormValue("import_password")
if secretfile.HasMagic(head[:n]) {
if password == "" {
s.renderBackupPage(w, r, http.StatusBadRequest, "That file is encrypted — enter the password it was exported with.")
return
}
env, err := secretfile.NewReader(source, password)
if err != nil {
s.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err))
return
}
if env.Type() != secretfile.TypeDomainExport {
s.renderBackupPage(w, r, http.StatusBadRequest, "That file is an encrypted "+env.Type().String()+", not a domain export.")
return
}
// Read the whole plaintext first: authentication of the last chunk is
// what proves the file is intact, and a streaming JSON decoder could
// accept a truncated document before ever reaching it.
plain, err := io.ReadAll(env)
if err != nil {
s.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err))
return
}
source = bytes.NewReader(plain)
} else if password != "" {
s.renderBackupPage(w, r, http.StatusBadRequest, "That file is not encrypted — leave the password empty.")
return
}
var exp domain.DomainExport
dec := json.NewDecoder(file)
dec := json.NewDecoder(source)
dec.DisallowUnknownFields()
if err := dec.Decode(&exp); err != nil {
s.renderBackupPage(w, r, http.StatusBadRequest, "That file is not a valid SelfPost domain export.")
@@ -117,7 +244,7 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
}
// Normalise and validate the domain name before it reaches the service, the
// same gate the add-domain form uses (spec 7.6.2).
// same gate the add-domain form uses (security.md).
exp.Domain = normalizeDomain(exp.Domain)
if err := validateDomain(exp.Domain); err != nil {
s.renderBackupPage(w, r, http.StatusBadRequest, "Invalid domain in export file: "+err.Error())
@@ -134,6 +261,46 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("/domains/%d?imported=1", d.ID), http.StatusSeeOther)
}
// secretFilePassword reads the "encrypt this download with a password" controls
// shared by the full-backup and domain-export forms. It returns the password to
// encrypt with — empty when the box is not ticked, which keeps the plain
// .tar.gz/.json behaviour of earlier versions — or a message to show above the
// form. The confirmation field is checked here rather than in the browser
// because a typo in an encryption password is unrecoverable: the archive would
// be sealed with a secret the operator does not know.
func secretFilePassword(r *http.Request) (password, errMsg string) {
if err := r.ParseForm(); err != nil {
return "", "Invalid form submission."
}
if r.PostFormValue("encrypt") == "" {
return "", ""
}
password = r.PostFormValue("password")
if len([]rune(password)) < minSecretFilePasswordLen {
return "", fmt.Sprintf("The encryption password must be at least %d characters.", minSecretFilePasswordLen)
}
if password != r.PostFormValue("password_confirm") {
return "", "The two passwords do not match."
}
return password, ""
}
// decryptErrorMessage phrases an envelope failure for the operator. A wrong
// password and a damaged file are deliberately indistinguishable to the code,
// so the message names both possibilities.
func decryptErrorMessage(err error) string {
switch {
case errors.Is(err, secretfile.ErrWrongPassword):
return "Wrong password, or the file has been altered since it was exported."
case errors.Is(err, secretfile.ErrCorrupt):
return "That file is damaged or incomplete."
case errors.Is(err, secretfile.ErrNotEncrypted):
return "That file is not a SelfPost export."
default:
return "Could not decrypt the file."
}
}
// 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
+127
View File
@@ -0,0 +1,127 @@
package web
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/mixeme/selfpost/internal/secretfile"
)
// postForm builds the kind of request the backup and export forms submit.
func postForm(values url.Values) *http.Request {
r := httptest.NewRequest(http.MethodPost, "/backup", strings.NewReader(values.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return r
}
// The encryption password is only ever typed once into a file nobody can
// recover without it, so every way of getting it wrong has to be caught before
// the archive is sealed — and leaving the box unticked has to keep producing
// the plain archive earlier versions produced.
func TestSecretFilePassword(t *testing.T) {
long := strings.Repeat("x", minSecretFilePasswordLen)
short := strings.Repeat("x", minSecretFilePasswordLen-1)
tests := []struct {
name string
form url.Values
wantPass string
wantErr bool
}{
{
name: "unticked box means no encryption",
form: url.Values{"password": {long}, "password_confirm": {long}},
wantPass: "",
},
{
name: "ticked with a matching password",
form: url.Values{"encrypt": {"1"}, "password": {long}, "password_confirm": {long}},
wantPass: long,
},
{
name: "mistyped confirmation",
form: url.Values{"encrypt": {"1"}, "password": {long}, "password_confirm": {long + "!"}},
wantErr: true,
},
{
name: "too short",
form: url.Values{"encrypt": {"1"}, "password": {short}, "password_confirm": {short}},
wantErr: true,
},
{
name: "ticked but empty",
form: url.Values{"encrypt": {"1"}},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pass, errMsg := secretFilePassword(postForm(tt.form))
if tt.wantErr {
if errMsg == "" {
t.Fatalf("password %q accepted, want a rejection", pass)
}
if pass != "" {
t.Errorf("a rejected form still yielded password %q", pass)
}
return
}
if errMsg != "" {
t.Fatalf("unexpected rejection: %s", errMsg)
}
if pass != tt.wantPass {
t.Errorf("password = %q, want %q", pass, tt.wantPass)
}
})
}
}
// The messages the import form shows must distinguish the operator's likely
// mistakes; a wrong password and a tampered file stay deliberately merged.
func TestDecryptErrorMessage(t *testing.T) {
tests := []struct {
err error
want string
}{
{secretfile.ErrWrongPassword, "Wrong password"},
{fmt.Errorf("read: %w", secretfile.ErrCorrupt), "damaged"},
{secretfile.ErrNotEncrypted, "not a SelfPost export"},
{errors.New("something else"), "Could not decrypt"},
}
for _, tt := range tests {
if got := decryptErrorMessage(tt.err); !strings.Contains(got, tt.want) {
t.Errorf("decryptErrorMessage(%v) = %q, want it to mention %q", tt.err, got, tt.want)
}
}
}
// The encryption controls are shared markup pulled into two pages; a page that
// forgets to include the partial (or the data it needs) loses the option
// silently, since the plain download still works.
func TestBackupPageOffersEncryption(t *testing.T) {
tmpl, err := loadTemplates()
if err != nil {
t.Fatalf("loadTemplates: %v", err)
}
s := &Server{tmpl: tmpl, cfg: Config{Version: "test"}}
rec := httptest.NewRecorder()
s.renderBackupPageWith(rec, httptest.NewRequest(http.MethodGet, "/backup", nil),
http.StatusOK, "", "The two passwords do not match.")
body := rec.Body.String()
for _, want := range []string{
`name="encrypt"`, `name="password"`, `name="password_confirm"`,
`name="import_password"`, "data-encrypt-toggle", "data-encrypt-fields",
fmt.Sprintf("at least %d characters", minSecretFilePasswordLen),
"The two passwords do not match.",
} {
if !strings.Contains(body, want) {
t.Errorf("backup page is missing %q", want)
}
}
}
+10 -9
View File
@@ -6,12 +6,12 @@ import (
"net/http"
"strconv"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/store"
)
// handleDashboard is the authenticated landing page: the list of sending
// domains with their DKIM/selector and application counts, plus the add-domain
// form (spec 7.2.2). Applications and the send log arrive in later phases.
// form (product.md).
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
s.renderDashboard(w, r, http.StatusOK, "", "")
}
@@ -48,7 +48,7 @@ func dashboardFlash(r *http.Request) string {
// handleAddDomain validates the submitted name, creates the domain (DKIM key +
// OpenDKIM reload), and redirects to the domain's page so the DNS record to
// publish is shown (spec 7.2.3).
// publish is shown (product.md).
func (s *Server) handleAddDomain(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "")
@@ -76,7 +76,7 @@ func (s *Server) handleAddDomain(w http.ResponseWriter, r *http.Request) {
}
// handleDeleteConfirm shows the cascade warning before a domain is removed: the
// panel must explicitly state that all bound applications go with it (spec 7.2.4).
// panel must explicitly state that all bound applications go with it (product.md).
func (s *Server) handleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
d, ok := s.lookupDomain(w, r)
if !ok {
@@ -116,11 +116,12 @@ func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
}
// handleReload re-applies both the OpenDKIM configuration and the Postfix
// sender map on demand (spec 7.2.12). Each Resync regenerates its files from the
// database and reloads its daemon, so the button doubles as a drift-recovery.
// The button lives on the status page (phase 13.D): it is a "put the daemons
// back in the state the database describes" action, which belongs with the rest
// of the server-health screen rather than in the domain list's top bar.
// sender map on demand (architecture.md § Panel HTTP surface). Each Resync
// regenerates its files from the database and reloads its daemon, so the
// button doubles as a drift-recovery. The button lives on the status page: it
// is a "put the daemons back in the state the database describes" action,
// which belongs with the rest of the server-health screen rather than in the
// domain list's top bar.
func (s *Server) handleReload(w http.ResponseWriter, r *http.Request) {
if err := s.domains.Resync(); err != nil {
logf("panel: manual reload (opendkim): %v", err)
+14 -11
View File
@@ -6,12 +6,12 @@ import (
"net/http"
"strconv"
"codeberg.org/mix/selfpost/internal/logtail"
"codeberg.org/mix/selfpost/internal/postfix"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/logtail"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
)
// sendLogPageSize bounds each send-log page (spec 7.2's monitoring screens
// sendLogPageSize bounds each send-log page (product.md's monitoring screens
// call for pagination); logTailLines bounds how much of mail.log the log view
// shows per refresh.
const (
@@ -20,10 +20,10 @@ const (
)
// handleDeliveries renders the Deliveries page over the send log: server-side
// filters by domain/application and pagination (spec 7.3.3). The row table
// itself is the "deliveries_rows" fragment, shared verbatim with
// handleDeliveriesRows so the initial page and its HTMX-polled refreshes never
// diverge.
// filters by domain/application and pagination (architecture.md §
// Persistence). The row table itself is the "deliveries_rows" fragment, shared
// verbatim with handleDeliveriesRows so the initial page and its HTMX-polled
// refreshes never diverge.
func (s *Server) handleDeliveries(w http.ResponseWriter, r *http.Request) {
data, err := s.sendLogData(r)
if err != nil {
@@ -38,7 +38,8 @@ func (s *Server) handleDeliveries(w http.ResponseWriter, r *http.Request) {
}
// handleDeliveriesRows serves the HTMX polling fragment for the delivery table
// (spec 7.1: fragment endpoints return HTML, not JSON).
// (architecture.md § Panel HTTP surface: fragment endpoints return HTML, not
// JSON).
func (s *Server) handleDeliveriesRows(w http.ResponseWriter, r *http.Request) {
data, err := s.sendLogData(r)
if err != nil {
@@ -110,7 +111,8 @@ func parsePage(v string) int {
return n
}
// handleMailQueue renders the Mail queue page (spec 7.2.11).
// handleMailQueue renders the Mail queue page (architecture.md § Panel HTTP
// surface).
func (s *Server) handleMailQueue(w http.ResponseWriter, r *http.Request) {
out, errText := readQueue()
s.render(w, http.StatusOK, "mail_queue", map[string]any{
@@ -143,7 +145,8 @@ func readQueue() (string, string) {
return out, ""
}
// handleSystemLog renders the System log page over mail.log (spec 7.2.13).
// handleSystemLog renders the System log page over mail.log (architecture.md §
// Panel HTTP surface).
func (s *Server) handleSystemLog(w http.ResponseWriter, r *http.Request) {
lines, errText := s.readLogTail()
s.render(w, http.StatusOK, "system_log", map[string]any{
+19 -16
View File
@@ -7,17 +7,18 @@ import (
"strconv"
"strings"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/store"
)
// defaultRateLimitWindowSeconds is the sliding-window length used when an admin
// sets a message ceiling but leaves the window blank (spec 7.4, matching the
// level-1 default hour, spec 8: RATE_LIMIT_WINDOW_SECONDS).
// defaultRateLimitWindowSeconds is the sliding-window length used when an
// admin sets a message ceiling but leaves the window blank (README § Rate
// limiting, matching the level-1 default hour; README § Environment variables:
// RATE_LIMIT_WINDOW_SECONDS).
const defaultRateLimitWindowSeconds = 3600
// rateLimitInput is the validated result of a rate-limit form submission. clear
// means "remove the differentiated limit" (spec 7.4: an empty IP binding leaves
// only level 1).
// rateLimitInput is the validated result of a rate-limit form submission.
// clear means "remove the differentiated limit" (README § Rate limiting: an
// empty IP binding leaves only level 1).
type rateLimitInput struct {
clear bool
ips []string
@@ -25,10 +26,10 @@ type rateLimitInput struct {
windowSeconds int
}
// parseRateLimitForm validates a rate-limit submission on the server (spec
// 7.6.2). It returns clear=true when the admin removes the limit or leaves the
// IP binding empty; otherwise it requires a positive ceiling and window. The
// returned error's message is safe to show to the admin.
// parseRateLimitForm validates a rate-limit submission on the server
// (security.md). It returns clear=true when the admin removes the limit or
// leaves the IP binding empty; otherwise it requires a positive ceiling and
// window. The returned error's message is safe to show to the admin.
func parseRateLimitForm(r *http.Request) (rateLimitInput, error) {
if err := r.ParseForm(); err != nil {
return rateLimitInput{}, fmt.Errorf("invalid form submission")
@@ -41,7 +42,8 @@ func parseRateLimitForm(r *http.Request) (rateLimitInput, error) {
return rateLimitInput{}, err
}
if len(ips) == 0 {
// No IP binding: the differentiated limit does not apply (spec 7.4).
// No IP binding: the differentiated limit does not apply (README § Rate
// limiting).
return rateLimitInput{clear: true}, nil
}
maxMessages, err := parsePositiveInt(r.PostFormValue("max_messages"), 0)
@@ -57,7 +59,7 @@ func parseRateLimitForm(r *http.Request) (rateLimitInput, error) {
// parseIPList parses the allowed-IP field (IPs separated by newlines, commas or
// whitespace) into a deduplicated list of canonical addresses, rejecting any
// token that is not a valid IP (spec 7.6.2). The values are only ever stored as
// token that is not a valid IP (security.md). The values are only ever stored as
// SQLite parameters and compared in the milter, never written to a config file.
func parseIPList(raw string) ([]string, error) {
fields := strings.FieldsFunc(raw, func(r rune) bool {
@@ -89,8 +91,9 @@ func parsePositiveInt(raw string, def int) (int, error) {
return strconv.Atoi(raw)
}
// handleDomainRateLimit saves or clears a domain-level differentiated rate limit
// (spec 7.4). No reload is needed — the milter reads the row live.
// handleDomainRateLimit saves or clears a domain-level differentiated rate
// limit (README § Rate limiting). No reload is needed — the milter reads the
// row live.
func (s *Server) handleDomainRateLimit(w http.ResponseWriter, r *http.Request) {
d, ok := s.lookupDomain(w, r)
if !ok {
@@ -113,7 +116,7 @@ func (s *Server) handleDomainRateLimit(w http.ResponseWriter, r *http.Request) {
}
// handleAppRateLimit saves or clears an application-level differentiated rate
// limit (spec 7.4).
// limit (README § Rate limiting).
func (s *Server) handleAppRateLimit(w http.ResponseWriter, r *http.Request) {
a, ok := s.lookupApplication(w, r)
if !ok {
+3 -3
View File
@@ -8,11 +8,11 @@ import (
)
// handleSetup serves the one-time administrator creation flow at
// /setup/<token> (spec 7.6.1). Once an administrator exists the whole route
// /setup/<token> (security.md). Once an administrator exists the whole route
// returns 404; an invalid or expired token is indistinguishable from a missing
// page, also 404.
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
// Route-specific rate limit, separate from login (spec 7.6.1).
// Route-specific rate limit, separate from login (security.md).
if !s.setupLimiter.Allow(clientIP(r, s.trustedProxies)) {
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
@@ -92,7 +92,7 @@ func (s *Server) submitSetup(w http.ResponseWriter, r *http.Request, token strin
return
}
// Setup is now permanently complete: burn the token (spec 7.6.1).
// Setup is now permanently complete: burn the token (security.md).
s.setup.complete()
logf("panel: administrator %q created; setup link is now disabled", username)
http.Redirect(w, r, "/login", http.StatusSeeOther)
+9 -7
View File
@@ -4,12 +4,12 @@ import (
"net/http"
"strings"
"codeberg.org/mix/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/health"
)
// handleStatus renders the server status page: the panel's landing page and the
// one screen that answers "is the service healthy and will mail be accepted"
// (phase 13.A). The cheap local checks live in the polled "status_body"
// one screen that answers "is the service healthy and will mail be accepted".
// The cheap local checks live in the polled "status_body"
// fragment; the hostname/PTR lookup and the configuration reload sit outside it,
// because neither belongs on a five-second timer.
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
@@ -26,7 +26,8 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
}
// handleStatusFragment serves the HTMX polling fragment for the local checks
// (spec 7.1: fragment endpoints return HTML, not JSON).
// (architecture.md § Panel HTTP surface: fragment endpoints return HTML, not
// JSON).
func (s *Server) handleStatusFragment(w http.ResponseWriter, _ *http.Request) {
s.renderFragment(w, http.StatusOK, "status_body", s.statusBody())
}
@@ -90,9 +91,10 @@ func (s *Server) statusBody() map[string]any {
}
}
// queueSummary reduces postqueue's listing to the one line worth showing on the
// status page; the full listing has its own screen (spec 7.2.11). postqueue
// prints either "Mail queue is empty" or a trailing "-- N Kbytes in M Requests."
// queueSummary reduces postqueue's listing to the one line worth showing on
// the status page; the full listing has its own screen (architecture.md §
// Panel HTTP surface). postqueue prints either "Mail queue is empty" or a
// trailing "-- N Kbytes in M Requests."
func queueSummary(out string) string {
lines := strings.Split(strings.TrimSpace(out), "\n")
for i := len(lines) - 1; i >= 0; i-- {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
)
// rateLimiter is a simple fixed-window per-key counter used to throttle the
// setup and login routes (spec 7.6.1, 7.6.5). Keys are client IPs. It is not a
// setup and login routes (security.md). Keys are client IPs. It is not a
// precise sliding window — a coarse backstop against brute-force and log noise
// is all these routes need.
type rateLimiter struct {
+8 -8
View File
@@ -8,7 +8,7 @@ import (
// contentSecurityPolicy is the panel's CSP. Everything the pages load —
// stylesheet, HTMX, the panel's own script, the favicon — is served from
// /static by this same origin, and no template carries an inline <script>,
// an inline event handler or a style="..." attribute (phase 14.A; the
// an inline event handler or a style="..." attribute (the
// template guard test enforces that), so 'self' needs no exemptions:
//
// - default-src 'self' covers scripts, styles, images and the XHR that
@@ -20,7 +20,7 @@ import (
// - form-action 'self' keeps a form from being retargeted at another host.
//
// This is a second line of defence: XSS is already prevented by
// html/template's contextual auto-escaping (spec 7.6.7).
// html/template's contextual auto-escaping (security.md).
const contentSecurityPolicy = "default-src 'self'; " +
"object-src 'none'; " +
"base-uri 'none'; " +
@@ -38,8 +38,8 @@ const contentSecurityPolicy = "default-src 'self'; " +
// subdomain of that domain for a year, which is not SelfPost's call to make.
const strictTransportSecurity = "max-age=31536000"
// secure wraps the whole router with the panel's two transport-level defences
// (phase 14.A): the security response headers, and an origin check on every
// secure wraps the whole router with the panel's two transport-level
// defences: the security response headers, and an origin check on every
// state-changing request.
//
// It sits outside the authentication middleware on purpose, so that POST
@@ -81,7 +81,7 @@ func (s *Server) secure(next http.Handler) http.Handler {
}
// originAllowed reports whether a state-changing request came from the panel's
// own origin (phase 14.A). This is what the session cookie's
// own origin. This is what the session cookie's
// SameSite=Lax attribute cannot do on its own: SameSite is judged per *site*
// (registrable domain), so a neighbouring subdomain — a CMS on the same
// domain, a forgotten staging host — counts as same-site and its forged POST
@@ -111,9 +111,9 @@ func originAllowed(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
// Neither header. A client this old cannot be checked at all; it is
// let through as the risk consciously accepted for phase 14.A
// (single-admin panel, the administrator picks the browser). Turning
// this return into false is the whole of the stricter policy.
// let through as the risk consciously accepted (single-admin panel,
// the administrator picks the browser). Turning this return into
// false is the whole of the stricter policy.
return true
}
u, err := url.Parse(origin)
+1 -1
View File
@@ -32,7 +32,7 @@ func post(secFetchSite, origin string) *http.Request {
return r
}
// The full matrix the origin check has to get right (phase 14.A). The row that
// The full matrix the origin check has to get right. The row that
// matters most is "same-site": a neighbouring host on example.com is same-site
// as far as the session cookie's SameSite=Lax is concerned, so this check is
// the only thing standing between it and a forged POST.
+2 -2
View File
@@ -5,7 +5,7 @@ import (
"encoding/hex"
"time"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/store"
)
// renewThreshold bounds how often an active session's expiry is written back
@@ -16,7 +16,7 @@ const renewThreshold = time.Hour
// sessionStore persists login sessions in the database (plan B.1): a login
// survives a container restart or redeploy. Only the SHA-256 of the token is
// stored, never the token itself (spec 7.6.6's crypto-random bearer token), so
// stored, never the token itself (security.md's crypto-random bearer token), so
// a stolen database file or backup archive cannot be replayed as a session —
// it only extends the login of whichever browser still holds the original
// cookie.
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"testing"
"time"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/store"
)
func newTestSessionStore(t *testing.T) *sessionStore {
+7 -7
View File
@@ -7,17 +7,17 @@ import (
"sync"
"time"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/store"
)
// setupTokenTTL is the lifetime of a setup token (spec 7.6.1). After it
// setupTokenTTL is the lifetime of a setup token (security.md). After it
// elapses the token is regenerated and re-announced on the next /setup hit.
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 (spec 7.6.1).
// store, so once that exists the token is gone for good (security.md).
type setupManager struct {
store *store.Store
hostname string
@@ -71,8 +71,8 @@ func (m *setupManager) activeToken() (string, bool) {
// validate reports whether provided matches the active token, using a
// constant-time comparison to avoid leaking a correct prefix via timing
// (spec 7.6.1). A mismatch does NOT regenerate or invalidate the token: failed
// attempts must not let an attacker DoS a legitimate setup (spec 7.6.1).
// (security.md). A mismatch does NOT regenerate or invalidate the token: failed
// attempts must not let an attacker DoS a legitimate setup (security.md).
func (m *setupManager) validate(provided string) bool {
token, ok := m.activeToken()
if !ok {
@@ -94,13 +94,13 @@ func (m *setupManager) complete() {
// regenerateLocked mints a fresh token, announces it and mirrors it to disk.
// Caller holds m.mu.
func (m *setupManager) regenerateLocked() {
m.token = randomToken(16) // 128 bits of entropy (spec 7.6.1)
m.token = randomToken(16) // 128 bits of entropy (security.md)
m.expiresAt = time.Now().Add(setupTokenTTL)
m.announce(m.token)
}
// announce prints the setup link to the container log and writes it to the
// token file so it can be read either way (spec 7.6.1).
// token file so it can be read either way (security.md).
func (m *setupManager) announce(token string) {
url := m.setupURL(token)
logf("panel: ==================================================================")
+1 -1
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="SelfPost">
<!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
Wordmark set in IBM Plex Sans (OFL) and converted to outlines: the
file needs no font installed to render as drawn. -->
<path d="M7.80 5.00 L13.00 5.00 A2.8 2.8 0 0 0 18.60 5.00 L23.80 5.00 A2.8 2.8 0 0 0 29.40 5.00 L34.60 5.00 A2.8 2.8 0 0 0 40.20 5.00 L45.40 5.00 A2.8 2.8 0 0 0 51.00 5.00 L56.20 5.00 A2.8 2.8 0 0 0 59.00 7.80 L59.00 13.00 A2.8 2.8 0 0 0 59.00 18.60 L59.00 23.80 A2.8 2.8 0 0 0 59.00 29.40 L59.00 34.60 A2.8 2.8 0 0 0 59.00 40.20 L59.00 45.40 A2.8 2.8 0 0 0 59.00 51.00 L59.00 56.20 A2.8 2.8 0 0 0 56.20 59.00 L51.00 59.00 A2.8 2.8 0 0 0 45.40 59.00 L40.20 59.00 A2.8 2.8 0 0 0 34.60 59.00 L29.40 59.00 A2.8 2.8 0 0 0 23.80 59.00 L18.60 59.00 A2.8 2.8 0 0 0 13.00 59.00 L7.80 59.00 A2.8 2.8 0 0 0 5.00 56.20 L5.00 51.00 A2.8 2.8 0 0 0 5.00 45.40 L5.00 40.20 A2.8 2.8 0 0 0 5.00 34.60 L5.00 29.40 A2.8 2.8 0 0 0 5.00 23.80 L5.00 18.60 A2.8 2.8 0 0 0 5.00 13.00 L5.00 7.80 A2.8 2.8 0 0 0 7.80 5.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.8" stroke-linejoin="round" />

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

+1 -1
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 100" width="220" height="100" role="img" aria-label="SelfPost">
<!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
Wordmark set in IBM Plex Sans (OFL) and converted to outlines: the
file needs no font installed to render as drawn. -->
<path d="M10.20 8.00 L12.83 8.00 A2.2 2.2 0 0 0 17.23 8.00 L19.87 8.00 A2.2 2.2 0 0 0 24.27 8.00 L26.90 8.00 A2.2 2.2 0 0 0 31.30 8.00 L33.94 8.00 A2.2 2.2 0 0 0 38.34 8.00 L40.97 8.00 A2.2 2.2 0 0 0 45.37 8.00 L48.01 8.00 A2.2 2.2 0 0 0 52.41 8.00 L55.04 8.00 A2.2 2.2 0 0 0 59.44 8.00 L62.08 8.00 A2.2 2.2 0 0 0 66.48 8.00 L69.11 8.00 A2.2 2.2 0 0 0 73.51 8.00 L76.14 8.00 A2.2 2.2 0 0 0 80.54 8.00 L83.18 8.00 A2.2 2.2 0 0 0 87.58 8.00 L90.21 8.00 A2.2 2.2 0 0 0 94.61 8.00 L97.25 8.00 A2.2 2.2 0 0 0 101.65 8.00 L104.28 8.00 A2.2 2.2 0 0 0 108.68 8.00 L111.32 8.00 A2.2 2.2 0 0 0 115.72 8.00 L118.35 8.00 A2.2 2.2 0 0 0 122.75 8.00 L125.39 8.00 A2.2 2.2 0 0 0 129.79 8.00 L132.42 8.00 A2.2 2.2 0 0 0 136.82 8.00 L139.46 8.00 A2.2 2.2 0 0 0 143.86 8.00 L146.49 8.00 A2.2 2.2 0 0 0 150.89 8.00 L153.52 8.00 A2.2 2.2 0 0 0 157.92 8.00 L160.56 8.00 A2.2 2.2 0 0 0 164.96 8.00 L167.59 8.00 A2.2 2.2 0 0 0 171.99 8.00 L174.63 8.00 A2.2 2.2 0 0 0 179.03 8.00 L181.66 8.00 A2.2 2.2 0 0 0 186.06 8.00 L188.70 8.00 A2.2 2.2 0 0 0 193.10 8.00 L195.73 8.00 A2.2 2.2 0 0 0 200.13 8.00 L202.77 8.00 A2.2 2.2 0 0 0 207.17 8.00 L209.80 8.00 A2.2 2.2 0 0 0 212.00 10.20 L212.00 12.80 A2.2 2.2 0 0 0 212.00 17.20 L212.00 19.80 A2.2 2.2 0 0 0 212.00 24.20 L212.00 26.80 A2.2 2.2 0 0 0 212.00 31.20 L212.00 33.80 A2.2 2.2 0 0 0 212.00 38.20 L212.00 40.80 A2.2 2.2 0 0 0 212.00 45.20 L212.00 47.80 A2.2 2.2 0 0 0 212.00 52.20 L212.00 54.80 A2.2 2.2 0 0 0 212.00 59.20 L212.00 61.80 A2.2 2.2 0 0 0 212.00 66.20 L212.00 68.80 A2.2 2.2 0 0 0 212.00 73.20 L212.00 75.80 A2.2 2.2 0 0 0 212.00 80.20 L212.00 82.80 A2.2 2.2 0 0 0 212.00 87.20 L212.00 89.80 A2.2 2.2 0 0 0 209.80 92.00 L207.17 92.00 A2.2 2.2 0 0 0 202.77 92.00 L200.13 92.00 A2.2 2.2 0 0 0 195.73 92.00 L193.10 92.00 A2.2 2.2 0 0 0 188.70 92.00 L186.06 92.00 A2.2 2.2 0 0 0 181.66 92.00 L179.03 92.00 A2.2 2.2 0 0 0 174.63 92.00 L171.99 92.00 A2.2 2.2 0 0 0 167.59 92.00 L164.96 92.00 A2.2 2.2 0 0 0 160.56 92.00 L157.92 92.00 A2.2 2.2 0 0 0 153.52 92.00 L150.89 92.00 A2.2 2.2 0 0 0 146.49 92.00 L143.86 92.00 A2.2 2.2 0 0 0 139.46 92.00 L136.82 92.00 A2.2 2.2 0 0 0 132.42 92.00 L129.79 92.00 A2.2 2.2 0 0 0 125.39 92.00 L122.75 92.00 A2.2 2.2 0 0 0 118.35 92.00 L115.72 92.00 A2.2 2.2 0 0 0 111.32 92.00 L108.68 92.00 A2.2 2.2 0 0 0 104.28 92.00 L101.65 92.00 A2.2 2.2 0 0 0 97.25 92.00 L94.61 92.00 A2.2 2.2 0 0 0 90.21 92.00 L87.58 92.00 A2.2 2.2 0 0 0 83.18 92.00 L80.54 92.00 A2.2 2.2 0 0 0 76.14 92.00 L73.51 92.00 A2.2 2.2 0 0 0 69.11 92.00 L66.48 92.00 A2.2 2.2 0 0 0 62.08 92.00 L59.44 92.00 A2.2 2.2 0 0 0 55.04 92.00 L52.41 92.00 A2.2 2.2 0 0 0 48.01 92.00 L45.37 92.00 A2.2 2.2 0 0 0 40.97 92.00 L38.34 92.00 A2.2 2.2 0 0 0 33.94 92.00 L31.30 92.00 A2.2 2.2 0 0 0 26.90 92.00 L24.27 92.00 A2.2 2.2 0 0 0 19.87 92.00 L17.23 92.00 A2.2 2.2 0 0 0 12.83 92.00 L10.20 92.00 A2.2 2.2 0 0 0 8.00 89.80 L8.00 87.20 A2.2 2.2 0 0 0 8.00 82.80 L8.00 80.20 A2.2 2.2 0 0 0 8.00 75.80 L8.00 73.20 A2.2 2.2 0 0 0 8.00 68.80 L8.00 66.20 A2.2 2.2 0 0 0 8.00 61.80 L8.00 59.20 A2.2 2.2 0 0 0 8.00 54.80 L8.00 52.20 A2.2 2.2 0 0 0 8.00 47.80 L8.00 45.20 A2.2 2.2 0 0 0 8.00 40.80 L8.00 38.20 A2.2 2.2 0 0 0 8.00 33.80 L8.00 31.20 A2.2 2.2 0 0 0 8.00 26.80 L8.00 24.20 A2.2 2.2 0 0 0 8.00 19.80 L8.00 17.20 A2.2 2.2 0 0 0 8.00 12.80 L8.00 10.20 A2.2 2.2 0 0 0 10.20 8.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.7" stroke-linejoin="round" />

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

+1 -1
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 330 150" width="330" height="150" role="img" aria-label="SelfPost — self-hosted SMTP relay">
<!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
Wordmark set in IBM Plex Sans (OFL) and converted to outlines: the
file needs no font installed to render as drawn. -->
<path d="M24.70 14.00 L27.97 14.00 A2.7 2.7 0 0 0 33.37 14.00 L36.63 14.00 A2.7 2.7 0 0 0 42.03 14.00 L45.30 14.00 A2.7 2.7 0 0 0 50.70 14.00 L53.97 14.00 A2.7 2.7 0 0 0 59.37 14.00 L62.63 14.00 A2.7 2.7 0 0 0 68.03 14.00 L71.30 14.00 A2.7 2.7 0 0 0 76.70 14.00 L79.97 14.00 A2.7 2.7 0 0 0 85.37 14.00 L88.63 14.00 A2.7 2.7 0 0 0 94.03 14.00 L97.30 14.00 A2.7 2.7 0 0 0 102.70 14.00 L105.97 14.00 A2.7 2.7 0 0 0 111.37 14.00 L114.63 14.00 A2.7 2.7 0 0 0 120.03 14.00 L123.30 14.00 A2.7 2.7 0 0 0 128.70 14.00 L131.97 14.00 A2.7 2.7 0 0 0 137.37 14.00 L140.63 14.00 A2.7 2.7 0 0 0 146.03 14.00 L149.30 14.00 A2.7 2.7 0 0 0 154.70 14.00 L157.97 14.00 A2.7 2.7 0 0 0 163.37 14.00 L166.63 14.00 A2.7 2.7 0 0 0 172.03 14.00 L175.30 14.00 A2.7 2.7 0 0 0 180.70 14.00 L183.97 14.00 A2.7 2.7 0 0 0 189.37 14.00 L192.63 14.00 A2.7 2.7 0 0 0 198.03 14.00 L201.30 14.00 A2.7 2.7 0 0 0 206.70 14.00 L209.97 14.00 A2.7 2.7 0 0 0 215.37 14.00 L218.63 14.00 A2.7 2.7 0 0 0 224.03 14.00 L227.30 14.00 A2.7 2.7 0 0 0 232.70 14.00 L235.97 14.00 A2.7 2.7 0 0 0 241.37 14.00 L244.63 14.00 A2.7 2.7 0 0 0 250.03 14.00 L253.30 14.00 A2.7 2.7 0 0 0 258.70 14.00 L261.97 14.00 A2.7 2.7 0 0 0 267.37 14.00 L270.63 14.00 A2.7 2.7 0 0 0 276.03 14.00 L279.30 14.00 A2.7 2.7 0 0 0 284.70 14.00 L287.97 14.00 A2.7 2.7 0 0 0 293.37 14.00 L296.63 14.00 A2.7 2.7 0 0 0 302.03 14.00 L305.30 14.00 A2.7 2.7 0 0 0 308.00 16.70 L308.00 20.01 A2.7 2.7 0 0 0 308.00 25.41 L308.00 28.73 A2.7 2.7 0 0 0 308.00 34.13 L308.00 37.44 A2.7 2.7 0 0 0 308.00 42.84 L308.00 46.16 A2.7 2.7 0 0 0 308.00 51.56 L308.00 54.87 A2.7 2.7 0 0 0 308.00 60.27 L308.00 63.59 A2.7 2.7 0 0 0 308.00 68.99 L308.00 72.30 A2.7 2.7 0 0 0 308.00 77.70 L308.00 81.01 A2.7 2.7 0 0 0 308.00 86.41 L308.00 89.73 A2.7 2.7 0 0 0 308.00 95.13 L308.00 98.44 A2.7 2.7 0 0 0 308.00 103.84 L308.00 107.16 A2.7 2.7 0 0 0 308.00 112.56 L308.00 115.87 A2.7 2.7 0 0 0 308.00 121.27 L308.00 124.59 A2.7 2.7 0 0 0 308.00 129.99 L308.00 133.30 A2.7 2.7 0 0 0 305.30 136.00 L302.03 136.00 A2.7 2.7 0 0 0 296.63 136.00 L293.37 136.00 A2.7 2.7 0 0 0 287.97 136.00 L284.70 136.00 A2.7 2.7 0 0 0 279.30 136.00 L276.03 136.00 A2.7 2.7 0 0 0 270.63 136.00 L267.37 136.00 A2.7 2.7 0 0 0 261.97 136.00 L258.70 136.00 A2.7 2.7 0 0 0 253.30 136.00 L250.03 136.00 A2.7 2.7 0 0 0 244.63 136.00 L241.37 136.00 A2.7 2.7 0 0 0 235.97 136.00 L232.70 136.00 A2.7 2.7 0 0 0 227.30 136.00 L224.03 136.00 A2.7 2.7 0 0 0 218.63 136.00 L215.37 136.00 A2.7 2.7 0 0 0 209.97 136.00 L206.70 136.00 A2.7 2.7 0 0 0 201.30 136.00 L198.03 136.00 A2.7 2.7 0 0 0 192.63 136.00 L189.37 136.00 A2.7 2.7 0 0 0 183.97 136.00 L180.70 136.00 A2.7 2.7 0 0 0 175.30 136.00 L172.03 136.00 A2.7 2.7 0 0 0 166.63 136.00 L163.37 136.00 A2.7 2.7 0 0 0 157.97 136.00 L154.70 136.00 A2.7 2.7 0 0 0 149.30 136.00 L146.03 136.00 A2.7 2.7 0 0 0 140.63 136.00 L137.37 136.00 A2.7 2.7 0 0 0 131.97 136.00 L128.70 136.00 A2.7 2.7 0 0 0 123.30 136.00 L120.03 136.00 A2.7 2.7 0 0 0 114.63 136.00 L111.37 136.00 A2.7 2.7 0 0 0 105.97 136.00 L102.70 136.00 A2.7 2.7 0 0 0 97.30 136.00 L94.03 136.00 A2.7 2.7 0 0 0 88.63 136.00 L85.37 136.00 A2.7 2.7 0 0 0 79.97 136.00 L76.70 136.00 A2.7 2.7 0 0 0 71.30 136.00 L68.03 136.00 A2.7 2.7 0 0 0 62.63 136.00 L59.37 136.00 A2.7 2.7 0 0 0 53.97 136.00 L50.70 136.00 A2.7 2.7 0 0 0 45.30 136.00 L42.03 136.00 A2.7 2.7 0 0 0 36.63 136.00 L33.37 136.00 A2.7 2.7 0 0 0 27.97 136.00 L24.70 136.00 A2.7 2.7 0 0 0 22.00 133.30 L22.00 129.99 A2.7 2.7 0 0 0 22.00 124.59 L22.00 121.27 A2.7 2.7 0 0 0 22.00 115.87 L22.00 112.56 A2.7 2.7 0 0 0 22.00 107.16 L22.00 103.84 A2.7 2.7 0 0 0 22.00 98.44 L22.00 95.13 A2.7 2.7 0 0 0 22.00 89.73 L22.00 86.41 A2.7 2.7 0 0 0 22.00 81.01 L22.00 77.70 A2.7 2.7 0 0 0 22.00 72.30 L22.00 68.99 A2.7 2.7 0 0 0 22.00 63.59 L22.00 60.27 A2.7 2.7 0 0 0 22.00 54.87 L22.00 51.56 A2.7 2.7 0 0 0 22.00 46.16 L22.00 42.84 A2.7 2.7 0 0 0 22.00 37.44 L22.00 34.13 A2.7 2.7 0 0 0 22.00 28.73 L22.00 25.41 A2.7 2.7 0 0 0 22.00 20.01 L22.00 16.70 A2.7 2.7 0 0 0 24.70 14.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.8" stroke-linejoin="round" />

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

+95 -70
View File
@@ -1,21 +1,60 @@
/* Panel stylesheet. It lives in a file rather than in a <style> block in the
layout so the panel's Content-Security-Policy can be a plain
"default-src 'self'" with no inline-style exemption (phase 14.A). Any rule
"default-src 'self'" with no inline-style exemption. Any rule
added here must therefore stay here: an inline style="..." attribute in a
template is blocked by that policy and silently does nothing. */
:root { color-scheme: light dark; }
/* Colour tokens. Light values live on :root; the dark media query below
reassigns the same names rather than re-declaring every rule that uses
them, so a rule needs !important nowhere in this file — the custom
property already carries the right value for the active scheme. */
:root {
color-scheme: light dark;
--bg: #f6f7f9; --fg: #1b1f24;
--card-bg: #fff;
--border: #e2e5e9; /* dividers: card, table, app, nav, code, encrypt-fields */
--control-border: #cfd4da; /* input, select, textarea, action buttons */
--input-bg: #fff;
--code-bg: #f0f2f4;
--surface-bg: #eef1f5; --surface-bg-hover: #e2e7ee; --surface-open-bg: #dde3ec;
--nav-active-bg: #e6ebf5;
--flash-bg: #ecfdf3; --flash-border: #abefc6; --flash-fg: #067647;
--credential-bg: #fffbeb; --credential-border: #f5c518;
--danger-bg: #fef3f2; --danger-border: #fecdca; --danger-fg: #b42318; --danger-bg-hover: #fee4e2;
--st-ok-bg: #ecfdf3; --st-ok-fg: #067647; --st-ok-border: #abefc6;
--st-warn-bg: #fffaeb; --st-warn-fg: #b54708; --st-warn-border: #fedf89;
--st-error-bg: #fef3f2; --st-error-fg: #b42318; --st-error-border: #fecdca;
--st-unknown-bg: #f0f2f4; --st-unknown-fg: #6b7280; --st-unknown-border: #e2e5e9;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14171a; --fg: #e6e8eb;
--card-bg: #1d2125;
--border: #2b3138;
--control-border: #2b3138;
--input-bg: #14171a;
--code-bg: #14171a;
--surface-bg: #22262b; --surface-bg-hover: #2b3138; --surface-open-bg: #313841;
--nav-active-bg: #22303f;
--flash-bg: #0d2818; --flash-border: #1a5336; --flash-fg: #75d99b;
--credential-bg: #2a2408; --credential-border: #6b5a10;
--danger-bg: #2d1211; --danger-border: #6b201a; --danger-fg: #f5a29b; --danger-bg-hover: #3d1a18;
--st-ok-bg: #0d2818; --st-ok-fg: #75d99b; --st-ok-border: #1a5336;
--st-warn-bg: #2e2308; --st-warn-fg: #f5c86b; --st-warn-border: #6b5210;
--st-error-bg: #2d1211; --st-error-fg: #f5a29b; --st-error-border: #6b201a;
--st-unknown-bg: #22262b; --st-unknown-fg: #9aa3ad; --st-unknown-border: #2b3138;
}
}
* { box-sizing: border-box; }
body {
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
margin: 0; padding: 2rem 1rem; background: #f6f7f9; color: #1b1f24;
margin: 0; padding: 2rem 1rem; background: var(--bg); color: var(--fg);
}
@media (prefers-color-scheme: dark) {
body { background: #14171a; color: #e6e8eb; }
.card { background: #1d2125 !important; border-color: #2b3138 !important; }
input { background: #14171a !important; color: inherit !important; border-color: #2b3138 !important; }
}
main { max-width: 42rem; margin: 0 auto; }
/* 48rem is a reading measure: right for the forms and prose that make up most
of the panel, and the width the navigation bar's two rows were sized for.
Page-specific overrides below widen or narrow it for the pages that need
something else. */
main { max-width: 48rem; margin: 0 auto; }
h1 { font-size: 1.4rem; margin: 0 0 1rem; }
/* The full mark, on the two pages that have no navigation bar to carry the
compact one. It takes the column's width so its edges line up with the card
@@ -25,14 +64,14 @@ h1 { font-size: 1.4rem; margin: 0 0 1rem; }
once the body's padding is taken off. */
.mark { display: block; width: 100%; max-width: 24rem; height: auto; margin-bottom: 1.4rem; }
.card {
background: #fff; border: 1px solid #e2e5e9; border-radius: 10px;
background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px;
padding: 1.5rem; margin: 0 auto;
}
.card.narrow { max-width: 24rem; }
label { display: block; font-weight: 600; margin: 0.9rem 0 0.3rem; }
input {
width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem;
border: 1px solid #cfd4da; border-radius: 6px; background: #fff;
border: 1px solid var(--control-border); border-radius: 6px; background: var(--input-bg); color: inherit;
}
/* One vocabulary for actions. Anything that performs an action looks like a
button: a <button>, or an <a> carrying .btn/.danger where the action is a
@@ -56,14 +95,12 @@ button:hover, a.btn:hover { background: #1d4ed8; }
/* form.inline only exists so a POST can sit next to other content without a
form's block layout; its button is styled like any other. */
form.inline { display: inline; margin: 0; }
main { max-width: 48rem; }
/* 48rem is a reading measure: right for the forms and prose that make up most
of the panel, and the width the navigation bar's two rows were sized for. The
three monitoring pages hold data instead — seven columns of send-log, and raw
mail.log lines that are long by nature — and at that width the send-log's
Subject and Status were fighting over the last inch while the log pages wrapped
every second line. They get a wider measure; every other page keeps the narrow
one. The class comes from the layout, which stamps the page name onto <main>. */
/* The three monitoring pages hold data instead of prose — seven columns of
send-log, and raw mail.log lines that are long by nature — and at the
default measure the send-log's Subject and Status were fighting over the
last inch while the log pages wrapped every second line. They get a wider
measure; every other page keeps the default one. The class comes from the
layout, which stamps the page name onto <main>. */
main.page-deliveries, main.page-mail_queue, main.page-system_log { max-width: 64rem; }
/* The signed-out pages are a single card and nothing else, and .card.narrow
centres itself inside whatever holds it — so at the panel's usual width the
@@ -73,19 +110,17 @@ main.page-deliveries, main.page-mail_queue, main.page-system_log { max-width: 64
the middle of the page. */
main.page-login, main.page-setup { max-width: 24rem; }
.card + .card { margin-top: 1.2rem; }
.flash { background: #ecfdf3; border: 1px solid #abefc6; color: #067647; padding: 0.7rem 1rem; border-radius: 8px; margin-bottom: 1.2rem; }
@media (prefers-color-scheme: dark) { .flash { background: #0d2818 !important; border-color: #1a5336 !important; color: #75d99b !important; } }
.flash { background: var(--flash-bg); border: 1px solid var(--flash-border); color: var(--flash-fg); padding: 0.7rem 1rem; border-radius: 8px; margin-bottom: 1.2rem; }
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
addresses, domains, queue ids. One 40-character recipient was enough to widen
the send-log past its card and hang Status over the edge. Cells may break mid
word, so a column can always be squeezed to the width available. */
th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid #e2e5e9; overflow-wrap: anywhere; }
th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
/* The exception: a timestamp broken across two lines is unreadable, and it is
short enough to never be the reason a row does not fit. */
td.time { white-space: nowrap; }
@media (prefers-color-scheme: dark) { th, td { border-color: #2b3138 !important; } }
th { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; color: #6b7280; }
td.actions { text-align: right; }
/* Subject is the one cell whose text we do not control. Breaking mid word (the
@@ -99,8 +134,7 @@ td.subject span {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.code { display: block; white-space: pre-wrap; word-break: break-all; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85rem; background: #f0f2f4; border: 1px solid #e2e5e9; border-radius: 6px; padding: 0.7rem 0.8rem; margin: 0.3rem 0 0; }
@media (prefers-color-scheme: dark) { .code { background: #14171a !important; border-color: #2b3138 !important; } }
font-size: 0.85rem; background: var(--code-bg); border: 1px solid var(--border); border-radius: 6px; padding: 0.7rem 0.8rem; margin: 0.3rem 0 0; }
h2 { font-size: 1.05rem; margin: 0 0 0.4rem; }
.back { display: inline-block; margin-bottom: 1rem; }
/* Build version, closing every authenticated page. Quiet on purpose: it is
@@ -108,13 +142,10 @@ h2 { font-size: 1.05rem; margin: 0 0 0.4rem; }
.version { margin-top: 1.6rem; text-align: right; font-size: 0.8rem; color: #6b7280; }
select, textarea {
width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem;
border: 1px solid #cfd4da; border-radius: 6px; background: #fff; color: inherit;
border: 1px solid var(--control-border); border-radius: 6px; background: var(--input-bg); color: inherit;
font-family: inherit;
}
textarea { resize: vertical; }
@media (prefers-color-scheme: dark) {
select, textarea { background: #14171a !important; color: inherit !important; border-color: #2b3138 !important; }
}
button.danger, a.danger { background: #b42318; }
button.danger:hover, a.danger:hover { background: #912018; }
/* The disclosure toggle is an action too, so it is drawn as a button (see the
@@ -124,15 +155,14 @@ button.danger:hover, a.danger:hover { background: #912018; }
.actions summary::-webkit-details-marker { display: none; }
/* Applications are a list of blocks, not table rows. As a table it fell apart:
four columns of which the last held six controls — two of them <details>
panels with textareas — never fit the panel's 48rem. The controls wrapped
into a staircase, .code on the login cell grew into a slab as tall as the
row, and the two text cells sat on the baseline halfway down it. One block
per application gives the identity a line of its own and the controls a row
of their own, at the width they actually need. */
panels with textareas — never fit the panel's default width. The controls
wrapped into a staircase, .code on the login cell grew into a slab as tall
as the row, and the two text cells sat on the baseline halfway down it. One
block per application gives the identity a line of its own and the
controls a row of their own, at the width they actually need. */
.apps { list-style: none; margin: 1.2rem 0 0; padding: 0; }
.app { padding: 0.9rem 0; border-top: 1px solid #e2e5e9; }
.app { padding: 0.9rem 0; border-top: 1px solid var(--border); }
.app:last-child { padding-bottom: 0; }
@media (prefers-color-scheme: dark) { .app { border-color: #2b3138 !important; } }
.app-login { margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 600; }
.app-addr { margin: 0.15rem 0 0; word-break: break-all; }
.app .actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.7rem; }
@@ -140,8 +170,7 @@ button.danger:hover, a.danger:hover { background: #912018; }
the block's full width, not the width of the summary that opened them. */
.app .actions > details[open] { flex: 1 0 100%; }
details form { margin-top: 0.6rem; }
.credential { border-color: #f5c518; background: #fffbeb; }
@media (prefers-color-scheme: dark) { .credential { background: #2a2408 !important; border-color: #6b5a10 !important; } }
.credential { border-color: var(--credential-border); background: var(--credential-bg); }
/* Panel navigation: rendered once from the layout, so it is present on every
authenticated page without each content template having to include it. */
/* Two deliberate rows, not one. The six page entries need about 660px and the
@@ -159,9 +188,8 @@ details form { margin-top: 0.6rem; }
.nav {
display: flex; flex-direction: column; align-items: stretch;
gap: 0.5rem; margin-bottom: 1.2rem; padding-bottom: 0.6rem;
border-bottom: 1px solid #e2e5e9;
border-bottom: 1px solid var(--border);
}
@media (prefers-color-scheme: dark) { .nav { border-color: #2b3138 !important; } }
.nav .links, .nav .session { display: flex; flex-wrap: wrap; gap: 0.2rem 0.9rem; align-items: center; }
/* The top row: mark at one edge, session block at the other. Splitting them to
the edges is what makes the two rows read as two blocks rather than as one
@@ -187,10 +215,7 @@ details form { margin-top: 0.6rem; }
padding: 0.2rem 0.5rem; border-radius: 6px;
}
.nav [aria-current] {
font-weight: 600; color: #1b1f24; background: #e6ebf5; box-shadow: inset 0 -2px 0 #2563eb;
}
@media (prefers-color-scheme: dark) {
.nav [aria-current] { color: #e6e8eb !important; background: #22303f !important; }
font-weight: 600; color: var(--fg); background: var(--nav-active-bg); box-shadow: inset 0 -2px 0 #2563eb;
}
/* Sign out carries an icon too, so it needs the same row layout; its padding
and colours come from the compact button rule further down. */
@@ -206,16 +231,10 @@ details form { margin-top: 0.6rem; }
font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
vertical-align: middle; border: 1px solid transparent;
}
.st-ok { background: #ecfdf3; color: #067647; border-color: #abefc6; }
.st-warn { background: #fffaeb; color: #b54708; border-color: #fedf89; }
.st-error { background: #fef3f2; color: #b42318; border-color: #fecdca; }
.st-unknown { background: #f0f2f4; color: #6b7280; border-color: #e2e5e9; }
@media (prefers-color-scheme: dark) {
.st-ok { background: #0d2818 !important; color: #75d99b !important; border-color: #1a5336 !important; }
.st-warn { background: #2e2308 !important; color: #f5c86b !important; border-color: #6b5210 !important; }
.st-error { background: #2d1211 !important; color: #f5a29b !important; border-color: #6b201a !important; }
.st-unknown { background: #22262b !important; color: #9aa3ad !important; border-color: #2b3138 !important; }
}
.st-ok { background: var(--st-ok-bg); color: var(--st-ok-fg); border-color: var(--st-ok-border); }
.st-warn { background: var(--st-warn-bg); color: var(--st-warn-fg); border-color: var(--st-warn-border); }
.st-error { background: var(--st-error-bg); color: var(--st-error-fg); border-color: var(--st-error-border); }
.st-unknown { background: var(--st-unknown-bg); color: var(--st-unknown-fg); border-color: var(--st-unknown-border); }
.code-row { display: flex; align-items: flex-start; gap: 0.5rem; }
.code-row .code { flex: 1; min-width: 0; }
/* Compact outlined button: same affordance as the filled one but quiet enough
@@ -227,25 +246,31 @@ details form { margin-top: 0.6rem; }
button.copy, .actions button, .actions summary, .actions a.danger, .nav button {
margin: 0; padding: 0.45rem 0.7rem; font-size: 0.8rem; font-weight: 600;
border-radius: 6px; white-space: nowrap;
background: #eef1f5; color: #2563eb; border: 1px solid #cfd4da;
background: var(--surface-bg); color: #2563eb; border: 1px solid var(--control-border);
}
button.copy:hover, .actions button:hover, .actions summary:hover,
.actions a.danger:hover, .nav button:hover { background: #e2e7ee; }
.actions a.danger:hover, .nav button:hover { background: var(--surface-bg-hover); }
button.copy { flex: none; margin-top: 0.3rem; }
.actions details[open] > summary { background: #dde3ec; }
.actions details[open] > summary { background: var(--surface-open-bg); }
.actions button.danger, .actions a.danger, .nav button.danger {
color: #b42318; background: #fef3f2; border-color: #fecdca;
color: var(--danger-fg); background: var(--danger-bg); border-color: var(--danger-border);
}
.actions button.danger:hover, .actions a.danger:hover, .nav button.danger:hover { background: #fee4e2; }
@media (prefers-color-scheme: dark) {
button.copy, .actions button, .actions summary, .actions a.danger, .nav button {
background: #22262b !important; border-color: #2b3138 !important;
}
button.copy:hover, .actions button:hover, .actions summary:hover,
.actions a.danger:hover, .nav button:hover { background: #2b3138 !important; }
.actions details[open] > summary { background: #313841 !important; }
.actions button.danger, .actions a.danger, .nav button.danger {
color: #f5a29b !important; background: #2d1211 !important; border-color: #6b201a !important;
}
.actions button.danger:hover, .actions a.danger:hover, .nav button.danger:hover { background: #3d1a18 !important; }
.actions button.danger:hover, .actions a.danger:hover, .nav button.danger:hover { background: var(--danger-bg-hover); }
/* The optional "encrypt this download" block on the backup, export and import
forms. Its label is the one checkbox in the panel, so it opts out of the
block-level label rule above and sits on one line with its box; the fields it
reveals are indented under it to read as its consequence rather than as three
more fields of the form. panel.js hides the inner block until the box is
ticked (and empties it when unticked); without JavaScript everything stays
visible, which the server handles identically. */
.encrypt { margin-top: 1.2rem; }
.encrypt label.check {
display: flex; align-items: center; gap: 0.5rem; margin: 0; font-weight: 600;
}
.encrypt label.check input { width: auto; margin: 0; }
.encrypt-fields {
margin-left: 1.6rem; padding-left: 0.9rem; border-left: 2px solid var(--border);
}
.encrypt-fields label { margin-top: 0.7rem; }
.encrypt-fields .muted { margin: 0.5rem 0 0; font-size: 0.85rem; }
+48 -1
View File
@@ -36,7 +36,7 @@
// Forms that delete something or invalidate a working credential carry a
// data-confirm message. The prompt lives here rather than in an inline
// onsubmit attribute because the panel's Content-Security-Policy allows no
// inline script (phase 14.A). The listener is delegated from the document,
// inline script. The listener is delegated from the document,
// so it also covers markup swapped in by HTMX. With JavaScript disabled the
// form submits without asking — exactly as the inline handler behaved.
document.addEventListener("submit", function (ev) {
@@ -72,7 +72,54 @@
});
}
// --- 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);
});
});
}
document.addEventListener("DOMContentLoaded", function () {
initAddressFields(document);
initEncryptFields(document);
});
// --- Skip polling while the tab is hidden ------------------------------
// The monitoring pages (status, mail queue, system log, deliveries) poll
// every 5s via hx-trigger="every 5s". htmx has a built-in way to make that
// conditional (an event filter, hx-trigger="every 5s [expr]"), but it
// evaluates the filter with `new Function`, which the panel's CSP
// (default-src 'self', no 'unsafe-eval') would silently break. Skipping the
// request here instead needs nothing beyond what the CSP already allows: a
// request due while the tab is hidden is simply not sent, and the next
// request after it becomes visible again picks up on schedule as usual.
document.body.addEventListener("htmx:beforeRequest", function (ev) {
var trigger = ev.target.getAttribute && ev.target.getAttribute("hx-trigger");
if (document.hidden && trigger && trigger.indexOf("every") !== -1) {
ev.preventDefault();
}
});
})();
+15 -10
View File
@@ -9,26 +9,30 @@ import (
// templates holds the parsed page and fragment templates. Each page is parsed
// together with the shared base layout so {{ template "base" . }} works.
// Fragments (HTMX polling targets, spec 7.1) are parsed standalone, without
// the layout, so they can be swapped into an existing page as an HTML snippet
// rather than a full document. Rendering always goes through html/template,
// which auto-escapes all interpolated data regardless (spec 7.6.7).
// Fragments (HTMX polling targets, architecture.md § Panel HTTP surface) are
// parsed standalone, without the layout, so they can be swapped into an
// existing page as an HTML snippet rather than a full document. Rendering
// always goes through html/template, which auto-escapes all interpolated data
// regardless (security.md).
type templates struct {
pages map[string]*template.Template
fragments map[string]*template.Template
}
// pageFiles maps a logical page name to its template files. Every page
// composes with layout.html; pages that embed a polling fragment (spec 7.1)
// list that fragment's file too, so the same {{define}} block renders both
// the initial page and the fragment's own refresh responses identically.
// composes with layout.html; pages that embed a polling fragment
// (architecture.md § Panel HTTP surface) list that fragment's file too, so the
// same {{define}} block renders both the initial page and the fragment's own
// refresh responses identically. Pages sharing a block of markup (the
// encryption fields on the two secret downloads) list that partial the same
// way.
var pageFiles = map[string][]string{
"setup": {"templates/setup.html"},
"login": {"templates/login.html"},
"dashboard": {"templates/dashboard.html"},
"account": {"templates/account.html"},
"backup": {"templates/backup.html"},
"domain_detail": {"templates/domain_detail.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"},
"deliveries": {"templates/deliveries.html", "templates/deliveries_rows.html"},
"mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"},
@@ -99,7 +103,8 @@ func (s *Server) render(w http.ResponseWriter, status int, page string, data any
}
// renderFragment writes an HTMX polling fragment as a bare HTML snippet, with
// no surrounding layout (spec 7.1: fragment endpoints return HTML, not JSON).
// no surrounding layout (architecture.md § Panel HTTP surface: fragment
// endpoints return HTML, not JSON).
func (s *Server) renderFragment(w http.ResponseWriter, status int, name string, data any) {
tmpl, ok := s.tmpl.fragments[name]
if !ok {
+16 -3
View File
@@ -10,8 +10,11 @@
start. TLS certificates and the mail queue are not included.</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
once the restore succeeds.</p>
<form class="inline" method="post" action="/backup">
once the restore succeeds. Encrypting it below is the simplest way to do that:
the download is then a <code>.spbk</code> file that only the password opens.</p>
{{if .BackupErr}}<p class="error">{{.BackupErr}}</p>{{end}}
<form method="post" action="/backup">
{{template "encryptfields" .}}
<button type="submit">Download full backup</button>
</form>
</div>
@@ -25,7 +28,17 @@
{{if .ImportErr}}<p class="error">{{.ImportErr}}</p>{{end}}
<form method="post" action="/domains/import" enctype="multipart/form-data">
<label for="importfile">Domain export file</label>
<input id="importfile" name="file" type="file" accept=".json,application/json" required>
<input id="importfile" name="file" type="file" accept=".json,.spde,application/json" required>
<div class="encrypt">
<label class="check">
<input type="checkbox" data-encrypt-toggle>
<span>The file is encrypted (<code>.spde</code>)</span>
</label>
<div class="encrypt-fields" data-encrypt-fields>
<label for="importpw">Password</label>
<input id="importpw" name="import_password" type="password" autocomplete="off">
</div>
</div>
<button type="submit">Import domain</button>
</form>
</div>
+5 -2
View File
@@ -283,8 +283,11 @@
import the DNS record stays the same, so no DNS change is needed.</p>
<p class="muted"><strong>The export file is a secret</strong> — it contains the
private DKIM key and application passwords. Transfer it securely and delete it
after the import.</p>
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/export">
after the import, or encrypt it below and move a <code>.spde</code> file
instead; the import form asks for the password.</p>
{{if .ExportErr}}<p class="error">{{.ExportErr}}</p>{{end}}
<form method="post" action="/domains/{{.Domain.ID}}/export">
{{template "encryptfields" .}}
<button type="submit">Export domain</button>
</form>
</div>
@@ -0,0 +1,23 @@
{{/* Password fields shared by the full-backup and domain-export forms. The
checkbox is the switch: unticked, the download keeps its historic plain
form (.tar.gz / .json); ticked, the file is sealed in a password-encrypted
envelope (.spbk / .spde). The fields start hidden and are revealed by
panel.js — with JavaScript blocked they are simply always visible, and the
server still decides from the checkbox alone. */}}
{{define "encryptfields"}}
<div class="encrypt">
<label class="check">
<input type="checkbox" name="encrypt" value="1" data-encrypt-toggle>
<span>Encrypt with a password</span>
</label>
<div class="encrypt-fields" data-encrypt-fields>
<label for="encpw">Password</label>
<input id="encpw" name="password" type="password" autocomplete="new-password"
minlength="{{.MinPwLen}}" placeholder="at least {{.MinPwLen}} characters">
<label for="encpw2">Repeat password</label>
<input id="encpw2" name="password_confirm" type="password" autocomplete="new-password">
<p class="muted">Keep this password: without it the file cannot be opened,
and SelfPost does not store it anywhere.</p>
</div>
</div>
{{end}}
+1 -1
View File
@@ -15,7 +15,7 @@
{{/* Unless told otherwise, htmx injects a stylesheet element of its own into
the head for the request-indicator classes. The panel uses no
hx-indicator, and that injected element would be the one thing the
Content-Security-Policy (phase 14.A) has to make an exception for, so it
Content-Security-Policy has to make an exception for, so it
is switched off here. */}}
<meta name="htmx-config" content='{"includeIndicatorStyles":false}'>
<script src="/static/htmx.min.js" defer></script>
+5 -5
View File
@@ -11,7 +11,7 @@ import (
"testing"
"time"
"codeberg.org/mix/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/health"
)
// The navigation is rendered from the layout, not copied into each page, so
@@ -142,9 +142,9 @@ func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) {
}
}
// Since the panel root now redirects to the status page, a link left pointing at
// Since the panel root redirects to the status page, a link left pointing at
// "/" silently lands on the wrong screen instead of failing — so no template may
// contain one (phase 13.C).
// contain one.
func TestNoTemplateLinksToTheBareRoot(t *testing.T) {
forEachTemplate(t, func(name, body string) {
if strings.Contains(body, `href="/"`) {
@@ -154,7 +154,7 @@ func TestNoTemplateLinksToTheBareRoot(t *testing.T) {
}
// The reload action is a server-health control and lives only on the status
// page (phase 13.D).
// page.
func TestReloadFormLivesOnlyOnTheStatusPage(t *testing.T) {
forEachTemplate(t, func(name, body string) {
if strings.Contains(body, `action="/reload"`) && name != "status.html" {
@@ -164,7 +164,7 @@ func TestReloadFormLivesOnlyOnTheStatusPage(t *testing.T) {
}
// The panel's Content-Security-Policy is a plain default-src 'self' with no
// inline exemption (phase 14.A), which makes inline script and inline style a
// inline exemption, which makes inline script and inline style a
// failure mode rather than a style question: an onclick= handler or a
// style="..." attribute added to a template does not error, it silently stops
// working in the browser. Behaviour belongs in static/panel.js (triggered from
+1 -1
View File
@@ -7,7 +7,7 @@ import (
// randomToken returns a URL-safe token with at least nBytes*8 bits of entropy
// drawn from crypto/rand. Setup and session tokens both use this; the setup
// token needs >=128 bits (spec 7.6.1), so callers pass nBytes >= 16.
// token needs >=128 bits (security.md), so callers pass nBytes >= 16.
//
// It panics if the system RNG fails: that is unrecoverable and must never be
// papered over with a weak fallback for a security token.
+9 -3
View File
@@ -7,7 +7,7 @@ import (
)
// minAdminPasswordLen is the floor for the administrator password. The panel is
// public (spec 7.6), so this is deliberately not tiny.
// public (security.md), so this is deliberately not tiny.
const minAdminPasswordLen = 12
const (
@@ -15,7 +15,13 @@ const (
maxUsernameLen = 64
)
// validateUsername enforces a strict server-side whitelist (spec 7.6.2):
// minSecretFilePasswordLen is the floor for the password protecting an
// encrypted backup or domain export. Such a file is offline and can be attacked
// at leisure, so the floor matches the administrator password's rather than the
// weaker "any password is better than none".
const minSecretFilePasswordLen = minAdminPasswordLen
// validateUsername enforces a strict server-side whitelist (security.md):
// letters, digits, dot, dash, underscore. Client validation is never trusted.
func validateUsername(u string) error {
if len(u) < minUsernameLen || len(u) > maxUsernameLen {
@@ -52,7 +58,7 @@ func normalizeDomain(name string) string {
}
// validateDomain enforces a strict server-side whitelist for sending-domain
// names (spec 7.6.2). The result is safe to write verbatim into the OpenDKIM
// names (security.md). The result is safe to write verbatim into the OpenDKIM
// KeyTable/SigningTable and to use as a filesystem path segment: only
// lower-case letters, digits, '.' and '-' are allowed, in valid DNS label
// shape. Input must already be normalised with normalizeDomain.
+41 -31
View File
@@ -1,6 +1,6 @@
// Package web implements the SelfPost control panel's HTTP surface: the
// one-time administrator setup flow (spec 7.6.1), login/session handling
// (spec 7.6.5-6) and the authenticated shell the later phases build on.
// one-time administrator setup flow (security.md), login/session handling
// (security.md) and the authenticated shell the later phases build on.
package web
import (
@@ -11,10 +11,11 @@ import (
"strings"
"time"
"codeberg.org/mix/selfpost/internal/app"
"codeberg.org/mix/selfpost/internal/dnscheck"
"codeberg.org/mix/selfpost/internal/domain"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/app"
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/store"
)
//go:embed templates/*.html static/*
@@ -23,24 +24,26 @@ var assetsFS embed.FS
// Config holds the panel's HTTP-facing configuration.
type Config struct {
// Hostname is the server's external hostname, used to build the absolute
// setup link shown in the logs (spec 7.6.1, 8: SELFPOST_HOSTNAME).
// setup link shown in the logs (security.md; README § Environment
// variables for SELFPOST_HOSTNAME).
Hostname string
// CookieSecure sets the Secure attribute on the session cookie. It defaults
// to true (spec 7.6.6); it exists as a knob only so the panel can be tested
// to true (security.md); it exists as a knob only so the panel can be tested
// over plain HTTP in development, never for production.
CookieSecure bool
// SubmissionEnabled mirrors SUBMISSION_ENABLE: whether this deployment also
// runs the 587/STARTTLS submission listener next to the primary 465 one
// (spec 5). The panel only reports it on the domain page's connection
// settings; it is a deploy-time flag, not something the panel can verify.
// (architecture.md § Mail path). The panel only reports it on the domain
// page's connection settings; it is a deploy-time flag, not something the
// panel can verify.
SubmissionEnabled bool
// MailLogPath is where Postfix's delivery log lives, read by the mail.log
// monitoring view (spec 7.2.13). It is the same path the log-tailer role
// follows in cmd/panel.
// monitoring view (architecture.md § Panel HTTP surface). It is the same path
// the log-tailer role follows in cmd/panel.
MailLogPath string
// DataDir and DBPath locate the persistent state a full backup archives
// (spec 7.5.A); Version is stamped into the backup manifest. They mirror the
// panel's own configuration.
// (architecture.md § Persistence); Version is stamped into the backup
// manifest. They mirror the panel's own configuration.
DataDir string
DBPath string
Version string
@@ -50,8 +53,9 @@ type Config struct {
// honoured, so the header can't be spoofed by anyone but a trusted proxy.
// Empty (the default) keeps rate-limiting keyed on RemoteAddr only.
TrustedProxyCIDRs []*net.IPNet
// TLSCertFile is the certificate Postfix serves on 465/587 (spec 8), read
// read-only by the status page to report how much validity is left.
// TLSCertFile is the certificate Postfix serves on 465/587 (README §
// Environment variables), read read-only by the status page to report how
// much validity is left.
TLSCertFile string
// OpenDKIMSocket and JournalSocket are the two milter sockets Postfix
// connects to. The status page stats them: the first is required for mail
@@ -88,9 +92,10 @@ type Server struct {
}
// New builds the panel server. setupTokenPath is where the current setup token
// is mirrored on disk (spec 7.6.1); domains is the sending-domain service that
// owns DKIM keys and the OpenDKIM tables (spec 6); apps owns application SASL
// accounts and the Postfix sender map (spec 5.1).
// is mirrored on disk (security.md); domains is the sending-domain service
// that owns DKIM keys and the OpenDKIM tables (architecture.md § OpenDKIM);
// apps owns application SASL accounts and the Postfix sender map
// (architecture.md § Mail path).
func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config, setupTokenPath string) (*Server, error) {
tmpl, err := loadTemplates()
if err != nil {
@@ -109,12 +114,12 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config
sessions: newSessionStore(st, time.Duration(idleDays)*24*time.Hour),
// Published-DNS checks for the status page and the domain pages. The
// checker caches its own results, so page views do not each pay for a
// round of lookups (phase 13).
// round of lookups.
dns: dnscheck.New(cfg.DNSResolvers),
// Setup: a handful of attempts per minute per IP is plenty for a
// legitimate admin and blunts automated probing (spec 7.6.1).
// legitimate admin and blunts automated probing (security.md).
setupLimiter: newRateLimiter(10, time.Minute),
// Login: throttle brute-force by IP (spec 7.6.5).
// Login: throttle brute-force by IP (security.md).
loginLimiter: newRateLimiter(10, 15*time.Minute),
trustedProxies: cfg.TrustedProxyCIDRs,
@@ -124,7 +129,7 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config
}
// Start performs first-run bootstrapping: if there is no administrator yet, it
// generates and announces the setup link (spec 7.6.1). Safe to call once at
// generates and announces the setup link (security.md). Safe to call once at
// server startup.
func (s *Server) Start() error {
return s.setup.bootstrap()
@@ -140,7 +145,7 @@ func (s *Server) Handler() http.Handler {
// Vendored static assets (HTMX). Served from the embedded FS.
mux.Handle("/static/", http.FileServer(http.FS(assetsFS)))
// One-time administrator setup (spec 7.6.1).
// One-time administrator setup (security.md).
mux.HandleFunc("/setup/", s.handleSetup)
// Authentication.
@@ -151,7 +156,7 @@ func (s *Server) Handler() http.Handler {
// above falls through to this sub-mux, wrapped once in the auth middleware.
authed := http.NewServeMux()
// The landing page is the server status (phase 13.C): the first thing an
// The landing page is the server status: the first thing an
// administrator should see after logging in is whether the service is
// healthy, not the domain list. handleLogin still redirects to "/".
authed.HandleFunc("GET /{$}", redirectToStatus)
@@ -178,13 +183,14 @@ func (s *Server) Handler() http.Handler {
// Administrator's own panel credentials.
authed.HandleFunc("/account", s.handleAccount)
// Backup and migration: the page with both actions (spec 7.5.A-B), and the
// full-server backup download itself.
// Backup and migration: the page with both actions (architecture.md §
// Persistence-B), and the full-server backup download itself.
authed.HandleFunc("GET /backup", s.handleBackupPage)
authed.HandleFunc("POST /backup", s.handleBackup)
// Monitoring screens (spec 7.2.11-13): each page and its HTMX polling
// fragment (spec 7.1 — the /rows and /body endpoints return HTML, not JSON).
// Monitoring screens (architecture.md § Panel HTTP surface): each page and
// its HTMX polling fragment (architecture.md § Panel HTTP surface — the /rows
// and /body endpoints return HTML, not JSON).
authed.HandleFunc("GET /deliveries", s.handleDeliveries)
authed.HandleFunc("GET /deliveries/rows", s.handleDeliveriesRows)
authed.HandleFunc("GET /mail-queue", s.handleMailQueue)
@@ -195,18 +201,22 @@ func (s *Server) Handler() http.Handler {
mux.Handle("/", s.requireAuth(authed))
// Security headers and the origin check wrap everything, including the
// unauthenticated login and setup routes (phase 14.A).
// unauthenticated login and setup routes.
return s.secure(mux)
}
// redirectToStatus points the panel root at the status page, so there is one
// canonical URL for that content instead of two (phase 13.C).
// canonical URL for that content instead of two.
func redirectToStatus(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/status", http.StatusSeeOther)
}
func handleHealth(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
if err := health.Liveness(); err != nil {
http.Error(w, "unhealthy\n", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok\n"))
}