diff --git a/build/Dockerfile b/build/Dockerfile index 052c6d1..ab5ebd2 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -58,15 +58,17 @@ RUN postconf -e "maillog_file=/var/log/mail.log" # Unprivileged user for the panel process (spec 7.6.8). RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel -# Shared group bridging the two unprivileged services (spec 6): the panel -# generates per-domain DKIM keys and rewrites the OpenDKIM tables, while -# OpenDKIM (a different user) must read them. Membership in this group — plus -# setgid dirs under /data/opendkim (set up in entrypoint.sh) — lets OpenDKIM -# read the panel-owned keys, and lets the panel reach the supervisor control -# socket to signal an OpenDKIM reload without any process running as root. +# Shared group bridging the unprivileged services (spec 5.1, 6): the panel +# generates per-domain DKIM keys, application SASL accounts (sasldb2) and the +# Postfix sender map, while OpenDKIM and Postfix (different users) must read +# them. Membership in this group — plus setgid dirs under /data (set up in +# entrypoint.sh) — lets OpenDKIM read the panel-owned keys and lets Postfix read +# the sasldb2/sender map, and lets the panel reach the supervisor control socket +# to signal OpenDKIM/Postfix reloads without any process running as root. RUN groupadd --system selfpost \ && usermod -aG selfpost panel \ - && usermod -aG selfpost opendkim + && usermod -aG selfpost opendkim \ + && usermod -aG selfpost postfix # Runtime directories: milter sockets and the consolidated persistent root. RUN mkdir -p /run/opendkim /run/selfpost /data \ diff --git a/build/entrypoint.sh b/build/entrypoint.sh index b8da487..cb4e356 100644 --- a/build/entrypoint.sh +++ b/build/entrypoint.sh @@ -32,4 +32,23 @@ find /data/opendkim -type d -exec chmod 2750 {} + chmod 0640 /data/opendkim/KeyTable /data/opendkim/SigningTable find /data/opendkim/keys -type f -name '*.private' -exec chmod 0640 {} + +# Application SASL accounts (spec 5.1, 9). The panel (user `panel`) writes the +# sasldb2 via saslpasswd2; Postfix (user `postfix`) reads it to authenticate SMTP +# clients. Share it through the `selfpost` group the same way as the DKIM tree: +# setgid directory so new files inherit the group, and the database itself +# group-readable (0640). Postfix wiring to actually consult it lands in Phase 5. +mkdir -p /data/sasl +chown -R panel:selfpost /data/sasl +chmod 2750 /data/sasl +[ -e /data/sasl/sasldb2 ] && chmod 0640 /data/sasl/sasldb2 + +# Postfix sender_login_maps (spec 5.1). The panel writes it; Postfix reads it. +# Ensure the file exists (empty is fine) before Postfix starts so a reload that +# references it never fails on a missing file, and keep it group-readable. +mkdir -p /data/postfix +[ -e /data/postfix/sender_login_maps ] || : > /data/postfix/sender_login_maps +chown -R panel:selfpost /data/postfix +chmod 2750 /data/postfix +chmod 0640 /data/postfix/sender_login_maps + exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf diff --git a/build/supervisord.conf b/build/supervisord.conf index f348d1c..5d4b11e 100644 --- a/build/supervisord.conf +++ b/build/supervisord.conf @@ -67,6 +67,27 @@ stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 +; One-shot reload helper (spec 5.2, 7.2.12). The panel runs unprivileged and +; cannot run `postfix reload` itself, and it cannot signal the Postfix master +; directly: `postfix start-fg` forks a separate master process, so a signal to +; the supervised foreground process never reaches it (unlike OpenDKIM, which +; runs in the foreground as the supervised process itself). Instead the panel +; asks supervisord — over the group-accessible control socket — to run this +; program, which executes the canonical `postfix reload` as root. autostart is +; off (it only runs on demand) and a fast, clean exit is expected, so it never +; trips the crashexit listener. +[program:postfix-reload] +command=/usr/sbin/postfix reload +autostart=false +autorestart=false +startsecs=0 +startretries=1 +exitcodes=0 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + [eventlistener:crashexit] command=/usr/local/bin/crashexit.py events=PROCESS_STATE_FATAL diff --git a/cmd/panel/httpserver.go b/cmd/panel/httpserver.go index 3866205..a2a6764 100644 --- a/cmd/panel/httpserver.go +++ b/cmd/panel/httpserver.go @@ -7,7 +7,9 @@ import ( "net/http" "time" + "codeberg.org/mix/selfpost/internal/app" "codeberg.org/mix/selfpost/internal/domain" + "codeberg.org/mix/selfpost/internal/postfix" "codeberg.org/mix/selfpost/internal/store" "codeberg.org/mix/selfpost/internal/web" ) @@ -22,9 +24,13 @@ func serveHTTP(ctx context.Context, cfg config) error { } defer st.Close() - domains := domain.NewService(st, domain.NewOpenDKIM(cfg.opendkimDir), cfg.dkimSelectorDef) + // Applications own the SASL accounts and the Postfix sender map; the domain + // service delegates to them when a domain (and its applications) is deleted. + pf := postfix.New(cfg.postfixDir) + apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf) + domains := domain.NewService(st, domain.NewOpenDKIM(cfg.opendkimDir), apps, cfg.dkimSelectorDef) - srvApp, err := web.New(st, domains, web.Config{ + srvApp, err := web.New(st, domains, apps, web.Config{ Hostname: cfg.hostname, CookieSecure: cfg.cookieSecure, }, cfg.setupTokenPath) diff --git a/cmd/panel/main.go b/cmd/panel/main.go index a3495e2..36166f6 100644 --- a/cmd/panel/main.go +++ b/cmd/panel/main.go @@ -53,6 +53,10 @@ type config struct { opendkimDir string dkimSelectorDef string + + saslDBPath string + saslRealm string + postfixDir string } func loadConfig() config { @@ -74,9 +78,30 @@ func loadConfig() config { // entrypoint.sh prepares (setgid, shared `selfpost` group). opendkimDir: envDefault("OPENDKIM_DIR", filepath.Join(dataDir, "opendkim")), dkimSelectorDef: envDefault("DKIM_SELECTOR_DEFAULT", "selfpost"), + + // Application SASL accounts and the Postfix sender map (spec 5.1, 9), + // both under /data so they survive restarts. The SASL realm defaults to + // the server hostname so account identities line up with Postfix's SASL + // configuration; it falls back to localhost outside the container. + saslDBPath: envDefault("SASL_DB_PATH", filepath.Join(dataDir, "sasl", "sasldb2")), + saslRealm: saslRealm(), + postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")), } } +// saslRealm chooses the realm new SASL accounts live under. It mirrors the +// hostname Postfix's SASL layer uses so a client authenticating with a bare +// login resolves to the right account (finalised in Phase 5). +func saslRealm() string { + if r := os.Getenv("SASL_REALM"); r != "" { + return r + } + if h := os.Getenv("SELFPOST_HOSTNAME"); h != "" { + return h + } + return "localhost" +} + func envDefault(key, def string) string { if v := os.Getenv(key); v != "" { return v diff --git a/docs/progress.md b/docs/progress.md index d705cc6..ab3c61c 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -46,10 +46,23 @@ ## Текущее состояние -- **Текущая фаза:** 3 ✅ закрыта → следующая **Фаза 4** (приложения + SASL + привязка к домену) -- **Модель для Фазы 4:** Opus (риск open relay / привязки отправителя, `sasldb2`, `smtpd_sender_login_maps`) -- **Статус:** домены + per-domain DKIM реализованы и проверены на сервере (`gofmt`/`go vet`/`go test` зелёные, docker-образ собирается, e2e в контейнере: add/delete домена, генерация ключа, показ TXT, reload OpenDKIM, персистентность ключей через рестарт) -- **Следующий шаг (Фаза 4):** создание приложения в карточке домена — SASL-пара логин/пароль в `sasldb2` (эквивалент `saslpasswd2`), сильный пароль, показ **один раз** (ТЗ 7.6.1); режим адресов «wildcard `@domain`» либо «список» с серверной валидацией принадлежности адреса домену (ТЗ 7.6.2); `smtpd_sender_login_maps` (many-to-one) соответственно режиму + `postfix reload`; список/редактирование режима/удаление приложения/перевыпуск пароля (ТЗ 7.2.5–9). **Важно для Фазы 4/5:** у панели уже есть безопасный privileged-reload через supervisord (см. журнал Фазы 3) — тот же паттерн (`supervisorctl signal`) переиспользовать для `postfix reload`/HUP; `sasldb2` должен переживать рестарт (bind-mount `/data`, ТЗ 9) и быть доступен Postfix/SASL — решить владельца/права по аналогии с DKIM-деревом. +- **Текущая фаза:** 4 ✅ закрыта → следующая **Фаза 5** (полная конфигурация Postfix — исходящий релей) +- **Модель для Фазы 5:** Opus (самый чувствительный тракт доставки: SASL/TLS на 465, `reject_sender_login_mismatch`, отсутствие open relay) +- **Статус:** приложения + SASL + привязка к домену реализованы и проверены на сервере (`gofmt`/`go vet`/`go test` зелёные, docker-образ собирается; контейнерный e2e: создание wildcard/list-приложений с показом пароля один раз, валидация (чужой домен → 400, дубль логина → 409, `@` в логине → 400), учётки в `sasldb2` под realm, генерация `smtpd_sender_login_maps` с many-to-one слиянием логинов, перевыпуск пароля, смена режима, удаление приложения, каскадное удаление домена с очисткой `sasldb2` и пересборкой карты, персистентность `sasldb2`+карты через рестарт с самолечением прав, **реальный `postfix reload`** через одноразовую supervisord-программу) +- **Следующий шаг (Фаза 5):** `master.cf` — `smtps` 465 (wrapper TLS) как основной, опционально `submission` 587 (STARTTLS, `smtpd_tls_auth_only=yes`); `main.cf` — `smtpd_sasl_auth_enable`, `smtpd_sasl_type=cyrus` + `smtpd/sasl` conf, указывающий на `/data/sasl/sasldb2`; **`smtpd_sender_login_maps = texthash:/data/postfix/sender_login_maps`** (файл уже генерируется панелью в Фазе 4) + `reject_sender_login_mismatch` в `smtpd_sender_restrictions`; `smtpd_recipient_restrictions` (`permit_sasl_authenticated`, `reject_unauth_destination`), никакого open relay; TLS cert/key из `TLS_CERT_FILE`/`TLS_KEY_FILE` (read-only mount) + периодический reload; исходящая доставка (MX-lookup, `smtp_tls_security_level=may`); rate-limit уровня 1 (`anvil`); milter-цепочка OpenDKIM + journal (fail-open). **Критично для Фазы 5:** определиться с реалмом SASL — панель создаёт учётки под realm `SELFPOST_HOSTNAME` (см. `SASL_REALM`); значение в карте — «голый» логин. Настроить Postfix так, чтобы `sasl_username` совпадал со значением карты (`smtpd_sasl_local_domain` пустой ИЛИ подгонка realm), и проверить реальной отправкой. Также решить chroot для `smtpd`: `/data/sasl/sasldb2` вне `/var/spool/postfix` — либо отключить chroot для сервиса приёма, либо иначе обеспечить доступ. + +### Сделано в Фазе 4 +- **Модель приложения** (`internal/store/applications.go`): CRUD над `applications`/`application_addresses` в транзакциях; `login` глобально уникален (арбитр — UNIQUE, `ErrLoginExists`); `ListBindings` (SQL `UNION ALL`) отдаёт пары «адрес→логин» (wildcard → `@domain`, list → каждый адрес) детерминированно отсортированными — сырьё для карты; `ListLoginsByDomain` для очистки `sasldb2` перед каскадом; `DeleteApplication` возвращает удалённую строку (нужен логин для `sasldb2`). +- **SASL `sasldb2`** (`internal/app/sasl.go`): обёртка над `saslpasswd2` (эквивалент по ТЗ 5.1). `Set` = `-p -c -f -u `, **пароль только через stdin** (не в argv → не течёт в `ps`/логи), логин — отдельный argv-элемент после строгого whitelist (`validateLogin`, без `@` — это разделитель realm в `sasldb2`), без shell (ТЗ 7.6.3). `Delete` = `-d ...`, идемпотентно. `run` — инъектируемое поле для тестов. +- **Пароль приложения** (`internal/app/password.go`): 24 байта `crypto/rand` → base64url (192 бита), генерируется панелью, показывается **один раз**, plaintext не хранится (ТЗ 7.6.1). +- **Валидация** (`internal/app/validate.go`): `validateLogin` (3–64, `[A-Za-z0-9._-]`, без `@`); `validateSenderAddress` — **критичная проверка ТЗ 7.6.2**: часть после `@` обязана строго равняться домену приложения (проверка ДО записи в конфиг, а не через `sender_login_maps` при доставке); строгий whitelist localpart; `parseAddresses` нормализует/дедуплицирует/требует ≥1 адрес. +- **`smtpd_sender_login_maps`** (`internal/postfix/postfix.go`): полная регенерация файла из всех привязок (чистая функция реестра, идемпотентно, как OpenDKIM-таблицы), атомарная запись (`write.go`, temp+rename). **Many-to-one**: несколько логинов на один адрес сливаются в одну строку `адрес log1,log2` (штатный случай ТЗ 5.1 §4). `assertMapSafe` — backstop против пробелов/переводов строк/запятых/`@` в логине (ТЗ 7.6.4). Тип карты для Фазы 5 — `texthash:` (без `postmap`). +- **Reload Postfix — исправленный механизм** (ключевое инфра-решение): изначальный план «`supervisorctl signal HUP postfix`» **не работает** — `postfix start-fg` форкает отдельный master, и сигнал супервизируемому foreground-процессу до master не доходит (в отличие от OpenDKIM, который сам и есть foreground-процесс). Решение: одноразовая supervisord-программа `[program:postfix-reload]` (`command=/usr/sbin/postfix reload`, `autostart=false`, `startsecs=0`, `exitcodes=0`); панель дёргает её `supervisorctl start postfix-reload` через тот же групповой контрол-сокет. Даёт настоящий `postfix reload` от root без привилегий у панели; postfix остаётся RUNNING, программа уходит в EXITED(0), crashexit не срабатывает. Проверено по `mail.log` (`reload -- version`). +- **Сервис приложений** (`internal/app/service.go`): координация store+`sasldb2`+карты. `Create` — сначала строка реестра (арбитр дубля, не даёт затереть чужой пароль в `sasldb2`), затем `sasldb2`, затем rebuild карты + reload; полный откат при сбое любого шага. `UpdateMode`/`Delete`/`RegeneratePassword`/`PurgeDomainSASL`/`Resync`. `SenderMaps` — интерфейс над Postfix для тестируемости. +- **Интеграция с доменами** (`internal/domain/service.go`): `Delete` теперь через интерфейс `Applications` — сначала `PurgeDomainSASL` (пока логины в реестре), затем каскад БД, затем rebuild карты + reload Postfix, затем удаление DKIM-ключа. Ручной reload (7.2.12) теперь перегружает и OpenDKIM, и Postfix. +- **Web** (`internal/web/handlers_apps.go`, шаблон `domain_detail.html`): создание/редактирование режима/перевыпуск/удаление приложения в карточке домена; одноразовый показ пароля рендерится **инлайн** (не через redirect — иначе пароль потерян); серверные ошибки валидации показываются на форме; флаги подтверждения на удаление/перевыпуск. +- **Инфра**: `postfix` добавлен в группу `selfpost` (Dockerfile) для чтения `sasldb2`/карты; `entrypoint.sh` нормализует `/data/sasl` (setgid 2750, `sasldb2` 0640) и `/data/postfix` (файл карты создаётся пустым до старта, 0640) — самолечение прав после restore. +- **Проверено на сервере** (selfpost.example.com): `gofmt`/`go vet`/`go test` зелёные (юниты: store CRUD/bindings/cascade, рендер карты+инъекции, валидация логина/адреса/принадлежности домену, argv/stdin `saslpasswd2`, сервис create/rollback/delete/mode/regen/purge с фейками); docker build ок; контейнерный e2e — весь жизненный цикл приложения + каскад домена + персистентность через рестарт + **реальный `postfix reload`** из пути создания и кнопки reload. ### Сделано в Фазе 3 - **Per-domain DKIM в чистом Go** (`internal/domain/dkim.go`): RSA-2048 через `crypto/rsa`, приватный ключ PKCS#1 PEM пишется атомарно (temp+rename, `writeFileAtomic`) с mode 0640; DNS TXT-запись (`v=DKIM1; h=sha256; k=rsa; p=`) вычисляется из ключа на лету — ключ на диске = единственный источник истины. `opendkim-genkey` **не** используется (никакого exec для keygen). @@ -101,3 +114,4 @@ - **Фаза 1** (2026-07-11, Opus) — Docker-образ + supervisord + три процесса, холодный старт (обёртка ждёт milter-сокеты) и crashexit проверены на сервере. Коммит `ed9e942`. - **Фаза 2** (2026-07-11, Opus) — SQLite (`modernc.org/sqlite`, миграции, схема ТЗ 9), setup secret-link (128-бит токен, TTL 10м, const-time, одноразово), bcrypt-админ, логин/сессии/cookie-флаги, rate-limit setup+логина, html/template + вендоренный HTMX, auth-middleware; entrypoint чинит владельца bind-mount `/data`. Проверено на сервере (e2e curl + docker run). - **Фаза 3** (2026-07-11, Opus) — домены + per-domain DKIM: keygen в чистом Go (RSA-2048, PKCS#1 PEM, TXT из ключа), OpenDKIM KeyTable/SigningTable (Mode `s`, `refile:`) с полной регенерацией, reload через `supervisorctl signal USR1 opendkim` (сокет открыт группе `selfpost`, панель без root — 7.6.3/7.6.8), межпользовательский доступ к ключам через общую группу `selfpost`+setgid+`RequireSafeKeys no`, add/delete/список/TXT/подтверждение каскада/ручной reload, строгая валидация имени домена (7.6.2), injection-safe запись таблиц (7.6.4). Юнит-тесты + контейнерный e2e (add/delete, персистентность ключей через рестарт, reload) зелёные. Проверено на сервере. +- **Фаза 4** (2026-07-12, Opus) — приложения + SASL + привязка к домену: учётки в `sasldb2` через `saslpasswd2` (пароль по stdin, логин whitelisted argv, без shell — 7.6.3), генерируемый пароль показывается один раз (7.6.1), режим адресов wildcard/list с серверной проверкой принадлежности адреса домену (7.6.2), генерация `smtpd_sender_login_maps` (many-to-one слияние, injection-safe — 7.6.4), CRUD приложений + перевыпуск пароля + каскад при удалении домена (очистка `sasldb2` + пересборка карты). **Исправлен reload Postfix:** `signal HUP` не доходит до форкнутого master → одноразовая supervisord-программа `postfix-reload` (настоящий `postfix reload` от root без привилегий панели). `postfix` в группе `selfpost`, `/data/sasl`+`/data/postfix` под setgid. Новые пакеты `internal/app`, `internal/postfix`. Юнит-тесты + контейнерный e2e (весь жизненный цикл, каскад, персистентность, реальный reload по `mail.log`) зелёные. diff --git a/internal/app/password.go b/internal/app/password.go new file mode 100644 index 0000000..8289a82 --- /dev/null +++ b/internal/app/password.go @@ -0,0 +1,27 @@ +package app + +import ( + "crypto/rand" + "encoding/base64" + "fmt" +) + +// 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). +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. +// +// 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 +// and passed to saslpasswd2 over stdin unchanged. +func generatePassword() (string, error) { + buf := make([]byte, passwordBytes) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("generate password: %w", err) + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} diff --git a/internal/app/sasl.go b/internal/app/sasl.go new file mode 100644 index 0000000..924d538 --- /dev/null +++ b/internal/app/sasl.go @@ -0,0 +1,81 @@ +package app + +import ( + "bytes" + "fmt" + "os/exec" + "strings" +) + +// 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). +type SASLDB struct { + path string // sasldb2 file, under /data so it survives restarts (spec 9) + 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 +} + +// 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. +func NewSASLDB(path, realm string) *SASLDB { + return &SASLDB{path: path, realm: realm, run: runSaslpasswd2} +} + +// 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. +// +// 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). +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. + 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 +// 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} + if err := s.run(args, nil); err != nil { + return fmt.Errorf("saslpasswd2 delete %q: %w", login, err) + } + return 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 (spec 7.6.3). +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 +} diff --git a/internal/app/sasl_test.go b/internal/app/sasl_test.go new file mode 100644 index 0000000..4421649 --- /dev/null +++ b/internal/app/sasl_test.go @@ -0,0 +1,76 @@ +package app + +import ( + "strings" + "testing" +) + +type fakeRun struct { + args []string + stdin string + calls int +} + +func newFakeSASL() (*SASLDB, *fakeRun) { + fr := &fakeRun{} + s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com") + s.run = func(args []string, stdin []byte) error { + fr.calls++ + fr.args = args + fr.stdin = string(stdin) + return nil + } + return s, fr +} + +func TestSASLSetPassesPasswordOnStdinNotArgv(t *testing.T) { + s, fr := newFakeSASL() + const secret = "s3cr3t-p4ss" + if err := s.Set("alerts", secret); err != nil { + t.Fatalf("Set: %v", err) + } + if fr.stdin != secret { + t.Errorf("password not passed on stdin: got %q", fr.stdin) + } + joined := strings.Join(fr.args, " ") + 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"} + if len(fr.args) != len(want) { + t.Fatalf("args = %v, want %v", fr.args, want) + } + for i := range want { + if fr.args[i] != want[i] { + t.Fatalf("args = %v, want %v", fr.args, want) + } + } +} + +func TestSASLDeleteArgs(t *testing.T) { + s, fr := newFakeSASL() + if err := s.Delete("alerts"); err != nil { + t.Fatalf("Delete: %v", err) + } + 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) + } + if fr.stdin != "" { + t.Errorf("delete should not send stdin, got %q", fr.stdin) + } +} + +func TestSASLRejectsInvalidLoginBeforeExec(t *testing.T) { + s, fr := newFakeSASL() + if err := s.Set("bad login", "pw"); err == nil { + t.Error("Set accepted invalid login") + } + if err := s.Delete("bad@login"); err == nil { + t.Error("Delete accepted invalid login") + } + if fr.calls != 0 { + t.Errorf("saslpasswd2 invoked %d times for invalid logins, want 0", fr.calls) + } +} diff --git a/internal/app/service.go b/internal/app/service.go new file mode 100644 index 0000000..e7dcde4 --- /dev/null +++ b/internal/app/service.go @@ -0,0 +1,200 @@ +// 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 + +import ( + "codeberg.org/mix/selfpost/internal/postfix" + "codeberg.org/mix/selfpost/internal/store" +) + +// SenderMaps is the slice of the Postfix manager the application service needs: +// rebuilding the sender_login_maps from the current bindings and reloading. +// *postfix.Postfix satisfies it; tests substitute a fake. +type SenderMaps interface { + RebuildSenderLoginMaps(bindings []postfix.Binding) error +} + +// 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 stores consistent. +type Service struct { + store *store.Store + sasl *SASLDB + pf SenderMaps +} + +// NewService builds the application service over the shared store, the sasldb2 +// manager and the Postfix manager. +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). +func (s *Service) List(domainID int64) ([]store.Application, error) { + return s.store.ListApplicationsByDomain(domainID) +} + +// Get returns one application by id (store.ErrApplicationNotFound if absent). +func (s *Service) Get(id int64) (store.Application, error) { + return s.store.GetApplication(id) +} + +// 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 +// 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. +// +// 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 +// clobbering an existing account's password in sasldb2). If the SASL write or +// the map rebuild fails, everything is rolled back so we never leave an +// application the panel cannot fully account for. +func (s *Service) Create(domainID int64, login, mode string, rawAddresses []string) (store.Application, string, error) { + addresses, err := s.validateForDomain(domainID, login, mode, rawAddresses) + if err != nil { + return store.Application{}, "", err + } + + password, err := generatePassword() + if err != nil { + return store.Application{}, "", err + } + + a, err := s.store.AddApplication(domainID, login, mode, addresses) + if err != nil { + return store.Application{}, "", err + } + + if err := s.sasl.Set(login, password); err != nil { + s.rollbackCreate(a.ID, "") // login has no SASL account yet; nothing to unset + return store.Application{}, "", err + } + if err := s.Resync(); err != nil { + s.rollbackCreate(a.ID, login) + return store.Application{}, "", err + } + return a, password, nil +} + +// rollbackCreate best-effort undoes a partially created application after a +// downstream failure: it removes the SASL account (if one was written) and the +// registry row. Errors here are subordinate to the original failure the caller +// returns. +func (s *Service) rollbackCreate(id int64, login string) { + if login != "" { + _ = s.sasl.Delete(login) + } + _, _ = s.store.DeleteApplication(id) +} + +// 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 +// re-validated against the application's domain. +func (s *Service) UpdateMode(id int64, mode string, rawAddresses []string) error { + a, err := s.store.GetApplication(id) + if err != nil { + return err + } + addresses, err := s.validateForDomain(a.DomainID, a.Login, mode, rawAddresses) + if err != nil { + return err + } + if err := s.store.UpdateApplicationMode(id, mode, addresses); err != nil { + return err + } + return s.Resync() +} + +// RegeneratePassword issues a fresh password for an existing application (spec +// 7.2.9). The old password is invalidated by overwriting the SASL account; the +// address mode and bindings are unchanged, so no map rebuild is needed. The new +// password is returned to be shown once. +func (s *Service) RegeneratePassword(id int64) (string, error) { + a, err := s.store.GetApplication(id) + if err != nil { + return "", err + } + password, err := generatePassword() + if err != nil { + return "", err + } + if err := s.sasl.Set(a.Login, password); err != nil { + return "", err + } + return password, nil +} + +// Delete removes an application: its SASL account, its registry row (and address +// rows via cascade) and its sender-map bindings, then reloads Postfix (spec +// 7.2.8). The domain and other applications are untouched. +func (s *Service) Delete(id int64) error { + a, err := s.store.DeleteApplication(id) + if err != nil { + return err + } + if err := s.sasl.Delete(a.Login); err != nil { + return err + } + return s.Resync() +} + +// 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 +// 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 { + logins, err := s.store.ListLoginsByDomain(domainID) + if err != nil { + return err + } + for _, login := range logins { + if err := s.sasl.Delete(login); err != nil { + return err + } + } + return nil +} + +// 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. +func (s *Service) Resync() error { + bindings, err := s.store.ListBindings() + if err != nil { + return err + } + pfBindings := make([]postfix.Binding, 0, len(bindings)) + for _, b := range bindings { + pfBindings = append(pfBindings, postfix.Binding{Address: b.Address, Login: b.Login}) + } + return s.pf.RebuildSenderLoginMaps(pfBindings) +} + +// 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 +// 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) + if err != nil { + return nil, err + } + if err := validateLogin(login); err != nil { + return nil, err + } + if err := validateAddressMode(mode); err != nil { + return nil, err + } + if mode == store.AddressModeWildcard { + return nil, nil + } + return parseAddresses(rawAddresses, d.Name) +} diff --git a/internal/app/service_test.go b/internal/app/service_test.go new file mode 100644 index 0000000..9404634 --- /dev/null +++ b/internal/app/service_test.go @@ -0,0 +1,243 @@ +package app + +import ( + "errors" + "path/filepath" + "testing" + + "codeberg.org/mix/selfpost/internal/postfix" + "codeberg.org/mix/selfpost/internal/store" +) + +// fakeMaps records the last set of bindings passed to a rebuild and can be told +// to fail, so we can exercise the rollback paths. +type fakeMaps struct { + last []postfix.Binding + calls int + failNext bool +} + +func (f *fakeMaps) RebuildSenderLoginMaps(b []postfix.Binding) error { + f.calls++ + if f.failNext { + f.failNext = false + return errors.New("boom") + } + f.last = b + return nil +} + +// saslRecorder is a fake sasldb2 backend recording set/delete calls. +type saslRecorder struct { + set map[string]string // login -> password + deleted []string + failNext bool +} + +func newServiceHarness(t *testing.T) (*Service, *store.Store, *saslRecorder, *fakeMaps) { + t.Helper() + st, err := store.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { st.Close() }) + + rec := &saslRecorder{set: map[string]string{}} + sasl := NewSASLDB("/data/sasl/sasldb2", "mail.example.com") + sasl.run = func(args []string, stdin []byte) error { + if rec.failNext { + rec.failNext = false + return errors.New("saslpasswd2 failed") + } + // args end with the login; a "-d" anywhere means delete. + login := args[len(args)-1] + del := false + for _, a := range args { + if a == "-d" { + del = true + } + } + if del { + rec.deleted = append(rec.deleted, login) + delete(rec.set, login) + } else { + rec.set[login] = string(stdin) + } + return nil + } + + maps := &fakeMaps{} + return NewService(st, sasl, maps), st, rec, maps +} + +func addDomain(t *testing.T, st *store.Store, name string) store.Domain { + t.Helper() + d, err := st.AddDomain(name, "selfpost") + if err != nil { + t.Fatalf("AddDomain: %v", err) + } + return d +} + +func TestServiceCreateWildcard(t *testing.T) { + svc, st, rec, maps := newServiceHarness(t) + d := addDomain(t, st, "example.com") + + a, pw, err := svc.Create(d.ID, "alerts", store.AddressModeWildcard, nil) + if err != nil { + t.Fatalf("Create: %v", err) + } + if a.Login != "alerts" { + t.Errorf("login = %q", a.Login) + } + if rec.set["alerts"] != pw { + t.Errorf("sasl password %q != returned %q", rec.set["alerts"], pw) + } + if len(maps.last) != 1 || maps.last[0].Address != "@example.com" || maps.last[0].Login != "alerts" { + t.Errorf("map bindings = %+v", maps.last) + } +} + +func TestServiceCreateListValidatesDomain(t *testing.T) { + svc, st, _, _ := newServiceHarness(t) + d := addDomain(t, st, "example.com") + + // A cross-domain address is rejected before anything is written. + _, _, err := svc.Create(d.ID, "app1", store.AddressModeList, []string{"a@evil.com"}) + if err == nil { + t.Fatal("Create accepted cross-domain address") + } + apps, _ := st.ListApplicationsByDomain(d.ID) + if len(apps) != 0 { + t.Errorf("application persisted despite validation failure: %+v", apps) + } +} + +func TestServiceCreateDuplicateLogin(t *testing.T) { + svc, st, _, _ := newServiceHarness(t) + d := addDomain(t, st, "example.com") + if _, _, err := svc.Create(d.ID, "dup", store.AddressModeWildcard, nil); err != nil { + t.Fatal(err) + } + _, _, err := svc.Create(d.ID, "dup", store.AddressModeWildcard, nil) + if !errors.Is(err, store.ErrLoginExists) { + t.Fatalf("duplicate create = %v, want ErrLoginExists", err) + } +} + +func TestServiceCreateRollsBackOnSASLFailure(t *testing.T) { + svc, st, rec, _ := newServiceHarness(t) + d := addDomain(t, st, "example.com") + + rec.failNext = true // saslpasswd2 fails on the first (set) call + _, _, err := svc.Create(d.ID, "app1", store.AddressModeWildcard, nil) + if err == nil { + t.Fatal("expected Create to fail when SASL set fails") + } + apps, _ := st.ListApplicationsByDomain(d.ID) + if len(apps) != 0 { + t.Errorf("registry row not rolled back: %+v", apps) + } +} + +func TestServiceCreateRollsBackOnMapFailure(t *testing.T) { + svc, st, rec, maps := newServiceHarness(t) + d := addDomain(t, st, "example.com") + + maps.failNext = true + _, _, err := svc.Create(d.ID, "app1", store.AddressModeWildcard, nil) + if err == nil { + t.Fatal("expected Create to fail when map rebuild fails") + } + apps, _ := st.ListApplicationsByDomain(d.ID) + if len(apps) != 0 { + t.Errorf("registry row not rolled back: %+v", apps) + } + if _, ok := rec.set["app1"]; ok { + t.Errorf("SASL account not rolled back") + } +} + +func TestServiceDelete(t *testing.T) { + svc, st, rec, _ := newServiceHarness(t) + d := addDomain(t, st, "example.com") + a, _, err := svc.Create(d.ID, "app1", store.AddressModeWildcard, nil) + if err != nil { + t.Fatal(err) + } + + if err := svc.Delete(a.ID); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, ok := rec.set["app1"]; ok { + t.Error("SASL account not deleted") + } + if len(rec.deleted) != 1 || rec.deleted[0] != "app1" { + t.Errorf("deleted logins = %v", rec.deleted) + } + apps, _ := st.ListApplicationsByDomain(d.ID) + if len(apps) != 0 { + t.Errorf("application not deleted: %+v", apps) + } +} + +func TestServiceUpdateMode(t *testing.T) { + svc, st, _, maps := newServiceHarness(t) + d := addDomain(t, st, "example.com") + a, _, err := svc.Create(d.ID, "app1", store.AddressModeWildcard, nil) + if err != nil { + t.Fatal(err) + } + + if err := svc.UpdateMode(a.ID, store.AddressModeList, []string{"alerts@example.com"}); err != nil { + t.Fatalf("UpdateMode: %v", err) + } + if len(maps.last) != 1 || maps.last[0].Address != "alerts@example.com" { + t.Errorf("map after mode switch = %+v", maps.last) + } + got, _ := st.GetApplication(a.ID) + if got.AddressMode != store.AddressModeList || len(got.Addresses) != 1 { + t.Errorf("stored app after switch = %+v", got) + } +} + +func TestServiceRegeneratePassword(t *testing.T) { + svc, st, rec, _ := newServiceHarness(t) + d := addDomain(t, st, "example.com") + a, pw1, err := svc.Create(d.ID, "app1", store.AddressModeWildcard, nil) + if err != nil { + t.Fatal(err) + } + + pw2, err := svc.RegeneratePassword(a.ID) + if err != nil { + t.Fatalf("RegeneratePassword: %v", err) + } + if pw1 == pw2 { + t.Error("regenerated password equals the old one") + } + if rec.set["app1"] != pw2 { + t.Errorf("sasl password not updated to new value") + } +} + +func TestServicePurgeDomainSASL(t *testing.T) { + svc, st, rec, _ := newServiceHarness(t) + d := addDomain(t, st, "example.com") + if _, _, err := svc.Create(d.ID, "app-a", store.AddressModeWildcard, nil); err != nil { + t.Fatal(err) + } + if _, _, err := svc.Create(d.ID, "app-b", store.AddressModeWildcard, nil); err != nil { + t.Fatal(err) + } + + if err := svc.PurgeDomainSASL(d.ID); err != nil { + t.Fatalf("PurgeDomainSASL: %v", err) + } + if len(rec.set) != 0 { + t.Errorf("SASL accounts remain after purge: %v", rec.set) + } + if len(rec.deleted) != 2 { + t.Errorf("deleted %d logins, want 2", len(rec.deleted)) + } +} diff --git a/internal/app/validate.go b/internal/app/validate.go new file mode 100644 index 0000000..5ba0865 --- /dev/null +++ b/internal/app/validate.go @@ -0,0 +1,119 @@ +package app + +import ( + "fmt" + "strings" + + "codeberg.org/mix/selfpost/internal/store" +) + +const ( + minLoginLen = 3 + maxLoginLen = 64 +) + +// validateLogin enforces a strict server-side whitelist for the SASL login +// (spec 7.6.2). 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 +// makes that safe. +func validateLogin(login string) error { + if len(login) < minLoginLen || len(login) > maxLoginLen { + return fmt.Errorf("login must be %d-%d characters", minLoginLen, maxLoginLen) + } + for _, r := range login { + lower := r >= 'a' && r <= 'z' + upper := r >= 'A' && r <= 'Z' + digit := r >= '0' && r <= '9' + if !lower && !upper && !digit && r != '.' && r != '-' && r != '_' { + return fmt.Errorf("login may contain only letters, digits, '.', '-' and '_'") + } + } + return nil +} + +// validateAddressMode checks the submitted mode is one of the two known values. +func validateAddressMode(mode string) error { + if mode != store.AddressModeWildcard && mode != store.AddressModeList { + return fmt.Errorf("invalid address mode") + } + return nil +} + +// normalizeAddress lower-cases and trims a sender address. Both the local part +// and domain are treated case-insensitively for the ownership check and for the +// generated map, matching how addresses are compared elsewhere. +func normalizeAddress(addr string) string { + return strings.ToLower(strings.TrimSpace(addr)) +} + +// 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 +// 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. +func validateSenderAddress(addr, domain string) error { + at := strings.LastIndexByte(addr, '@') + if at < 0 { + return fmt.Errorf("%q is not a valid email address", addr) + } + local, host := addr[:at], addr[at+1:] + if host != domain { + return fmt.Errorf("%q does not belong to domain %s", addr, domain) + } + if err := validateLocalPart(local); err != nil { + return fmt.Errorf("%q: %w", addr, err) + } + return nil +} + +// 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). +func validateLocalPart(local string) error { + if local == "" { + return fmt.Errorf("missing the part before '@'") + } + if local[0] == '.' || local[len(local)-1] == '.' { + return fmt.Errorf("local part must not start or end with '.'") + } + for i := 0; i < len(local); i++ { + c := local[i] + lower := c >= 'a' && c <= 'z' + digit := c >= '0' && c <= '9' + if !lower && !digit && c != '.' && c != '-' && c != '_' && c != '+' { + return fmt.Errorf("local part may contain only lower-case letters, digits, '.', '-', '_' and '+'") + } + } + return nil +} + +// parseAddresses normalises, validates and de-duplicates a list of submitted +// sender addresses for a list-mode application. It requires at least one address +// and that each belongs to domain. The returned slice is de-duplicated but keeps +// submission order stable for display; the store sorts on read. +func parseAddresses(raw []string, domain string) ([]string, error) { + seen := make(map[string]bool) + out := make([]string, 0, len(raw)) + for _, r := range raw { + addr := normalizeAddress(r) + if addr == "" { + continue + } + if err := validateSenderAddress(addr, domain); err != nil { + return nil, err + } + if seen[addr] { + continue + } + seen[addr] = true + out = append(out, addr) + } + if len(out) == 0 { + return nil, fmt.Errorf("list mode requires at least one address") + } + return out, nil +} diff --git a/internal/app/validate_test.go b/internal/app/validate_test.go new file mode 100644 index 0000000..288a62b --- /dev/null +++ b/internal/app/validate_test.go @@ -0,0 +1,92 @@ +package app + +import "testing" + +func TestValidateLogin(t *testing.T) { + good := []string{"alerts", "prod-server", "app_1", "News.Letter"} + for _, l := range good { + if err := validateLogin(l); err != nil { + t.Errorf("validateLogin(%q) = %v, want nil", l, err) + } + } + bad := []string{ + "ab", // too short + "alerts@example.com", // '@' not allowed (sasldb realm separator) + "has space", // whitespace + "inject\nline", // newline + "comma,login", // map value separator + "colon:login", // config separator + } + for _, l := range bad { + if err := validateLogin(l); err == nil { + t.Errorf("validateLogin(%q) = nil, want error", l) + } + } +} + +func TestValidateSenderAddressDomainOwnership(t *testing.T) { + // The critical check (spec 7.6.2): 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) + } + if err := validateSenderAddress("alerts@evil.com", "example.com"); err == nil { + t.Error("cross-domain address accepted, want rejection") + } + // A trailing-domain trick must not pass as ownership. + if err := validateSenderAddress("a@notexample.com", "example.com"); err == nil { + t.Error("suffix-domain address accepted, want rejection") + } +} + +func TestValidateSenderAddressForm(t *testing.T) { + bad := []string{ + "noat.example.com", // no '@' + "@example.com", // empty local part + ".dot@example.com", // leading dot + "dot.@example.com", // trailing dot + "in ject@example.com", // space + "quote\"@example.com", // disallowed char + } + for _, a := range bad { + if err := validateSenderAddress(a, "example.com"); err == nil { + t.Errorf("validateSenderAddress(%q) = nil, want error", a) + } + } +} + +func TestParseAddresses(t *testing.T) { + // Normalises case, trims, drops blanks, de-duplicates. + got, err := parseAddresses([]string{" Alerts@Example.com ", "", "noreply@example.com", "alerts@example.com"}, "example.com") + if err != nil { + t.Fatalf("parseAddresses: %v", err) + } + if len(got) != 2 || got[0] != "alerts@example.com" || got[1] != "noreply@example.com" { + t.Fatalf("parseAddresses = %v", got) + } + + // Empty list in list mode is an error. + if _, err := parseAddresses([]string{"", " "}, "example.com"); err == nil { + t.Error("empty address list accepted, want error") + } + // A cross-domain address rejects the whole submission. + if _, err := parseAddresses([]string{"ok@example.com", "bad@other.com"}, "example.com"); err == nil { + t.Error("cross-domain address in list accepted, want error") + } +} + +func TestGeneratePasswordStrength(t *testing.T) { + seen := make(map[string]bool) + for i := 0; i < 50; i++ { + p, err := generatePassword() + if err != nil { + t.Fatalf("generatePassword: %v", err) + } + if len(p) < 30 { + t.Fatalf("password too short: %d chars", len(p)) + } + if seen[p] { + t.Fatalf("duplicate password generated: %q", p) + } + seen[p] = true + } +} diff --git a/internal/domain/service.go b/internal/domain/service.go index 65fa723..a204c51 100644 --- a/internal/domain/service.go +++ b/internal/domain/service.go @@ -6,21 +6,38 @@ import ( "codeberg.org/mix/selfpost/internal/store" ) -// Service coordinates the three places a sending domain lives: the SQLite -// registry, the on-disk DKIM keys and OpenDKIM's tables. Callers (the web -// handlers) validate user input first; Service keeps the three stores in -// agreement and drives the OpenDKIM reload (spec 6, 7.2.2-4, 7.2.10). +// Applications is the slice of the application service the domain service needs +// to keep the SASL database and sender map consistent when a domain (and its +// applications, via cascade) is deleted. *app.Service satisfies it; it is an +// interface here to avoid a package import cycle and to keep domain deletion +// testable in isolation. +type Applications interface { + // PurgeDomainSASL removes the SASL accounts of the domain's applications. + // It must run before the registry cascade so the logins are still known. + PurgeDomainSASL(domainID int64) error + // Resync rebuilds smtpd_sender_login_maps from the remaining applications + // and reloads Postfix. + Resync() 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). type Service struct { store *store.Store odk *OpenDKIM + apps Applications selector string } // 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. -func NewService(st *store.Store, odk *OpenDKIM, selectorDefault string) *Service { - return &Service{store: st, odk: odk, selector: selectorDefault} +// 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). @@ -68,21 +85,30 @@ func (s *Service) rollbackAdd(id int64) { _ = s.store.DeleteDomain(id) } -// Delete removes a domain and everything bound to it — applications and their -// SASL/binding rows go via the DB cascade, and the DKIM key and table entries -// are removed here (spec 7.2.4, 6.5). The registry row and tables are updated -// (so OpenDKIM stops signing for the domain) before the key is deleted. +// Delete removes a domain and everything bound to it (spec 7.2.4, 6.5). 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 +// Postfix sender map are rebuilt from what remains — so OpenDKIM stops signing +// and Postfix stops authorising the domain's senders — before the DKIM key is +// deleted. func (s *Service) Delete(id int64) error { d, err := s.store.GetDomain(id) if err != nil { return err } + if err := s.apps.PurgeDomainSASL(id); err != nil { + return fmt.Errorf("clear SASL accounts for %s: %w", d.Name, err) + } if err := s.store.DeleteDomain(id); err != nil { return err } if err := s.resync(); err != nil { return err } + if err := s.apps.Resync(); err != nil { + return fmt.Errorf("rebuild sender map after deleting %s: %w", d.Name, err) + } if err := s.odk.RemoveKey(d.Name); err != nil { // The domain is gone from the registry and tables; a leftover key // directory is harmless. Surface it so it is not silently ignored. diff --git a/internal/postfix/postfix.go b/internal/postfix/postfix.go new file mode 100644 index 0000000..2db9e30 --- /dev/null +++ b/internal/postfix/postfix.go @@ -0,0 +1,159 @@ +// 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. +package postfix + +import ( + "fmt" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +// Postfix manages the on-disk Postfix state the panel is responsible for. After +// rewriting a map it asks Postfix to reload. +type Postfix struct { + senderLoginMapsPath string + + // reload asks the running Postfix to re-read its configuration. It is a + // field so tests can substitute a no-op; the default drives supervisord. + reload func() error +} + +// New builds a manager rooted at dir (typically /data/postfix), the same layout +// entrypoint.sh prepares. The default reload path signals Postfix through +// supervisord. +func New(dir string) *Postfix { + return &Postfix{ + senderLoginMapsPath: filepath.Join(dir, "sender_login_maps"), + reload: reloadViaSupervisor, + } +} + +// 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. +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". +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. +// +// 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. +func (p *Postfix) RebuildSenderLoginMaps(bindings []Binding) error { + content, err := renderSenderLoginMaps(bindings) + if err != nil { + return err + } + if err := writeFileAtomic(p.senderLoginMapsPath, content, 0o640); err != nil { + return err + } + return p.reload() +} + +// Reload asks Postfix to re-read its configuration without regenerating any +// file. It backs the panel's manual reload button (spec 7.2.12). +func (p *Postfix) Reload() error { + return p.reload() +} + +// 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 +// this, but the writer refuses to emit anything unsafe as a hard backstop. +func renderSenderLoginMaps(bindings []Binding) ([]byte, error) { + byAddr := make(map[string][]string) + order := make([]string, 0) + for _, b := range bindings { + if err := assertMapSafe(b.Address, b.Login); err != nil { + return nil, err + } + if _, seen := byAddr[b.Address]; !seen { + order = append(order, b.Address) + } + byAddr[b.Address] = appendUnique(byAddr[b.Address], b.Login) + } + sort.Strings(order) + + var sb strings.Builder + for _, addr := range order { + logins := byAddr[addr] + sort.Strings(logins) + // texthash format: . A comma-separated value + // lists every login permitted to use this sender (spec 5.1 §4). + fmt.Fprintf(&sb, "%s %s\n", addr, strings.Join(logins, ",")) + } + return []byte(sb.String()), nil +} + +func appendUnique(list []string, v string) []string { + for _, x := range list { + if x == v { + return list + } + } + return append(list, v) +} + +// 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 +// letting whitespace, a newline or a comma (the value separator) through into +// the file (spec 7.6.4). +func assertMapSafe(address, login string) error { + if address == "" || login == "" { + return fmt.Errorf("postfix: empty address or login") + } + if strings.ContainsAny(address, " \t\r\n,:\\") { + return fmt.Errorf("postfix: unsafe character in address %q", address) + } + if strings.ContainsAny(login, " \t\r\n,:@\\") { + return fmt.Errorf("postfix: unsafe character in login %q", login) + } + return nil +} + +// 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 +// 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). +// +// Arguments are fixed literals — no user input is interpolated into the command, +// and it never goes through a shell (spec 7.6.3). +func reloadViaSupervisor() error { + cmd := exec.Command("supervisorctl", + "-c", "/etc/supervisor/supervisord.conf", + "start", "postfix-reload") + out, err := cmd.CombinedOutput() + if err != nil { + // A reload already in flight is not a failure: that pending run reloads + // Postfix after our file is in place (the file is written before this). + if strings.Contains(string(out), "already started") { + return nil + } + return fmt.Errorf("reload postfix via supervisor: %w: %s", err, strings.TrimSpace(string(out))) + } + return nil +} diff --git a/internal/postfix/postfix_test.go b/internal/postfix/postfix_test.go new file mode 100644 index 0000000..4b70efc --- /dev/null +++ b/internal/postfix/postfix_test.go @@ -0,0 +1,118 @@ +package postfix + +import ( + "os" + "testing" +) + +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. + bindings := []Binding{ + {"@zeta.example", "z1"}, + {"alerts@alpha.example", "a-listed"}, + {"@alpha.example", "a2"}, + {"@alpha.example", "a1"}, + } + got, err := renderSenderLoginMaps(bindings) + if err != nil { + t.Fatalf("renderSenderLoginMaps: %v", err) + } + want := "@alpha.example a1,a2\n" + + "@zeta.example z1\n" + + "alerts@alpha.example a-listed\n" + if string(got) != want { + t.Errorf("map =\n%q\nwant\n%q", got, want) + } +} + +func TestRenderSenderLoginMapsEmpty(t *testing.T) { + got, err := renderSenderLoginMaps(nil) + if err != nil { + t.Fatalf("renderSenderLoginMaps(nil): %v", err) + } + if len(got) != 0 { + t.Errorf("expected empty map, got %q", got) + } +} + +func TestRenderSenderLoginMapsDedupesLogin(t *testing.T) { + bindings := []Binding{ + {"@a.example", "dup"}, + {"@a.example", "dup"}, + } + got, err := renderSenderLoginMaps(bindings) + if err != nil { + t.Fatal(err) + } + if string(got) != "@a.example dup\n" { + t.Errorf("map = %q, want single deduped login", got) + } +} + +func TestAssertMapSafeRejectsInjection(t *testing.T) { + bad := []struct{ addr, login string }{ + {"@exa mple.com", "log"}, + {"@example.com\nx y z", "log"}, + {"@example.com", "log,evil"}, + {"@example.com", "log in"}, + {"@example.com", "log@realm"}, // '@' would confuse sasldb realm handling + {"", "log"}, + {"@example.com", ""}, + } + for _, b := range bad { + if err := assertMapSafe(b.addr, b.login); err == nil { + t.Errorf("assertMapSafe(%q,%q) = nil, want error", b.addr, b.login) + } + } + if err := assertMapSafe("alerts@example.com", "app_1-x"); err != nil { + t.Errorf("assertMapSafe of a clean pair errored: %v", err) + } +} + +func newTestPostfix(t *testing.T) (*Postfix, *int) { + t.Helper() + dir := t.TempDir() + reloads := 0 + p := New(dir) + p.reload = func() error { reloads++; return nil } + return p, &reloads +} + +func TestRebuildSenderLoginMapsWritesAndReloads(t *testing.T) { + p, reloads := newTestPostfix(t) + if err := p.RebuildSenderLoginMaps([]Binding{{"@example.com", "app1"}}); err != nil { + t.Fatalf("RebuildSenderLoginMaps: %v", err) + } + if *reloads != 1 { + t.Errorf("reload called %d times, want 1", *reloads) + } + data, err := os.ReadFile(p.senderLoginMapsPath) + if err != nil { + t.Fatal(err) + } + if string(data) != "@example.com app1\n" { + t.Errorf("map file = %q", data) + } +} + +func TestRebuildRejectsUnsafeWithoutWriting(t *testing.T) { + p, reloads := newTestPostfix(t) + // Seed a known-good file so we can prove the failed rebuild left it untouched. + if err := p.RebuildSenderLoginMaps([]Binding{{"@good.example", "ok"}}); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(p.senderLoginMapsPath) + + err := p.RebuildSenderLoginMaps([]Binding{{"@bad.example", "evil\nlogin"}}) + if err == nil { + t.Fatal("expected rebuild to reject unsafe login") + } + after, _ := os.ReadFile(p.senderLoginMapsPath) + if string(after) != string(before) { + t.Errorf("map file changed on failed rebuild: %q", after) + } + if *reloads != 1 { + t.Errorf("reload called %d times, want 1 (no reload on failure)", *reloads) + } +} diff --git a/internal/postfix/write.go b/internal/postfix/write.go new file mode 100644 index 0000000..6cc7ada --- /dev/null +++ b/internal/postfix/write.go @@ -0,0 +1,47 @@ +package postfix + +import ( + "fmt" + "os" + "path/filepath" +) + +// 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 +// same primitive used for the OpenDKIM tables. +func writeFileAtomic(path string, data []byte, perm os.FileMode) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".tmp-*") + if err != nil { + return fmt.Errorf("create temp in %s: %w", dir, err) + } + tmpName := tmp.Name() + cleanup := true + defer func() { + if cleanup { + _ = os.Remove(tmpName) + } + }() + + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return fmt.Errorf("write temp %s: %w", tmpName, err) + } + if err := tmp.Chmod(perm); err != nil { + tmp.Close() + return fmt.Errorf("chmod temp %s: %w", tmpName, err) + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return fmt.Errorf("sync temp %s: %w", tmpName, err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp %s: %w", tmpName, err) + } + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("rename %s -> %s: %w", tmpName, path, err) + } + cleanup = false + return nil +} diff --git a/internal/store/applications.go b/internal/store/applications.go new file mode 100644 index 0000000..fe6e5a4 --- /dev/null +++ b/internal/store/applications.go @@ -0,0 +1,302 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + "time" +) + +// 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). +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. +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. +type Application struct { + ID int64 + DomainID int64 + Login string + AddressMode string + CreatedAt time.Time + Addresses []string +} + +// 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. +type Binding struct { + Address string + Login string +} + +// 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 +// login maps to ErrLoginExists. +func (s *Store) AddApplication(domainID int64, login, mode string, addresses []string) (Application, error) { + now := time.Now().UTC() + tx, err := s.db.Begin() + if err != nil { + return Application{}, fmt.Errorf("begin add application: %w", err) + } + defer tx.Rollback() + + res, err := tx.Exec( + "INSERT INTO applications (domain_id, login, address_mode, created_at) VALUES (?, ?, ?, ?)", + domainID, login, mode, now.Format(time.RFC3339), + ) + if err != nil { + if isUniqueViolation(err) { + return Application{}, ErrLoginExists + } + return Application{}, fmt.Errorf("insert application: %w", err) + } + id, err := res.LastInsertId() + if err != nil { + return Application{}, fmt.Errorf("application id: %w", err) + } + if err := insertAddresses(tx, id, mode, addresses); err != nil { + return Application{}, err + } + if err := tx.Commit(); err != nil { + return Application{}, fmt.Errorf("commit add application: %w", err) + } + return Application{ + ID: id, DomainID: domainID, Login: login, AddressMode: mode, + CreatedAt: now, Addresses: normalizedList(mode, addresses), + }, nil +} + +// UpdateApplicationMode switches an application's address mode and replaces its +// address list atomically (spec 7.2.7). 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() + if err != nil { + return fmt.Errorf("begin update application: %w", err) + } + defer tx.Rollback() + + res, err := tx.Exec("UPDATE applications SET address_mode = ? WHERE id = ?", mode, id) + if err != nil { + return fmt.Errorf("update application mode: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("update application rows: %w", err) + } + if n == 0 { + return ErrApplicationNotFound + } + if _, err := tx.Exec("DELETE FROM application_addresses WHERE application_id = ?", id); err != nil { + return fmt.Errorf("clear addresses: %w", err) + } + if err := insertAddresses(tx, id, mode, addresses); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit update application: %w", err) + } + return nil +} + +// insertAddresses writes the address rows for a list-mode application. In +// wildcard mode no address rows are stored (the wildcard is derived from the +// domain at map-generation time). +func insertAddresses(tx *sql.Tx, appID int64, mode string, addresses []string) error { + if mode != AddressModeList { + return nil + } + for _, addr := range addresses { + if _, err := tx.Exec( + "INSERT INTO application_addresses (application_id, address) VALUES (?, ?)", + appID, addr, + ); err != nil { + if isUniqueViolation(err) { + continue // a repeated address in the same submission is harmless + } + return fmt.Errorf("insert address %q: %w", addr, err) + } + } + return nil +} + +func normalizedList(mode string, addresses []string) []string { + if mode != AddressModeList { + return nil + } + return addresses +} + +// GetApplication returns one application (with its addresses) by id, or +// ErrApplicationNotFound. +func (s *Store) GetApplication(id int64) (Application, error) { + row := s.db.QueryRow( + "SELECT id, domain_id, login, address_mode, created_at FROM applications WHERE id = ?", id) + a, err := scanApplication(row) + if errors.Is(err, sql.ErrNoRows) { + return Application{}, ErrApplicationNotFound + } + if err != nil { + return Application{}, err + } + addrs, err := s.applicationAddresses(a.ID) + if err != nil { + return Application{}, err + } + a.Addresses = addrs + return a, nil +} + +// ListApplicationsByDomain returns a domain's applications ordered by login, +// each with its address list populated (spec 7.2.6). +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", + domainID) + if err != nil { + return nil, fmt.Errorf("list applications: %w", err) + } + defer rows.Close() + + var out []Application + for rows.Next() { + a, err := scanApplication(rows) + if err != nil { + return nil, err + } + out = append(out, a) + } + if err := rows.Err(); err != nil { + return nil, err + } + // Fill address lists after the first query is drained (MaxOpenConns is 1). + for i := range out { + addrs, err := s.applicationAddresses(out[i].ID) + if err != nil { + return nil, err + } + out[i].Addresses = addrs + } + return out, nil +} + +// 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). +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 { + return nil, fmt.Errorf("list logins: %w", err) + } + defer rows.Close() + + var out []string + for rows.Next() { + var login string + if err := rows.Scan(&login); err != nil { + return nil, err + } + out = append(out, login) + } + 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. +func (s *Store) ListBindings() ([]Binding, error) { + rows, err := s.db.Query(` + SELECT '@' || d.name, a.login + FROM applications a + JOIN domains d ON d.id = a.domain_id + WHERE a.address_mode = 'wildcard' + UNION ALL + SELECT aa.address, a.login + FROM application_addresses aa + JOIN applications a ON a.id = aa.application_id + WHERE a.address_mode = 'list' + ORDER BY 1, 2`) + if err != nil { + return nil, fmt.Errorf("list bindings: %w", err) + } + defer rows.Close() + + var out []Binding + for rows.Next() { + var b Binding + if err := rows.Scan(&b.Address, &b.Login); err != nil { + return nil, err + } + out = append(out, b) + } + return out, rows.Err() +} + +// 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. +func (s *Store) DeleteApplication(id int64) (Application, error) { + a, err := s.GetApplication(id) + if err != nil { + return Application{}, err + } + res, err := s.db.Exec("DELETE FROM applications WHERE id = ?", id) + if err != nil { + return Application{}, fmt.Errorf("delete application: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return Application{}, fmt.Errorf("delete application rows: %w", err) + } + if n == 0 { + return Application{}, ErrApplicationNotFound + } + return a, nil +} + +func (s *Store) applicationAddresses(appID int64) ([]string, error) { + rows, err := s.db.Query( + "SELECT address FROM application_addresses WHERE application_id = ? ORDER BY address", appID) + if err != nil { + return nil, fmt.Errorf("application addresses: %w", err) + } + defer rows.Close() + + var out []string + for rows.Next() { + var a string + if err := rows.Scan(&a); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +func scanApplication(r scanRow) (Application, error) { + var ( + a Application + createdAt string + ) + if err := r.Scan(&a.ID, &a.DomainID, &a.Login, &a.AddressMode, &createdAt); err != nil { + return Application{}, err + } + a.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + return a, nil +} diff --git a/internal/store/applications_test.go b/internal/store/applications_test.go new file mode 100644 index 0000000..b853de8 --- /dev/null +++ b/internal/store/applications_test.go @@ -0,0 +1,189 @@ +package store + +import ( + "errors" + "testing" +) + +func addTestDomain(t *testing.T, st *Store, name string) Domain { + t.Helper() + d, err := st.AddDomain(name, "selfpost") + if err != nil { + t.Fatalf("AddDomain(%q): %v", name, err) + } + return d +} + +func TestAddApplicationWildcard(t *testing.T) { + st := openTestStore(t) + d := addTestDomain(t, st, "example.com") + + a, err := st.AddApplication(d.ID, "alerts", AddressModeWildcard, nil) + if err != nil { + t.Fatalf("AddApplication: %v", err) + } + if a.ID == 0 || a.Login != "alerts" || a.AddressMode != AddressModeWildcard { + t.Fatalf("unexpected application: %+v", a) + } + if len(a.Addresses) != 0 { + t.Errorf("wildcard app should have no addresses, got %v", a.Addresses) + } + + got, err := st.GetApplication(a.ID) + if err != nil { + t.Fatalf("GetApplication: %v", err) + } + if got.Login != "alerts" || len(got.Addresses) != 0 { + t.Fatalf("round-trip mismatch: %+v", got) + } +} + +func TestAddApplicationListStoresAddresses(t *testing.T) { + st := openTestStore(t) + d := addTestDomain(t, st, "example.com") + + addrs := []string{"noreply@example.com", "alerts@example.com"} + a, err := st.AddApplication(d.ID, "app1", AddressModeList, addrs) + if err != nil { + t.Fatalf("AddApplication: %v", err) + } + got, err := st.GetApplication(a.ID) + if err != nil { + t.Fatal(err) + } + // Addresses come back sorted. + if len(got.Addresses) != 2 || got.Addresses[0] != "alerts@example.com" || got.Addresses[1] != "noreply@example.com" { + t.Fatalf("addresses = %v", got.Addresses) + } +} + +func TestAddApplicationDuplicateLogin(t *testing.T) { + st := openTestStore(t) + d := addTestDomain(t, st, "example.com") + d2 := addTestDomain(t, st, "other.com") + + if _, err := st.AddApplication(d.ID, "shared", AddressModeWildcard, nil); err != nil { + t.Fatal(err) + } + // Same login under a different domain must still collide (global uniqueness). + _, err := st.AddApplication(d2.ID, "shared", AddressModeWildcard, nil) + if !errors.Is(err, ErrLoginExists) { + t.Fatalf("duplicate login error = %v, want ErrLoginExists", err) + } +} + +func TestUpdateApplicationMode(t *testing.T) { + st := openTestStore(t) + d := addTestDomain(t, st, "example.com") + a, err := st.AddApplication(d.ID, "app1", AddressModeList, []string{"a@example.com"}) + if err != nil { + t.Fatal(err) + } + + // list -> wildcard drops the addresses. + if err := st.UpdateApplicationMode(a.ID, AddressModeWildcard, nil); err != nil { + t.Fatalf("UpdateApplicationMode: %v", err) + } + got, _ := st.GetApplication(a.ID) + if got.AddressMode != AddressModeWildcard || len(got.Addresses) != 0 { + t.Fatalf("after wildcard switch: %+v", got) + } + + // wildcard -> list adds a fresh set. + if err := st.UpdateApplicationMode(a.ID, AddressModeList, []string{"b@example.com", "c@example.com"}); err != nil { + t.Fatal(err) + } + got, _ = st.GetApplication(a.ID) + if got.AddressMode != AddressModeList || len(got.Addresses) != 2 { + t.Fatalf("after list switch: %+v", got) + } +} + +func TestUpdateApplicationModeNotFound(t *testing.T) { + st := openTestStore(t) + if err := st.UpdateApplicationMode(999, AddressModeWildcard, nil); !errors.Is(err, ErrApplicationNotFound) { + t.Fatalf("UpdateApplicationMode(missing) = %v, want ErrApplicationNotFound", err) + } +} + +func TestDeleteApplication(t *testing.T) { + st := openTestStore(t) + d := addTestDomain(t, st, "example.com") + a, err := st.AddApplication(d.ID, "app1", AddressModeList, []string{"a@example.com"}) + if err != nil { + t.Fatal(err) + } + + deleted, err := st.DeleteApplication(a.ID) + if err != nil { + t.Fatalf("DeleteApplication: %v", err) + } + if deleted.Login != "app1" { + t.Errorf("deleted login = %q, want app1", deleted.Login) + } + assertCount(t, st, "applications", 0) + assertCount(t, st, "application_addresses", 0) + + if _, err := st.DeleteApplication(a.ID); !errors.Is(err, ErrApplicationNotFound) { + t.Fatalf("second delete = %v, want ErrApplicationNotFound", err) + } +} + +func TestListBindingsMixedModes(t *testing.T) { + st := openTestStore(t) + d1 := addTestDomain(t, st, "example.com") + d2 := addTestDomain(t, st, "other.com") + + if _, err := st.AddApplication(d1.ID, "wild", AddressModeWildcard, nil); err != nil { + t.Fatal(err) + } + if _, err := st.AddApplication(d1.ID, "listed", AddressModeList, + []string{"alerts@example.com", "noreply@example.com"}); err != nil { + t.Fatal(err) + } + if _, err := st.AddApplication(d2.ID, "wild2", AddressModeWildcard, nil); err != nil { + t.Fatal(err) + } + + bindings, err := st.ListBindings() + if err != nil { + t.Fatalf("ListBindings: %v", err) + } + want := []Binding{ + {"@example.com", "wild"}, + {"@other.com", "wild2"}, + {"alerts@example.com", "listed"}, + {"noreply@example.com", "listed"}, + } + if len(bindings) != len(want) { + t.Fatalf("bindings = %+v, want %+v", bindings, want) + } + for i := range want { + if bindings[i] != want[i] { + t.Errorf("binding[%d] = %+v, want %+v", i, bindings[i], want[i]) + } + } +} + +func TestListLoginsByDomain(t *testing.T) { + st := openTestStore(t) + d := addTestDomain(t, st, "example.com") + other := addTestDomain(t, st, "other.com") + if _, err := st.AddApplication(d.ID, "a", AddressModeWildcard, nil); err != nil { + t.Fatal(err) + } + if _, err := st.AddApplication(d.ID, "b", AddressModeWildcard, nil); err != nil { + t.Fatal(err) + } + if _, err := st.AddApplication(other.ID, "c", AddressModeWildcard, nil); err != nil { + t.Fatal(err) + } + + logins, err := st.ListLoginsByDomain(d.ID) + if err != nil { + t.Fatal(err) + } + if len(logins) != 2 || logins[0] != "a" || logins[1] != "b" { + t.Fatalf("logins = %v, want [a b]", logins) + } +} diff --git a/internal/web/handlers_apps.go b/internal/web/handlers_apps.go new file mode 100644 index 0000000..87a0cf8 --- /dev/null +++ b/internal/web/handlers_apps.go @@ -0,0 +1,240 @@ +package web + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "codeberg.org/mix/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. +type newCred struct { + Login string + Password string +} + +// detailView holds the one-shot, request-specific extras layered on top of a +// domain's persistent state when rendering its page: an application-form error, +// the values to repopulate that form, and any just-issued credential to show +// once. +type detailView struct { + FormErr string + FormLogin string + FormMode string + FormAddrs string + NewCred *newCred +} + +// handleDomainDetail shows a single domain: its DKIM DNS record (spec 7.2.10) +// and its applications with the controls to add, edit, delete and re-issue +// credentials (spec 7.2.5-9). +func (s *Server) handleDomainDetail(w http.ResponseWriter, r *http.Request) { + d, ok := s.lookupDomain(w, r) + if !ok { + return + } + s.renderDomainDetail(w, r, http.StatusOK, d, detailView{FormMode: store.AddressModeWildcard}) +} + +// renderDomainDetail renders the domain page. view supplies request-specific +// extras (form error/values, a one-time credential); everything else is loaded +// fresh from the stores so the page always reflects committed state. +func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, status int, d store.Domain, view detailView) { + record, err := s.domains.DKIMRecord(d) + if err != nil { + logf("panel: domain %d: dkim record: %v", d.ID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + apps, err := s.apps.List(d.ID) + if err != nil { + logf("panel: domain %d: list applications: %v", d.ID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + s.render(w, status, "domain_detail", map[string]any{ + "Title": "SelfPost — " + d.Name, + "User": currentUser(r), + "Domain": d, + "Record": record, + "Apps": apps, + "Error": view.FormErr, + "FormLogin": view.FormLogin, + "FormMode": view.FormMode, + "FormAddrs": view.FormAddrs, + "NewCred": view.NewCred, + "Flash": detailFlash(r), + "Wildcard": store.AddressModeWildcard, + "List": store.AddressModeList, + }) +} + +// detailFlash maps a fixed redirect flag to a fixed message, so status text +// after a redirect is never attacker-influenced. +func detailFlash(r *http.Request) string { + switch { + case r.URL.Query().Get("appdeleted") != "": + return "Application deleted." + case r.URL.Query().Get("modeupdated") != "": + return "Application address mode updated." + default: + return "" + } +} + +// 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 +// password cannot be recovered later, this deliberately renders inline rather +// than redirecting. +func (s *Server) handleAddApplication(w http.ResponseWriter, r *http.Request) { + d, ok := s.lookupDomain(w, r) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + s.renderDomainDetail(w, r, http.StatusBadRequest, d, + detailView{FormErr: "Invalid form submission.", FormMode: store.AddressModeWildcard}) + return + } + login := strings.TrimSpace(r.PostFormValue("login")) + mode := r.PostFormValue("mode") + addrs := splitAddresses(r.PostFormValue("addresses")) + + repopulate := detailView{ + FormLogin: login, + FormMode: mode, + FormAddrs: r.PostFormValue("addresses"), + } + + a, password, err := s.apps.Create(d.ID, login, mode, addrs) + if err != nil { + repopulate.FormErr = applicationErrorMessage(err) + status := http.StatusBadRequest + if errors.Is(err, store.ErrLoginExists) { + status = http.StatusConflict + } + s.renderDomainDetail(w, r, status, d, repopulate) + return + } + s.renderDomainDetail(w, r, http.StatusCreated, d, detailView{ + FormMode: store.AddressModeWildcard, + NewCred: &newCred{Login: a.Login, Password: password}, + }) +} + +// handleUpdateAppMode switches an application's address mode / list (spec 7.2.7). +func (s *Server) handleUpdateAppMode(w http.ResponseWriter, r *http.Request) { + a, ok := s.lookupApplication(w, r) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + mode := r.PostFormValue("mode") + addrs := splitAddresses(r.PostFormValue("addresses")) + + if err := s.apps.UpdateMode(a.ID, mode, addrs); err != nil { + d, derr := s.domains.Get(a.DomainID) + if derr != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{ + FormErr: fmt.Sprintf("Could not update %s: %s", a.Login, applicationErrorMessage(err)), + FormMode: store.AddressModeWildcard, + }) + return + } + http.Redirect(w, r, fmt.Sprintf("/domains/%d?modeupdated=1", a.DomainID), http.StatusSeeOther) +} + +// 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. +func (s *Server) handleRegenPassword(w http.ResponseWriter, r *http.Request) { + a, ok := s.lookupApplication(w, r) + if !ok { + return + } + d, err := s.domains.Get(a.DomainID) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + password, err := s.apps.RegeneratePassword(a.ID) + if err != nil { + logf("panel: regenerate password for application %d: %v", a.ID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + s.renderDomainDetail(w, r, http.StatusOK, d, detailView{ + FormMode: store.AddressModeWildcard, + NewCred: &newCred{Login: a.Login, Password: password}, + }) +} + +// handleDeleteApplication removes an application and returns to its domain page +// (spec 7.2.8). +func (s *Server) handleDeleteApplication(w http.ResponseWriter, r *http.Request) { + a, ok := s.lookupApplication(w, r) + if !ok { + return + } + if err := s.apps.Delete(a.ID); err != nil { + logf("panel: delete application %d: %v", a.ID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, fmt.Sprintf("/domains/%d?appdeleted=1", a.DomainID), http.StatusSeeOther) +} + +// lookupApplication resolves the {aid} path value to an application, writing a +// 404 for a bad id or missing application. +func (s *Server) lookupApplication(w http.ResponseWriter, r *http.Request) (store.Application, bool) { + id, err := strconv.ParseInt(r.PathValue("aid"), 10, 64) + if err != nil || id <= 0 { + http.NotFound(w, r) + return store.Application{}, false + } + a, err := s.apps.Get(id) + if err != nil { + if errors.Is(err, store.ErrApplicationNotFound) { + http.NotFound(w, r) + return store.Application{}, false + } + logf("panel: get application %d: %v", id, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return store.Application{}, false + } + return a, true +} + +// 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). +func splitAddresses(s string) []string { + return strings.FieldsFunc(s, func(r rune) bool { + return r == '\n' || r == '\r' || r == ',' || r == ' ' || r == '\t' || r == ';' + }) +} + +// applicationErrorMessage turns a service error into a user-facing message, +// passing through the validation errors (which are safe, fixed strings) and +// masking anything unexpected. +func applicationErrorMessage(err error) string { + switch { + case errors.Is(err, store.ErrLoginExists): + return "That login is already in use. Choose another." + case errors.Is(err, store.ErrDomainNotFound), errors.Is(err, store.ErrApplicationNotFound): + return "The item no longer exists." + default: + // Validation errors from the app service are safe to surface verbatim; + // they describe what the admin must fix (login/address rules). + return err.Error() + } +} diff --git a/internal/web/handlers_domains.go b/internal/web/handlers_domains.go index 1423abb..7da6d3e 100644 --- a/internal/web/handlers_domains.go +++ b/internal/web/handlers_domains.go @@ -78,26 +78,6 @@ func (s *Server) handleAddDomain(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther) } -// handleDomainDetail shows a single domain and its DKIM DNS record (spec 7.2.10). -func (s *Server) handleDomainDetail(w http.ResponseWriter, r *http.Request) { - d, ok := s.lookupDomain(w, r) - if !ok { - return - } - record, err := s.domains.DKIMRecord(d) - if err != nil { - logf("panel: domain %d: dkim record: %v", d.ID, err) - http.Error(w, "internal error", http.StatusInternalServerError) - return - } - s.render(w, http.StatusOK, "domain_detail", map[string]any{ - "Title": "SelfPost — " + d.Name, - "User": currentUser(r), - "Domain": d, - "Record": record, - }) -} - // 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). func (s *Server) handleDeleteConfirm(w http.ResponseWriter, r *http.Request) { @@ -131,11 +111,17 @@ func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/?deleted=1", http.StatusSeeOther) } -// handleReload re-applies the OpenDKIM configuration on demand (spec 7.2.12). -// The Postfix side of the reload button lands in Phase 5. +// 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. func (s *Server) handleReload(w http.ResponseWriter, r *http.Request) { if err := s.domains.Resync(); err != nil { - logf("panel: manual reload: %v", err) + logf("panel: manual reload (opendkim): %v", err) + http.Error(w, "reload failed", http.StatusInternalServerError) + return + } + if err := s.apps.Resync(); err != nil { + logf("panel: manual reload (postfix): %v", err) http.Error(w, "reload failed", http.StatusInternalServerError) return } diff --git a/internal/web/templates/domain_detail.html b/internal/web/templates/domain_detail.html index 07e9db1..16be697 100644 --- a/internal/web/templates/domain_detail.html +++ b/internal/web/templates/domain_detail.html @@ -11,6 +11,20 @@ ← All domains +{{if .Flash}}
{{.Flash}}
{{end}} + +{{if .NewCred}} +
+

