From bf477f4e6a223d996aa9f0740ed29aaa01ce04cc Mon Sep 17 00:00:00 2001 From: Mikhail Yenuchenko Date: Sat, 11 Jul 2026 22:20:35 +0300 Subject: [PATCH] Phase 3: sending domains + per-domain OpenDKIM signing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add/list/delete of sending domains with per-domain DKIM keys and the OpenDKIM tables that drive signing (spec 6, 7.2.2-4, 7.2.10). internal/domain: - Pure-Go RSA-2048 keygen; PKCS#1 PEM written atomically at 0640; the published DNS TXT record is derived from the key on disk (single source of truth) rather than persisted. No os/exec for key generation. - KeyTable/SigningTable fully regenerated from the registry on every add/delete (idempotent), written atomically; SigningTable via refile: with *@domain, KeyTable with absolute key paths. Table writer refuses any unsafe character as a backstop (spec 7.6.4). - Reload without root: the unprivileged panel signals OpenDKIM through supervisord (`supervisorctl signal USR1 opendkim`, fixed args, no shell, no user input — spec 7.6.3). An existing key is reused, never overwritten, so re-adding a domain keeps its published DNS valid. - Service orchestrates registry -> key -> table rebuild -> reload, with rollback of the row if a downstream step fails; delete cascades apps via the DB FK and removes the key + table entries. Infra: - Shared `selfpost` group bridges panel (writes keys) and opendkim (reads them); /data/opendkim is setgid so panel-created files inherit the group, keys are 0640, RequireSafeKeys is disabled by design. - opendkim.conf moves from verify-only (Mode v) to signing (Mode s). - entrypoint.sh normalises the DKIM tree on every start (ownership, setgid, perms, empty tables before opendkim starts) — self-healing after a restore. - supervisord control socket opened to the `selfpost` group so the panel can request the reload. web/store: - Strict domain-name validation (whitelist [a-z0-9.-], DNS shape, >=2 labels), lower-case normalisation (spec 7.6.2). - Domain queries with application counts; delete relies on ON DELETE CASCADE. Dashboard lists domains + add form; domain page shows the DKIM record; a dedicated confirm page warns about the app cascade before deletion (spec 7.2.4); manual reload button (spec 7.2.12, OpenDKIM side; Postfix reload lands in Phase 5). - Authenticated routes moved to a sub-mux using Go 1.22 method/wildcard patterns. Tests: validateDomain, DKIM keygen/record roundtrip, table rendering + injection-safety, key reuse, store cascade. Verified on the dev server: gofmt/vet/test green, image builds, container e2e (add/delete a domain, DKIM record shown, OpenDKIM reads panel keys and reloads, keys and tables persist across a restart). Co-Authored-By: Claude Opus 4.8 --- build/Dockerfile | 10 ++ build/entrypoint.sh | 18 +++ build/opendkim.conf | 28 +++- build/supervisord.conf | 9 +- cmd/panel/httpserver.go | 5 +- cmd/panel/main.go | 8 + docs/progress.md | 17 +- internal/domain/dkim.go | 127 +++++++++++++++ internal/domain/dkim_test.go | 86 ++++++++++ internal/domain/opendkim.go | 184 ++++++++++++++++++++++ internal/domain/opendkim_test.go | 156 ++++++++++++++++++ internal/domain/service.go | 117 ++++++++++++++ internal/store/domains.go | 138 ++++++++++++++++ internal/store/domains_test.go | 117 ++++++++++++++ internal/web/handlers_domains.go | 172 ++++++++++++++++++++ internal/web/middleware.go | 13 -- internal/web/templates.go | 8 +- internal/web/templates/dashboard.html | 46 +++++- internal/web/templates/domain_delete.html | 32 ++++ internal/web/templates/domain_detail.html | 44 ++++++ internal/web/templates/layout.html | 16 ++ internal/web/validate.go | 59 +++++++ internal/web/validate_test.go | 67 ++++++++ internal/web/web.go | 20 ++- 24 files changed, 1461 insertions(+), 36 deletions(-) create mode 100644 internal/domain/dkim.go create mode 100644 internal/domain/dkim_test.go create mode 100644 internal/domain/opendkim.go create mode 100644 internal/domain/opendkim_test.go create mode 100644 internal/domain/service.go create mode 100644 internal/store/domains.go create mode 100644 internal/store/domains_test.go create mode 100644 internal/web/handlers_domains.go create mode 100644 internal/web/templates/domain_delete.html create mode 100644 internal/web/templates/domain_detail.html create mode 100644 internal/web/validate_test.go diff --git a/build/Dockerfile b/build/Dockerfile index 45bd809..052c6d1 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -58,6 +58,16 @@ RUN postconf -e "maillog_file=/var/log/mail.log" # Unprivileged user for the panel process (spec 7.6.8). RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel +# Shared group bridging the two unprivileged services (spec 6): the panel +# generates per-domain DKIM keys and rewrites the OpenDKIM tables, while +# OpenDKIM (a different user) must read them. Membership in this group — plus +# setgid dirs under /data/opendkim (set up in entrypoint.sh) — lets OpenDKIM +# read the panel-owned keys, and lets the panel reach the supervisor control +# socket to signal an OpenDKIM reload without any process running as root. +RUN groupadd --system selfpost \ + && usermod -aG selfpost panel \ + && usermod -aG selfpost opendkim + # Runtime directories: milter sockets and the consolidated persistent root. RUN mkdir -p /run/opendkim /run/selfpost /data \ && chown opendkim:opendkim /run/opendkim \ diff --git a/build/entrypoint.sh b/build/entrypoint.sh index b9d14ee..b8da487 100644 --- a/build/entrypoint.sh +++ b/build/entrypoint.sh @@ -14,4 +14,22 @@ chown panel:panel /data # later phase deliberately hands to another service. find /data -mindepth 1 -maxdepth 1 ! -user panel -exec chown -R panel:panel {} + +# DKIM key tree (spec 6, 9). The panel (user `panel`) generates keys and writes +# the OpenDKIM tables; OpenDKIM (user `opendkim`) must read them. Normalise the +# tree on every start so it is correct whether /data is fresh, restarted, or +# just restored from a backup: +# - group `selfpost` + setgid on directories so anything the panel creates +# inherits the shared group OpenDKIM traverses; +# - private keys and tables group-readable (0640); +# - both table files present (empty is fine) BEFORE OpenDKIM starts, so the +# daemon comes up cleanly with no domains yet. +mkdir -p /data/opendkim/keys +for t in /data/opendkim/KeyTable /data/opendkim/SigningTable; do + [ -e "$t" ] || : > "$t" +done +chown -R panel:selfpost /data/opendkim +find /data/opendkim -type d -exec chmod 2750 {} + +chmod 0640 /data/opendkim/KeyTable /data/opendkim/SigningTable +find /data/opendkim/keys -type f -name '*.private' -exec chmod 0640 {} + + exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf diff --git a/build/opendkim.conf b/build/opendkim.conf index 510e414..4a6898a 100644 --- a/build/opendkim.conf +++ b/build/opendkim.conf @@ -1,13 +1,29 @@ -# OpenDKIM — Phase 1 minimal config. +# OpenDKIM — per-domain signing (spec 6). # -# The goal here is only that OpenDKIM starts and exposes its milter socket so -# the Postfix start wrapper's readiness probe passes. Per-domain signing -# (KeyTable / SigningTable, Mode s) is generated and reloaded by the panel in -# Phase 3; until keys exist we run in verify-only mode so startup needs no keys. +# Signing is strictly per-domain: each sending domain has its own key pair and +# selector. The panel generates keys under /data/opendkim/keys// and +# keeps KeyTable/SigningTable in sync as domains are added or removed, then asks +# supervisord to send this process SIGUSR1 to reload the tables (spec 6.2, 6.5). +# +# Both table files are created (empty) by entrypoint.sh before OpenDKIM starts, +# so the daemon comes up cleanly on a fresh /data with no domains yet and simply +# signs nothing until the first domain is added. Syslog no UMask 007 -Mode v +Mode s + +# SigningTable uses refile: so the "*@example.com" left-hand patterns match any +# local-part for a domain; KeyTable maps each domain to its key and selector. +KeyTable /data/opendkim/KeyTable +SigningTable refile:/data/opendkim/SigningTable + +# The private keys are owned by `panel` and read by `opendkim` through the shared +# `selfpost` group, so they are deliberately group-readable (mode 0640). That is +# safe on this single-tenant, private bind mount, but it trips OpenDKIM's default +# key-safety check, so the check is disabled here by design. +RequireSafeKeys no + Socket local:/run/opendkim/opendkim.sock PidFile /run/opendkim/opendkim.pid UserID opendkim diff --git a/build/supervisord.conf b/build/supervisord.conf index 9389e93..f348d1c 100644 --- a/build/supervisord.conf +++ b/build/supervisord.conf @@ -16,9 +16,16 @@ logfile_maxbytes=0 pidfile=/run/supervisord.pid loglevel=info +; The control socket is reachable by the shared `selfpost` group so the panel +; (running as the unprivileged `panel` user) can ask supervisord to send +; OpenDKIM a reload signal after it rewrites the DKIM tables (spec 6.2). It is +; still unreachable by world; only root and the two SelfPost services are in the +; group. No HTTP auth is configured because access is gated by socket +; permissions, not the network. [unix_http_server] file=/run/supervisor.sock -chmod=0700 +chown=root:selfpost +chmod=0770 [rpcinterface:supervisor] supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface diff --git a/cmd/panel/httpserver.go b/cmd/panel/httpserver.go index 4c6672e..3866205 100644 --- a/cmd/panel/httpserver.go +++ b/cmd/panel/httpserver.go @@ -7,6 +7,7 @@ import ( "net/http" "time" + "codeberg.org/mix/selfpost/internal/domain" "codeberg.org/mix/selfpost/internal/store" "codeberg.org/mix/selfpost/internal/web" ) @@ -21,7 +22,9 @@ func serveHTTP(ctx context.Context, cfg config) error { } defer st.Close() - srvApp, err := web.New(st, web.Config{ + domains := domain.NewService(st, domain.NewOpenDKIM(cfg.opendkimDir), cfg.dkimSelectorDef) + + srvApp, err := web.New(st, domains, web.Config{ Hostname: cfg.hostname, CookieSecure: cfg.cookieSecure, }, cfg.setupTokenPath) diff --git a/cmd/panel/main.go b/cmd/panel/main.go index d73a89a..a3495e2 100644 --- a/cmd/panel/main.go +++ b/cmd/panel/main.go @@ -50,6 +50,9 @@ type config struct { setupTokenPath string hostname string cookieSecure bool + + opendkimDir string + dkimSelectorDef string } func loadConfig() config { @@ -66,6 +69,11 @@ func loadConfig() config { // Secure cookies by default (spec 7.6.6); PANEL_COOKIE_SECURE=false is a // development-only escape hatch for testing over plain HTTP. cookieSecure: envDefault("PANEL_COOKIE_SECURE", "true") != "false", + + // Per-domain DKIM state (spec 6). The directory layout matches what + // entrypoint.sh prepares (setgid, shared `selfpost` group). + opendkimDir: envDefault("OPENDKIM_DIR", filepath.Join(dataDir, "opendkim")), + dkimSelectorDef: envDefault("DKIM_SELECTOR_DEFAULT", "selfpost"), } } diff --git a/docs/progress.md b/docs/progress.md index 0f06169..92426b9 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -46,10 +46,18 @@ ## Текущее состояние -- **Текущая фаза:** 2 ✅ закрыта → следующая **Фаза 3** (домены + OpenDKIM) -- **Модель для Фазы 3:** Opus (генерация конфигов + exec-safety 7.6.3–4, валидация имени домена 7.6.2) -- **Статус:** SQLite-персистентность, setup secret-link и вход админа реализованы и проверены на сервере (`go vet`/`build`/`test` зелёные, docker-образ собирается, контейнер поднимает три процесса и создаёт БД под `panel`) -- **Следующий шаг (Фаза 3):** добавить/список/удалить домен (при удалении — предупреждение о каскаде приложений, ТЗ 7.2.4); генерация DKIM-ключа + селектор per-domain (дефолт из `DKIM_SELECTOR_DEFAULT`), ключи в `/data/...` переживают рестарт (ТЗ 6, 9); `KeyTable`/`SigningTable` + reload OpenDKIM (сейчас `opendkim.conf` в Mode `v` без ключей — перевести в `s` + KeyTable); показ DKIM TXT-записи per-domain (ТЗ 7.2.10). **Безопасность:** строгая валидация имени домена (whitelist, 7.6.2), безопасная запись конфигов с экранированием (7.6.4), `os/exec` без shell и без интерполяции ввода (7.6.3). Таблицы `domains`/`applications`/`application_addresses` уже в схеме (миграция 0001). +- **Текущая фаза:** 3 ✅ закрыта → следующая **Фаза 4** (приложения + SASL + привязка к домену) +- **Модель для Фазы 4:** Opus (риск open relay / привязки отправителя, `sasldb2`, `smtpd_sender_login_maps`) +- **Статус:** домены + per-domain DKIM реализованы и проверены на сервере (`gofmt`/`go vet`/`go test` зелёные, docker-образ собирается, e2e в контейнере: add/delete домена, генерация ключа, показ TXT, reload OpenDKIM, персистентность ключей через рестарт) +- **Следующий шаг (Фаза 4):** создание приложения в карточке домена — SASL-пара логин/пароль в `sasldb2` (эквивалент `saslpasswd2`), сильный пароль, показ **один раз** (ТЗ 7.6.1); режим адресов «wildcard `@domain`» либо «список» с серверной валидацией принадлежности адреса домену (ТЗ 7.6.2); `smtpd_sender_login_maps` (many-to-one) соответственно режиму + `postfix reload`; список/редактирование режима/удаление приложения/перевыпуск пароля (ТЗ 7.2.5–9). **Важно для Фазы 4/5:** у панели уже есть безопасный privileged-reload через supervisord (см. журнал Фазы 3) — тот же паттерн (`supervisorctl signal`) переиспользовать для `postfix reload`/HUP; `sasldb2` должен переживать рестарт (bind-mount `/data`, ТЗ 9) и быть доступен Postfix/SASL — решить владельца/права по аналогии с DKIM-деревом. + +### Сделано в Фазе 3 +- **Per-domain DKIM в чистом Go** (`internal/domain/dkim.go`): RSA-2048 через `crypto/rsa`, приватный ключ PKCS#1 PEM пишется атомарно (temp+rename, `writeFileAtomic`) с mode 0640; DNS TXT-запись (`v=DKIM1; h=sha256; k=rsa; p=`) вычисляется из ключа на лету — ключ на диске = единственный источник истины. `opendkim-genkey` **не** используется (никакого exec для keygen). +- **OpenDKIM KeyTable/SigningTable** (`internal/domain/opendkim.go`): полная регенерация обеих таблиц из реестра при каждом add/delete (идемпотентно), атомарная запись; `KeyTable` — абсолютный путь к ключу (OpenDKIM резолвит относительные от CWD — проверено), `SigningTable` через `refile:` с шаблоном `*@domain`. `assertConfigSafe` (backstop 7.6.4) отклоняет пробелы/переводы строк/`:`/`/` в имени/селекторе перед записью. +- **Reload OpenDKIM без root** (ключевое инфра-решение): панель (uid 999, `panel`) **не может** сигналить процесс opendkim (uid 100) напрямую. Reload = `supervisorctl signal USR1 opendkim` (exec, фикс-аргументы, без shell/ввода — 7.6.3); контрол-сокет supervisord открыт группе (`chown=root:selfpost chmod=0770`), `panel` в группе `selfpost`. `ExecReload` opendkim = `kill -USR1` (SIGUSR1 перечитывает таблицы) — подтверждено. +- **Межпользовательский доступ к ключам:** общая группа `selfpost` (в неё добавлены `panel` и `opendkim`); дерево `/data/opendkim` с setgid (mode 2750) → файлы, созданные панелью, наследуют группу `selfpost`; ключи 0640 → opendkim читает по группе. `RequireSafeKeys no` (ключи group-readable — безопасно на приватном single-tenant bind-mount). `entrypoint.sh` нормализует дерево на каждом старте (group/​setgid/​пермишены + пустые KeyTable/SigningTable до старта opendkim), самолечение после restore. `opendkim.conf` переведён Mode `v`→`s`. +- **Сервис/веб** (`internal/domain/service.go`, `internal/store/domains.go`, `internal/web/handlers_domains.go`): Add (реестр→ключ→rebuild→reload, откат строки при сбое; существующий ключ переиспользуется, не перегенерируется — иначе сломается опубликованный DNS), Delete (каскад приложений через FK `ON DELETE CASCADE` + удаление ключа + rebuild+reload), список на дашборде со счётчиком приложений, карточка домена с TXT-записью (7.2.10), отдельная страница подтверждения удаления с предупреждением о каскаде (7.2.4), ручной Reload (7.2.12, пока только OpenDKIM — Postfix в Фазе 5). Валидация имени домена — строгий whitelist `[a-z0-9.-]`, DNS-форма, ≥2 меток (7.6.2), нормализация в lower-case. Роутинг переведён на authenticated под-mux с method+wildcard-паттернами Go 1.22. +- **Проверено на сервере** (selfpost.mixfed.ru): `gofmt`/`go vet`/`go test` (юниты: validateDomain, DKIM keygen/record roundtrip, renderTables + injection-safety, EnsureKey reuse, store cascade) зелёные; `docker build` ок; контейнерный e2e (curl+сессия): три процесса живы, add `Example.COM`→нормализация→ключ+таблицы+TXT, opendkim читает ключ панели и остаётся RUNNING после reload, **рестарт → ключ и таблицы персистентны (хэш совпал)**, delete → каскад, удаление ключа, пустые таблицы, opendkim reload; лог панели без ошибок. ### Сделано в Фазе 2 - **SQLite-персистентность** (`internal/store`): драйвер `modernc.org/sqlite` (чистый Go, без cgo — статик-бинарник сохранён; первые сторонние зависимости → появились `go.mod` require + `go.sum`), WAL + `foreign_keys(ON)` + `busy_timeout` через DSN `_pragma`, `MaxOpenConns(1)`. Встроенные (`embed`) нумерованные миграции с версионированием через `PRAGMA user_version`; миграция `0001_init.sql` заводит всю схему ТЗ 9: `admin` (одна строка, `CHECK id=1`), `settings`, `domains`, `applications`, `application_addresses`, `send_log` (+индексы), `rate_limits`. Запросы `AdminExists/CreateAdmin/GetAdmin`. @@ -92,3 +100,4 @@ - **Фаза 0** (2026-07-11, Opus) — каркас проекта + build-пайплайн + спайк go-milter (риск ТЗ 7.3 снят). Коммиты `4e589e1` (каркас), `87388b4` (план). - **Фаза 1** (2026-07-11, Opus) — Docker-образ + supervisord + три процесса, холодный старт (обёртка ждёт milter-сокеты) и crashexit проверены на сервере. Коммит `ed9e942`. - **Фаза 2** (2026-07-11, Opus) — SQLite (`modernc.org/sqlite`, миграции, схема ТЗ 9), setup secret-link (128-бит токен, TTL 10м, const-time, одноразово), bcrypt-админ, логин/сессии/cookie-флаги, rate-limit setup+логина, html/template + вендоренный HTMX, auth-middleware; entrypoint чинит владельца bind-mount `/data`. Проверено на сервере (e2e curl + docker run). +- **Фаза 3** (2026-07-11, Opus) — домены + per-domain DKIM: keygen в чистом Go (RSA-2048, PKCS#1 PEM, TXT из ключа), OpenDKIM KeyTable/SigningTable (Mode `s`, `refile:`) с полной регенерацией, reload через `supervisorctl signal USR1 opendkim` (сокет открыт группе `selfpost`, панель без root — 7.6.3/7.6.8), межпользовательский доступ к ключам через общую группу `selfpost`+setgid+`RequireSafeKeys no`, add/delete/список/TXT/подтверждение каскада/ручной reload, строгая валидация имени домена (7.6.2), injection-safe запись таблиц (7.6.4). Юнит-тесты + контейнерный e2e (add/delete, персистентность ключей через рестарт, reload) зелёные. Проверено на сервере. diff --git a/internal/domain/dkim.go b/internal/domain/dkim.go new file mode 100644 index 0000000..40fe459 --- /dev/null +++ b/internal/domain/dkim.go @@ -0,0 +1,127 @@ +// Package domain owns SelfPost's sending-domain model: per-domain DKIM key +// generation, the OpenDKIM KeyTable/SigningTable that drive signing, and the +// orchestration that keeps the SQLite registry, the on-disk keys and OpenDKIM +// in agreement (spec 4.1, 6). Key material lives under /data so it survives +// container restarts (spec 6.1, 9). +package domain + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "os" + "path/filepath" +) + +// dkimKeyBits is the RSA key size for DKIM signing keys. 2048 is the DKIM +// interoperability sweet spot: strong, and short enough that the published +// public key still fits comfortably in a DNS TXT record. +const dkimKeyBits = 2048 + +// generateDKIMKey creates a fresh RSA private key for signing a domain. +func generateDKIMKey() (*rsa.PrivateKey, error) { + key, err := rsa.GenerateKey(rand.Reader, dkimKeyBits) + if err != nil { + return nil, fmt.Errorf("generate dkim key: %w", err) + } + return key, nil +} + +// writePrivateKeyPEM writes key to path as a PKCS#1 "RSA PRIVATE KEY" PEM, +// atomically and group-readable (0640). The file is owned by the panel user and +// read by OpenDKIM through the shared `selfpost` group (see build/opendkim.conf +// and entrypoint.sh); the parent directory carries setgid so the group is +// inherited. The write is atomic (temp file + rename) so OpenDKIM never observes +// a half-written key. +func writePrivateKeyPEM(path string, key *rsa.PrivateKey) error { + block := &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + } + return writeFileAtomic(path, pem.EncodeToMemory(block), 0o640) +} + +// loadPrivateKeyPEM reads and parses a PKCS#1 RSA private key written by +// writePrivateKeyPEM. It is used to recompute the public DNS record on demand, +// keeping the private key file the single source of truth (spec 7.2.10). +func loadPrivateKeyPEM(path string) (*rsa.PrivateKey, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + block, _ := pem.Decode(data) + if block == nil || block.Type != "RSA PRIVATE KEY" { + return nil, fmt.Errorf("dkim key %s: not a PKCS#1 RSA private key", path) + } + key, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse dkim key %s: %w", path, err) + } + return key, nil +} + +// DKIMRecord is the DNS TXT record a user must publish for a domain (spec 7.2.10). +type DKIMRecord struct { + // Name is the record's host, e.g. "selfpost._domainkey.example.com". + Name string + // Value is the TXT payload, e.g. "v=DKIM1; h=sha256; k=rsa; p=MIIB...". + Value string +} + +// dkimRecord builds the published DKIM DNS record for a public key. The value +// mirrors what opendkim-genkey emits: v=DKIM1, sha256, RSA, and the public key +// as base64-encoded SubjectPublicKeyInfo (PKIX) DER. +func dkimRecord(selector, domainName string, pub *rsa.PublicKey) (DKIMRecord, error) { + der, err := x509.MarshalPKIXPublicKey(pub) + if err != nil { + return DKIMRecord{}, fmt.Errorf("marshal dkim public key: %w", err) + } + p := base64.StdEncoding.EncodeToString(der) + return DKIMRecord{ + Name: fmt.Sprintf("%s._domainkey.%s", selector, domainName), + Value: fmt.Sprintf("v=DKIM1; h=sha256; k=rsa; p=%s", p), + }, nil +} + +// writeFileAtomic writes data to path via a temp file in the same directory +// followed by a rename, so readers only ever see the complete old or new file. +// It is the single safe-write primitive for DKIM keys and OpenDKIM tables +// (spec 7.6.4). +func writeFileAtomic(path string, data []byte, perm os.FileMode) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".tmp-*") + if err != nil { + return fmt.Errorf("create temp in %s: %w", dir, err) + } + tmpName := tmp.Name() + cleanup := true + defer func() { + if cleanup { + _ = os.Remove(tmpName) + } + }() + + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return fmt.Errorf("write temp %s: %w", tmpName, err) + } + if err := tmp.Chmod(perm); err != nil { + tmp.Close() + return fmt.Errorf("chmod temp %s: %w", tmpName, err) + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return fmt.Errorf("sync temp %s: %w", tmpName, err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp %s: %w", tmpName, err) + } + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("rename %s -> %s: %w", tmpName, path, err) + } + cleanup = false + return nil +} diff --git a/internal/domain/dkim_test.go b/internal/domain/dkim_test.go new file mode 100644 index 0000000..5db75b1 --- /dev/null +++ b/internal/domain/dkim_test.go @@ -0,0 +1,86 @@ +package domain + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWriteLoadPrivateKeyRoundtrip(t *testing.T) { + key, err := generateDKIMKey() + if err != nil { + t.Fatalf("generateDKIMKey: %v", err) + } + path := filepath.Join(t.TempDir(), "selfpost.private") + if err := writePrivateKeyPEM(path, key); err != nil { + t.Fatalf("writePrivateKeyPEM: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o640 { + t.Errorf("key perm = %o, want 0640", perm) + } + + loaded, err := loadPrivateKeyPEM(path) + if err != nil { + t.Fatalf("loadPrivateKeyPEM: %v", err) + } + if loaded.N.Cmp(key.N) != 0 || loaded.E != key.E { + t.Error("loaded key does not match generated key") + } +} + +func TestLoadPrivateKeyRejectsGarbage(t *testing.T) { + path := filepath.Join(t.TempDir(), "bad.private") + if err := os.WriteFile(path, []byte("not a pem"), 0o640); err != nil { + t.Fatal(err) + } + if _, err := loadPrivateKeyPEM(path); err == nil { + t.Error("expected error for non-PEM key file") + } +} + +func TestDKIMRecord(t *testing.T) { + key, err := generateDKIMKey() + if err != nil { + t.Fatal(err) + } + rec, err := dkimRecord("selfpost", "example.com", &key.PublicKey) + if err != nil { + t.Fatalf("dkimRecord: %v", err) + } + if rec.Name != "selfpost._domainkey.example.com" { + t.Errorf("record name = %q", rec.Name) + } + for _, want := range []string{"v=DKIM1", "h=sha256", "k=rsa", "p="} { + if !strings.Contains(rec.Value, want) { + t.Errorf("record value %q missing %q", rec.Value, want) + } + } +} + +func TestWriteFileAtomicOverwrite(t *testing.T) { + path := filepath.Join(t.TempDir(), "f") + if err := writeFileAtomic(path, []byte("one"), 0o640); err != nil { + t.Fatal(err) + } + if err := writeFileAtomic(path, []byte("two"), 0o640); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != "two" { + t.Errorf("content = %q, want %q", got, "two") + } + // No stray temp files left behind in the directory. + entries, _ := os.ReadDir(filepath.Dir(path)) + if len(entries) != 1 { + t.Errorf("expected 1 file after atomic writes, found %d", len(entries)) + } +} diff --git a/internal/domain/opendkim.go b/internal/domain/opendkim.go new file mode 100644 index 0000000..a964814 --- /dev/null +++ b/internal/domain/opendkim.go @@ -0,0 +1,184 @@ +package domain + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +// OpenDKIM manages the on-disk OpenDKIM state the panel is responsible for +// (spec 6): per-domain signing keys under keysDir and the KeyTable/SigningTable +// that map domains to those keys. After rewriting the tables it asks OpenDKIM to +// reload them. +type OpenDKIM struct { + keysDir string + keyTablePath string + signingTablePath string + + // reload sends the running OpenDKIM a reload signal. It is a field so tests + // can substitute a no-op; the default drives supervisord (see reloadViaSupervisor). + reload func() error +} + +// NewOpenDKIM builds a manager rooted at dir (typically /data/opendkim), the +// same layout entrypoint.sh prepares. The default reload path signals OpenDKIM +// through supervisord. +func NewOpenDKIM(dir string) *OpenDKIM { + return &OpenDKIM{ + keysDir: filepath.Join(dir, "keys"), + keyTablePath: filepath.Join(dir, "KeyTable"), + signingTablePath: filepath.Join(dir, "SigningTable"), + reload: reloadViaSupervisor, + } +} + +// SigningDomain is one row's worth of signing configuration. +type SigningDomain struct { + Name string + Selector string +} + +// keyPath is the private-key path for a domain/selector, matching the KeyTable. +func (o *OpenDKIM) keyPath(domainName, selector string) string { + return filepath.Join(o.keysDir, domainName, selector+".private") +} + +// EnsureKey makes sure a signing key exists for the domain. An existing key is +// reused untouched — critical because overwriting it would silently invalidate +// the DKIM record already published in DNS (spec 6.1). Returns whether a new key +// was generated. +func (o *OpenDKIM) EnsureKey(domainName, selector string) (bool, error) { + if err := assertConfigSafe(domainName, selector); err != nil { + return false, err + } + path := o.keyPath(domainName, selector) + if _, err := os.Stat(path); err == nil { + return false, nil // reuse existing key + } else if !os.IsNotExist(err) { + return false, fmt.Errorf("stat dkim key: %w", err) + } + // setgid on keysDir (entrypoint.sh) makes the per-domain dir inherit the + // shared `selfpost` group so OpenDKIM can traverse into it. + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return false, fmt.Errorf("create key dir: %w", err) + } + key, err := generateDKIMKey() + if err != nil { + return false, err + } + if err := writePrivateKeyPEM(path, key); err != nil { + return false, err + } + return true, nil +} + +// RemoveKey deletes a domain's key directory (spec 6.5). A missing directory is +// not an error. +func (o *OpenDKIM) RemoveKey(domainName string) error { + if err := assertConfigSafe(domainName, "x"); err != nil { + return err + } + if err := os.RemoveAll(filepath.Join(o.keysDir, domainName)); err != nil { + return fmt.Errorf("remove key dir for %s: %w", domainName, err) + } + return nil +} + +// Record returns the published DKIM DNS record for a domain, recomputed from the +// private key on disk (spec 7.2.10). +func (o *OpenDKIM) Record(domainName, selector string) (DKIMRecord, error) { + key, err := loadPrivateKeyPEM(o.keyPath(domainName, selector)) + if err != nil { + return DKIMRecord{}, err + } + return dkimRecord(selector, domainName, &key.PublicKey) +} + +// Rebuild regenerates KeyTable and SigningTable from the full domain set and +// reloads OpenDKIM (spec 6.2). Full regeneration (rather than incremental +// edits) keeps the files a pure function of the registry, so add and delete +// share one idempotent path. Both files are written atomically before the +// reload signal is sent. +func (o *OpenDKIM) Rebuild(domains []SigningDomain) error { + keyTable, signingTable, err := renderTables(o.keysDir, domains) + if err != nil { + return err + } + if err := writeFileAtomic(o.keyTablePath, keyTable, 0o640); err != nil { + return err + } + if err := writeFileAtomic(o.signingTablePath, signingTable, 0o640); err != nil { + return err + } + return o.reload() +} + +// Reload asks OpenDKIM to re-read its tables without regenerating them. It backs +// the panel's manual reload button (spec 7.2.12). +func (o *OpenDKIM) Reload() error { + return o.reload() +} + +// renderTables builds the KeyTable and SigningTable byte contents for a domain +// set, sorted by name so the output is deterministic. Every domain is +// re-checked for shell/config-injection safety before being written (spec +// 7.6.4) — validation upstream already guarantees this, but the table writer +// refuses to emit anything unsafe as a hard backstop. +func renderTables(keysDir string, domains []SigningDomain) (keyTable, signingTable []byte, err error) { + sorted := append([]SigningDomain(nil), domains...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name }) + + var kt, st strings.Builder + for _, d := range sorted { + if err := assertConfigSafe(d.Name, d.Selector); err != nil { + return nil, nil, err + } + keyName := d.Name // one key per domain; the domain name is a fine handle + // Absolute key path so OpenDKIM resolves it independently of its CWD. + keyFile := filepath.Join(keysDir, d.Name, d.Selector+".private") + // KeyTable: :: + fmt.Fprintf(&kt, "%s %s:%s:%s\n", keyName, d.Name, d.Selector, keyFile) + // SigningTable (refile): + fmt.Fprintf(&st, "*@%s %s\n", d.Name, keyName) + } + return []byte(kt.String()), []byte(st.String()), nil +} + +// assertConfigSafe rejects any domain/selector value that could break out of a +// single table line. Domains are already whitelisted to [a-z0-9.-] and selectors +// to a similar set before they reach here (spec 7.6.2); this is defence in depth +// against a validation gap ever letting whitespace, a newline or a field +// separator through into a config file (spec 7.6.4). +func assertConfigSafe(domainName, selector string) error { + for _, v := range []string{domainName, selector} { + if v == "" { + return fmt.Errorf("opendkim: empty domain or selector") + } + if strings.ContainsAny(v, " \t\r\n:/\\") { + return fmt.Errorf("opendkim: unsafe character in %q", v) + } + } + return nil +} + +// reloadViaSupervisor asks supervisord (PID 1, running as root) to send the +// OpenDKIM process SIGUSR1, which makes it re-read KeyTable/SigningTable +// (opendkim's documented reload signal). The panel runs unprivileged and cannot +// signal another user's process directly, so it goes through the supervisor +// control socket, reachable via the shared `selfpost` group (spec 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", + "signal", "USR1", "opendkim") + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("reload opendkim via supervisor: %w: %s", err, strings.TrimSpace(string(out))) + } + return nil +} diff --git a/internal/domain/opendkim_test.go b/internal/domain/opendkim_test.go new file mode 100644 index 0000000..6a4f4f0 --- /dev/null +++ b/internal/domain/opendkim_test.go @@ -0,0 +1,156 @@ +package domain + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRenderTables(t *testing.T) { + keysDir := "/data/opendkim/keys" + // Deliberately out of order to exercise the deterministic sort. + domains := []SigningDomain{ + {Name: "zeta.example", Selector: "selfpost"}, + {Name: "alpha.example", Selector: "sel2"}, + } + kt, st, err := renderTables(keysDir, domains) + if err != nil { + t.Fatalf("renderTables: %v", err) + } + + wantKT := "alpha.example alpha.example:sel2:/data/opendkim/keys/alpha.example/sel2.private\n" + + "zeta.example zeta.example:selfpost:/data/opendkim/keys/zeta.example/selfpost.private\n" + if string(kt) != wantKT { + t.Errorf("KeyTable =\n%q\nwant\n%q", kt, wantKT) + } + + wantST := "*@alpha.example alpha.example\n*@zeta.example zeta.example\n" + if string(st) != wantST { + t.Errorf("SigningTable =\n%q\nwant\n%q", st, wantST) + } +} + +func TestRenderTablesEmpty(t *testing.T) { + kt, st, err := renderTables("/keys", nil) + if err != nil { + t.Fatalf("renderTables(nil): %v", err) + } + if len(kt) != 0 || len(st) != 0 { + t.Errorf("expected empty tables, got kt=%q st=%q", kt, st) + } +} + +func TestAssertConfigSafeRejectsInjection(t *testing.T) { + bad := []struct{ name, sel string }{ + {"exa mple.com", "selfpost"}, + {"example.com\nInject yes", "selfpost"}, + {"example.com", "sel:evil"}, + {"../etc", "selfpost"}, + {"", "selfpost"}, + {"example.com", ""}, + } + for _, b := range bad { + if err := assertConfigSafe(b.name, b.sel); err == nil { + t.Errorf("assertConfigSafe(%q,%q) = nil, want error", b.name, b.sel) + } + } + if err := assertConfigSafe("example.com", "selfpost"); err != nil { + t.Errorf("assertConfigSafe of a clean pair errored: %v", err) + } +} + +// newTestOpenDKIM builds a manager rooted at a temp dir with reload stubbed out. +func newTestOpenDKIM(t *testing.T) (*OpenDKIM, *int) { + t.Helper() + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "keys"), 0o750); err != nil { + t.Fatal(err) + } + reloads := 0 + o := NewOpenDKIM(dir) + o.reload = func() error { reloads++; return nil } + return o, &reloads +} + +func TestEnsureKeyReusesExisting(t *testing.T) { + o, _ := newTestOpenDKIM(t) + + created, err := o.EnsureKey("example.com", "selfpost") + if err != nil { + t.Fatalf("EnsureKey: %v", err) + } + if !created { + t.Fatal("expected a new key to be created") + } + first, err := os.ReadFile(o.keyPath("example.com", "selfpost")) + if err != nil { + t.Fatal(err) + } + + created, err = o.EnsureKey("example.com", "selfpost") + if err != nil { + t.Fatalf("EnsureKey (second): %v", err) + } + if created { + t.Error("expected existing key to be reused, not regenerated") + } + second, _ := os.ReadFile(o.keyPath("example.com", "selfpost")) + if string(first) != string(second) { + t.Error("key file changed on reuse — published DNS record would break") + } +} + +func TestRebuildWritesTablesAndReloads(t *testing.T) { + o, reloads := newTestOpenDKIM(t) + if _, err := o.EnsureKey("example.com", "selfpost"); err != nil { + t.Fatal(err) + } + if err := o.Rebuild([]SigningDomain{{Name: "example.com", Selector: "selfpost"}}); err != nil { + t.Fatalf("Rebuild: %v", err) + } + if *reloads != 1 { + t.Errorf("reload called %d times, want 1", *reloads) + } + kt, err := os.ReadFile(o.keyTablePath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(kt), "example.com:selfpost:") { + t.Errorf("KeyTable missing entry: %q", kt) + } +} + +func TestRemoveKey(t *testing.T) { + o, _ := newTestOpenDKIM(t) + if _, err := o.EnsureKey("example.com", "selfpost"); err != nil { + t.Fatal(err) + } + if err := o.RemoveKey("example.com"); err != nil { + t.Fatalf("RemoveKey: %v", err) + } + if _, err := os.Stat(filepath.Join(o.keysDir, "example.com")); !os.IsNotExist(err) { + t.Error("key directory still present after RemoveKey") + } + // Removing a non-existent key is not an error. + if err := o.RemoveKey("example.com"); err != nil { + t.Errorf("RemoveKey on missing dir errored: %v", err) + } +} + +func TestRecordFromWrittenKey(t *testing.T) { + o, _ := newTestOpenDKIM(t) + if _, err := o.EnsureKey("example.com", "selfpost"); err != nil { + t.Fatal(err) + } + rec, err := o.Record("example.com", "selfpost") + if err != nil { + t.Fatalf("Record: %v", err) + } + if rec.Name != "selfpost._domainkey.example.com" { + t.Errorf("record name = %q", rec.Name) + } + if !strings.HasPrefix(rec.Value, "v=DKIM1;") { + t.Errorf("record value = %q", rec.Value) + } +} diff --git a/internal/domain/service.go b/internal/domain/service.go new file mode 100644 index 0000000..65fa723 --- /dev/null +++ b/internal/domain/service.go @@ -0,0 +1,117 @@ +package domain + +import ( + "fmt" + + "codeberg.org/mix/selfpost/internal/store" +) + +// Service coordinates the three places a sending domain lives: the SQLite +// registry, the on-disk DKIM keys and OpenDKIM's tables. Callers (the web +// handlers) validate user input first; Service keeps the three stores in +// agreement and drives the OpenDKIM reload (spec 6, 7.2.2-4, 7.2.10). +type Service struct { + store *store.Store + odk *OpenDKIM + selector string +} + +// NewService builds the domain service. selectorDefault is the DKIM selector +// assigned to new domains (spec 8: DKIM_SELECTOR_DEFAULT); it is +// operator-configured, not user input. +func NewService(st *store.Store, odk *OpenDKIM, selectorDefault string) *Service { + return &Service{store: st, odk: odk, selector: selectorDefault} +} + +// List returns all domains with application counts (spec 7.2.2). +func (s *Service) List() ([]store.Domain, error) { + return s.store.ListDomains() +} + +// Get returns one domain by id (store.ErrDomainNotFound if absent). +func (s *Service) Get(id int64) (store.Domain, error) { + return s.store.GetDomain(id) +} + +// Add registers a new sending domain: it records the row, ensures a DKIM key +// exists on disk, and regenerates + reloads the OpenDKIM tables (spec 7.2.3). +// name must already be normalised and validated by the caller. A duplicate +// returns store.ErrDomainExists. +// +// The registry row is written first so its UNIQUE constraint is the single +// arbiter of "already exists" (avoiding a check-then-act race). An existing +// on-disk key is reused rather than overwritten, so re-adding a domain whose DB +// row was lost keeps its published DNS record valid. If key generation or the +// OpenDKIM rebuild fails, the row is rolled back so we never leave a registered +// domain that OpenDKIM cannot sign. +func (s *Service) Add(name string) (store.Domain, error) { + d, err := s.store.AddDomain(name, s.selector) + if err != nil { + return store.Domain{}, err + } + + if _, err := s.odk.EnsureKey(d.Name, d.DKIMSelector); err != nil { + s.rollbackAdd(d.ID) + return store.Domain{}, err + } + if err := s.resync(); err != nil { + s.rollbackAdd(d.ID) + return store.Domain{}, err + } + return d, nil +} + +// rollbackAdd best-effort removes a half-created domain after a downstream +// failure. Errors here are logged by the caller's returned error path; the key +// (if freshly generated) is left in place harmlessly and reused on retry. +func (s *Service) rollbackAdd(id int64) { + _ = s.store.DeleteDomain(id) +} + +// Delete removes a domain and everything bound to it — applications and their +// SASL/binding rows go via the DB cascade, and the DKIM key and table entries +// are removed here (spec 7.2.4, 6.5). The registry row and tables are updated +// (so OpenDKIM stops signing for the domain) before the key is deleted. +func (s *Service) Delete(id int64) error { + d, err := s.store.GetDomain(id) + if err != nil { + return err + } + if err := s.store.DeleteDomain(id); err != nil { + return err + } + if err := s.resync(); err != nil { + return err + } + if err := s.odk.RemoveKey(d.Name); err != nil { + // The domain is gone from the registry and tables; a leftover key + // directory is harmless. Surface it so it is not silently ignored. + return fmt.Errorf("domain deleted but key cleanup failed: %w", err) + } + return nil +} + +// DKIMRecord returns the DNS TXT record to publish for a domain (spec 7.2.10). +func (s *Service) DKIMRecord(d store.Domain) (DKIMRecord, error) { + return s.odk.Record(d.Name, d.DKIMSelector) +} + +// Resync regenerates the OpenDKIM tables from the registry and reloads OpenDKIM. +// It backs the manual reload button (spec 7.2.12) and doubles as a recovery path +// if the tables ever drift from the database. +func (s *Service) Resync() error { + return s.resync() +} + +// resync rebuilds KeyTable/SigningTable from the current domain set and reloads. +func (s *Service) resync() error { + domains, err := s.store.ListDomains() + if err != nil { + return err + } + signing := make([]SigningDomain, 0, len(domains)) + for _, d := range domains { + signing = append(signing, SigningDomain{Name: d.Name, Selector: d.DKIMSelector}) + } + return s.odk.Rebuild(signing) +} diff --git a/internal/store/domains.go b/internal/store/domains.go new file mode 100644 index 0000000..fba6ce1 --- /dev/null +++ b/internal/store/domains.go @@ -0,0 +1,138 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + "time" + + "modernc.org/sqlite" + sqlite3 "modernc.org/sqlite/lib" +) + +// ErrDomainExists is returned by AddDomain when the domain is already managed. +var ErrDomainExists = errors.New("domain already exists") + +// ErrDomainNotFound is returned when a domain id/name does not exist. +var ErrDomainNotFound = errors.New("domain not found") + +// Domain is a sending domain managed through the panel (spec 4.1). The DKIM key +// material itself lives on disk under /data; this row records the selector and +// metadata. AppCount is populated by the listing queries, not stored. +type Domain struct { + ID int64 + Name string + DKIMSelector string + CreatedAt time.Time + AppCount int +} + +// AddDomain inserts a new sending domain. The caller is responsible for having +// validated name (spec 7.6.2) before it reaches SQL; the query is parameterised +// regardless. A duplicate name maps to ErrDomainExists. +func (s *Store) AddDomain(name, selector string) (Domain, error) { + now := time.Now().UTC() + res, err := s.db.Exec( + "INSERT INTO domains (name, dkim_selector, created_at) VALUES (?, ?, ?)", + name, selector, now.Format(time.RFC3339), + ) + if err != nil { + if isUniqueViolation(err) { + return Domain{}, ErrDomainExists + } + return Domain{}, fmt.Errorf("insert domain: %w", err) + } + id, err := res.LastInsertId() + if err != nil { + return Domain{}, fmt.Errorf("domain id: %w", err) + } + return Domain{ID: id, Name: name, DKIMSelector: selector, CreatedAt: now}, nil +} + +// ListDomains returns every domain with its bound-application count (spec 7.2.2), +// ordered by name. +func (s *Store) ListDomains() ([]Domain, error) { + rows, err := s.db.Query(` + SELECT d.id, d.name, d.dkim_selector, d.created_at, + (SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id) + FROM domains d + ORDER BY d.name`) + if err != nil { + return nil, fmt.Errorf("list domains: %w", err) + } + defer rows.Close() + + var out []Domain + for rows.Next() { + d, err := scanDomain(rows) + if err != nil { + return nil, err + } + out = append(out, d) + } + return out, rows.Err() +} + +// GetDomain returns a single domain (with its application count) by id, or +// ErrDomainNotFound. +func (s *Store) GetDomain(id int64) (Domain, error) { + row := s.db.QueryRow(` + SELECT d.id, d.name, d.dkim_selector, d.created_at, + (SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id) + FROM domains d + WHERE d.id = ?`, id) + d, err := scanDomain(row) + if errors.Is(err, sql.ErrNoRows) { + return Domain{}, ErrDomainNotFound + } + if err != nil { + return Domain{}, err + } + return d, nil +} + +// DeleteDomain removes a domain. Its applications and their address/binding rows +// go with it via ON DELETE CASCADE (spec 7.2.4). Returns ErrDomainNotFound if no +// such row existed. +func (s *Store) DeleteDomain(id int64) error { + res, err := s.db.Exec("DELETE FROM domains WHERE id = ?", id) + if err != nil { + return fmt.Errorf("delete domain: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("delete domain rows: %w", err) + } + if n == 0 { + return ErrDomainNotFound + } + return nil +} + +// scanRow is the minimal surface shared by *sql.Row and *sql.Rows. +type scanRow interface { + Scan(dest ...any) error +} + +func scanDomain(r scanRow) (Domain, error) { + var ( + d Domain + createdAt string + ) + if err := r.Scan(&d.ID, &d.Name, &d.DKIMSelector, &createdAt, &d.AppCount); err != nil { + return Domain{}, err + } + d.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + return d, nil +} + +// isUniqueViolation reports whether err is a SQLite UNIQUE/PRIMARY-KEY conflict, +// so callers can turn a duplicate insert into a friendly domain-level error. +func isUniqueViolation(err error) bool { + var se *sqlite.Error + if errors.As(err, &se) { + code := se.Code() + return code == sqlite3.SQLITE_CONSTRAINT_UNIQUE || code == sqlite3.SQLITE_CONSTRAINT_PRIMARYKEY + } + return false +} diff --git a/internal/store/domains_test.go b/internal/store/domains_test.go new file mode 100644 index 0000000..6d8e8ae --- /dev/null +++ b/internal/store/domains_test.go @@ -0,0 +1,117 @@ +package store + +import ( + "errors" + "path/filepath" + "testing" + "time" +) + +func openTestStore(t *testing.T) *Store { + t.Helper() + st, err := Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { st.Close() }) + return st +} + +func TestAddAndListDomains(t *testing.T) { + st := openTestStore(t) + + d, err := st.AddDomain("example.com", "selfpost") + if err != nil { + t.Fatalf("AddDomain: %v", err) + } + if d.ID == 0 || d.Name != "example.com" || d.DKIMSelector != "selfpost" { + t.Fatalf("unexpected domain: %+v", d) + } + + list, err := st.ListDomains() + if err != nil { + t.Fatalf("ListDomains: %v", err) + } + if len(list) != 1 || list[0].Name != "example.com" || list[0].AppCount != 0 { + t.Fatalf("unexpected list: %+v", list) + } +} + +func TestAddDomainDuplicate(t *testing.T) { + st := openTestStore(t) + if _, err := st.AddDomain("example.com", "selfpost"); err != nil { + t.Fatal(err) + } + _, err := st.AddDomain("example.com", "other") + if !errors.Is(err, ErrDomainExists) { + t.Fatalf("duplicate AddDomain error = %v, want ErrDomainExists", err) + } +} + +func TestGetDomainNotFound(t *testing.T) { + st := openTestStore(t) + if _, err := st.GetDomain(999); !errors.Is(err, ErrDomainNotFound) { + t.Fatalf("GetDomain(missing) = %v, want ErrDomainNotFound", err) + } +} + +func TestDeleteDomainCascadesApplications(t *testing.T) { + st := openTestStore(t) + d, err := st.AddDomain("example.com", "selfpost") + if err != nil { + t.Fatal(err) + } + + // Insert an application + address directly (the AddApplication API lands in + // Phase 4); this verifies the ON DELETE CASCADE wiring now. + now := time.Now().UTC().Format(time.RFC3339) + res, err := st.db.Exec( + "INSERT INTO applications (domain_id, login, address_mode, created_at) VALUES (?, ?, 'wildcard', ?)", + d.ID, "app1", now) + if err != nil { + t.Fatal(err) + } + appID, _ := res.LastInsertId() + if _, err := st.db.Exec( + "INSERT INTO application_addresses (application_id, address) VALUES (?, ?)", + appID, "noreply@example.com"); err != nil { + t.Fatal(err) + } + + got, err := st.GetDomain(d.ID) + if err != nil { + t.Fatal(err) + } + if got.AppCount != 1 { + t.Fatalf("AppCount = %d, want 1", got.AppCount) + } + + if err := st.DeleteDomain(d.ID); err != nil { + t.Fatalf("DeleteDomain: %v", err) + } + + assertCount(t, st, "applications", 0) + assertCount(t, st, "application_addresses", 0) + if _, err := st.GetDomain(d.ID); !errors.Is(err, ErrDomainNotFound) { + t.Errorf("domain still present after delete: %v", err) + } +} + +func TestDeleteDomainNotFound(t *testing.T) { + st := openTestStore(t) + if err := st.DeleteDomain(123); !errors.Is(err, ErrDomainNotFound) { + t.Fatalf("DeleteDomain(missing) = %v, want ErrDomainNotFound", err) + } +} + +func assertCount(t *testing.T, st *Store, table string, want int) { + t.Helper() + var n int + // table is a trusted literal from the test, not user input. + if err := st.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&n); err != nil { + t.Fatalf("count %s: %v", table, err) + } + if n != want { + t.Errorf("%s count = %d, want %d", table, n, want) + } +} diff --git a/internal/web/handlers_domains.go b/internal/web/handlers_domains.go new file mode 100644 index 0000000..1423abb --- /dev/null +++ b/internal/web/handlers_domains.go @@ -0,0 +1,172 @@ +package web + +import ( + "errors" + "fmt" + "net/http" + "strconv" + + "codeberg.org/mix/selfpost/internal/store" +) + +// handleDashboard is the authenticated landing page: the list of sending +// domains with their DKIM/selector and application counts, plus the add-domain +// form (spec 7.2.2). Applications and the send log arrive in later phases. +func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { + s.renderDashboard(w, r, http.StatusOK, "", "") +} + +// renderDashboard renders the domain list. formErr and formName repopulate the +// add-domain form after a rejected submission; flash surfaces a one-shot status +// message keyed by a redirect query flag (never reflected user input). +func (s *Server) renderDashboard(w http.ResponseWriter, r *http.Request, status int, formErr, formName string) { + domains, err := s.domains.List() + if err != nil { + logf("panel: dashboard: list domains: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + s.render(w, status, "dashboard", map[string]any{ + "Title": "SelfPost", + "User": currentUser(r), + "Domains": domains, + "Error": formErr, + "FormName": formName, + "Flash": dashboardFlash(r), + }) +} + +// dashboardFlash maps a fixed redirect flag to a fixed message, so status text +// after a redirect is never attacker-influenced. +func dashboardFlash(r *http.Request) string { + switch { + case r.URL.Query().Get("reloaded") != "": + return "Configuration reloaded." + case r.URL.Query().Get("deleted") != "": + return "Domain deleted." + default: + return "" + } +} + +// handleAddDomain validates the submitted name, creates the domain (DKIM key + +// OpenDKIM reload), and redirects to the domain's page so the DNS record to +// publish is shown (spec 7.2.3). +func (s *Server) handleAddDomain(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + s.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "") + return + } + raw := r.PostFormValue("name") + name := normalizeDomain(raw) + if err := validateDomain(name); err != nil { + s.renderDashboard(w, r, http.StatusBadRequest, err.Error(), raw) + return + } + + d, err := s.domains.Add(name) + if err != nil { + if errors.Is(err, store.ErrDomainExists) { + s.renderDashboard(w, r, http.StatusConflict, "That domain is already configured.", raw) + return + } + logf("panel: add domain %q: %v", name, err) + s.renderDashboard(w, r, http.StatusInternalServerError, + "Could not add the domain. Please check the logs and try again.", raw) + return + } + http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther) +} + +// handleDomainDetail shows a single domain and its DKIM DNS record (spec 7.2.10). +func (s *Server) handleDomainDetail(w http.ResponseWriter, r *http.Request) { + d, ok := s.lookupDomain(w, r) + if !ok { + return + } + record, err := s.domains.DKIMRecord(d) + if err != nil { + logf("panel: domain %d: dkim record: %v", d.ID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + s.render(w, http.StatusOK, "domain_detail", map[string]any{ + "Title": "SelfPost — " + d.Name, + "User": currentUser(r), + "Domain": d, + "Record": record, + }) +} + +// handleDeleteConfirm shows the cascade warning before a domain is removed: the +// panel must explicitly state that all bound applications go with it (spec 7.2.4). +func (s *Server) handleDeleteConfirm(w http.ResponseWriter, r *http.Request) { + d, ok := s.lookupDomain(w, r) + if !ok { + return + } + s.render(w, http.StatusOK, "domain_delete", map[string]any{ + "Title": "SelfPost — delete " + d.Name, + "User": currentUser(r), + "Domain": d, + }) +} + +// handleDeleteDomain performs the deletion (cascade + DKIM key + OpenDKIM reload) +// and returns to the domain list. +func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) { + id, ok := parseDomainID(w, r) + if !ok { + return + } + if err := s.domains.Delete(id); err != nil { + if errors.Is(err, store.ErrDomainNotFound) { + http.NotFound(w, r) + return + } + logf("panel: delete domain %d: %v", id, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/?deleted=1", http.StatusSeeOther) +} + +// handleReload re-applies the OpenDKIM configuration on demand (spec 7.2.12). +// The Postfix side of the reload button lands in Phase 5. +func (s *Server) handleReload(w http.ResponseWriter, r *http.Request) { + if err := s.domains.Resync(); err != nil { + logf("panel: manual reload: %v", err) + http.Error(w, "reload failed", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/?reloaded=1", http.StatusSeeOther) +} + +// lookupDomain resolves the {id} path value to a domain, writing a 404 for a +// bad id or a missing domain and reporting ok=false in that case. +func (s *Server) lookupDomain(w http.ResponseWriter, r *http.Request) (store.Domain, bool) { + id, ok := parseDomainID(w, r) + if !ok { + return store.Domain{}, false + } + d, err := s.domains.Get(id) + if err != nil { + if errors.Is(err, store.ErrDomainNotFound) { + http.NotFound(w, r) + return store.Domain{}, false + } + logf("panel: get domain %d: %v", id, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return store.Domain{}, false + } + return d, true +} + +func parseDomainID(w http.ResponseWriter, r *http.Request) (int64, bool) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || id <= 0 { + http.NotFound(w, r) + return 0, false + } + return id, true +} diff --git a/internal/web/middleware.go b/internal/web/middleware.go index 3259012..09b9e02 100644 --- a/internal/web/middleware.go +++ b/internal/web/middleware.go @@ -36,16 +36,3 @@ func currentUser(r *http.Request) string { } return "" } - -// handleDashboard is the authenticated landing page. Phase 2 shows a minimal -// shell; domains, applications and the send log arrive in later phases. -func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/" { - http.NotFound(w, r) - return - } - s.render(w, http.StatusOK, "dashboard", map[string]any{ - "Title": "SelfPost", - "User": currentUser(r), - }) -} diff --git a/internal/web/templates.go b/internal/web/templates.go index a420bf0..4d3b15f 100644 --- a/internal/web/templates.go +++ b/internal/web/templates.go @@ -17,9 +17,11 @@ type templates struct { // pageFiles maps a logical page name to its template file. Every page composes // with layout.html. var pageFiles = map[string]string{ - "setup": "templates/setup.html", - "login": "templates/login.html", - "dashboard": "templates/dashboard.html", + "setup": "templates/setup.html", + "login": "templates/login.html", + "dashboard": "templates/dashboard.html", + "domain_detail": "templates/domain_detail.html", + "domain_delete": "templates/domain_delete.html", } func loadTemplates() (*templates, error) { diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index 21db85a..2008818 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -1,15 +1,53 @@ {{define "content"}}

