c0d9aa7518
Codeberg is being retired as the project's public site, so every reference now points at GitHub. That includes the Go module path (codeberg.org/mix/selfpost → github.com/mixeme/selfpost): leaving an import path on a host that is going away would break `go get` and `go install`, so this is not only a docs change. Touches go.mod, test/e2e/go.mod, all imports, Makefile MODULE, the -ldflags version stamp in build/Dockerfile and docs/development.md, the licence headers in the SVG/HTML assets, and README (no more primary/mirror pair). Comments no longer cite the archived specification. "spec 7.6.1", "spec 5.1" and friends pointed into docs/archive/specification-v1.0.md, which is marked as not a source of truth; each is now a reference to the live document that owns the subject — architecture.md (with section), product.md, security.md or the README. The review only asked for the 7.x refs (code-review.md § 4), but 4/5/6/ 8/9 had the same defect, so they went too. Comments only, no behaviour change. Also closes the remaining review items: architecture.md gained a Code layers section with the layer diagram (A2), and TestParseDelivery gained the exotic mail.log cases (§ 3). Fixes a bug that last test found: the delivery-line pattern matched status= greedily, taking the *last* occurrence on the line. Postfix appends the remote server's reply verbatim, so a rejection whose reply quoted "status=sent" was filed as a delivered message in the send log. It now takes the first status= after the recipient, which is the real field. R7 (CONTRIBUTING.md) moved to roadmap 2.x — one developer, no external PR flow, so the file would have no audience yet. R1 (compose image tag) and the git tag stay in roadmap § v1.x as the release-commit steps. gofmt/go vet clean on both modules; go test ./... green except the three known Windows-only failures (file perms, backslash paths, renaming an open file). Not exercised on the dev server — no Docker locally. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
187 lines
6.7 KiB
Go
187 lines
6.7 KiB
Go
package app
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"os/exec"
|
|
"strings"
|
|
)
|
|
|
|
// 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", per the plan).
|
|
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
|
|
|
|
// 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
|
|
|
|
// 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.
|
|
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
|
|
// (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
|
|
// (security.md).
|
|
func (s *SASLDB) Set(login, password string) error {
|
|
if err := validateLogin(login); err != nil {
|
|
return err
|
|
}
|
|
// -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.run(args, []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.
|
|
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.run(args, 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.
|
|
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
|
|
}
|