test / test (push) Canceled after 0s
Cyrus SASL 2.1.28 opens sasldb2 with no Berkeley DB environment, so with
no locks (sasldb/db_berkeley.c, berkeleydb_open(): db_create(mbdb, NULL,
0)); saslpasswd2 puts and deletes in the file itself (_sasldb_putdata()),
while smtpd opens and closes it at every AUTH (_sasldb_getdata()) and the
backup copies it whenever it runs. Either could read a half-written page:
a spurious 535, or a torn sasldb2 in a backup whose restore breaks every
application's AUTH (assumptions C-S3, C-Q3).
SASLDB.Set and Delete now run saslpasswd2 on sasldb2.new - a byte copy of
the database, or no file when there is none yet (saslpasswd2 creates it,
DB_CREATE) - then chmod 0640, fsync, rename over sasldb2; a failed run
removes the copy and leaves the database; a copy a killed panel left is
removed first. A mutex keeps two changes of the panel from copying the
same old file. The backup needs no lock after all (the plan said one): it
reads each file through one descriptor, and a rename leaves that file
whole; the walk now leaves out a file that vanished between listing and
open (a writer's temporary file), while the SQLite snapshot's absence
stays an error. CHANGELOG also gains the entries of f2ad699 (supervision),
written then without them.
Tests: the copy holds the database's bytes, saslpasswd2 never gets the
live path, a reader of the old file keeps reading it (Linux), mode 0640,
nothing left behind; a failed change leaves the database; the first
account creates it. Stand (2026-10-15, make e2e, exit 0): new
sasldb_changed_by_rename - the inode of sasldb2 changes across one
password change (it cannot be the old one's: both exist at the copy),
then 35 AUTHs of app1 from three clients during 104 password changes of
another application, none refused; afterwards 640 panel selfpost, no copy
left in /data/sasl, the last password authenticates. That cannot prove a
race absent; it guards the mechanism. (A first run compared the inode
after 15 changes and found it equal: Linux reuses a freed inode number.)
go vet and go test ./... pass on the server (24 packages).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
325 lines
12 KiB
Go
325 lines
12 KiB
Go
package app
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// SASLDB manages the Cyrus SASL account database (sasldb2) the panel maintains
|
|
// 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 is never pointed at the live file. Cyrus SASL 2.1.28 opens
|
|
// sasldb2 with no Berkeley DB environment, so without locks
|
|
// (sasldb/db_berkeley.c, berkeleydb_open(): db_create(mbdb, NULL, 0)), and a
|
|
// write puts and deletes in the file itself (_sasldb_putdata()); smtpd opens
|
|
// and closes it for every lookup (_sasldb_getdata()), and the backup reads it
|
|
// whenever it runs. Either could read a page half written: a spurious 535 for
|
|
// an application, or a torn sasldb2 in a backup whose restore breaks every
|
|
// application's AUTH (assumptions C-S3, C-Q3). So each change is made to a
|
|
// copy, which is then renamed over the file (replaceSASLDB): a reader opens
|
|
// the old file or the new one, whole, and needs no lock. mu keeps the
|
|
// panel's own changes from copying the same old file at once.
|
|
type SASLDB struct {
|
|
path string // sasldb2 file, under /data so it survives restarts
|
|
realm string // SASL realm, so lookups match what Postfix's SASL uses
|
|
|
|
mu sync.Mutex
|
|
|
|
// run executes saslpasswd2. It is a field so tests can substitute a fake;
|
|
// the default shells out to the real binary via runSaslpasswd2.
|
|
run func(args []string, stdin []byte) error
|
|
|
|
// replace makes a change to the database through a copy: edit gets the
|
|
// path to give saslpasswd2. The default is replaceSASLDB.
|
|
replace func(path string, edit func(path string) error) error
|
|
|
|
// dump reads the raw sasldb2 as db_dump key/value pairs (Berkeley DB). It is
|
|
// a field so tests can substitute a fake; the default runs db_dump.
|
|
dump func(path string) ([]byte, error)
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// Postfix 3.7.11, src/xsasl/xsasl_cyrus_server.c, xsasl_cyrus_server_create():
|
|
// sasl_server_new() gets $myhostname and, with smtpd_sasl_local_domain empty,
|
|
// no user realm; Cyrus SASL 2.1.28, plugins/sasldb.c,
|
|
// sasldb_auxprop_lookup(), then looks a login without "@" up under the
|
|
// server's name (common/plugin_common.c, _plug_parseuser()).
|
|
func NewSASLDB(path, realm string) *SASLDB {
|
|
return &SASLDB{path: path, realm: realm, run: runSaslpasswd2, dump: dumpSASLDB, replace: replaceSASLDB}
|
|
}
|
|
|
|
// change runs saslpasswd2 with args on a copy of the database and puts the
|
|
// copy in its place; at in args stands for the path of the copy.
|
|
func (s *SASLDB) change(args []string, at int, stdin []byte) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.replace(s.path, func(path string) error {
|
|
a := append([]string(nil), args...)
|
|
a[at] = path
|
|
return s.run(a, stdin)
|
|
})
|
|
}
|
|
|
|
// replaceSASLDB copies the database at path to path+".new", lets edit change
|
|
// the copy, and renames it over path. With no database yet, edit gets a path
|
|
// where none exists, and saslpasswd2 creates one there (db_berkeley.c,
|
|
// berkeleydb_open(): DB_CREATE for a writer). The copy is a byte copy of a
|
|
// file no one else writes, which is a whole Berkeley DB file: smtpd and
|
|
// db_dump only read it. The new file is 0640 in the setgid directory, group
|
|
// selfpost, as build/entrypoint.sh sets the old one, so Postfix reads it.
|
|
// A copy left by a panel killed mid-change is removed first.
|
|
func replaceSASLDB(path string, edit func(path string) error) error {
|
|
tmp := path + ".new"
|
|
if err := os.Remove(tmp); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("remove %s: %w", tmp, err)
|
|
}
|
|
if err := copySASLDB(path, tmp); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return err
|
|
}
|
|
if err := edit(tmp); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return err
|
|
}
|
|
// Read-write: fsync of a file opened read-only is refused on Windows.
|
|
f, err := os.OpenFile(tmp, os.O_RDWR, 0)
|
|
if err == nil {
|
|
err = f.Chmod(0o640)
|
|
if err == nil {
|
|
err = f.Sync()
|
|
}
|
|
if cerr := f.Close(); err == nil {
|
|
err = cerr
|
|
}
|
|
}
|
|
if err != nil {
|
|
_ = os.Remove(tmp)
|
|
return fmt.Errorf("finish %s: %w", tmp, err)
|
|
}
|
|
if err := os.Rename(tmp, path); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return fmt.Errorf("rename %s -> %s: %w", tmp, path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// copySASLDB copies path to tmp; with no file at path it makes no copy.
|
|
func copySASLDB(path, tmp string) error {
|
|
src, err := os.Open(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("open %s: %w", path, err)
|
|
}
|
|
defer src.Close()
|
|
dst, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640)
|
|
if err != nil {
|
|
return fmt.Errorf("create %s: %w", tmp, err)
|
|
}
|
|
if _, err := io.Copy(dst, src); err != nil {
|
|
dst.Close()
|
|
return fmt.Errorf("copy %s: %w", path, err)
|
|
}
|
|
return dst.Close()
|
|
}
|
|
|
|
// Set creates or updates an application's SASL account with the given password
|
|
// (architecture.md § Mail path). Used both at creation and when a password is
|
|
// regenerated; saslpasswd2 overwrites an existing entry (in the copy, see
|
|
// SASLDB).
|
|
//
|
|
// 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
|
|
// (security.md).
|
|
func (s *SASLDB) Set(login, password string) error {
|
|
if err := validateLogin(login); err != nil {
|
|
return err
|
|
}
|
|
// Cyrus SASL 2.1.28, utils/saslpasswd.c, main(): getopt(), which ends the
|
|
// options at "--"; -p reads the password with one read() and no prompt
|
|
// (read_password()); -c is SASL_SET_CREATE, which lib/server.c,
|
|
// sasl_setpass(), checks against nothing - an existing entry is written
|
|
// over (sasldb/db_berkeley.c, _sasldb_putdata(), put with no flags).
|
|
// -p: read the passphrase from stdin (pipe mode, no tty prompt).
|
|
// -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.
|
|
// --: 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.change(args, 3, []byte(password)); err != nil {
|
|
return fmt.Errorf("saslpasswd2 set %q: %w", login, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// Cyrus SASL 2.1.28, plugins/sasldb.c, sasldb_auxprop_store(): "Deleting
|
|
// something which is not there is not an error" - the SASL_NOUSER of
|
|
// sasldb/db_berkeley.c, _sasldb_putdata(), becomes SASL_OK, so saslpasswd2 -d
|
|
// on a missing login exits 0 (utils/saslpasswd.c, main()).
|
|
func (s *SASLDB) Delete(login string) error {
|
|
if err := validateLogin(login); err != nil {
|
|
return err
|
|
}
|
|
// -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.change(args, 2, nil); err != nil {
|
|
return fmt.Errorf("saslpasswd2 delete %q: %w", login, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ErrSecretNotFound is returned by Secret when the sasldb2 has no password entry
|
|
// for the login under this realm.
|
|
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 (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 — 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
|
|
}
|
|
out, err := s.dump(s.path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read sasldb2 for %q: %w", login, err)
|
|
}
|
|
secret, ok, err := parseSASLSecret(out, login, s.realm)
|
|
if err != nil {
|
|
return "", fmt.Errorf("parse sasldb2 for %q: %w", login, err)
|
|
}
|
|
if !ok {
|
|
return "", fmt.Errorf("login %q: %w", login, ErrSecretNotFound)
|
|
}
|
|
return secret, nil
|
|
}
|
|
|
|
// parseSASLSecret scans db_dump's byte-value output for the userPassword entry
|
|
// keyed by (login, realm). sasldb2 keys are NUL-separated tuples
|
|
// "<login>\0<realm>\0<property>"; the matching value is the stored password.
|
|
//
|
|
// Cyrus SASL 2.1.28: sasldb/allockey.c, _sasldb_alloc_key(), builds the key
|
|
// with no NUL at its end; plugins/sasldb.c, sasldb_auxprop_store(), stores the
|
|
// value with strlen(); lib/server.c, sasl_setpass(), stores the plaintext
|
|
// userPassword unless SASL_SET_NOPLAIN (saslpasswd2 -n, which Set does not
|
|
// pass). Built against Berkeley DB (m4/sasldb.m4 auto_detect; debian/rules
|
|
// records BDB_VERSION). Berkeley DB 5.3 Programmer's Reference, "Dump output
|
|
// formats" (docs.oracle.com/cd/E17076_02/html/programmer_reference/
|
|
// dumpload_format.html): header lines up to HEADER=END, then for a hash or
|
|
// btree database key and data lines in turn, each a single space and hex,
|
|
// and DATA=END.
|
|
func parseSASLSecret(dump []byte, login, realm string) (string, bool, error) {
|
|
sc := bufio.NewScanner(bytes.NewReader(dump))
|
|
// sasldb2 records are tiny, but raise the line cap so a long hex line is
|
|
// never silently truncated.
|
|
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
|
|
inData := false
|
|
var keyBytes []byte
|
|
haveKey := false
|
|
for sc.Scan() {
|
|
line := sc.Text()
|
|
if !inData {
|
|
if line == "HEADER=END" {
|
|
inData = true
|
|
}
|
|
continue
|
|
}
|
|
if line == "DATA=END" {
|
|
break
|
|
}
|
|
// Each data line is a single leading space followed by hex.
|
|
hexStr := strings.TrimPrefix(line, " ")
|
|
raw, err := hex.DecodeString(hexStr)
|
|
if err != nil {
|
|
return "", false, fmt.Errorf("bad db_dump hex line: %w", err)
|
|
}
|
|
if !haveKey {
|
|
keyBytes = raw
|
|
haveKey = true
|
|
continue
|
|
}
|
|
// raw is the value for keyBytes.
|
|
haveKey = false
|
|
parts := bytes.Split(keyBytes, []byte{0})
|
|
if len(parts) != 3 {
|
|
continue
|
|
}
|
|
if string(parts[0]) == login && string(parts[1]) == realm && string(parts[2]) == "userPassword" {
|
|
return string(raw), true, nil
|
|
}
|
|
}
|
|
if err := sc.Err(); err != nil {
|
|
return "", false, err
|
|
}
|
|
return "", false, nil
|
|
}
|
|
|
|
// 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 (security.md).
|
|
func runSaslpasswd2(args []string, stdin []byte) error {
|
|
cmd := exec.Command("saslpasswd2", args...)
|
|
if stdin != nil {
|
|
cmd.Stdin = bytes.NewReader(stdin)
|
|
}
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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 (security.md).
|
|
func dumpSASLDB(path string) ([]byte, error) {
|
|
cmd := exec.Command("db_dump", path)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("db_dump: %w: %s", err, strings.TrimSpace(string(out)))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// WithRunner replaces the command that runs saslpasswd2 and returns the same
|
|
// manager. It exists for tests outside this package that need an application
|
|
// service without the real binary; the panel never calls it. With no real
|
|
// database either, the runner is given the database's own path, not a copy.
|
|
func (s *SASLDB) WithRunner(run func(args []string, stdin []byte) error) *SASLDB {
|
|
s.run = run
|
|
s.replace = inPlace
|
|
return s
|
|
}
|
|
|
|
// inPlace hands edit the path itself: for tests whose runner touches no file.
|
|
func inPlace(path string, edit func(path string) error) error { return edit(path) }
|