SelfPost

-
- {{.User}} · +
+ {{.User}} +
+ +
+ +{{if .Flash}}
{{.Flash}}
{{end}} +
-

You are signed in. Domains, applications and the send log arrive in the - next phases.

+

Add a sending domain

+
+ + + {{if .Error}}

{{.Error}}

{{end}} + +
+

A DKIM key is generated for the domain; you then publish the + shown DNS record. Adding a domain does not create an application.

+
+ +
+

Domains

+ {{if .Domains}} + + + + + + {{range .Domains}} + + + + + + + {{end}} + +
DomainSelectorApps
{{.Name}}{{.DKIMSelector}}{{.AppCount}}Delete
+ {{else}} +

No domains yet. Add one above to get started.

+ {{end}}
{{end}} diff --git a/internal/web/templates/domain_delete.html b/internal/web/templates/domain_delete.html new file mode 100644 index 0000000..cbd7adb --- /dev/null +++ b/internal/web/templates/domain_delete.html @@ -0,0 +1,32 @@ +{{define "content"}} +
+

Delete {{.Domain.Name}}

+
+ {{.User}} +
+ +
+
+
+ +← Back to {{.Domain.Name}} + +
+

Confirm deletion

+

You are about to delete {{.Domain.Name}}. This will:

+
    +
  • permanently delete its DKIM signing key;
  • +
  • delete + {{if eq .Domain.AppCount 0}}its (currently none) bound applications{{else}} + all {{.Domain.AppCount}} bound application(s), including + their SASL credentials and sender bindings{{end}};
  • +
  • reload OpenDKIM so the domain is no longer signed.
  • +
