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
+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()