New application password

+

This password is shown once only and is not + stored. Copy it now — if it is lost, regenerate a new one.

+ + {{.NewCred.Login}} + + {{.NewCred.Password}} +
+{{end}} +

DKIM DNS record

Publish this TXT record in the DNS for {{.Domain.Name}}. @@ -31,8 +45,81 @@

Applications

-

{{.Domain.AppCount}} application(s) bound to this domain. - Creating and managing applications arrives in the next phase.

+

Each application is a SASL login/password an app or script + uses to send mail as this domain. A login may send from any address of the + domain (wildcard) or only from a fixed list of addresses.

+ + {{if .Apps}} + + + + + + {{range .Apps}} + + + + + + + {{end}} + +
LoginModeAddresses
{{.Login}}{{if eq .AddressMode $.Wildcard}}Any address (@{{$.Domain.Name}}){{else}}List{{end}} + {{if eq .AddressMode $.Wildcard}}*@{{$.Domain.Name}}{{else}} + {{range $i, $a := .Addresses}}{{if $i}}, {{end}}{{$a}}{{end}} + {{end}} + +
+ Edit mode +
+ + + + + +
+
+
+ +
+
+ +
+
+ {{else}} +

No applications yet. Create one below.

+ {{end}} +
+ +
+

Add an application

