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:
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
+22
-6
@@ -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/<domain>/ 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+13
-4
@@ -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=<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
|
||||
- **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) зелёные. Проверено на сервере.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ var pageFiles = map[string]string{
|
||||
"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) {
|
||||
|
||||
@@ -1,15 +1,53 @@
|
||||
{{define "content"}}
|
||||
<div class="topbar">
|
||||
<h1>SelfPost</h1>
|
||||
<div class="muted">
|
||||
{{.User}} ·
|
||||
<div class="actions muted">
|
||||
<span>{{.User}}</span>
|
||||
<form class="inline" method="post" action="/reload">
|
||||
<button type="submit">Reload</button>
|
||||
</form>
|
||||
<form class="inline" method="post" action="/logout">
|
||||
<button type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||
|
||||
<div class="card">
|
||||
<p>You are signed in. Domains, applications and the send log arrive in the
|
||||
next phases.</p>
|
||||
<h2>Add a sending domain</h2>
|
||||
<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>
|
||||
{{end}}
|
||||
|
||||
@@ -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}}">← 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}}
|
||||
@@ -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="/">← 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}}
|
||||
@@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user