+

This cannot be undone. The published DKIM DNS record becomes + invalid; remove it from DNS if you do not plan to re-add the domain.

+ +
+ +
+
+{{end}} diff --git a/internal/web/templates/domain_detail.html b/internal/web/templates/domain_detail.html new file mode 100644 index 0000000..07e9db1 --- /dev/null +++ b/internal/web/templates/domain_detail.html @@ -0,0 +1,44 @@ +{{define "content"}} +
+

{{.Domain.Name}}

+
+ {{.User}} +
+ +
+
+
+ +← All domains + +
+

DKIM DNS record

+

Publish this TXT record in the DNS for {{.Domain.Name}}. + It is not a secret and can be viewed at any time.

+ + + {{.Record.Name}} + + + TXT + + + {{.Record.Value}} + +

Also configure SPF and DMARC for the domain (see the + documentation). Mail is signed with selector {{.Domain.DKIMSelector}}.

+
+ +
+

Applications

+

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

+
+ +
+

Danger zone

+

Deleting this domain also deletes its DKIM key and every + application bound to it.

+ Delete domain +
+{{end}} diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 42d4818..25b031b 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -37,9 +37,25 @@ .error { color: #b42318; margin: 0.6rem 0 0; font-weight: 600; } .muted { color: #6b7280; } .topbar { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 1.2rem; } + .topbar .actions { display: flex; gap: 0.9rem; align-items: baseline; } form.inline { display: inline; margin: 0; } form.inline button { background: none; color: #2563eb; padding: 0; margin: 0; font-weight: 600; } form.inline button:hover { text-decoration: underline; background: none; } + main { max-width: 48rem; } + .card + .card { margin-top: 1.2rem; } + .flash { background: #ecfdf3; border: 1px solid #abefc6; color: #067647; padding: 0.7rem 1rem; border-radius: 8px; margin-bottom: 1.2rem; } + @media (prefers-color-scheme: dark) { .flash { background: #0d2818 !important; border-color: #1a5336 !important; color: #75d99b !important; } } + table { width: 100%; border-collapse: collapse; } + th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid #e2e5e9; } + @media (prefers-color-scheme: dark) { th, td { border-color: #2b3138 !important; } } + th { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; color: #6b7280; } + td.actions { text-align: right; } + a.danger { color: #b42318; } + .code { display: block; white-space: pre-wrap; word-break: break-all; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.85rem; background: #f0f2f4; border: 1px solid #e2e5e9; border-radius: 6px; padding: 0.7rem 0.8rem; margin: 0.3rem 0 0; } + @media (prefers-color-scheme: dark) { .code { background: #14171a !important; border-color: #2b3138 !important; } } + h2 { font-size: 1.05rem; margin: 0 0 0.4rem; } + .back { display: inline-block; margin-bottom: 1rem; } diff --git a/internal/web/validate.go b/internal/web/validate.go index afd4784..c720d93 100644 --- a/internal/web/validate.go +++ b/internal/web/validate.go @@ -2,6 +2,7 @@ package web import ( "fmt" + "strings" "unicode" ) @@ -40,3 +41,61 @@ func validateAdminPassword(p string) error { func isASCIILetterOrDigit(r rune) bool { return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') } + +const maxDomainLen = 253 // RFC 1035 limit on a fully-qualified name + +// normalizeDomain lower-cases and trims a domain name. Domain names are +// case-insensitive, and the generated OpenDKIM tables/keys use the canonical +// lower-case form, so we normalise before both validation and storage. +func normalizeDomain(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} + +// validateDomain enforces a strict server-side whitelist for sending-domain +// names (spec 7.6.2). The result is safe to write verbatim into the OpenDKIM +// KeyTable/SigningTable and to use as a filesystem path segment: only +// lower-case letters, digits, '.' and '-' are allowed, in valid DNS label +// shape. Input must already be normalised with normalizeDomain. +// +// This is deliberately stricter than "any string DNS might accept" — no +// leading/trailing dots or hyphens, no empty or over-long labels, and at least +// two labels so single-word hostnames cannot be registered as sending domains. +func validateDomain(name string) error { + if name == "" { + return fmt.Errorf("domain is required") + } + if len(name) > maxDomainLen { + return fmt.Errorf("domain must be at most %d characters", maxDomainLen) + } + labels := strings.Split(name, ".") + if len(labels) < 2 { + return fmt.Errorf("domain must include at least one dot (e.g. example.com)") + } + for _, label := range labels { + if err := validateDomainLabel(label); err != nil { + return err + } + } + return nil +} + +func validateDomainLabel(label string) error { + if len(label) == 0 { + return fmt.Errorf("domain must not contain an empty label") + } + if len(label) > 63 { + return fmt.Errorf("each domain label must be at most 63 characters") + } + if label[0] == '-' || label[len(label)-1] == '-' { + return fmt.Errorf("domain labels must not start or end with '-'") + } + for i := 0; i < len(label); i++ { + c := label[i] + lower := c >= 'a' && c <= 'z' + digit := c >= '0' && c <= '9' + if !lower && !digit && c != '-' { + return fmt.Errorf("domain may contain only lower-case letters, digits, '.' and '-'") + } + } + return nil +} diff --git a/internal/web/validate_test.go b/internal/web/validate_test.go new file mode 100644 index 0000000..b7556e4 --- /dev/null +++ b/internal/web/validate_test.go @@ -0,0 +1,67 @@ +package web + +import "testing" + +func TestNormalizeDomain(t *testing.T) { + cases := map[string]string{ + " Example.COM ": "example.com", + "MAIL.Example.Org": "mail.example.org", + "example.com": "example.com", + } + for in, want := range cases { + if got := normalizeDomain(in); got != want { + t.Errorf("normalizeDomain(%q) = %q, want %q", in, got, want) + } + } +} + +func TestValidateDomainValid(t *testing.T) { + valid := []string{ + "example.com", + "mail.example.com", + "a.co", + "sub-domain.example.co.uk", + "x1.y2.z3", + "1example.com", + } + for _, d := range valid { + if err := validateDomain(d); err != nil { + t.Errorf("validateDomain(%q) unexpected error: %v", d, err) + } + } +} + +func TestValidateDomainInvalid(t *testing.T) { + invalid := []string{ + "", // empty + "localhost", // single label + "example", // single label + ".example.com", // leading dot -> empty label + "example.com.", // trailing dot -> empty label + "exa mple.com", // space + "example..com", // empty label + "-example.com", // label starts with '-' + "example-.com", // label ends with '-' + "example.com\n", // newline (config injection attempt) + "exa*mple.com", // disallowed char + "exa_mple.com", // underscore not allowed in domains + "Example.com", // upper-case (must be normalised first) + "пример.рф", // non-ASCII + "example.c/m", // slash (path-traversal attempt) + } + for _, d := range invalid { + if err := validateDomain(d); err == nil { + t.Errorf("validateDomain(%q) = nil, want error", d) + } + } +} + +func TestValidateDomainLongLabelRejected(t *testing.T) { + label := make([]byte, 64) + for i := range label { + label[i] = 'a' + } + if err := validateDomain(string(label) + ".com"); err == nil { + t.Error("expected error for over-long label") + } +} diff --git a/internal/web/web.go b/internal/web/web.go index e1b279d..1a1d42b 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -10,6 +10,7 @@ import ( "net/http" "time" + "codeberg.org/mix/selfpost/internal/domain" "codeberg.org/mix/selfpost/internal/store" ) @@ -30,6 +31,7 @@ type Config struct { // Server is the panel HTTP application. type Server struct { store *store.Store + domains *domain.Service cfg Config tmpl *templates sessions *sessionStore @@ -40,14 +42,16 @@ type Server struct { } // New builds the panel server. setupTokenPath is where the current setup token -// is mirrored on disk (spec 7.6.1). -func New(st *store.Store, cfg Config, setupTokenPath string) (*Server, error) { +// is mirrored on disk (spec 7.6.1); domains is the sending-domain service that +// owns DKIM keys and the OpenDKIM tables (spec 6). +func New(st *store.Store, domains *domain.Service, cfg Config, setupTokenPath string) (*Server, error) { tmpl, err := loadTemplates() if err != nil { return nil, err } s := &Server{ store: st, + domains: domains, cfg: cfg, tmpl: tmpl, sessions: newSessionStore(), @@ -85,8 +89,16 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/login", s.handleLogin) mux.HandleFunc("/logout", s.handleLogout) - // Authenticated panel. - mux.Handle("/", s.requireAuth(http.HandlerFunc(s.handleDashboard))) + // Authenticated panel. Everything not matched by a more specific pattern + // above falls through to this sub-mux, wrapped once in the auth middleware. + authed := http.NewServeMux() + authed.HandleFunc("GET /{$}", s.handleDashboard) + authed.HandleFunc("POST /domains", s.handleAddDomain) + authed.HandleFunc("GET /domains/{id}", s.handleDomainDetail) + authed.HandleFunc("GET /domains/{id}/delete", s.handleDeleteConfirm) + authed.HandleFunc("POST /domains/{id}/delete", s.handleDeleteDomain) + authed.HandleFunc("POST /reload", s.handleReload) + mux.Handle("/", s.requireAuth(authed)) return mux }