+
+ + + + + + + + + + {{if .Error}}

{{.Error}}

{{end}} + +
+

A strong password is generated and shown once. The login must + be unique across all domains and may contain letters, digits, '.', '-' and '_'.

diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 25b031b..e5aca4a 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -56,6 +56,24 @@ @media (prefers-color-scheme: dark) { .code { background: #14171a !important; border-color: #2b3138 !important; } } h2 { font-size: 1.05rem; margin: 0 0 0.4rem; } .back { display: inline-block; margin-bottom: 1rem; } + select, textarea { + width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem; + border: 1px solid #cfd4da; border-radius: 6px; background: #fff; 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 { background: #b42318; } + button.danger:hover { background: #912018; } + form.inline button.danger { background: none; color: #b42318; } + form.inline button.danger:hover { background: none; } + td.actions form.inline, td.actions details { margin-left: 0.6rem; } + details summary { cursor: pointer; color: #2563eb; font-weight: 600; } + 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; } } diff --git a/internal/web/web.go b/internal/web/web.go index 1a1d42b..d0390e2 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -10,6 +10,7 @@ import ( "net/http" "time" + "codeberg.org/mix/selfpost/internal/app" "codeberg.org/mix/selfpost/internal/domain" "codeberg.org/mix/selfpost/internal/store" ) @@ -32,6 +33,7 @@ type Config struct { type Server struct { store *store.Store domains *domain.Service + apps *app.Service cfg Config tmpl *templates sessions *sessionStore @@ -43,8 +45,9 @@ 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). -func New(st *store.Store, domains *domain.Service, cfg Config, setupTokenPath string) (*Server, error) { +// owns DKIM keys and the OpenDKIM tables (spec 6); apps owns application SASL +// accounts and the Postfix sender map (spec 5.1). +func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config, setupTokenPath string) (*Server, error) { tmpl, err := loadTemplates() if err != nil { return nil, err @@ -52,6 +55,7 @@ func New(st *store.Store, domains *domain.Service, cfg Config, setupTokenPath st s := &Server{ store: st, domains: domains, + apps: apps, cfg: cfg, tmpl: tmpl, sessions: newSessionStore(), @@ -97,6 +101,10 @@ func (s *Server) Handler() http.Handler { authed.HandleFunc("GET /domains/{id}", s.handleDomainDetail) authed.HandleFunc("GET /domains/{id}/delete", s.handleDeleteConfirm) authed.HandleFunc("POST /domains/{id}/delete", s.handleDeleteDomain) + authed.HandleFunc("POST /domains/{id}/applications", s.handleAddApplication) + authed.HandleFunc("POST /applications/{aid}/mode", s.handleUpdateAppMode) + authed.HandleFunc("POST /applications/{aid}/password", s.handleRegenPassword) + authed.HandleFunc("POST /applications/{aid}/delete", s.handleDeleteApplication) authed.HandleFunc("POST /reload", s.handleReload) mux.Handle("/", s.requireAuth(authed))