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>
This commit is contained in:
@@ -91,6 +91,33 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
sender was empty, so a bounce could be refused or allowed by an entry the
|
sender was empty, so a bounce could be refused or allowed by an entry the
|
||||||
Filter lists page never showed as used. They now look at the envelope
|
Filter lists page never showed as used. They now look at the envelope
|
||||||
sender only (assumptions C-R4).
|
sender only (assumptions C-R4).
|
||||||
|
- **Stopping or restarting the container stops Postfix as Postfix means it
|
||||||
|
to be stopped.** Supervisord's stop signal reached only the shell that
|
||||||
|
started Postfix, never Postfix's master process: a restart of the program
|
||||||
|
found Postfix "already running", and a container stop cut Postfix off at
|
||||||
|
the end. The start wrapper now runs `postfix stop` (assumptions C-P42).
|
||||||
|
- **Postfix that cannot start takes the container down instead of waiting
|
||||||
|
for ever.** When the milter sockets never appear, the wrapper gives up
|
||||||
|
after `MILTER_WAIT_TIMEOUT`; that exit was taken for an ordinary one and
|
||||||
|
the wrapper started again, endlessly. It now counts as a failed start,
|
||||||
|
and once supervisord's three retries are spent the container exits for
|
||||||
|
Docker to restart it. For `MILTER_WAIT_TIMEOUT` plus ten seconds after
|
||||||
|
each start, Server > Health shows Postfix as starting (assumptions C-V6).
|
||||||
|
- **A logrotate configuration it would ignore ends in a fault shown on
|
||||||
|
Server > Health,** where logrotate was restarted every second for ever;
|
||||||
|
the container stays up, mail is not affected (assumptions C-V5).
|
||||||
|
- **A failed Postfix reload is reported.** The panel took a reload as done
|
||||||
|
as soon as it was started, and a reload already running as its own. It now
|
||||||
|
waits for `postfix reload` to finish and shows its failure on the page that
|
||||||
|
saved; a reload while Postfix is still starting is left to the start
|
||||||
|
(assumptions B-U2).
|
||||||
|
- **Changing an application's password no longer rewrites the live SASL
|
||||||
|
database.** `saslpasswd2` wrote into the file Postfix reads at every
|
||||||
|
`AUTH` and the backup copies, with no lock between them: an application
|
||||||
|
signing in at that moment could be refused, and a backup taken then could
|
||||||
|
carry a damaged database whose restore would refuse every application.
|
||||||
|
The change is now made to a copy that replaces the file whole (assumptions
|
||||||
|
C-S3, C-Q3).
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -962,7 +962,7 @@ those after it move up one patch number each.
|
|||||||
- **Opus**
|
- **Opus**
|
||||||
*Done 2026-10-14:* `e174537`; cleanup takes port 25's limit when it is
|
*Done 2026-10-14:* `e174537`; cleanup takes port 25's limit when it is
|
||||||
larger than main.cf's; Postfix refuses the address literal at RCPT.
|
larger than main.cf's; Postfix refuses the address literal at RCPT.
|
||||||
- [ ] **Supervision as supervisord does it** (C-P42, C-V5, C-V6, C-V10,
|
- [x] **Supervision as supervisord does it** (C-P42, C-V5, C-V6, C-V10,
|
||||||
B-U2, C-P34): master leaves the shell's process group, so stopping the
|
B-U2, C-P34): master leaves the shell's process group, so stopping the
|
||||||
program never stops Postfix - the wrapper traps TERM and runs `postfix
|
program never stops Postfix - the wrapper traps TERM and runs `postfix
|
||||||
stop`; `startsecs` above the milter wait for Postfix and 1 for logrotate
|
stop`; `startsecs` above the milter wait for Postfix and 1 for logrotate
|
||||||
@@ -970,11 +970,16 @@ those after it move up one patch number each.
|
|||||||
postfix-reload rests in EXITED after a run; a reload's exit status is
|
postfix-reload rests in EXITED after a run; a reload's exit status is
|
||||||
read, not assumed; the dead set-permissions branch goes. e2e: restart
|
read, not assumed; the dead set-permissions branch goes. e2e: restart
|
||||||
of the program, stop of the container. - **Opus**
|
of the program, stop of the container. - **Opus**
|
||||||
- [ ] **sasldb written in place** (C-S3, C-Q3): Cyrus opens sasldb2 without
|
*Done 2026-10-14:* `f2ad699`; startsecs is MILTER_WAIT_TIMEOUT + 10, set
|
||||||
|
by the entrypoint; the reload's status is read over supervisord's XML-RPC.
|
||||||
|
- [x] **sasldb written in place** (C-S3, C-Q3): Cyrus opens sasldb2 without
|
||||||
a Berkeley DB environment, so without locks, and `saslpasswd2` writes in
|
a Berkeley DB environment, so without locks, and `saslpasswd2` writes in
|
||||||
place; an AUTH or a backup during a change can read it torn. The panel
|
place; an AUTH or a backup during a change can read it torn. The panel
|
||||||
writes a copy and renames it, and the backup reads it under the same
|
writes a copy and renames it, and the backup reads it under the same
|
||||||
lock. - **Opus**
|
lock. - **Opus**
|
||||||
|
*Done 2026-10-15:* no lock for the backup after all: it reads each file
|
||||||
|
through one descriptor, and a rename leaves that file whole; the panel's
|
||||||
|
own changes are serialised by a mutex.
|
||||||
- [ ] **Health says what is served** (A-38, A-65, D-P1, D-P3): a socket file
|
- [ ] **Health says what is served** (A-38, A-65, D-P1, D-P3): a socket file
|
||||||
is not a listener - unix sockets are dialled as inet ones are; the
|
is not a listener - unix sockets are dialled as inet ones are; the
|
||||||
certificate shown is the one Postfix serves (`postconf -h
|
certificate shown is the one Postfix serves (`postconf -h
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
|||||||
| from-header-binding | Hold a message's `From:` header against the application that sends it | shipped `2.4.0` | 9/9 | [plans/from-header-binding.md](plans/from-header-binding.md) |
|
| from-header-binding | Hold a message's `From:` header against the application that sends it | shipped `2.4.0` | 9/9 | [plans/from-header-binding.md](plans/from-header-binding.md) |
|
||||||
| panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
| panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
||||||
| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
||||||
| audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | in progress (stage 0 shipped `2.4.2`, stages 1+2 `2.5.0`, 2b `2.5.1`; next stage 2c → `2.5.2`) | 38/81 | [audit-2026-10-12.md](audit-2026-10-12.md) |
|
| audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | in progress (stage 0 shipped `2.4.2`, stages 1+2 `2.5.0`, 2b `2.5.1`; next stage 2c → `2.5.2`) | 40/81 | [audit-2026-10-12.md](audit-2026-10-12.md) |
|
||||||
|
|
||||||
**Recommended order** (not binding; owner, 2026-10-09: feature order follows
|
**Recommended order** (not binding; owner, 2026-10-09: feature order follows
|
||||||
development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and
|
development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and
|
||||||
|
|||||||
+109
-5
@@ -4,23 +4,45 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SASLDB manages the Cyrus SASL account database (sasldb2) the panel maintains
|
// SASLDB manages the Cyrus SASL account database (sasldb2) the panel maintains
|
||||||
// for application credentials (architecture.md § Mail path). The panel is the
|
// for application credentials (architecture.md § Mail path). The panel is the
|
||||||
// only writer; Postfix reads it to authenticate SMTP clients. Accounts are
|
// only writer; Postfix reads it to authenticate SMTP clients. Accounts are
|
||||||
// created and removed with the standard saslpasswd2 tool.
|
// 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 {
|
type SASLDB struct {
|
||||||
path string // sasldb2 file, under /data so it survives restarts
|
path string // sasldb2 file, under /data so it survives restarts
|
||||||
realm string // SASL realm, so lookups match what Postfix's SASL uses
|
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;
|
// run executes saslpasswd2. It is a field so tests can substitute a fake;
|
||||||
// the default shells out to the real binary via runSaslpasswd2.
|
// the default shells out to the real binary via runSaslpasswd2.
|
||||||
run func(args []string, stdin []byte) error
|
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
|
// 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.
|
// a field so tests can substitute a fake; the default runs db_dump.
|
||||||
dump func(path string) ([]byte, error)
|
dump func(path string) ([]byte, error)
|
||||||
@@ -36,12 +58,89 @@ type SASLDB struct {
|
|||||||
// sasldb_auxprop_lookup(), then looks a login without "@" up under the
|
// sasldb_auxprop_lookup(), then looks a login without "@" up under the
|
||||||
// server's name (common/plugin_common.c, _plug_parseuser()).
|
// server's name (common/plugin_common.c, _plug_parseuser()).
|
||||||
func NewSASLDB(path, realm string) *SASLDB {
|
func NewSASLDB(path, realm string) *SASLDB {
|
||||||
return &SASLDB{path: path, realm: realm, run: runSaslpasswd2, dump: dumpSASLDB}
|
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
|
// 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
|
// (architecture.md § Mail path). Used both at creation and when a password is
|
||||||
// regenerated; saslpasswd2 overwrites an existing entry in place.
|
// 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
|
// 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
|
// cannot leak through the process table or logs). The login is passed as a
|
||||||
@@ -65,7 +164,7 @@ func (s *SASLDB) Set(login, password string) error {
|
|||||||
// whitelist already forbids nothing that getopt would eat, but a login
|
// whitelist already forbids nothing that getopt would eat, but a login
|
||||||
// starting with '-' is legal there — this keeps it an operand).
|
// starting with '-' is legal there — this keeps it an operand).
|
||||||
args := []string{"-p", "-c", "-f", s.path, "-u", s.realm, "--", login}
|
args := []string{"-p", "-c", "-f", s.path, "-u", s.realm, "--", login}
|
||||||
if err := s.run(args, []byte(password)); err != nil {
|
if err := s.change(args, 3, []byte(password)); err != nil {
|
||||||
return fmt.Errorf("saslpasswd2 set %q: %w", login, err)
|
return fmt.Errorf("saslpasswd2 set %q: %w", login, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -84,7 +183,7 @@ func (s *SASLDB) Delete(login string) error {
|
|||||||
}
|
}
|
||||||
// -d: delete the account. "--" as in Set: the login is always an operand.
|
// -d: delete the account. "--" as in Set: the login is always an operand.
|
||||||
args := []string{"-d", "-f", s.path, "-u", s.realm, "--", login}
|
args := []string{"-d", "-f", s.path, "-u", s.realm, "--", login}
|
||||||
if err := s.run(args, nil); err != nil {
|
if err := s.change(args, 2, nil); err != nil {
|
||||||
return fmt.Errorf("saslpasswd2 delete %q: %w", login, err)
|
return fmt.Errorf("saslpasswd2 delete %q: %w", login, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -213,8 +312,13 @@ func dumpSASLDB(path string) ([]byte, error) {
|
|||||||
|
|
||||||
// WithRunner replaces the command that runs saslpasswd2 and returns the same
|
// WithRunner replaces the command that runs saslpasswd2 and returns the same
|
||||||
// manager. It exists for tests outside this package that need an application
|
// manager. It exists for tests outside this package that need an application
|
||||||
// service without the real binary; the panel never calls it.
|
// 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 {
|
func (s *SASLDB) WithRunner(run func(args []string, stdin []byte) error) *SASLDB {
|
||||||
s.run = run
|
s.run = run
|
||||||
|
s.replace = inPlace
|
||||||
return s
|
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) }
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A change is made to a copy and renamed over the database: saslpasswd2
|
||||||
|
// never gets the live file, and a reader that opened the old file keeps
|
||||||
|
// reading it whole (assumptions C-S3).
|
||||||
|
func TestSASLChangeGoesThroughACopy(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
db := filepath.Join(dir, "sasldb2")
|
||||||
|
if err := os.WriteFile(db, []byte("old database"), 0o640); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// smtpd in the middle of a lookup. Windows refuses a rename over an open
|
||||||
|
// file; the container is Linux, where the reader keeps the old inode.
|
||||||
|
var reader *os.File
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
var err error
|
||||||
|
if reader, err = os.Open(db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
s := NewSASLDB(db, "mail.example.com")
|
||||||
|
var gotPath string
|
||||||
|
s.run = func(args []string, stdin []byte) error {
|
||||||
|
gotPath = args[3]
|
||||||
|
before, err := os.ReadFile(gotPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if string(before) != "old database" {
|
||||||
|
t.Errorf("the copy holds %q, want the database's bytes", before)
|
||||||
|
}
|
||||||
|
return os.WriteFile(gotPath, []byte("new database"), 0o600)
|
||||||
|
}
|
||||||
|
if err := s.Set("alerts", "pw"); err != nil {
|
||||||
|
t.Fatalf("Set: %v", err)
|
||||||
|
}
|
||||||
|
if gotPath == db {
|
||||||
|
t.Fatal("saslpasswd2 was given the live database")
|
||||||
|
}
|
||||||
|
if got, _ := os.ReadFile(db); string(got) != "new database" {
|
||||||
|
t.Fatalf("database after the change: %q", got)
|
||||||
|
}
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
if fi, _ := os.Stat(db); fi.Mode().Perm() != 0o640 {
|
||||||
|
t.Fatalf("database mode %v, want 0640 for Postfix's group", fi.Mode().Perm())
|
||||||
|
}
|
||||||
|
old := make([]byte, 64)
|
||||||
|
n, _ := reader.ReadAt(old, 0)
|
||||||
|
if string(old[:n]) != "old database" {
|
||||||
|
t.Fatalf("a reader of the old file read %q", old[:n])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(db + ".new"); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("the copy is left behind: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSASLFailedChangeLeavesTheDatabase(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
db := filepath.Join(dir, "sasldb2")
|
||||||
|
if err := os.WriteFile(db, []byte("old database"), 0o640); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A copy a killed panel left behind is not taken for the database.
|
||||||
|
if err := os.WriteFile(db+".new", []byte("stale"), 0o640); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := NewSASLDB(db, "mail.example.com")
|
||||||
|
s.run = func(args []string, stdin []byte) error {
|
||||||
|
if b, _ := os.ReadFile(args[2]); string(b) != "old database" {
|
||||||
|
t.Errorf("the copy holds %q", b)
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(args[2], []byte("half"), 0o640)
|
||||||
|
return errors.New("saslpasswd2: exit status 1")
|
||||||
|
}
|
||||||
|
if err := s.Delete("alerts"); err == nil {
|
||||||
|
t.Fatal("Delete succeeded with saslpasswd2 failing")
|
||||||
|
}
|
||||||
|
if got, _ := os.ReadFile(db); string(got) != "old database" {
|
||||||
|
t.Fatalf("database after a failed change: %q", got)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(db + ".new"); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("the copy is left behind: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first account: there is no database to copy, and saslpasswd2 creates
|
||||||
|
// one at the path it is given.
|
||||||
|
func TestSASLFirstAccountCreatesTheDatabase(t *testing.T) {
|
||||||
|
db := filepath.Join(t.TempDir(), "sasldb2")
|
||||||
|
s := NewSASLDB(db, "mail.example.com")
|
||||||
|
s.run = func(args []string, stdin []byte) error {
|
||||||
|
if _, err := os.Stat(args[3]); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Errorf("saslpasswd2 given an existing file %s: %v", args[3], err)
|
||||||
|
}
|
||||||
|
return os.WriteFile(args[3], []byte("created"), 0o660)
|
||||||
|
}
|
||||||
|
if err := s.Set("alerts", "pw"); err != nil {
|
||||||
|
t.Fatalf("Set: %v", err)
|
||||||
|
}
|
||||||
|
if got, _ := os.ReadFile(db); string(got) != "created" {
|
||||||
|
t.Fatalf("database: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ type fakeRun struct {
|
|||||||
func newFakeSASL() (*SASLDB, *fakeRun) {
|
func newFakeSASL() (*SASLDB, *fakeRun) {
|
||||||
fr := &fakeRun{}
|
fr := &fakeRun{}
|
||||||
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
|
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
|
||||||
|
s.replace = inPlace
|
||||||
s.run = func(args []string, stdin []byte) error {
|
s.run = func(args []string, stdin []byte) error {
|
||||||
fr.calls++
|
fr.calls++
|
||||||
fr.args = args
|
fr.args = args
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ func newServiceHarness(t *testing.T) (*Service, *store.Store, *saslRecorder, *fa
|
|||||||
|
|
||||||
rec := &saslRecorder{set: map[string]string{}}
|
rec := &saslRecorder{set: map[string]string{}}
|
||||||
sasl := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
|
sasl := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
|
||||||
|
sasl.replace = inPlace
|
||||||
sasl.run = func(args []string, stdin []byte) error {
|
sasl.run = func(args []string, stdin []byte) error {
|
||||||
if rec.failNext {
|
if rec.failNext {
|
||||||
rec.failNext = false
|
rec.failNext = false
|
||||||
|
|||||||
@@ -323,7 +323,13 @@ func addTree(tw *tar.Writer, root, archivePrefix string, exclude map[string]bool
|
|||||||
}
|
}
|
||||||
return tw.WriteHeader(hdr)
|
return tw.WriteHeader(hdr)
|
||||||
case info.Mode().IsRegular():
|
case info.Mode().IsRegular():
|
||||||
return writeTarFile(tw, name, info.Mode().Perm(), path)
|
err := writeTarFile(tw, name, info.Mode().Perm(), path)
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
// A writer's temporary file, renamed into place between
|
||||||
|
// the listing and the open: not part of /data's state.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -349,6 +355,14 @@ func writeTarBytes(tw *tar.Writer, name string, mode int64, data []byte) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// writeTarFile streams a file from disk into the archive under name.
|
// writeTarFile streams a file from disk into the archive under name.
|
||||||
|
//
|
||||||
|
// The file is read through one descriptor, sized by that descriptor: the
|
||||||
|
// panel's writers of what a backup carries put a new file in place by rename
|
||||||
|
// (atomicfile.Write; sasldb2 through a copy, app.replaceSASLDB - the setup
|
||||||
|
// token alone is written in place, and it is not carried; the database and
|
||||||
|
// the queue are not walked), and a rename leaves the file open here as it
|
||||||
|
// was: the archive holds the old file or the new one, whole (assumptions
|
||||||
|
// C-Q3).
|
||||||
func writeTarFile(tw *tar.Writer, name string, mode fs.FileMode, srcPath string) error {
|
func writeTarFile(tw *tar.Writer, name string, mode fs.FileMode, srcPath string) error {
|
||||||
f, err := os.Open(srcPath)
|
f, err := os.Open(srcPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -334,6 +334,9 @@ func TestE2E(t *testing.T) {
|
|||||||
run("session_survives_restart", func(t *testing.T) {
|
run("session_survives_restart", func(t *testing.T) {
|
||||||
testSessionSurvivesRestart(t, sc)
|
testSessionSurvivesRestart(t, sc)
|
||||||
})
|
})
|
||||||
|
run("sasldb_changed_by_rename", func(t *testing.T) {
|
||||||
|
testSASLDBChangedByRename(t, sc)
|
||||||
|
})
|
||||||
run("supervision_stops_postfix_and_gives_up", func(t *testing.T) {
|
run("supervision_stops_postfix_and_gives_up", func(t *testing.T) {
|
||||||
testSupervision(t, sc)
|
testSupervision(t, sc)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/smtp"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testSASLDBChangedByRename: the panel changes sasldb2 through a copy renamed
|
||||||
|
// over it (internal/app, replaceSASLDB), so an AUTH while another
|
||||||
|
// application's password changes reads the old database or the new one,
|
||||||
|
// whole. Before, saslpasswd2 wrote in the live file, which Cyrus SASL 2.1.28
|
||||||
|
// opens with no lock (sasldb/db_berkeley.c, berkeleydb_open()) and smtpd
|
||||||
|
// reads at every AUTH (_sasldb_getdata()) - assumptions C-S3. A torn read is
|
||||||
|
// a matter of timing, so this guards the mechanism (the inode changes, the
|
||||||
|
// mode and group stay) and that nothing fails under the load; it cannot prove
|
||||||
|
// a race absent.
|
||||||
|
func testSASLDBChangedByRename(t *testing.T, sc *scenario) {
|
||||||
|
churnLogin, churnPassword, err := sc.panel.addApplication(sc.domainID, "churn", "wildcard", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("add application: %v", err)
|
||||||
|
}
|
||||||
|
churnID, err := sc.panel.applicationID(sc.domainID, churnLogin)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stat := func() string {
|
||||||
|
t.Helper()
|
||||||
|
out, err := h.execIn("selfpost", "stat", "-c", "%i %a %U %G", "/data/sasl/sasldb2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat sasldb2: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(out)
|
||||||
|
}
|
||||||
|
newPassword := func(i int) (string, error) {
|
||||||
|
resp, body, err := sc.panel.postForm("/outbound/domains/"+sc.domainID+"/applications/"+churnID+"/password", url.Values{})
|
||||||
|
if err != nil || resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("new password %d: %v, status %v\n%.600s", i, err, resp, body)
|
||||||
|
}
|
||||||
|
pw, ok := extractCodeRow(body, "Password")
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("new password %d: no password on the page\n%.600s", i, body)
|
||||||
|
}
|
||||||
|
return pw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// One change, the inode looked at on both sides of it: the copy is made
|
||||||
|
// while the old file still exists, so it cannot have the old file's
|
||||||
|
// number. (After many changes it may: a freed inode number is reused.)
|
||||||
|
before := strings.Fields(stat())
|
||||||
|
if churnPassword, err = newPassword(0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after := strings.Fields(stat())
|
||||||
|
if len(before) != 4 || len(after) != 4 {
|
||||||
|
t.Fatalf("stat sasldb2: %v, %v", before, after)
|
||||||
|
}
|
||||||
|
if before[0] == after[0] {
|
||||||
|
t.Fatalf("sasldb2 kept inode %s across a password change: it was written in place", after[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then changes until other AUTHs have run alongside them for a while.
|
||||||
|
const wantAuths, maxChanges = 30, 300
|
||||||
|
stop := make(chan struct{})
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
var auths int
|
||||||
|
var failures []string
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
err := authOnly(sc.appLogin, sc.appPassword)
|
||||||
|
mu.Lock()
|
||||||
|
auths++
|
||||||
|
if err != nil {
|
||||||
|
failures = append(failures, err.Error())
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
changes := 1
|
||||||
|
for ; changes < maxChanges; changes++ {
|
||||||
|
pw, err := newPassword(changes)
|
||||||
|
if err != nil {
|
||||||
|
close(stop)
|
||||||
|
wg.Wait()
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
churnPassword = pw
|
||||||
|
mu.Lock()
|
||||||
|
enough := auths >= wantAuths
|
||||||
|
mu.Unlock()
|
||||||
|
if enough {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(stop)
|
||||||
|
wg.Wait()
|
||||||
|
if len(failures) > 0 {
|
||||||
|
t.Fatalf("%d of %d AUTHs of %s failed while %s's password changed %d times:\n%s",
|
||||||
|
len(failures), auths, sc.appLogin, churnLogin, changes, strings.Join(failures, "\n"))
|
||||||
|
}
|
||||||
|
t.Logf("%d AUTHs of %s during %d password changes of %s, none refused", auths, sc.appLogin, changes, churnLogin)
|
||||||
|
|
||||||
|
after = strings.Fields(stat())
|
||||||
|
if strings.Join(after[1:], " ") != "640 panel selfpost" {
|
||||||
|
t.Fatalf("sasldb2 after the changes: mode, owner, group %v; want 640 panel selfpost (Postfix reads it through the group)", after[1:])
|
||||||
|
}
|
||||||
|
if out, _ := h.execIn("selfpost", "sh", "-c", "ls /data/sasl"); strings.TrimSpace(out) != "sasldb2" {
|
||||||
|
t.Fatalf("/data/sasl holds %q, want sasldb2 alone (no copy left behind)", out)
|
||||||
|
}
|
||||||
|
if err := authOnly(churnLogin, churnPassword); err != nil {
|
||||||
|
t.Fatalf("%s with its last password: %v", churnLogin, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// authOnly connects to the submission port, authenticates and quits.
|
||||||
|
func authOnly(login, password string) error {
|
||||||
|
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}, "tcp", smtpsAddr, &tls.Config{
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
ServerName: selfpostHostname,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
|
||||||
|
c, err := smtp.NewClient(conn, selfpostHostname)
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
if err := c.Hello("e2e-client.e2e.test"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.Auth(smtp.PlainAuth("", login, password, selfpostHostname)); err != nil {
|
||||||
|
return fmt.Errorf("AUTH %s: %w", login, err)
|
||||||
|
}
|
||||||
|
return c.Quit()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user