Phase 3: sending domains + per-domain OpenDKIM signing

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 22:20:35 +03:00
parent f62c714e2b
commit bf477f4e6a
24 changed files with 1461 additions and 36 deletions
+10
View File
@@ -58,6 +58,16 @@ 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
# 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. # 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 \
&& chown opendkim:opendkim /run/opendkim \ && chown opendkim:opendkim /run/opendkim \
+18
View File
@@ -14,4 +14,22 @@ chown panel:panel /data
# later phase deliberately hands to another service. # later phase deliberately hands to another service.
find /data -mindepth 1 -maxdepth 1 ! -user panel -exec chown -R panel:panel {} + 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 exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
+22 -6
View File
@@ -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 # Signing is strictly per-domain: each sending domain has its own key pair and
# the Postfix start wrapper's readiness probe passes. Per-domain signing # selector. The panel generates keys under /data/opendkim/keys/<domain>/ and
# (KeyTable / SigningTable, Mode s) is generated and reloaded by the panel in # keeps KeyTable/SigningTable in sync as domains are added or removed, then asks
# Phase 3; until keys exist we run in verify-only mode so startup needs no keys. # 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 Syslog no
UMask 007 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 Socket local:/run/opendkim/opendkim.sock
PidFile /run/opendkim/opendkim.pid PidFile /run/opendkim/opendkim.pid
UserID opendkim UserID opendkim
+8 -1
View File
@@ -16,9 +16,16 @@ logfile_maxbytes=0
pidfile=/run/supervisord.pid pidfile=/run/supervisord.pid
loglevel=info 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] [unix_http_server]
file=/run/supervisor.sock file=/run/supervisor.sock
chmod=0700 chown=root:selfpost
chmod=0770
[rpcinterface:supervisor] [rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
+4 -1
View File
@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"time" "time"
"codeberg.org/mix/selfpost/internal/domain"
"codeberg.org/mix/selfpost/internal/store" "codeberg.org/mix/selfpost/internal/store"
"codeberg.org/mix/selfpost/internal/web" "codeberg.org/mix/selfpost/internal/web"
) )
@@ -21,7 +22,9 @@ func serveHTTP(ctx context.Context, cfg config) error {
} }
defer st.Close() 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, Hostname: cfg.hostname,
CookieSecure: cfg.cookieSecure, CookieSecure: cfg.cookieSecure,
}, cfg.setupTokenPath) }, cfg.setupTokenPath)
+8
View File
@@ -50,6 +50,9 @@ type config struct {
setupTokenPath string setupTokenPath string
hostname string hostname string
cookieSecure bool cookieSecure bool
opendkimDir string
dkimSelectorDef string
} }
func loadConfig() config { func loadConfig() config {
@@ -66,6 +69,11 @@ func loadConfig() config {
// Secure cookies by default (spec 7.6.6); PANEL_COOKIE_SECURE=false is a // Secure cookies by default (spec 7.6.6); PANEL_COOKIE_SECURE=false is a
// development-only escape hatch for testing over plain HTTP. // development-only escape hatch for testing over plain HTTP.
cookieSecure: envDefault("PANEL_COOKIE_SECURE", "true") != "false", 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"),
} }
} }
+13 -4
View File
@@ -46,10 +46,18 @@
## Текущее состояние ## Текущее состояние
- **Текущая фаза:** 2 ✅ закрыта → следующая **Фаза 3** (домены + OpenDKIM) - **Текущая фаза:** 3 ✅ закрыта → следующая **Фаза 4** (приложения + SASL + привязка к домену)
- **Модель для Фазы 3:** Opus (генерация конфигов + exec-safety 7.6.34, валидация имени домена 7.6.2) - **Модель для Фазы 4:** Opus (риск open relay / привязки отправителя, `sasldb2`, `smtpd_sender_login_maps`)
- **Статус:** SQLite-персистентность, setup secret-link и вход админа реализованы и проверены на сервере (`go vet`/`build`/`test` зелёные, docker-образ собирается, контейнер поднимает три процесса и создаёт БД под `panel`) - **Статус:** домены + per-domain DKIM реализованы и проверены на сервере (`gofmt`/`go vet`/`go test` зелёные, docker-образ собирается, e2e в контейнере: add/delete домена, генерация ключа, показ TXT, reload OpenDKIM, персистентность ключей через рестарт)
- **Следующий шаг (Фаза 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). - **Следующий шаг (Фаза 4):** создание приложения в карточке домена — SASL-пара логин/пароль в `sasldb2` (эквивалент `saslpasswd2`), сильный пароль, показ **один раз** (ТЗ 7.6.1); режим адресов «wildcard `@domain`» либо «список» с серверной валидацией принадлежности адреса домену (ТЗ 7.6.2); `smtpd_sender_login_maps` (many-to-one) соответственно режиму + `postfix reload`; список/редактирование режима/удаление приложения/перевыпуск пароля (ТЗ 7.2.59). **Важно для Фазы 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=<base64 PKIX DER>`) вычисляется из ключа на лету — ключ на диске = единственный источник истины. `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 ### Сделано в Фазе 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`. - **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` (план). - **Фаза 0** (2026-07-11, Opus) — каркас проекта + build-пайплайн + спайк go-milter (риск ТЗ 7.3 снят). Коммиты `4e589e1` (каркас), `87388b4` (план).
- **Фаза 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) зелёные. Проверено на сервере.
+127
View File
@@ -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
}
+86
View File
@@ -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))
}
}
+184
View File
@@ -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: <key-name> <domain>:<selector>:<key-path>
fmt.Fprintf(&kt, "%s %s:%s:%s\n", keyName, d.Name, d.Selector, keyFile)
// SigningTable (refile): <address-pattern> <key-name>
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
}
+156
View File
@@ -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)
}
}
+117
View File
@@ -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)
}
+138
View File
@@ -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
}
+117
View File
@@ -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)
}
}
+172
View File
@@ -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
}
-13
View File
@@ -36,16 +36,3 @@ func currentUser(r *http.Request) string {
} }
return "" 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),
})
}
+2
View File
@@ -20,6 +20,8 @@ var pageFiles = map[string]string{
"setup": "templates/setup.html", "setup": "templates/setup.html",
"login": "templates/login.html", "login": "templates/login.html",
"dashboard": "templates/dashboard.html", "dashboard": "templates/dashboard.html",
"domain_detail": "templates/domain_detail.html",
"domain_delete": "templates/domain_delete.html",
} }
func loadTemplates() (*templates, error) { func loadTemplates() (*templates, error) {
+42 -4
View File
@@ -1,15 +1,53 @@
{{define "content"}} {{define "content"}}
<div class="topbar"> <div class="topbar">
<h1>SelfPost</h1> <h1>SelfPost</h1>
<div class="muted"> <div class="actions muted">
{{.User}} &middot; <span>{{.User}}</span>
<form class="inline" method="post" action="/reload">
<button type="submit">Reload</button>
</form>
<form class="inline" method="post" action="/logout"> <form class="inline" method="post" action="/logout">
<button type="submit">Sign out</button> <button type="submit">Sign out</button>
</form> </form>
</div> </div>
</div> </div>
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
<div class="card"> <div class="card">
<p>You are signed in. Domains, applications and the send log arrive in the <h2>Add a sending domain</h2>
next phases.</p> <form method="post" action="/domains">
<label for="name">Domain</label>
<input id="name" name="name" type="text" placeholder="example.com"
autocomplete="off" autocapitalize="none" spellcheck="false"
value="{{.FormName}}" autofocus required>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<button type="submit">Add domain</button>
</form>
<p class="muted">A DKIM key is generated for the domain; you then publish the
shown DNS record. Adding a domain does not create an application.</p>
</div>
<div class="card">
<h2>Domains</h2>
{{if .Domains}}
<table>
<thead>
<tr><th>Domain</th><th>Selector</th><th>Apps</th><th></th></tr>
</thead>
<tbody>
{{range .Domains}}
<tr>
<td><a href="/domains/{{.ID}}">{{.Name}}</a></td>
<td class="muted">{{.DKIMSelector}}</td>
<td>{{.AppCount}}</td>
<td class="actions"><a class="danger" href="/domains/{{.ID}}/delete">Delete</a></td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">No domains yet. Add one above to get started.</p>
{{end}}
</div> </div>
{{end}} {{end}}
+32
View File
@@ -0,0 +1,32 @@
{{define "content"}}
<div class="topbar">
<h1>Delete {{.Domain.Name}}</h1>
<div class="actions muted">
<span>{{.User}}</span>
<form class="inline" method="post" action="/logout">
<button type="submit">Sign out</button>
</form>
</div>
</div>
<a class="back" href="/domains/{{.Domain.ID}}">&larr; Back to {{.Domain.Name}}</a>
<div class="card">
<h2>Confirm deletion</h2>
<p>You are about to delete <strong>{{.Domain.Name}}</strong>. This will:</p>
<ul>
<li>permanently delete its DKIM signing key;</li>
<li>delete
{{if eq .Domain.AppCount 0}}its (currently none) bound applications{{else}}
<strong>all {{.Domain.AppCount}} bound application(s)</strong>, including
their SASL credentials and sender bindings{{end}};</li>
<li>reload OpenDKIM so the domain is no longer signed.</li>
</ul>
<p class="muted">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.</p>
<form method="post" action="/domains/{{.Domain.ID}}/delete">
<button type="submit" style="background:#b42318">Delete {{.Domain.Name}}</button>
</form>
</div>
{{end}}
+44
View File
@@ -0,0 +1,44 @@
{{define "content"}}
<div class="topbar">
<h1>{{.Domain.Name}}</h1>
<div class="actions muted">
<span>{{.User}}</span>
<form class="inline" method="post" action="/logout">
<button type="submit">Sign out</button>
</form>
</div>
</div>
<a class="back" href="/">&larr; All domains</a>
<div class="card">
<h2>DKIM DNS record</h2>
<p class="muted">Publish this TXT record in the DNS for <strong>{{.Domain.Name}}</strong>.
It is not a secret and can be viewed at any time.</p>
<label>Host / name</label>
<span class="code">{{.Record.Name}}</span>
<label>Type</label>
<span class="code">TXT</span>
<label>Value</label>
<span class="code">{{.Record.Value}}</span>
<p class="muted">Also configure SPF and DMARC for the domain (see the
documentation). Mail is signed with selector <strong>{{.Domain.DKIMSelector}}</strong>.</p>
</div>
<div class="card">
<h2>Applications</h2>
<p class="muted">{{.Domain.AppCount}} application(s) bound to this domain.
Creating and managing applications arrives in the next phase.</p>
</div>
<div class="card">
<h2>Danger zone</h2>
<p class="muted">Deleting this domain also deletes its DKIM key and every
application bound to it.</p>
<a class="danger" href="/domains/{{.Domain.ID}}/delete">Delete domain</a>
</div>
{{end}}
+16
View File
@@ -37,9 +37,25 @@
.error { color: #b42318; margin: 0.6rem 0 0; font-weight: 600; } .error { color: #b42318; margin: 0.6rem 0 0; font-weight: 600; }
.muted { color: #6b7280; } .muted { color: #6b7280; }
.topbar { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 1.2rem; } .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 { display: inline; margin: 0; }
form.inline button { background: none; color: #2563eb; padding: 0; margin: 0; font-weight: 600; } form.inline button { background: none; color: #2563eb; padding: 0; margin: 0; font-weight: 600; }
form.inline button:hover { text-decoration: underline; background: none; } 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; }
</style> </style>
</head> </head>
<body> <body>
+59
View File
@@ -2,6 +2,7 @@ package web
import ( import (
"fmt" "fmt"
"strings"
"unicode" "unicode"
) )
@@ -40,3 +41,61 @@ func validateAdminPassword(p string) error {
func isASCIILetterOrDigit(r rune) bool { func isASCIILetterOrDigit(r rune) bool {
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') 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
}
+67
View File
@@ -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")
}
}
+16 -4
View File
@@ -10,6 +10,7 @@ import (
"net/http" "net/http"
"time" "time"
"codeberg.org/mix/selfpost/internal/domain"
"codeberg.org/mix/selfpost/internal/store" "codeberg.org/mix/selfpost/internal/store"
) )
@@ -30,6 +31,7 @@ type Config struct {
// Server is the panel HTTP application. // Server is the panel HTTP application.
type Server struct { type Server struct {
store *store.Store store *store.Store
domains *domain.Service
cfg Config cfg Config
tmpl *templates tmpl *templates
sessions *sessionStore sessions *sessionStore
@@ -40,14 +42,16 @@ 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). // is mirrored on disk (spec 7.6.1); domains is the sending-domain service that
func New(st *store.Store, cfg Config, setupTokenPath string) (*Server, error) { // 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() tmpl, err := loadTemplates()
if err != nil { if err != nil {
return nil, err return nil, err
} }
s := &Server{ s := &Server{
store: st, store: st,
domains: domains,
cfg: cfg, cfg: cfg,
tmpl: tmpl, tmpl: tmpl,
sessions: newSessionStore(), sessions: newSessionStore(),
@@ -85,8 +89,16 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/login", s.handleLogin) mux.HandleFunc("/login", s.handleLogin)
mux.HandleFunc("/logout", s.handleLogout) mux.HandleFunc("/logout", s.handleLogout)
// Authenticated panel. // Authenticated panel. Everything not matched by a more specific pattern
mux.Handle("/", s.requireAuth(http.HandlerFunc(s.handleDashboard))) // 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 return mux
} }