Phase 4: applications + SASL (sasldb2) + sender_login_maps
Adds application accounts bound to domains: a SASL login/password in sasldb2, a per-application address mode (wildcard @domain or an explicit list), and matching smtpd_sender_login_maps bindings — with create, list, edit-mode, delete and password regeneration (spec 4.1, 5.1, 7.2.5-9). Generated passwords are shown exactly once and never stored in plaintext (7.6.1). - internal/store/applications.go: transactional CRUD; globally unique login; ListBindings (address->login) as the map source; logins-by- domain for pre-cascade SASL cleanup. - internal/app: saslpasswd2 wrapper (password via stdin, login as a whitelisted argv element, no shell — 7.6.3); strong base64url password; address validation that enforces domain ownership before any config write (7.6.2); service orchestrating store + sasldb2 + map with full rollback on partial failure. - internal/postfix: sender_login_maps regenerated as a pure function of the registry (many-to-one logins merged per address), atomic write, injection backstop (7.6.4). - Postfix reload, corrected: `postfix start-fg` forks a separate master, so signalling the supervised process never reaches it. Reload now runs the canonical `postfix reload` via a one-shot supervisord program the unprivileged panel triggers over the group control socket. Verified in mail.log. - domain.Service.Delete purges the domain's SASL accounts, then cascades, then rebuilds the sender map and reloads; manual reload now covers both OpenDKIM and Postfix. - web: application management in the domain page, one-time credential shown inline; postfix joins the selfpost group and entrypoint normalises /data/sasl and /data/postfix (setgid, group-readable) with self-heal. Verified on the dev server: gofmt/vet/test green, image builds, and a container e2e covers the full application lifecycle, domain-delete cascade, restart persistence, and a real postfix reload. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+9
-7
@@ -58,15 +58,17 @@ RUN postconf -e "maillog_file=/var/log/mail.log"
|
|||||||
# Unprivileged user for the panel process (spec 7.6.8).
|
# Unprivileged user for the panel process (spec 7.6.8).
|
||||||
RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel
|
RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel
|
||||||
|
|
||||||
# Shared group bridging the two unprivileged services (spec 6): the panel
|
# Shared group bridging the unprivileged services (spec 5.1, 6): the panel
|
||||||
# generates per-domain DKIM keys and rewrites the OpenDKIM tables, while
|
# generates per-domain DKIM keys, application SASL accounts (sasldb2) and the
|
||||||
# OpenDKIM (a different user) must read them. Membership in this group — plus
|
# Postfix sender map, while OpenDKIM and Postfix (different users) must read
|
||||||
# setgid dirs under /data/opendkim (set up in entrypoint.sh) — lets OpenDKIM
|
# them. Membership in this group — plus setgid dirs under /data (set up in
|
||||||
# read the panel-owned keys, and lets the panel reach the supervisor control
|
# entrypoint.sh) — lets OpenDKIM read the panel-owned keys and lets Postfix read
|
||||||
# socket to signal an OpenDKIM reload without any process running as root.
|
# 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 \
|
RUN groupadd --system selfpost \
|
||||||
&& usermod -aG selfpost panel \
|
&& 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.
|
# Runtime directories: milter sockets and the consolidated persistent root.
|
||||||
RUN mkdir -p /run/opendkim /run/selfpost /data \
|
RUN mkdir -p /run/opendkim /run/selfpost /data \
|
||||||
|
|||||||
@@ -32,4 +32,23 @@ find /data/opendkim -type d -exec chmod 2750 {} +
|
|||||||
chmod 0640 /data/opendkim/KeyTable /data/opendkim/SigningTable
|
chmod 0640 /data/opendkim/KeyTable /data/opendkim/SigningTable
|
||||||
find /data/opendkim/keys -type f -name '*.private' -exec chmod 0640 {} +
|
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
|
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
|
||||||
|
|||||||
@@ -67,6 +67,27 @@ stdout_logfile_maxbytes=0
|
|||||||
stderr_logfile=/dev/stderr
|
stderr_logfile=/dev/stderr
|
||||||
stderr_logfile_maxbytes=0
|
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]
|
[eventlistener:crashexit]
|
||||||
command=/usr/local/bin/crashexit.py
|
command=/usr/local/bin/crashexit.py
|
||||||
events=PROCESS_STATE_FATAL
|
events=PROCESS_STATE_FATAL
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/mix/selfpost/internal/app"
|
||||||
"codeberg.org/mix/selfpost/internal/domain"
|
"codeberg.org/mix/selfpost/internal/domain"
|
||||||
|
"codeberg.org/mix/selfpost/internal/postfix"
|
||||||
"codeberg.org/mix/selfpost/internal/store"
|
"codeberg.org/mix/selfpost/internal/store"
|
||||||
"codeberg.org/mix/selfpost/internal/web"
|
"codeberg.org/mix/selfpost/internal/web"
|
||||||
)
|
)
|
||||||
@@ -22,9 +24,13 @@ func serveHTTP(ctx context.Context, cfg config) error {
|
|||||||
}
|
}
|
||||||
defer st.Close()
|
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,
|
Hostname: cfg.hostname,
|
||||||
CookieSecure: cfg.cookieSecure,
|
CookieSecure: cfg.cookieSecure,
|
||||||
}, cfg.setupTokenPath)
|
}, cfg.setupTokenPath)
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ type config struct {
|
|||||||
|
|
||||||
opendkimDir string
|
opendkimDir string
|
||||||
dkimSelectorDef string
|
dkimSelectorDef string
|
||||||
|
|
||||||
|
saslDBPath string
|
||||||
|
saslRealm string
|
||||||
|
postfixDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig() config {
|
func loadConfig() config {
|
||||||
@@ -74,9 +78,30 @@ func loadConfig() config {
|
|||||||
// entrypoint.sh prepares (setgid, shared `selfpost` group).
|
// entrypoint.sh prepares (setgid, shared `selfpost` group).
|
||||||
opendkimDir: envDefault("OPENDKIM_DIR", filepath.Join(dataDir, "opendkim")),
|
opendkimDir: envDefault("OPENDKIM_DIR", filepath.Join(dataDir, "opendkim")),
|
||||||
dkimSelectorDef: envDefault("DKIM_SELECTOR_DEFAULT", "selfpost"),
|
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 {
|
func envDefault(key, def string) string {
|
||||||
if v := os.Getenv(key); v != "" {
|
if v := os.Getenv(key); v != "" {
|
||||||
return v
|
return v
|
||||||
|
|||||||
+18
-4
@@ -46,10 +46,23 @@
|
|||||||
|
|
||||||
## Текущее состояние
|
## Текущее состояние
|
||||||
|
|
||||||
- **Текущая фаза:** 3 ✅ закрыта → следующая **Фаза 4** (приложения + SASL + привязка к домену)
|
- **Текущая фаза:** 4 ✅ закрыта → следующая **Фаза 5** (полная конфигурация Postfix — исходящий релей)
|
||||||
- **Модель для Фазы 4:** Opus (риск open relay / привязки отправителя, `sasldb2`, `smtpd_sender_login_maps`)
|
- **Модель для Фазы 5:** Opus (самый чувствительный тракт доставки: SASL/TLS на 465, `reject_sender_login_mismatch`, отсутствие open relay)
|
||||||
- **Статус:** домены + per-domain DKIM реализованы и проверены на сервере (`gofmt`/`go vet`/`go test` зелёные, docker-образ собирается, e2e в контейнере: add/delete домена, генерация ключа, показ TXT, reload OpenDKIM, персистентность ключей через рестарт)
|
- **Статус:** приложения + 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-программу)
|
||||||
- **Следующий шаг (Фаза 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-деревом.
|
- **Следующий шаг (Фаза 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 <db> -u <realm> <login>`, **пароль только через 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.mixfed.ru): `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
|
### Сделано в Фазе 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=<base64 PKIX DER>`) вычисляется из ключа на лету — ключ на диске = единственный источник истины. `opendkim-genkey` **не** используется (никакого exec для keygen).
|
- **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=<base64 PKIX DER>`) вычисляется из ключа на лету — ключ на диске = единственный источник истины. `opendkim-genkey` **не** используется (никакого exec для keygen).
|
||||||
@@ -101,3 +114,4 @@
|
|||||||
- **Фаза 1** (2026-07-11, Opus) — Docker-образ + supervisord + три процесса, холодный старт (обёртка ждёт milter-сокеты) и crashexit проверены на сервере. Коммит `ed9e942`.
|
- **Фаза 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).
|
- **Фаза 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) зелёные. Проверено на сервере.
|
- **Фаза 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`) зелёные.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
-11
@@ -6,21 +6,38 @@ import (
|
|||||||
"codeberg.org/mix/selfpost/internal/store"
|
"codeberg.org/mix/selfpost/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service coordinates the three places a sending domain lives: the SQLite
|
// Applications is the slice of the application service the domain service needs
|
||||||
// registry, the on-disk DKIM keys and OpenDKIM's tables. Callers (the web
|
// to keep the SASL database and sender map consistent when a domain (and its
|
||||||
// handlers) validate user input first; Service keeps the three stores in
|
// applications, via cascade) is deleted. *app.Service satisfies it; it is an
|
||||||
// agreement and drives the OpenDKIM reload (spec 6, 7.2.2-4, 7.2.10).
|
// 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 {
|
type Service struct {
|
||||||
store *store.Store
|
store *store.Store
|
||||||
odk *OpenDKIM
|
odk *OpenDKIM
|
||||||
|
apps Applications
|
||||||
selector string
|
selector string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService builds the domain service. selectorDefault is the DKIM selector
|
// NewService builds the domain service. selectorDefault is the DKIM selector
|
||||||
// assigned to new domains (spec 8: DKIM_SELECTOR_DEFAULT); it is
|
// assigned to new domains (spec 8: DKIM_SELECTOR_DEFAULT); it is
|
||||||
// operator-configured, not user input.
|
// operator-configured, not user input. apps is used only on deletion, to clear
|
||||||
func NewService(st *store.Store, odk *OpenDKIM, selectorDefault string) *Service {
|
// the SASL accounts and sender-map bindings of the domain's applications.
|
||||||
return &Service{store: st, odk: odk, selector: selectorDefault}
|
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).
|
// 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)
|
_ = s.store.DeleteDomain(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete removes a domain and everything bound to it — applications and their
|
// Delete removes a domain and everything bound to it (spec 7.2.4, 6.5). The
|
||||||
// SASL/binding rows go via the DB cascade, and the DKIM key and table entries
|
// order matters: the applications' SASL accounts are cleared first, while their
|
||||||
// are removed here (spec 7.2.4, 6.5). The registry row and tables are updated
|
// logins are still in the registry; then the registry rows (applications and
|
||||||
// (so OpenDKIM stops signing for the domain) before the key is deleted.
|
// 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 {
|
func (s *Service) Delete(id int64) error {
|
||||||
d, err := s.store.GetDomain(id)
|
d, err := s.store.GetDomain(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if err := s.store.DeleteDomain(id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.resync(); err != nil {
|
if err := s.resync(); err != nil {
|
||||||
return err
|
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 {
|
if err := s.odk.RemoveKey(d.Name); err != nil {
|
||||||
// The domain is gone from the registry and tables; a leftover key
|
// The domain is gone from the registry and tables; a leftover key
|
||||||
// directory is harmless. Surface it so it is not silently ignored.
|
// directory is harmless. Surface it so it is not silently ignored.
|
||||||
|
|||||||
@@ -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: <key><whitespace><value>. 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
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
|
// 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).
|
// 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) {
|
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)
|
http.Redirect(w, r, "/?deleted=1", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleReload re-applies the OpenDKIM configuration on demand (spec 7.2.12).
|
// handleReload re-applies both the OpenDKIM configuration and the Postfix
|
||||||
// The Postfix side of the reload button lands in Phase 5.
|
// 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) {
|
func (s *Server) handleReload(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := s.domains.Resync(); err != nil {
|
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)
|
http.Error(w, "reload failed", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,20 @@
|
|||||||
|
|
||||||
<a class="back" href="/">← All domains</a>
|
<a class="back" href="/">← All domains</a>
|
||||||
|
|
||||||
|
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||||
|
|
||||||
|
{{if .NewCred}}
|
||||||
|
<div class="card credential">
|
||||||
|
<h2>New application password</h2>
|
||||||
|
<p class="muted">This password is shown <strong>once only</strong> and is not
|
||||||
|
stored. Copy it now — if it is lost, regenerate a new one.</p>
|
||||||
|
<label>Login</label>
|
||||||
|
<span class="code">{{.NewCred.Login}}</span>
|
||||||
|
<label>Password</label>
|
||||||
|
<span class="code">{{.NewCred.Password}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>DKIM DNS record</h2>
|
<h2>DKIM DNS record</h2>
|
||||||
<p class="muted">Publish this TXT record in the DNS for <strong>{{.Domain.Name}}</strong>.
|
<p class="muted">Publish this TXT record in the DNS for <strong>{{.Domain.Name}}</strong>.
|
||||||
@@ -31,8 +45,81 @@
|
|||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Applications</h2>
|
<h2>Applications</h2>
|
||||||
<p class="muted">{{.Domain.AppCount}} application(s) bound to this domain.
|
<p class="muted">Each application is a SASL login/password an app or script
|
||||||
Creating and managing applications arrives in the next phase.</p>
|
uses to send mail as this domain. A login may send from any address of the
|
||||||
|
domain (<em>wildcard</em>) or only from a fixed list of addresses.</p>
|
||||||
|
|
||||||
|
{{if .Apps}}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Login</th><th>Mode</th><th>Addresses</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Apps}}
|
||||||
|
<tr>
|
||||||
|
<td class="code">{{.Login}}</td>
|
||||||
|
<td>{{if eq .AddressMode $.Wildcard}}Any address (@{{$.Domain.Name}}){{else}}List{{end}}</td>
|
||||||
|
<td class="muted">
|
||||||
|
{{if eq .AddressMode $.Wildcard}}*@{{$.Domain.Name}}{{else}}
|
||||||
|
{{range $i, $a := .Addresses}}{{if $i}}, {{end}}{{$a}}{{end}}
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td class="actions">
|
||||||
|
<details>
|
||||||
|
<summary>Edit mode</summary>
|
||||||
|
<form method="post" action="/applications/{{.ID}}/mode">
|
||||||
|
<label>Address mode</label>
|
||||||
|
<select name="mode">
|
||||||
|
<option value="{{$.Wildcard}}" {{if eq .AddressMode $.Wildcard}}selected{{end}}>Any address of the domain</option>
|
||||||
|
<option value="{{$.List}}" {{if eq .AddressMode $.List}}selected{{end}}>Specific addresses (list)</option>
|
||||||
|
</select>
|
||||||
|
<label>Addresses (for list mode; one per line or comma-separated)</label>
|
||||||
|
<textarea name="addresses" rows="3" placeholder="alerts@{{$.Domain.Name}}">{{range $i, $a := .Addresses}}{{if $i}}
|
||||||
|
{{end}}{{$a}}{{end}}</textarea>
|
||||||
|
<button type="submit">Save mode</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
<form class="inline" method="post" action="/applications/{{.ID}}/password"
|
||||||
|
onsubmit="return confirm('Regenerate the password for {{.Login}}? The current password stops working immediately.')">
|
||||||
|
<button type="submit">New password</button>
|
||||||
|
</form>
|
||||||
|
<form class="inline" method="post" action="/applications/{{.ID}}/delete"
|
||||||
|
onsubmit="return confirm('Delete application {{.Login}}? Its credentials stop working immediately.')">
|
||||||
|
<button type="submit" class="danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}
|
||||||
|
<p class="muted">No applications yet. Create one below.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Add an application</h2>
|
||||||
|
<form method="post" action="/domains/{{.Domain.ID}}/applications">
|
||||||
|
<label for="login">Login</label>
|
||||||
|
<input id="login" name="login" type="text" placeholder="prod-server"
|
||||||
|
autocomplete="off" autocapitalize="none" spellcheck="false"
|
||||||
|
value="{{.FormLogin}}" required>
|
||||||
|
|
||||||
|
<label for="mode">Address mode</label>
|
||||||
|
<select id="mode" name="mode">
|
||||||
|
<option value="{{.Wildcard}}" {{if eq .FormMode .Wildcard}}selected{{end}}>Any address of the domain</option>
|
||||||
|
<option value="{{.List}}" {{if eq .FormMode .List}}selected{{end}}>Specific addresses (list)</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="addresses">Addresses (for list mode; one per line or comma-separated)</label>
|
||||||
|
<textarea id="addresses" name="addresses" rows="3"
|
||||||
|
placeholder="alerts@{{.Domain.Name}}">{{.FormAddrs}}</textarea>
|
||||||
|
|
||||||
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||||
|
<button type="submit">Create application</button>
|
||||||
|
</form>
|
||||||
|
<p class="muted">A strong password is generated and shown once. The login must
|
||||||
|
be unique across all domains and may contain letters, digits, '.', '-' and '_'.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
@@ -56,6 +56,24 @@
|
|||||||
@media (prefers-color-scheme: dark) { .code { background: #14171a !important; border-color: #2b3138 !important; } }
|
@media (prefers-color-scheme: dark) { .code { background: #14171a !important; border-color: #2b3138 !important; } }
|
||||||
h2 { font-size: 1.05rem; margin: 0 0 0.4rem; }
|
h2 { font-size: 1.05rem; margin: 0 0 0.4rem; }
|
||||||
.back { display: inline-block; margin-bottom: 1rem; }
|
.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; } }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+10
-2
@@ -10,6 +10,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/mix/selfpost/internal/app"
|
||||||
"codeberg.org/mix/selfpost/internal/domain"
|
"codeberg.org/mix/selfpost/internal/domain"
|
||||||
"codeberg.org/mix/selfpost/internal/store"
|
"codeberg.org/mix/selfpost/internal/store"
|
||||||
)
|
)
|
||||||
@@ -32,6 +33,7 @@ type Config struct {
|
|||||||
type Server struct {
|
type Server struct {
|
||||||
store *store.Store
|
store *store.Store
|
||||||
domains *domain.Service
|
domains *domain.Service
|
||||||
|
apps *app.Service
|
||||||
cfg Config
|
cfg Config
|
||||||
tmpl *templates
|
tmpl *templates
|
||||||
sessions *sessionStore
|
sessions *sessionStore
|
||||||
@@ -43,8 +45,9 @@ type Server struct {
|
|||||||
|
|
||||||
// New builds the panel server. setupTokenPath is where the current setup token
|
// 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
|
// is mirrored on disk (spec 7.6.1); domains is the sending-domain service that
|
||||||
// owns DKIM keys and the OpenDKIM tables (spec 6).
|
// owns DKIM keys and the OpenDKIM tables (spec 6); apps owns application SASL
|
||||||
func New(st *store.Store, domains *domain.Service, cfg Config, setupTokenPath string) (*Server, error) {
|
// 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()
|
tmpl, err := loadTemplates()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -52,6 +55,7 @@ func New(st *store.Store, domains *domain.Service, cfg Config, setupTokenPath st
|
|||||||
s := &Server{
|
s := &Server{
|
||||||
store: st,
|
store: st,
|
||||||
domains: domains,
|
domains: domains,
|
||||||
|
apps: apps,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
tmpl: tmpl,
|
tmpl: tmpl,
|
||||||
sessions: newSessionStore(),
|
sessions: newSessionStore(),
|
||||||
@@ -97,6 +101,10 @@ func (s *Server) Handler() http.Handler {
|
|||||||
authed.HandleFunc("GET /domains/{id}", s.handleDomainDetail)
|
authed.HandleFunc("GET /domains/{id}", s.handleDomainDetail)
|
||||||
authed.HandleFunc("GET /domains/{id}/delete", s.handleDeleteConfirm)
|
authed.HandleFunc("GET /domains/{id}/delete", s.handleDeleteConfirm)
|
||||||
authed.HandleFunc("POST /domains/{id}/delete", s.handleDeleteDomain)
|
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)
|
authed.HandleFunc("POST /reload", s.handleReload)
|
||||||
mux.Handle("/", s.requireAuth(authed))
|
mux.Handle("/", s.requireAuth(authed))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user