Phase 9: full backup/restore + domain export/import (spec 7.5, 11.6)

Full server backup (spec 7.5.A): internal/backup produces a tar.gz of all of
/data — a consistent SQLite snapshot via VACUUM INTO, DKIM keys, sasldb2 and a
version manifest; TLS certs (tls/) and the Postfix queue are excluded. Two equal
paths: the panel button (POST /backup, no-store) and the selfpost-backup CLI via
docker exec (spec 11.6). CheckRestore runs before store.Open: a manifest version
mismatch refuses to boot with the image tag to use; a match consumes the
manifest so it only guards the first post-restore boot. Restore is not a
separate branch — Postfix/OpenDKIM regenerate from the restored SQLite as on any
start.

Domain export/import (spec 7.5.B): DomainExport carries the DKIM private key and
each application's working password. SASL secrets are read from sasldb2 via
db_dump (the userPassword property is plaintext) and, on import, re-keyed under
the local realm with saslpasswd2 — so credentials keep working on an instance
with a different hostname, with no DKIM DNS change. Import validates and rolls
back atomically on any failure. db-util (db_dump) is now an explicit image dep.

Verified on the server (selfpost:p9): gofmt/vet/test green; container e2e for
cross-realm domain export/import (SMTP AUTH 235 under the new realm), CLI and
panel backups, same-version restore, and version-mismatch refusal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 22:33:07 +03:00
parent 56a4fa892d
commit f88d8dabcb
22 changed files with 1409 additions and 22 deletions
+1
View File
@@ -46,6 +46,7 @@ RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \
opendkim-tools \ opendkim-tools \
sasl2-bin \ sasl2-bin \
libsasl2-modules \ libsasl2-modules \
db-util \
supervisor \ supervisor \
logrotate \ logrotate \
ca-certificates \ ca-certificates \
+4
View File
@@ -8,6 +8,7 @@ import (
"time" "time"
"codeberg.org/mix/selfpost/internal/app" "codeberg.org/mix/selfpost/internal/app"
"codeberg.org/mix/selfpost/internal/buildinfo"
"codeberg.org/mix/selfpost/internal/domain" "codeberg.org/mix/selfpost/internal/domain"
"codeberg.org/mix/selfpost/internal/postfix" "codeberg.org/mix/selfpost/internal/postfix"
"codeberg.org/mix/selfpost/internal/store" "codeberg.org/mix/selfpost/internal/store"
@@ -28,6 +29,9 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
Hostname: cfg.hostname, Hostname: cfg.hostname,
CookieSecure: cfg.cookieSecure, CookieSecure: cfg.cookieSecure,
MailLogPath: cfg.mailLog, MailLogPath: cfg.mailLog,
DataDir: cfg.dataDir,
DBPath: cfg.dbPath,
Version: buildinfo.Version,
}, cfg.setupTokenPath) }, cfg.setupTokenPath)
if err != nil { if err != nil {
return err return err
+11
View File
@@ -19,6 +19,7 @@ import (
"sync" "sync"
"syscall" "syscall"
"codeberg.org/mix/selfpost/internal/backup"
"codeberg.org/mix/selfpost/internal/buildinfo" "codeberg.org/mix/selfpost/internal/buildinfo"
"codeberg.org/mix/selfpost/internal/logtail" "codeberg.org/mix/selfpost/internal/logtail"
"codeberg.org/mix/selfpost/internal/store" "codeberg.org/mix/selfpost/internal/store"
@@ -51,6 +52,7 @@ type config struct {
dataDir string dataDir string
dbPath string dbPath string
manifestPath string
setupTokenPath string setupTokenPath string
hostname string hostname string
cookieSecure bool cookieSecure bool
@@ -75,6 +77,7 @@ func loadConfig() config {
dataDir: dataDir, dataDir: dataDir,
dbPath: envDefault("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db")), dbPath: envDefault("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db")),
manifestPath: filepath.Join(dataDir, backup.ManifestName),
setupTokenPath: envDefault("SELFPOST_SETUP_TOKEN_FILE", filepath.Join(dataDir, "setup-token")), setupTokenPath: envDefault("SELFPOST_SETUP_TOKEN_FILE", filepath.Join(dataDir, "setup-token")),
hostname: os.Getenv("SELFPOST_HOSTNAME"), hostname: os.Getenv("SELFPOST_HOSTNAME"),
// 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
@@ -140,6 +143,14 @@ func run() error {
log.Printf("starting selfpost panel %s", buildinfo.Version) log.Printf("starting selfpost panel %s", buildinfo.Version)
// Restore version guard (spec 7.5.A): if a backup was extracted into /data,
// its manifest version must match this binary before we touch the database,
// so schema/format skew between versions cannot corrupt the restored state.
// A match consumes the manifest; its absence is the normal (non-restore) case.
if err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version); err != nil {
return err
}
// One database handle shared by every role. The store serialises writes // One database handle shared by every role. The store serialises writes
// (MaxOpenConns(1)), so the HTTP panel, the journal-milter and the tailer // (MaxOpenConns(1)), so the HTTP panel, the journal-milter and the tailer
// can all use it without stepping on each other under WAL. // can all use it without stepping on each other under WAL.
+53 -6
View File
@@ -1,20 +1,29 @@
// Command selfpost-backup produces (and helps restore) the full persistent-state // Command selfpost-backup produces the full persistent-state archive from inside
// archive from inside the container, invoked via `docker exec` for scripted/cron // the container, invoked via `docker exec` for scripted/cron backups — the CLI
// backups — the CLI equivalent of the panel's backup button (spec 7.5.A, 11.6). // equivalent of the panel's backup button (spec 7.5.A, 11.6).
// //
// This is the Phase 0 skeleton: it only reports its version. The actual archive // By default the gzip-compressed tar is written to stdout, so the usual form is:
// logic lands in Phase 9. //
// docker exec <container> selfpost-backup > selfpost-backup.tar.gz
//
// Use -o to write to a file instead. The resulting archive contains DKIM private
// keys, the admin password hash and SASL credentials — treat it as a secret
// (spec 7.5.A).
package main package main
import ( import (
"flag" "flag"
"fmt" "fmt"
"os"
"path/filepath"
"codeberg.org/mix/selfpost/internal/backup"
"codeberg.org/mix/selfpost/internal/buildinfo" "codeberg.org/mix/selfpost/internal/buildinfo"
) )
func main() { func main() {
showVersion := flag.Bool("version", false, "print version and exit") showVersion := flag.Bool("version", false, "print version and exit")
out := flag.String("o", "", "write the archive to this file instead of stdout")
flag.Parse() flag.Parse()
if *showVersion { if *showVersion {
@@ -22,5 +31,43 @@ func main() {
return return
} }
fmt.Printf("selfpost-backup %s (skeleton)\n", buildinfo.Version) if err := run(*out); err != nil {
fmt.Fprintf(os.Stderr, "selfpost-backup: %v\n", err)
os.Exit(1)
}
}
func run(outPath string) error {
dataDir := envDefault("SELFPOST_DATA_DIR", "/data")
dbPath := envDefault("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db"))
w := os.Stdout
if outPath != "" {
// Backups are secret; create them owner-only.
f, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return err
}
defer f.Close()
w = f
}
if err := backup.Create(w, backup.Params{
DataDir: dataDir,
DBPath: dbPath,
Version: buildinfo.Version,
}); err != nil {
return err
}
if outPath != "" {
fmt.Fprintf(os.Stderr, "selfpost-backup: wrote %s (SelfPost %s)\n", outPath, buildinfo.Version)
}
return nil
}
func envDefault(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
} }
+16 -1
View File
@@ -46,13 +46,27 @@
## Текущее состояние ## Текущее состояние
- **Текущая фаза:** 8**закрыта** → следующая **Фаза 9** (бэкап/restore + экспорт/импорт домена) на **Opus** (целостность данных, версионирование). - **Текущая фаза:** 9**закрыта** → следующая **Фаза 10** (деплой + документация) на **Sonnet** (compose-файлы и документация).
- **Ключевая находка Фазы 9 (SASL-секреты обратимы, как и предвидело ТЗ 7.5.Б):** `sasldb2` (Berkeley DB, db5.3) хранит пароль приложения как **плейнтекст** в свойстве `userPassword` — подтверждено на сервере. Значит экспорт домена читает его через `db_dump` и на импорте **перезаписывает под локальный realm** через `saslpasswd2` (плейнтекст realm-независим) → креды работают на другом хосте с ДРУГИМ hostname/realm без перевыпуска. Полный бэкап копирует `sasldb2` **побитово** (keyed по исходному realm), поэтому restore обязан идти на **тот же hostname** (миграция всей машины). `db-util` (даёт `db_dump`) добавлен явной зависимостью в Dockerfile. Файл `/data/setup-token` содержит **полный URL**, а не голый токен (для e2e: `TOKEN=${FULL##*/}`).
- **Ключевая находка Фазы 6 (исправлена):** go-milter хранит имена макросов **как их шлёт Postfix** — многосимвольные имена приходят в фигурных скобках (`{auth_authen}`, `{client_addr}`), односимвольные — голыми (`i`). Спайк Фазы 0 без SASL этого не увидел (`auth_authen` был пуст «и так»). Первый прогон в контейнере дал пустой `app_login`; фикс — brace-толерантный `macro(m,name)` (пробует голый ключ, затем `{name}`). Зафиксировано в памяти [[milter-implementation-facts]]. - **Ключевая находка Фазы 6 (исправлена):** go-milter хранит имена макросов **как их шлёт Postfix** — многосимвольные имена приходят в фигурных скобках (`{auth_authen}`, `{client_addr}`), односимвольные — голыми (`i`). Спайк Фазы 0 без SASL этого не увидел (`auth_authen` был пуст «и так»). Первый прогон в контейнере дал пустой `app_login`; фикс — brace-толерантный `macro(m,name)` (пробует голый ключ, затем `{name}`). Зафиксировано в памяти [[milter-implementation-facts]].
- **Прежняя фаза:** 5 ✅ закрыта (код `b2692e4`, доки `ec4d4b9`/`2dbd8d0`). - **Прежняя фаза:** 5 ✅ закрыта (код `b2692e4`, доки `ec4d4b9`/`2dbd8d0`).
- **Финальное подтверждение доставки** (2026-07-13): реальное письмо `dtester@test.example.org → selfpost@mixeme.ru` доставлено и принято `mx.example.net`, заголовок `Authentication-Results: dkim=pass (d=test.example.org s=selfpost) ... spf=pass ... dmarc=none` (прочитано по IMAP). Попало в Junk из-за репутации нового IP/домена (`IP_REPUTATION_SPAM`, Bayes, `MX_INVALID`у test.example.org только A без MX) — это прогрев IP/DNS уровня деплоя (ТЗ 10), не дефект релея; аутентификация (зона ответственности SelfPost) идеальна. - **Финальное подтверждение доставки** (2026-07-13): реальное письмо `dtester@test.example.org → selfpost@mixeme.ru` доставлено и принято `mx.example.net`, заголовок `Authentication-Results: dkim=pass (d=test.example.org s=selfpost) ... spf=pass ... dmarc=none` (прочитано по IMAP). Попало в Junk из-за репутации нового IP/домена (`IP_REPUTATION_SPAM`, Bayes, `MX_INVALID`у test.example.org только A без MX) — это прогрев IP/DNS уровня деплоя (ТЗ 10), не дефект релея; аутентификация (зона ответственности SelfPost) идеальна.
- **Тупик, который обошли (важно для будущих тестов доставки):** нельзя тестировать доставку, отправляя с домена, который хостит сам приёмник. `mx.example.net` хостит `mixeme.ru`, поэтому письма `mixeme.ru → mixeme.ru` он жёстко отбивал `554 does not meet our delivery requirements` (own-domain anti-spoofing) при валидном DKIM+SPF. Решение: отдельный домен-отправитель `test.example.org` (не на `mc`), которому приёмник доверяет как обычной входящей почте. Первый контакт был `451 Greylisted` (норма) → принят после авто-ретраев Postfix. - **Тупик, который обошли (важно для будущих тестов доставки):** нельзя тестировать доставку, отправляя с домена, который хостит сам приёмник. `mx.example.net` хостит `mixeme.ru`, поэтому письма `mixeme.ru → mixeme.ru` он жёстко отбивал `554 does not meet our delivery requirements` (own-domain anti-spoofing) при валидном DKIM+SPF. Решение: отдельный домен-отправитель `test.example.org` (не на `mc`), которому приёмник доверяет как обычной входящей почте. Первый контакт был `451 Greylisted` (норма) → принят после авто-ретраев Postfix.
- **Артефакты теста на сервере:** контейнер `p5` (домены mixeme.ru id1 / test.example.org id2), скрипт/лог `/tmp/p5retry.sh`+`/root/p5retry.log`, IMAP-читалка `/tmp/imapread5.py`. DNS `test.example.org` (A/SPF/DKIM) можно снять после Фазы 6-тестов; в ящике `selfpost@mixeme.ru` остались bounce-письма от ранних mixeme.ru→mixeme.ru попыток (шум, можно удалить). - **Артефакты теста на сервере:** контейнер `p5` (домены mixeme.ru id1 / test.example.org id2), скрипт/лог `/tmp/p5retry.sh`+`/root/p5retry.log`, IMAP-читалка `/tmp/imapread5.py`. DNS `test.example.org` (A/SPF/DKIM) можно снять после Фазы 6-тестов; в ящике `selfpost@mixeme.ru` остались bounce-письма от ранних mixeme.ru→mixeme.ru попыток (шум, можно удалить).
### Сделано в Фазе 9
- **Полный бэкап** (`internal/backup/backup.go`, ТЗ 7.5.А): `Create(w, Params)` пишет `tar.gz` всего `/data`**консистентный снимок SQLite через `VACUUM INTO`** во временный файл (не побайтовое копирование живого WAL-файла), DKIM-ключи, `sasldb2`, карта Postfix, + `manifest.json` (`format`/`version`/`created_at`). Имена в архиве — относительно `/data`, так что распаковка в bind-mount восстанавливает состояние на месте. **Исключаются**: живой `selfpost.db`(+`-wal`/`-shm`/`-journal`, заменён снимком под тем же именем), `setup-token`, стейл-`manifest.json`, и каталог **`tls/`** — сертификаты это зона reverse-proxy (ТЗ 7.5.А); исключение держит гарантию даже если оператор положил серты в `/data/tls`. Каталоги-записи сохраняются (моды/пустые). Очередь Postfix не входит (ТЗ).
- **Гварда версии при restore** (`CheckRestore`, вызывается в `run()` **до** `store.Open` в `cmd/panel/main.go`): если в `/data` лежит `manifest.json` (значит бэкап распакован), его версия обязана совпасть с версией бинарника, иначе панель **отказывается стартовать** с сообщением, каким тегом образа восстанавливать (`selfpost:<ver>`); при совпадении манифест **потребляется** (удаляется) — гвардит только первый старт после restore и не блокирует обычный in-place апгрейд образа; отсутствие манифеста = обычный старт. Restore — не отдельная ветка кода: состояние (Postfix/OpenDKIM) регенерируется из восстановленного SQLite тем же путём, что при любом старте.
- **CLI `selfpost-backup`** (`cmd/selfpost-backup/main.go`, ТЗ 11.6): по умолчанию пишет `tar.gz` в **stdout** (`docker exec <c> selfpost-backup > backup.tar.gz`), флаг `-o` — в файл (0600). Читает `SELFPOST_DATA_DIR`/`SELFPOST_DB_PATH` из env, зовёт `backup.Create`. Эквивалент кнопки в панели.
- **Кнопка бэкапа** (`POST /backup`, `internal/web/handlers_backup.go`): аутентифицированная отдача `application/gzip` вложением с `Cache-Control: no-store` (архив секретен). `web.Config` расширен `DataDir`/`DBPath`/`Version`.
- **Экспорт/импорт домена** (ТЗ 7.5.Б): `domain.Service.Export/Import` (`internal/domain/transfer.go`) + тип `DomainExport` (JSON: `format`/`version`/`domain`/`dkim_selector`/`dkim_private_key` PKCS#1 PEM/`applications[]` с `login`/`address_mode`/`addresses`/`password`).
- **Секреты SASL:** `SASLDB.Secret(login)` (`internal/app/sasl.go`) читает `sasldb2` через `db_dump` (Berkeley DB, фикс-argv, без shell — 7.6.3), парсит hex-пары, достаёт `userPassword` по ключу `login\0realm\0userPassword` (плейнтекст). `db-util` в образе. `ErrSecretNotFound` при отсутствии.
- **Ключи DKIM:** `OpenDKIM.ExportKey`/`ImportKey` — экспорт ре-маршалит ключ (ловит битые), импорт валидирует PKCS#1 и пишет атомарно (перезапись — импорт (пере)создаёт домен именно этим ключом, чтобы **DNS-запись не менялась**).
- **Импорт-оркестрация** (`Import`): `assertConfigSafe(domain,selector)``AddDomain` (UNIQUE — арбитр дубля, `ErrDomainExists`) → `ImportKey` → resync таблиц → на каждое приложение `apps.ImportApplication` (валидирует login/адреса/пароль, вставляет строку, `sasl.Set` **под локальным realm** = ре-кей) → один `apps.Resync` (карта Postfix). Любой сбой → `importRollback` (= обычный `Delete`: чистит SASL, каскад строк, обе карты, удаляет ключ). `Applications`-интерфейс расширен `Secret`/`ImportApplication`.
- **Веб** (`POST /domains/{id}/export` — секретная отдача JSON вложением `no-store`; `POST /domains/import` — multipart-загрузка ≤1 MiB, `DisallowUnknownFields`, нормализация+валидация имени домена как в add-форме, дружелюбные ошибки дубля/валидации баннером на дашборде, редирект на страницу нового домена с флешем «DNS менять не надо»). Карточка «Backup & migration» (скачать бэкап + импорт домена) на дашборде; карточка «Export domain» на странице домена; обе с предупреждением «файл — секрет».
- **Валидация импортного пароля** (`validateImportedPassword`): непустой, ≤1024, без управляющих символов (перевод строки обрезал бы passphrase на stdin `saslpasswd2`).
- **Проверено на сервере** (selfpost.example.com, образ `selfpost:p9`): `gofmt`/`vet`/`test` зелёные (юниты: backup create включает состояние/исключает transient+tls+wal, снимок — валидный SQLite; CheckRestore нет-манифеста/совпадение-потребляет/несовпадение-отказ+сохраняет/чужой-формат; парс `db_dump` секрета incl. realm-mismatch/not-found; import roundtrip + дубль-домена + rollback при сбое приложения; `ImportApplication` пишет строку+SASL, не трогает карту, реджект битого пароля/чужого адреса). **Контейнерный e2e:** (1) экспорт домена — JSON с PEM+паролем, пароль из `db_dump` **точно совпал** с показанным при создании; (2) импорт на **другой инстанс** (hostname `mail.dst.test``mail.src.test`) — домен/приложение/ключ восстановлены, DKIM-ключ побитово тот же, **ре-кейнутый креденшл аутентифицировался (235) под новым realm** и письмо принято (250); (3) CLI-бэкап через `docker exec` (архив без `tls/`) и кнопка-бэкап (заголовки `no-store`/attachment); (4) restore на чистом контейнере той же версии с **тем же hostname** — стартует без setup, манифест потреблён, домен/приложение/DKIM/админ восстановлены, SMTP-auth работает; (5) **несовпадение версии** (manifest `1.3.0` vs бинарник `dev`) — панель отказывается стартовать с точным сообщением, недоступна, манифест сохранён, контейнер в итоге `Exited(0)` через crashexit. Все критерии «Готово когда» Фазы 9 выполнены.
### Сделано в Фазе 8 ### Сделано в Фазе 8
- **Уровень 2 rate-limit в journal-milter** (ТЗ 7.4): milter, бывший чистым монитором, теперь **отклоняет** письмо `4xx` (`milter.RespTempFail` = 451) при превышении дифференцированного лимита. Проверка на стадии **MAIL FROM** — самой ранней, где известны и домен (из `From`), и приложение (SASL-логин), — до предложения получателей. - **Уровень 2 rate-limit в journal-milter** (ТЗ 7.4): milter, бывший чистым монитором, теперь **отклоняет** письмо `4xx` (`milter.RespTempFail` = 451) при превышении дифференцированного лимита. Проверка на стадии **MAIL FROM** — самой ранней, где известны и домен (из `From`), и приложение (SASL-логин), — до предложения получателей.
- **Когда лимит применяется:** только если у домена/приложения заданы непустой список IP **и** потолок сообщений **и** окно, **и** client IP входит в этот список (`RateLimit.Active()` + `AllowsIP`). Пустая IP-привязка → уровень 2 не применяется (ТЗ 7.4: «оставить пустой → не применяется»); IP вне списка → остаётся только уровень 1 (anvil). Ключ — client IP из `Connect()` (ТЗ 7.4). - **Когда лимит применяется:** только если у домена/приложения заданы непустой список IP **и** потолок сообщений **и** окно, **и** client IP входит в этот список (`RateLimit.Active()` + `AllowsIP`). Пустая IP-привязка → уровень 2 не применяется (ТЗ 7.4: «оставить пустой → не применяется»); IP вне списка → остаётся только уровень 1 (anvil). Ключ — client IP из `Connect()` (ТЗ 7.4).
@@ -163,4 +177,5 @@
- **Фаза 5** (2026-07-12…13, Opus) — полный исходящий релей Postfix: `smtps` 465 (wrapper TLS) как основной + опциональный `submission` 587 (STARTTLS), SASL (`cyrus`/`sasldb2`, реалм через пустой `smtpd_sasl_local_domain` + `myhostname`), привязка отправителя (`smtpd_sender_login_maps`+`reject_sender_login_mismatch`), без open relay (только по кредам, нет `permit_mynetworks`), исходящая доставка (MX-lookup, TLS may), rate-limit L1 (`anvil`), milter-цепочка с per-milter действиями (OpenDKIM tempfail / journal accept). Конфиг генерируется из env в `postfix-config.sh` (вызов из entrypoint). Два инфра-фикса на сервере: `postconf -F '*/*/chroot=n'` (chroot ломал DNS доставки) и права milter-сокетов (группа `selfpost`+setgid, `chmod 0660` на journal-сокет). **Реальная доставка подтверждена:** `test.example.org → selfpost@mixeme.ru`, `dkim=pass`+`spf=pass` в `Authentication-Results` (по IMAP). Коммиты `b2692e4` (релей), `ec4d4b9`/этот (доки). - **Фаза 5** (2026-07-12…13, Opus) — полный исходящий релей Postfix: `smtps` 465 (wrapper TLS) как основной + опциональный `submission` 587 (STARTTLS), SASL (`cyrus`/`sasldb2`, реалм через пустой `smtpd_sasl_local_domain` + `myhostname`), привязка отправителя (`smtpd_sender_login_maps`+`reject_sender_login_mismatch`), без open relay (только по кредам, нет `permit_mynetworks`), исходящая доставка (MX-lookup, TLS may), rate-limit L1 (`anvil`), milter-цепочка с per-milter действиями (OpenDKIM tempfail / journal accept). Конфиг генерируется из env в `postfix-config.sh` (вызов из entrypoint). Два инфра-фикса на сервере: `postconf -F '*/*/chroot=n'` (chroot ломал DNS доставки) и права milter-сокетов (группа `selfpost`+setgid, `chmod 0660` на journal-сокет). **Реальная доставка подтверждена:** `test.example.org → selfpost@mixeme.ru`, `dkim=pass`+`spf=pass` в `Authentication-Results` (по IMAP). Коммиты `b2692e4` (релей), `ec4d4b9`/этот (доки).
- **Фаза 6** (2026-07-13, Opus) — journal-milter + обновление статусов Send Log (наивысший риск ТЗ 7.3): milter на go-milter v0.4.1 (запись `send_log` на пару queue-id/получатель на EOM, строго fail-open — колбэки только Continue/Accept), log-tailer с ротацией `mail.log` (парс `sent/deferred/bounced/expired` → апдейт по queue-id+получатель), retention (`SEND_LOG_RETENTION_DAYS`=90, чистка при старте+каждые 6ч), bounded milter-таймауты (15/15/30с) для fail-open при зависании. Store открывается один раз и шарится ролями. **Найден и исправлен** пустой `app_login`: имена макросов приходят в фигурных скобках (`{auth_authen}`) — brace-толерантный `macro()`. Юниты + контейнерный e2e зелёные; **fail-open проверен дважды** (недоступность и зависание), retention проверен. Новая зависимость go-milter (BSD-2). - **Фаза 6** (2026-07-13, Opus) — journal-milter + обновление статусов Send Log (наивысший риск ТЗ 7.3): milter на go-milter v0.4.1 (запись `send_log` на пару queue-id/получатель на EOM, строго fail-open — колбэки только Continue/Accept), log-tailer с ротацией `mail.log` (парс `sent/deferred/bounced/expired` → апдейт по queue-id+получатель), retention (`SEND_LOG_RETENTION_DAYS`=90, чистка при старте+каждые 6ч), bounded milter-таймауты (15/15/30с) для fail-open при зависании. Store открывается один раз и шарится ролями. **Найден и исправлен** пустой `app_login`: имена макросов приходят в фигурных скобках (`{auth_authen}`) — brace-толерантный `macro()`. Юниты + контейнерный e2e зелёные; **fail-open проверен дважды** (недоступность и зависание), retention проверен. Новая зависимость go-milter (BSD-2).
- **Фаза 8** (2026-07-14, Opus) — дифференцированные лимиты (rate limit уровень 2, ТЗ 7.4): journal-milter из чистого монитора стал отклонять письмо `4xx` (`RespTempFail` 451) на стадии MAIL FROM при превышении лимита домена/приложения; ключ — client IP, счёт — `COUNT(DISTINCT queue_id)` в скользящем окне по `send_log`, применяется только при непустой IP-привязке (иначе только уровень 1). Строго **fail-open** на собственных ошибках (сбой лимитера не блокирует почту, уровень-1 anvil независим). Отклонения пишутся `send_log` статусом `rejected` для UI. Store `internal/store/ratelimits.go` (таблица `rate_limits` уже была с Фазы 2 — миграции нет), панель-формы на домене и приложении с серверной валидацией IP/чисел (ТЗ 7.6.2), очистка лимитов при каскадном удалении. Юниты + контейнерный e2e (`p8`): реджект на обоих уровнях, unregistered-IP игнор, fail-open при остановке панели — зелёные. Все критерии «Готово когда» Фазы 8 выполнены. - **Фаза 8** (2026-07-14, Opus) — дифференцированные лимиты (rate limit уровень 2, ТЗ 7.4): journal-milter из чистого монитора стал отклонять письмо `4xx` (`RespTempFail` 451) на стадии MAIL FROM при превышении лимита домена/приложения; ключ — client IP, счёт — `COUNT(DISTINCT queue_id)` в скользящем окне по `send_log`, применяется только при непустой IP-привязке (иначе только уровень 1). Строго **fail-open** на собственных ошибках (сбой лимитера не блокирует почту, уровень-1 anvil независим). Отклонения пишутся `send_log` статусом `rejected` для UI. Store `internal/store/ratelimits.go` (таблица `rate_limits` уже была с Фазы 2 — миграции нет), панель-формы на домене и приложении с серверной валидацией IP/чисел (ТЗ 7.6.2), очистка лимитов при каскадном удалении. Юниты + контейнерный e2e (`p8`): реджект на обоих уровнях, unregistered-IP игнор, fail-open при остановке панели — зелёные. Все критерии «Готово когда» Фазы 8 выполнены.
- **Фаза 9** (2026-07-14, Opus) — бэкап/restore + экспорт/импорт домена (ТЗ 7.5, 11.6): полный `tar.gz`-бэкап всего `/data` (консистентный снимок SQLite через `VACUUM INTO`, DKIM-ключи, `sasldb2`, `manifest.json` с версией; без TLS-сертов/`tls/` и очереди Postfix) двумя путями — кнопка `POST /backup` и CLI `selfpost-backup` через `docker exec`; гварда версии `CheckRestore` до `store.Open` (несовпадение → отказ старта с указанием тега; совпадение → манифест потребляется, restore идёт обычным стартом без отдельной ветки). Экспорт/импорт домена: `DomainExport` (DKIM-ключ PKCS#1 PEM + приложения с **рабочими паролями**), секреты SASL читаются из `sasldb2` через `db_dump` (`userPassword` — плейнтекст), на импорте **ре-кеятся под локальный realm** через `saslpasswd2` → креды работают на инстансе с другим hostname без перевыпуска, DKIM DNS-запись не меняется. `db-util` добавлен в образ. Юниты + контейнерный e2e (экспорт↔импорт кросс-realm с проверкой SMTP-auth 235; CLI+кнопка бэкап; restore той же версии; отказ при несовпадении версии) зелёные.
- **Фаза 7** (2026-07-13, Sonnet) — UI мониторинга: три экрана (журнал отправки с серверными фильтрами домен/приложение + пагинацией, очередь Postfix `postqueue -p`, хвост `mail.log`), все с HTMX-polling каждые 5с; fragment-эндпоинты отдают HTML (ТЗ 7.1), вывод экранирован `html/template` (ТЗ 7.6.7, проверено на `<script>` в теме письма). Новое: `store.QuerySendLog/CountSendLog/ListApplicationLogins`, `postfix.Queue()`, `logtail.TailLines` (точечное обратное чтение хвоста, независимо от фонового `follow()`). Юниты/vet/gofmt зелёные; контейнерный e2e (фильтры, пагинация на 60 строках, экранирование, `postqueue -p`, реальные строки `mail.log`, существующий Reload не сломан) — зелёный. - **Фаза 7** (2026-07-13, Sonnet) — UI мониторинга: три экрана (журнал отправки с серверными фильтрами домен/приложение + пагинацией, очередь Postfix `postqueue -p`, хвост `mail.log`), все с HTMX-polling каждые 5с; fragment-эндпоинты отдают HTML (ТЗ 7.1), вывод экранирован `html/template` (ТЗ 7.6.7, проверено на `<script>` в теме письма). Новое: `store.QuerySendLog/CountSendLog/ListApplicationLogins`, `postfix.Queue()`, `logtail.TailLines` (точечное обратное чтение хвоста, независимо от фонового `follow()`). Юниты/vet/gofmt зелёные; контейнерный e2e (фильтры, пагинация на 60 строках, экранирование, `postqueue -p`, реальные строки `mail.log`, существующий Reload не сломан) — зелёный.
+101 -1
View File
@@ -1,7 +1,9 @@
package app package app
import ( import (
"bufio"
"bytes" "bytes"
"encoding/hex"
"fmt" "fmt"
"os/exec" "os/exec"
"strings" "strings"
@@ -18,13 +20,17 @@ type SASLDB struct {
// run executes saslpasswd2. It is a field so tests can substitute a fake; // run executes saslpasswd2. It is a field so tests can substitute a fake;
// the default shells out to the real binary via runSaslpasswd2. // the default shells out to the real binary via runSaslpasswd2.
run func(args []string, stdin []byte) error run func(args []string, stdin []byte) error
// dump reads the raw sasldb2 as db_dump key/value pairs (Berkeley DB). It is
// a field so tests can substitute a fake; the default runs db_dump.
dump func(path string) ([]byte, error)
} }
// NewSASLDB builds a manager for the sasldb2 at path with the given realm. The // NewSASLDB builds a manager for the sasldb2 at path with the given realm. The
// realm should match SELFPOST_HOSTNAME so the account identity lines up with // realm should match SELFPOST_HOSTNAME so the account identity lines up with
// Postfix's SASL configuration in Phase 5. // Postfix's SASL configuration in Phase 5.
func NewSASLDB(path, realm string) *SASLDB { func NewSASLDB(path, realm string) *SASLDB {
return &SASLDB{path: path, realm: realm, run: runSaslpasswd2} return &SASLDB{path: path, realm: realm, run: runSaslpasswd2, dump: dumpSASLDB}
} }
// Set creates or updates an application's SASL account with the given password // Set creates or updates an application's SASL account with the given password
@@ -65,6 +71,88 @@ func (s *SASLDB) Delete(login string) error {
return nil return nil
} }
// ErrSecretNotFound is returned by Secret when the sasldb2 has no password entry
// for the login under this realm.
var ErrSecretNotFound = fmt.Errorf("sasl secret not found")
// Secret returns an application's stored password so it can be carried in a
// domain export and re-created verbatim on another instance (spec 7.5.B). This
// is possible because sasldb2 keeps the SASL secret in a password-equivalent
// form (the plaintext userPassword property, to serve challenge-response
// mechanisms) — unlike the admin's one-way bcrypt hash (spec 7.6). The value is
// realm-independent, so the importer can re-key it under its own realm.
//
// It reads the database with db_dump (Berkeley DB), passing only our own file
// path as a fixed argument (no shell, no user input — spec 7.6.3), and returns
// ErrSecretNotFound if the login has no entry.
func (s *SASLDB) Secret(login string) (string, error) {
if err := validateLogin(login); err != nil {
return "", err
}
out, err := s.dump(s.path)
if err != nil {
return "", fmt.Errorf("read sasldb2 for %q: %w", login, err)
}
secret, ok, err := parseSASLSecret(out, login, s.realm)
if err != nil {
return "", fmt.Errorf("parse sasldb2 for %q: %w", login, err)
}
if !ok {
return "", fmt.Errorf("login %q: %w", login, ErrSecretNotFound)
}
return secret, nil
}
// parseSASLSecret scans db_dump's byte-value output for the userPassword entry
// keyed by (login, realm). sasldb2 keys are NUL-separated tuples
// "<login>\0<realm>\0<property>"; the matching value is the stored password.
func parseSASLSecret(dump []byte, login, realm string) (string, bool, error) {
sc := bufio.NewScanner(bytes.NewReader(dump))
// sasldb2 records are tiny, but raise the line cap so a long hex line is
// never silently truncated.
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
inData := false
var keyBytes []byte
haveKey := false
for sc.Scan() {
line := sc.Text()
if !inData {
if line == "HEADER=END" {
inData = true
}
continue
}
if line == "DATA=END" {
break
}
// Each data line is a single leading space followed by hex.
hexStr := strings.TrimPrefix(line, " ")
raw, err := hex.DecodeString(hexStr)
if err != nil {
return "", false, fmt.Errorf("bad db_dump hex line: %w", err)
}
if !haveKey {
keyBytes = raw
haveKey = true
continue
}
// raw is the value for keyBytes.
haveKey = false
parts := bytes.Split(keyBytes, []byte{0})
if len(parts) != 3 {
continue
}
if string(parts[0]) == login && string(parts[1]) == realm && string(parts[2]) == "userPassword" {
return string(raw), true, nil
}
}
if err := sc.Err(); err != nil {
return "", false, err
}
return "", false, nil
}
// runSaslpasswd2 executes the real saslpasswd2 with the given arguments and // runSaslpasswd2 executes the real saslpasswd2 with the given arguments and
// stdin. Arguments are passed as a fixed argv (no shell), so no user input is // stdin. Arguments are passed as a fixed argv (no shell), so no user input is
// ever interpreted as a command (spec 7.6.3). // ever interpreted as a command (spec 7.6.3).
@@ -79,3 +167,15 @@ func runSaslpasswd2(args []string, stdin []byte) error {
} }
return nil return nil
} }
// dumpSASLDB runs db_dump to export the sasldb2 as key/value hex pairs. The path
// is our own sasldb2 file (never user input) and is passed as a fixed argument
// with no shell (spec 7.6.3).
func dumpSASLDB(path string) ([]byte, error) {
cmd := exec.Command("db_dump", path)
out, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("db_dump: %w: %s", err, strings.TrimSpace(string(out)))
}
return out, nil
}
+75
View File
@@ -1,6 +1,9 @@
package app package app
import ( import (
"encoding/hex"
"errors"
"fmt"
"strings" "strings"
"testing" "testing"
) )
@@ -62,6 +65,78 @@ func TestSASLDeleteArgs(t *testing.T) {
} }
} }
// makeDump builds a db_dump byte-value document from key/value byte pairs, the
// same shape `db_dump <sasldb2>` emits.
func makeDump(pairs [][2][]byte) []byte {
var b strings.Builder
b.WriteString("VERSION=3\nformat=bytevalue\ntype=hash\nHEADER=END\n")
for _, p := range pairs {
fmt.Fprintf(&b, " %s\n", hex.EncodeToString(p[0]))
fmt.Fprintf(&b, " %s\n", hex.EncodeToString(p[1]))
}
b.WriteString("DATA=END\n")
return []byte(b.String())
}
func saslKey(login, realm, prop string) []byte {
return []byte(login + "\x00" + realm + "\x00" + prop)
}
func TestSecretExtractsPassword(t *testing.T) {
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
s.dump = func(path string) ([]byte, error) {
if path != "/data/sasl/sasldb2" {
t.Errorf("dump path = %q", path)
}
return makeDump([][2][]byte{
{saslKey("other", "mail.example.com", "userPassword"), []byte("otherpw")},
{saslKey("alerts", "mail.example.com", "userPassword"), []byte("hunter2-pass")},
}), nil
}
got, err := s.Secret("alerts")
if err != nil {
t.Fatalf("Secret: %v", err)
}
if got != "hunter2-pass" {
t.Errorf("Secret = %q, want %q", got, "hunter2-pass")
}
}
func TestSecretRealmMismatchNotFound(t *testing.T) {
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
s.dump = func(string) ([]byte, error) {
// Same login but a different realm must not match.
return makeDump([][2][]byte{
{saslKey("alerts", "other.host", "userPassword"), []byte("hunter2")},
}), nil
}
if _, err := s.Secret("alerts"); !errors.Is(err, ErrSecretNotFound) {
t.Errorf("Secret err = %v, want ErrSecretNotFound", err)
}
}
func TestSecretMissingLoginNotFound(t *testing.T) {
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
s.dump = func(string) ([]byte, error) {
return makeDump(nil), nil
}
if _, err := s.Secret("ghost"); !errors.Is(err, ErrSecretNotFound) {
t.Errorf("Secret err = %v, want ErrSecretNotFound", err)
}
}
func TestSecretRejectsInvalidLoginBeforeDump(t *testing.T) {
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
called := false
s.dump = func(string) ([]byte, error) { called = true; return nil, nil }
if _, err := s.Secret("bad login"); err == nil {
t.Error("Secret accepted invalid login")
}
if called {
t.Error("db_dump invoked for an invalid login")
}
}
func TestSASLRejectsInvalidLoginBeforeExec(t *testing.T) { func TestSASLRejectsInvalidLoginBeforeExec(t *testing.T) {
s, fr := newFakeSASL() s, fr := newFakeSASL()
if err := s.Set("bad login", "pw"); err == nil { if err := s.Set("bad login", "pw"); err == nil {
+32
View File
@@ -92,6 +92,38 @@ func (s *Service) rollbackCreate(id int64, login string) {
_, _ = s.store.DeleteApplication(id) _, _ = s.store.DeleteApplication(id)
} }
// ImportApplication re-creates an application from a domain-export file (spec
// 7.5.B): it validates the login and (in list mode) that every address belongs
// to the domain, inserts the registry row and writes the SASL account with the
// imported password verbatim, re-keyed under this instance's realm so the
// credential keeps working without regeneration. It deliberately does not
// rebuild the sender map — the caller (domain import) does that once after all
// applications are in — and returns store.ErrLoginExists if the login collides.
func (s *Service) ImportApplication(domainID int64, login, mode string, rawAddresses []string, password string) error {
addresses, err := s.validateForDomain(domainID, login, mode, rawAddresses)
if err != nil {
return err
}
if err := validateImportedPassword(password); err != nil {
return err
}
a, err := s.store.AddApplication(domainID, login, mode, addresses)
if err != nil {
return err // ErrLoginExists surfaces to the caller as a friendly message
}
if err := s.sasl.Set(login, password); err != nil {
_, _ = s.store.DeleteApplication(a.ID)
return err
}
return nil
}
// Secret returns an application's stored password for a domain export (spec
// 7.5.B). See SASLDB.Secret for why this is possible and safe.
func (s *Service) Secret(login string) (string, error) {
return s.sasl.Secret(login)
}
// UpdateMode switches an application's address mode / list and rebuilds the // UpdateMode switches an application's address mode / list and rebuilds the
// sender map (spec 7.2.7). The login and password are untouched. Addresses are // sender map (spec 7.2.7). The login and password are untouched. Addresses are
// re-validated against the application's domain. // re-validated against the application's domain.
+46
View File
@@ -221,6 +221,52 @@ func TestServiceRegeneratePassword(t *testing.T) {
} }
} }
func TestImportApplicationWritesRowAndSASL(t *testing.T) {
svc, st, rec, maps := newServiceHarness(t)
d := addDomain(t, st, "example.com")
if err := svc.ImportApplication(d.ID, "mailer", store.AddressModeList,
[]string{"a@example.com"}, "imported-pw"); err != nil {
t.Fatalf("ImportApplication: %v", err)
}
// Registry row and SASL account written with the imported password verbatim.
apps, _ := st.ListApplicationsByDomain(d.ID)
if len(apps) != 1 || apps[0].Login != "mailer" {
t.Fatalf("apps = %+v", apps)
}
if rec.set["mailer"] != "imported-pw" {
t.Errorf("SASL password = %q, want the imported one", rec.set["mailer"])
}
// Import does not rebuild the sender map itself (the caller batches that).
if maps.calls != 0 {
t.Errorf("ImportApplication rebuilt the map %d times, want 0", maps.calls)
}
}
func TestImportApplicationRejectsBadInput(t *testing.T) {
svc, st, rec, _ := newServiceHarness(t)
d := addDomain(t, st, "example.com")
// Empty password.
if err := svc.ImportApplication(d.ID, "mailer", store.AddressModeWildcard, nil, ""); err == nil {
t.Error("accepted empty imported password")
}
// Password with an embedded newline would truncate on the saslpasswd2 stdin.
if err := svc.ImportApplication(d.ID, "mailer", store.AddressModeWildcard, nil, "line1\nline2"); err == nil {
t.Error("accepted password with control characters")
}
// Cross-domain address.
if err := svc.ImportApplication(d.ID, "mailer", store.AddressModeList, []string{"x@evil.com"}, "pw"); err == nil {
t.Error("accepted cross-domain address")
}
if apps, _ := st.ListApplicationsByDomain(d.ID); len(apps) != 0 {
t.Errorf("rows persisted despite validation failure: %+v", apps)
}
if len(rec.set) != 0 {
t.Errorf("SASL accounts written despite validation failure: %v", rec.set)
}
}
func TestServicePurgeDomainSASL(t *testing.T) { func TestServicePurgeDomainSASL(t *testing.T) {
svc, st, rec, _ := newServiceHarness(t) svc, st, rec, _ := newServiceHarness(t)
d := addDomain(t, st, "example.com") d := addDomain(t, st, "example.com")
+20
View File
@@ -35,6 +35,26 @@ func validateLogin(login string) error {
return nil return nil
} }
// validateImportedPassword guards a password taken from a domain-export file
// (spec 7.5.B) before it is written to sasldb2. Our own exports carry base64url
// passwords, but the file is untrusted input, so we reject an empty value or one
// containing control characters — saslpasswd2 reads the passphrase from stdin
// and a newline would silently truncate it (spec 7.6.2).
func validateImportedPassword(password string) error {
if password == "" {
return fmt.Errorf("imported application password is empty")
}
if len(password) > 1024 {
return fmt.Errorf("imported application password is too long")
}
for _, r := range password {
if r < 0x20 || r == 0x7f {
return fmt.Errorf("imported application password contains control characters")
}
}
return nil
}
// validateAddressMode checks the submitted mode is one of the two known values. // validateAddressMode checks the submitted mode is one of the two known values.
func validateAddressMode(mode string) error { func validateAddressMode(mode string) error {
if mode != store.AddressModeWildcard && mode != store.AddressModeList { if mode != store.AddressModeWildcard && mode != store.AddressModeList {
+290
View File
@@ -0,0 +1,290 @@
// Package backup implements SelfPost's full-server backup and the restore
// version guard (spec 7.5.A). A full backup is a gzip-compressed tar of the
// consolidated persistent state under /data — the SQLite database (as a
// consistent snapshot), the per-domain DKIM keys and the SASL database — plus a
// manifest recording the SelfPost version that produced it. TLS certificates
// (the reverse proxy's responsibility) and the Postfix queue are deliberately
// excluded (spec 7.5.A).
//
// Restore is intentionally not a separate code path: a backup is extracted into
// the /data bind mount before first start, and the panel regenerates Postfix and
// OpenDKIM from the restored SQLite state exactly as on any normal start. The
// only restore-specific step is CheckRestore, which refuses to boot if the
// manifest's version does not match the running binary, so schema/format skew
// between versions cannot silently corrupt state (spec 7.5.A).
package backup
import (
"archive/tar"
"compress/gzip"
"database/sql"
"encoding/json"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"time"
_ "modernc.org/sqlite" // pure-Go SQLite driver, for the VACUUM INTO snapshot
)
// FormatFull identifies a full-server backup manifest.
const FormatFull = "selfpost-full-backup"
// ManifestName is the manifest's filename, both inside the archive and, after a
// restore extraction, at the root of the data directory where CheckRestore
// looks for it.
const ManifestName = "manifest.json"
// Manifest is the small JSON document embedded in every backup archive. Its
// Version is the single fact that makes restore safe: the panel refuses to boot
// a data directory whose manifest version does not match its own binary (spec
// 7.5.A).
type Manifest struct {
Format string `json:"format"`
Version string `json:"version"`
CreatedAt string `json:"created_at"`
}
// Params configures a backup. DataDir is the consolidated state root (/data);
// DBPath is the live SQLite file within it, snapshotted consistently rather than
// copied byte-for-byte while it may be mid-write; Version is stamped into the
// manifest.
type Params struct {
DataDir string
DBPath string
Version string
}
// excludedFromArchive lists the data-directory entries a backup never carries.
// The live database files are replaced by a consistent VACUUM INTO snapshot
// written under the canonical name; the setup token is transient bootstrap
// state; a stale manifest from a previous restore must not be re-captured (a
// fresh one is written instead); and a "tls" directory holds the reverse
// proxy's certificates, which are explicitly out of scope for a SelfPost backup
// (spec 7.5.A) — excluding it keeps that guarantee even when an operator points
// TLS_CERT_FILE inside /data.
var excludedFromArchive = map[string]bool{
"selfpost.db": true,
"selfpost.db-wal": true,
"selfpost.db-shm": true,
"selfpost.db-journal": true,
"setup-token": true,
"tls": true,
ManifestName: true,
}
// Create writes a gzip-compressed tar backup to w. Archive entries are named
// relative to DataDir, so extracting the archive into the /data bind mount
// reconstructs the state in place (spec 7.5.A). The SQLite database is added as
// a consistent snapshot under "selfpost.db"; everything else under DataDir is
// copied as-is except the entries in excludedFromArchive.
func Create(w io.Writer, p Params) error {
if p.DataDir == "" || p.DBPath == "" {
return fmt.Errorf("backup: DataDir and DBPath are required")
}
snapshot, cleanup, err := snapshotDB(p.DBPath)
if err != nil {
return err
}
defer cleanup()
gz := gzip.NewWriter(w)
tw := tar.NewWriter(gz)
manifest := Manifest{
Format: FormatFull,
Version: p.Version,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}
manifestJSON, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return fmt.Errorf("backup: encode manifest: %w", err)
}
if err := writeTarBytes(tw, ManifestName, 0o600, manifestJSON); err != nil {
return err
}
// The consistent SQLite snapshot, under the canonical filename the panel
// opens on start (the live file and its WAL/SHM are excluded from the walk).
if err := writeTarFile(tw, "selfpost.db", 0o640, snapshot); err != nil {
return err
}
if err := addTree(tw, p.DataDir); err != nil {
return err
}
if err := tw.Close(); err != nil {
return fmt.Errorf("backup: close tar: %w", err)
}
if err := gz.Close(); err != nil {
return fmt.Errorf("backup: close gzip: %w", err)
}
return nil
}
// addTree walks dataDir and adds every regular file (and directory, to preserve
// empty ones and modes) to tw under its path relative to dataDir, skipping the
// excluded entries. Non-regular, non-directory entries (symlinks, sockets) are
// skipped: /data holds none in normal operation, and copying them into a backup
// would be meaningless or unsafe.
func addTree(tw *tar.Writer, dataDir string) error {
return filepath.WalkDir(dataDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(dataDir, path)
if err != nil {
return err
}
if rel == "." {
return nil // the data root itself is implicit
}
// filepath.Rel yields OS separators; tar names use forward slashes.
name := filepath.ToSlash(rel)
// Exclude by top-level name (the live DB, setup token and stale manifest
// all live at the data root).
if excludedFromArchive[name] {
if d.IsDir() {
return fs.SkipDir
}
return nil
}
info, err := d.Info()
if err != nil {
return err
}
switch {
case d.IsDir():
hdr := &tar.Header{
Typeflag: tar.TypeDir,
Name: name + "/",
Mode: int64(info.Mode().Perm()),
ModTime: info.ModTime(),
}
return tw.WriteHeader(hdr)
case info.Mode().IsRegular():
return writeTarFile(tw, name, info.Mode().Perm(), path)
default:
return nil // skip symlinks/sockets/devices
}
})
}
// writeTarBytes writes an in-memory file entry.
func writeTarBytes(tw *tar.Writer, name string, mode int64, data []byte) error {
hdr := &tar.Header{
Typeflag: tar.TypeReg,
Name: name,
Mode: mode,
Size: int64(len(data)),
ModTime: time.Now().UTC(),
}
if err := tw.WriteHeader(hdr); err != nil {
return fmt.Errorf("backup: write header %s: %w", name, err)
}
if _, err := tw.Write(data); err != nil {
return fmt.Errorf("backup: write %s: %w", name, err)
}
return nil
}
// writeTarFile streams a file from disk into the archive under name.
func writeTarFile(tw *tar.Writer, name string, mode fs.FileMode, srcPath string) error {
f, err := os.Open(srcPath)
if err != nil {
return fmt.Errorf("backup: open %s: %w", srcPath, err)
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return fmt.Errorf("backup: stat %s: %w", srcPath, err)
}
hdr := &tar.Header{
Typeflag: tar.TypeReg,
Name: name,
Mode: int64(mode.Perm()),
Size: info.Size(),
ModTime: info.ModTime(),
}
if err := tw.WriteHeader(hdr); err != nil {
return fmt.Errorf("backup: write header %s: %w", name, err)
}
if _, err := io.Copy(tw, f); err != nil {
return fmt.Errorf("backup: copy %s: %w", name, err)
}
return nil
}
// snapshotDB produces a consistent copy of the SQLite database at dbPath using
// VACUUM INTO, so the backup captures a coherent point-in-time image even while
// the panel is writing to the live file under WAL. It returns the snapshot path
// and a cleanup function the caller must defer.
func snapshotDB(dbPath string) (path string, cleanup func(), err error) {
dir, err := os.MkdirTemp("", "selfpost-backup-")
if err != nil {
return "", nil, fmt.Errorf("backup: temp dir: %w", err)
}
cleanup = func() { _ = os.RemoveAll(dir) }
target := filepath.Join(dir, "selfpost.db")
// A short busy timeout lets VACUUM INTO wait out a brief writer rather than
// failing immediately if the panel happens to be mid-write.
db, err := sql.Open("sqlite", "file:"+dbPath+"?_pragma=busy_timeout(5000)")
if err != nil {
cleanup()
return "", nil, fmt.Errorf("backup: open database: %w", err)
}
defer db.Close()
db.SetMaxOpenConns(1)
// VACUUM INTO takes a string literal, not a bound parameter. target is a path
// we generated (never user input); single quotes are doubled defensively.
stmt := "VACUUM INTO '" + strings.ReplaceAll(target, "'", "''") + "'"
if _, err := db.Exec(stmt); err != nil {
cleanup()
return "", nil, fmt.Errorf("backup: snapshot database: %w", err)
}
return target, cleanup, nil
}
// CheckRestore enforces the backup version guard (spec 7.5.A). If manifestPath
// exists (a backup was extracted into the data directory), its version must
// match binaryVersion or the panel refuses to start, telling the operator which
// image tag to use. On a match the manifest is consumed (deleted) so it guards
// only the first boot after a restore and never blocks a later in-place image
// upgrade. Absence of the manifest is the normal case and returns nil.
func CheckRestore(manifestPath, binaryVersion string) error {
data, err := os.ReadFile(manifestPath)
if os.IsNotExist(err) {
return nil // ordinary start, not a restore
}
if err != nil {
return fmt.Errorf("backup: read restore manifest: %w", err)
}
var m Manifest
if err := json.Unmarshal(data, &m); err != nil {
return fmt.Errorf("backup: restore manifest %s is not valid JSON: %w", manifestPath, err)
}
if m.Format != FormatFull {
return fmt.Errorf("backup: %s is not a SelfPost full backup manifest (format %q)", manifestPath, m.Format)
}
if m.Version != binaryVersion {
return fmt.Errorf(
"backup: this backup was created by SelfPost %s but this image is %s — restore into the matching image (selfpost:%s)",
m.Version, binaryVersion, m.Version)
}
// Version matches: consume the manifest so subsequent normal starts (and
// in-place upgrades) are not gated by it.
if err := os.Remove(manifestPath); err != nil {
return fmt.Errorf("backup: consume restore manifest: %w", err)
}
return nil
}
+189
View File
@@ -0,0 +1,189 @@
package backup
import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
"testing"
"codeberg.org/mix/selfpost/internal/store"
)
// seedDataDir builds a realistic /data tree: a migrated SQLite database plus the
// DKIM key, SASL and transient files a backup must include or exclude.
func seedDataDir(t *testing.T) (dataDir, dbPath string) {
t.Helper()
dataDir = t.TempDir()
dbPath = filepath.Join(dataDir, "selfpost.db")
st, err := store.Open(dbPath)
if err != nil {
t.Fatalf("open store: %v", err)
}
if _, err := st.AddDomain("example.com", "selfpost"); err != nil {
t.Fatalf("add domain: %v", err)
}
if err := st.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
writeFile(t, filepath.Join(dataDir, "opendkim", "keys", "example.com", "selfpost.private"), "PRIVATE KEY")
writeFile(t, filepath.Join(dataDir, "sasl", "sasldb2"), "SASLDB")
writeFile(t, filepath.Join(dataDir, "postfix", "sender_login_maps"), "@example.com login")
// Transient files that must NOT be archived.
writeFile(t, filepath.Join(dataDir, "setup-token"), "secret-token")
writeFile(t, filepath.Join(dataDir, "selfpost.db-wal"), "wal")
writeFile(t, filepath.Join(dataDir, "selfpost.db-shm"), "shm")
return dataDir, dbPath
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
// readArchive returns the set of regular-file entries (name -> content) in a
// gzip tar produced by Create.
func readArchive(t *testing.T, data []byte) map[string]string {
t.Helper()
gz, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
t.Fatalf("gzip: %v", err)
}
tr := tar.NewReader(gz)
out := map[string]string{}
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("tar next: %v", err)
}
if hdr.Typeflag != tar.TypeReg {
continue
}
b, err := io.ReadAll(tr)
if err != nil {
t.Fatalf("tar read %s: %v", hdr.Name, err)
}
out[hdr.Name] = string(b)
}
return out
}
func TestCreateIncludesStateExcludesTransient(t *testing.T) {
dataDir, dbPath := seedDataDir(t)
var buf bytes.Buffer
if err := Create(&buf, Params{DataDir: dataDir, DBPath: dbPath, Version: "1.2.3"}); err != nil {
t.Fatalf("Create: %v", err)
}
files := readArchive(t, buf.Bytes())
// Present.
for _, name := range []string{
ManifestName,
"selfpost.db",
"opendkim/keys/example.com/selfpost.private",
"sasl/sasldb2",
"postfix/sender_login_maps",
} {
if _, ok := files[name]; !ok {
t.Errorf("archive missing %s", name)
}
}
// Excluded.
for _, name := range []string{"setup-token", "selfpost.db-wal", "selfpost.db-shm"} {
if _, ok := files[name]; ok {
t.Errorf("archive should not contain %s", name)
}
}
// Manifest is well-formed and carries the version.
var m Manifest
if err := json.Unmarshal([]byte(files[ManifestName]), &m); err != nil {
t.Fatalf("manifest json: %v", err)
}
if m.Format != FormatFull || m.Version != "1.2.3" {
t.Errorf("manifest = %+v, want format=%s version=1.2.3", m, FormatFull)
}
// The archived selfpost.db is a real, openable SQLite snapshot with our data.
snapPath := filepath.Join(t.TempDir(), "restored.db")
if err := os.WriteFile(snapPath, []byte(files["selfpost.db"]), 0o640); err != nil {
t.Fatalf("write snapshot: %v", err)
}
st, err := store.Open(snapPath)
if err != nil {
t.Fatalf("open snapshot: %v", err)
}
defer st.Close()
domains, err := st.ListDomains()
if err != nil {
t.Fatalf("list domains from snapshot: %v", err)
}
if len(domains) != 1 || domains[0].Name != "example.com" {
t.Errorf("snapshot domains = %+v, want one example.com", domains)
}
}
func writeManifest(t *testing.T, dir, format, version string) string {
t.Helper()
path := filepath.Join(dir, ManifestName)
b, _ := json.Marshal(Manifest{Format: format, Version: version, CreatedAt: "now"})
if err := os.WriteFile(path, b, 0o600); err != nil {
t.Fatalf("write manifest: %v", err)
}
return path
}
func TestCheckRestoreNoManifestIsNormalStart(t *testing.T) {
if err := CheckRestore(filepath.Join(t.TempDir(), "manifest.json"), "1.0.0"); err != nil {
t.Errorf("CheckRestore with no manifest = %v, want nil", err)
}
}
func TestCheckRestoreMatchConsumesManifest(t *testing.T) {
dir := t.TempDir()
path := writeManifest(t, dir, FormatFull, "1.0.0")
if err := CheckRestore(path, "1.0.0"); err != nil {
t.Fatalf("CheckRestore matching = %v, want nil", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("manifest should be consumed after a matching restore, stat err = %v", err)
}
}
func TestCheckRestoreVersionMismatchRefusesAndKeeps(t *testing.T) {
dir := t.TempDir()
path := writeManifest(t, dir, FormatFull, "1.0.0")
err := CheckRestore(path, "2.0.0")
if err == nil {
t.Fatal("CheckRestore mismatch = nil, want error")
}
if !strings.Contains(err.Error(), "1.0.0") || !strings.Contains(err.Error(), "2.0.0") {
t.Errorf("error should name both versions: %v", err)
}
if _, statErr := os.Stat(path); statErr != nil {
t.Errorf("manifest must be kept on mismatch so the operator can switch images: %v", statErr)
}
}
func TestCheckRestoreWrongFormatRejected(t *testing.T) {
dir := t.TempDir()
path := writeManifest(t, dir, "something-else", "1.0.0")
if err := CheckRestore(path, "1.0.0"); err == nil {
t.Error("CheckRestore accepted a non-backup manifest")
}
}
+42
View File
@@ -1,6 +1,8 @@
package domain package domain
import ( import (
"crypto/x509"
"encoding/pem"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
@@ -87,6 +89,46 @@ func (o *OpenDKIM) RemoveKey(domainName string) error {
return nil return nil
} }
// ExportKey returns a domain's DKIM private key as PKCS#1 PEM, for carrying in a
// domain export so the receiving instance signs with the same key and the DNS
// TXT record never has to change (spec 7.5.B). It re-marshals the parsed key
// rather than returning the raw file, so a malformed on-disk key is caught here.
func (o *OpenDKIM) ExportKey(domainName, selector string) ([]byte, error) {
if err := assertConfigSafe(domainName, selector); err != nil {
return nil, err
}
key, err := loadPrivateKeyPEM(o.keyPath(domainName, selector))
if err != nil {
return nil, err
}
block := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}
return pem.EncodeToMemory(block), nil
}
// ImportKey writes an imported DKIM private key to disk for a domain (spec
// 7.5.B). The PEM is parsed and re-marshalled through the same writer used for
// generated keys, so only a well-formed PKCS#1 RSA key is ever stored. Unlike
// EnsureKey it overwrites: an import (re-)creates the domain with exactly this
// key, which is the whole point of keeping the published DNS record valid.
func (o *OpenDKIM) ImportKey(domainName, selector string, pemKey []byte) error {
if err := assertConfigSafe(domainName, selector); err != nil {
return err
}
block, _ := pem.Decode(pemKey)
if block == nil || block.Type != "RSA PRIVATE KEY" {
return fmt.Errorf("import dkim key for %s: not a PKCS#1 RSA private key", domainName)
}
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return fmt.Errorf("import dkim key for %s: %w", domainName, err)
}
path := o.keyPath(domainName, selector)
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return fmt.Errorf("create key dir: %w", err)
}
return writePrivateKeyPEM(path, key)
}
// Record returns the published DKIM DNS record for a domain, recomputed from the // Record returns the published DKIM DNS record for a domain, recomputed from the
// private key on disk (spec 7.2.10). // private key on disk (spec 7.2.10).
func (o *OpenDKIM) Record(domainName, selector string) (DKIMRecord, error) { func (o *OpenDKIM) Record(domainName, selector string) (DKIMRecord, error) {
+6
View File
@@ -18,6 +18,12 @@ type Applications interface {
// Resync rebuilds smtpd_sender_login_maps from the remaining applications // Resync rebuilds smtpd_sender_login_maps from the remaining applications
// and reloads Postfix. // and reloads Postfix.
Resync() error Resync() error
// Secret returns an application's stored password, for a domain export
// (spec 7.5.B).
Secret(login string) (string, error)
// ImportApplication re-creates an application (registry row + SASL account)
// from a domain-export file, without rebuilding the sender map (spec 7.5.B).
ImportApplication(domainID int64, login, mode string, addresses []string, password string) error
} }
// Service coordinates the places a sending domain lives: the SQLite registry, // Service coordinates the places a sending domain lives: the SQLite registry,
+131
View File
@@ -0,0 +1,131 @@
package domain
import (
"fmt"
"codeberg.org/mix/selfpost/internal/buildinfo"
"codeberg.org/mix/selfpost/internal/store"
)
// FormatDomainExport identifies a single-domain export file (spec 7.5.B).
const FormatDomainExport = "selfpost-domain-export"
// DomainExport is the serialisable form of one sending domain, for moving it
// between two independently running SelfPost instances (spec 7.5.B). It carries
// the DKIM private key (so the published DNS record stays valid) and each
// application's working password (so credentials transfer without regeneration).
// The file is therefore as sensitive as a full backup and must be handled as a
// secret.
type DomainExport struct {
Format string `json:"format"`
Version string `json:"version"`
Domain string `json:"domain"`
DKIMSelector string `json:"dkim_selector"`
DKIMPrivateKey string `json:"dkim_private_key"` // PKCS#1 PEM
Applications []AppExport `json:"applications"`
}
// AppExport is one application within a DomainExport.
type AppExport struct {
Login string `json:"login"`
AddressMode string `json:"address_mode"`
Addresses []string `json:"addresses,omitempty"` // list mode only
Password string `json:"password"`
}
// Export builds the transferable representation of a domain: its DKIM key, its
// selector and every application with its address mode and working password
// (spec 7.5.B). The returned struct is marshalled to JSON by the caller and
// offered as a secret download.
func (s *Service) Export(id int64) (DomainExport, error) {
d, err := s.store.GetDomain(id)
if err != nil {
return DomainExport{}, err
}
pem, err := s.odk.ExportKey(d.Name, d.DKIMSelector)
if err != nil {
return DomainExport{}, fmt.Errorf("export DKIM key for %s: %w", d.Name, err)
}
apps, err := s.store.ListApplicationsByDomain(id)
if err != nil {
return DomainExport{}, err
}
exp := DomainExport{
Format: FormatDomainExport,
Version: buildinfo.Version,
Domain: d.Name,
DKIMSelector: d.DKIMSelector,
DKIMPrivateKey: string(pem),
Applications: make([]AppExport, 0, len(apps)),
}
for _, a := range apps {
password, err := s.apps.Secret(a.Login)
if err != nil {
return DomainExport{}, fmt.Errorf("export credential for %s: %w", a.Login, err)
}
exp.Applications = append(exp.Applications, AppExport{
Login: a.Login,
AddressMode: a.AddressMode,
Addresses: a.Addresses,
Password: password,
})
}
return exp, nil
}
// Import re-creates a domain from an export file on this instance (spec 7.5.B):
// it stores the imported DKIM key (so the published DNS record needs no change),
// registers the domain and rebuilds the OpenDKIM tables, then re-creates each
// application with its working password and rebuilds the Postfix sender map.
//
// exp.Domain must already be normalised and validated by the caller (spec
// 7.6.2); the selector is checked for config-injection safety here. A domain or
// login that already exists is rejected (store.ErrDomainExists /
// store.ErrLoginExists) rather than merged. If any step fails, everything the
// import created is rolled back, so a partial import never leaves the instance
// in an inconsistent state.
func (s *Service) Import(exp DomainExport) (store.Domain, error) {
if exp.Format != FormatDomainExport {
return store.Domain{}, fmt.Errorf("not a SelfPost domain export (format %q)", exp.Format)
}
if err := assertConfigSafe(exp.Domain, exp.DKIMSelector); err != nil {
return store.Domain{}, err
}
// Registry row first, so its UNIQUE constraint is the sole arbiter of a
// duplicate domain before we touch the filesystem.
d, err := s.store.AddDomain(exp.Domain, exp.DKIMSelector)
if err != nil {
return store.Domain{}, err // ErrDomainExists surfaces to the caller
}
if err := s.odk.ImportKey(d.Name, d.DKIMSelector, []byte(exp.DKIMPrivateKey)); err != nil {
s.importRollback(d.ID)
return store.Domain{}, err
}
if err := s.resync(); err != nil {
s.importRollback(d.ID)
return store.Domain{}, err
}
for _, a := range exp.Applications {
if err := s.apps.ImportApplication(d.ID, a.Login, a.AddressMode, a.Addresses, a.Password); err != nil {
s.importRollback(d.ID)
return store.Domain{}, fmt.Errorf("import application %q: %w", a.Login, err)
}
}
if err := s.apps.Resync(); err != nil {
s.importRollback(d.ID)
return store.Domain{}, err
}
return d, nil
}
// importRollback best-effort tears down a partially imported domain by running
// the normal deletion path, which clears the SASL accounts of any applications
// already created, removes the registry rows (cascade), rebuilds both maps and
// removes the DKIM key. Any error here is subordinate to the original failure
// the caller returns.
func (s *Service) importRollback(id int64) {
_ = s.Delete(id)
}
+190
View File
@@ -0,0 +1,190 @@
package domain
import (
"errors"
"fmt"
"testing"
"codeberg.org/mix/selfpost/internal/store"
)
// fakeApps stands in for *app.Service in the domain-transfer tests: it records
// the import calls and hands back canned SASL secrets.
type fakeApps struct {
secrets map[string]string
imported []importedApp
importErr error
}
type importedApp struct {
domainID int64
login string
mode string
addresses []string
password string
}
func (f *fakeApps) PurgeDomainSASL(int64) error { return nil }
func (f *fakeApps) Resync() error { return nil }
func (f *fakeApps) Secret(login string) (string, error) {
pw, ok := f.secrets[login]
if !ok {
return "", fmt.Errorf("no secret for %q", login)
}
return pw, nil
}
func (f *fakeApps) ImportApplication(domainID int64, login, mode string, addresses []string, password string) error {
if f.importErr != nil {
return f.importErr
}
f.imported = append(f.imported, importedApp{domainID, login, mode, addresses, password})
return nil
}
// newTestService builds a Service over a fresh SQLite store and OpenDKIM tree in
// a temp dir, with the OpenDKIM reload signal stubbed out.
func newTestService(t *testing.T, apps Applications) (*Service, *OpenDKIM) {
t.Helper()
st, err := store.Open(t.TempDir() + "/selfpost.db")
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { st.Close() })
odk := NewOpenDKIM(t.TempDir())
odk.reload = func() error { return nil }
return NewService(st, odk, apps, "selfpost"), odk
}
func TestExportImportRoundTrip(t *testing.T) {
// Source instance: a domain with two applications and their secrets.
srcApps := &fakeApps{secrets: map[string]string{"mailer": "pw-mailer", "alerts": "pw-alerts"}}
src, srcOdk := newTestService(t, srcApps)
d, err := src.Add("example.com")
if err != nil {
t.Fatalf("add domain: %v", err)
}
if _, err := src.store.AddApplication(d.ID, "mailer", store.AddressModeWildcard, nil); err != nil {
t.Fatalf("add mailer: %v", err)
}
if _, err := src.store.AddApplication(d.ID, "alerts", store.AddressModeList, []string{"a@example.com"}); err != nil {
t.Fatalf("add alerts: %v", err)
}
exp, err := src.Export(d.ID)
if err != nil {
t.Fatalf("Export: %v", err)
}
if exp.Format != FormatDomainExport || exp.Domain != "example.com" || exp.DKIMSelector != "selfpost" {
t.Fatalf("export header = %+v", exp)
}
if len(exp.Applications) != 2 {
t.Fatalf("exported %d apps, want 2", len(exp.Applications))
}
srcKey, err := srcOdk.ExportKey("example.com", "selfpost")
if err != nil {
t.Fatalf("read source key: %v", err)
}
if exp.DKIMPrivateKey != string(srcKey) {
t.Error("export DKIM key does not match the on-disk key")
}
// Target instance: import the file.
dstApps := &fakeApps{}
dst, dstOdk := newTestService(t, dstApps)
nd, err := dst.Import(exp)
if err != nil {
t.Fatalf("Import: %v", err)
}
// Domain row landed with the exported selector.
got, err := dst.Get(nd.ID)
if err != nil {
t.Fatalf("get imported domain: %v", err)
}
if got.Name != "example.com" || got.DKIMSelector != "selfpost" {
t.Errorf("imported domain = %+v", got)
}
// The DKIM key was imported byte-for-byte, so the DNS record is unchanged.
dstKey, err := dstOdk.ExportKey("example.com", "selfpost")
if err != nil {
t.Fatalf("read imported key: %v", err)
}
if string(dstKey) != string(srcKey) {
t.Error("imported DKIM key differs from the source key")
}
// Applications were re-created with their working passwords.
if len(dstApps.imported) != 2 {
t.Fatalf("imported %d apps, want 2", len(dstApps.imported))
}
byLogin := map[string]importedApp{}
for _, a := range dstApps.imported {
byLogin[a.login] = a
}
if byLogin["mailer"].password != "pw-mailer" || byLogin["alerts"].password != "pw-alerts" {
t.Errorf("imported passwords = %+v", dstApps.imported)
}
if byLogin["alerts"].mode != store.AddressModeList {
t.Errorf("alerts mode = %q", byLogin["alerts"].mode)
}
}
func TestImportRejectsWrongFormat(t *testing.T) {
dst, _ := newTestService(t, &fakeApps{})
if _, err := dst.Import(DomainExport{Format: "nope", Domain: "example.com"}); err == nil {
t.Error("Import accepted a non-export file")
}
}
func TestImportRejectsDuplicateDomain(t *testing.T) {
dst, _ := newTestService(t, &fakeApps{})
if _, err := dst.Add("example.com"); err != nil {
t.Fatalf("seed domain: %v", err)
}
// A minimal but well-formed export of the same domain.
src, srcOdk := newTestService(t, &fakeApps{})
d, _ := src.Add("example.com")
key, _ := srcOdk.ExportKey("example.com", "selfpost")
exp := DomainExport{
Format: FormatDomainExport, Domain: "example.com", DKIMSelector: "selfpost",
DKIMPrivateKey: string(key),
}
_ = d
if _, err := dst.Import(exp); !errors.Is(err, store.ErrDomainExists) {
t.Errorf("Import duplicate = %v, want ErrDomainExists", err)
}
}
func TestImportRollsBackOnAppFailure(t *testing.T) {
// Build a valid export from a source instance.
src, _ := newTestService(t, &fakeApps{secrets: map[string]string{"mailer": "pw"}})
d, _ := src.Add("example.com")
if _, err := src.store.AddApplication(d.ID, "mailer", store.AddressModeWildcard, nil); err != nil {
t.Fatalf("add app: %v", err)
}
exp, err := src.Export(d.ID)
if err != nil {
t.Fatalf("Export: %v", err)
}
// Target rejects the application import; the whole domain must roll back.
dstApps := &fakeApps{importErr: errors.New("boom")}
dst, dstOdk := newTestService(t, dstApps)
if _, err := dst.Import(exp); err == nil {
t.Fatal("Import succeeded despite an application failure")
}
// Domain row removed.
domains, err := dst.List()
if err != nil {
t.Fatalf("list: %v", err)
}
if len(domains) != 0 {
t.Errorf("expected rollback to remove the domain, got %+v", domains)
}
// DKIM key removed.
if _, err := dstOdk.ExportKey("example.com", "selfpost"); err == nil {
t.Error("expected rollback to remove the imported DKIM key")
}
}
+2
View File
@@ -144,6 +144,8 @@ func detailFlash(r *http.Request) string {
return "Application address mode updated." return "Application address mode updated."
case r.URL.Query().Get("ratelimit") != "": case r.URL.Query().Get("ratelimit") != "":
return "Rate limit updated." return "Rate limit updated."
case r.URL.Query().Get("imported") != "":
return "Domain imported. Its DKIM DNS record is unchanged — no DNS update is needed."
default: default:
return "" return ""
} }
+133
View File
@@ -0,0 +1,133 @@
package web
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"codeberg.org/mix/selfpost/internal/backup"
"codeberg.org/mix/selfpost/internal/domain"
"codeberg.org/mix/selfpost/internal/store"
)
// maxImportBytes caps a domain-import upload. A domain export is a small JSON
// document (a DKIM key and a handful of credentials); this leaves generous head
// room while refusing anything large enough to be an abuse attempt.
const maxImportBytes = 1 << 20 // 1 MiB
// handleBackup streams a full-server backup as a download (spec 7.5.A). It is an
// authenticated admin action (this handler sits behind the auth middleware). The
// archive carries DKIM private keys, the admin password hash and SASL
// credentials, so it is served with no-store and as an attachment to discourage
// caching of secret material.
func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) {
filename := fmt.Sprintf("selfpost-backup-%s.tar.gz", time.Now().UTC().Format("20060102-150405"))
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
w.Header().Set("Cache-Control", "no-store")
if err := backup.Create(w, backup.Params{
DataDir: s.cfg.DataDir,
DBPath: s.cfg.DBPath,
Version: s.cfg.Version,
}); err != nil {
// Headers (and possibly some bytes) may already be on the wire, so we
// cannot switch to a clean error page; log it and let the truncated
// download fail loudly on the client side.
logf("panel: full backup failed: %v", err)
return
}
}
// handleExportDomain streams a single-domain export as a secret download (spec
// 7.5.B). Like the full backup it is POST-only (state is not changed, but the
// response contains the domain's DKIM private key and application passwords, so
// it must not be prefetchable or cached).
func (s *Server) handleExportDomain(w http.ResponseWriter, r *http.Request) {
d, ok := s.lookupDomain(w, r)
if !ok {
return
}
exp, err := s.domains.Export(d.ID)
if err != nil {
logf("panel: export domain %d: %v", d.ID, err)
http.Error(w, "export failed", http.StatusInternalServerError)
return
}
body, err := json.MarshalIndent(exp, "", " ")
if err != nil {
logf("panel: export domain %d: encode: %v", d.ID, err)
http.Error(w, "export failed", http.StatusInternalServerError)
return
}
filename := fmt.Sprintf("selfpost-domain-%s.json", d.Name)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body)
}
// handleImportDomain accepts an uploaded domain-export file and re-creates the
// domain on this instance (spec 7.5.B). The domain name is normalised and
// validated here (spec 7.6.2); the domain service validates the selector, each
// login and address, and the DKIM key before writing anything. On success it
// redirects to the new domain's page; on failure it re-renders the dashboard
// with a friendly message.
func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxImportBytes)
if err := r.ParseMultipartForm(maxImportBytes); err != nil {
s.renderDashboard(w, r, http.StatusBadRequest, "", "", "Could not read the uploaded file (too large or not a valid upload).")
return
}
file, _, err := r.FormFile("file")
if err != nil {
s.renderDashboard(w, r, http.StatusBadRequest, "", "", "Choose a domain export file to import.")
return
}
defer file.Close()
var exp domain.DomainExport
dec := json.NewDecoder(file)
dec.DisallowUnknownFields()
if err := dec.Decode(&exp); err != nil {
s.renderDashboard(w, r, http.StatusBadRequest, "", "", "That file is not a valid SelfPost domain export.")
return
}
// Normalise and validate the domain name before it reaches the service, the
// same gate the add-domain form uses (spec 7.6.2).
exp.Domain = normalizeDomain(exp.Domain)
if err := validateDomain(exp.Domain); err != nil {
s.renderDashboard(w, r, http.StatusBadRequest, "", "", "Invalid domain in export file: "+err.Error())
return
}
d, err := s.domains.Import(exp)
if err != nil {
logf("panel: import domain %q: %v", exp.Domain, err)
status, msg := importErrorMessage(err)
s.renderDashboard(w, r, status, "", "", msg)
return
}
http.Redirect(w, r, fmt.Sprintf("/domains/%d?imported=1", d.ID), http.StatusSeeOther)
}
// importErrorMessage maps a domain-import failure (already logged by the caller)
// to an HTTP status and a user-facing message. Duplicate domain/login are called
// out specifically; other failures — validation errors describing what is wrong
// with the file, or an internal write/reload problem — are surfaced verbatim to
// this admin-only panel so the operator can act on them.
func importErrorMessage(err error) (int, string) {
switch {
case errors.Is(err, store.ErrDomainExists):
return http.StatusConflict, "A domain with that name already exists here. Delete it first, or import into a fresh instance."
case errors.Is(err, store.ErrLoginExists):
return http.StatusConflict, "One of the application logins in the file is already in use on this instance. Application logins must be unique across all domains."
default:
return http.StatusBadRequest, "Could not import the domain: " + err.Error()
}
}
+16 -14
View File
@@ -13,13 +13,14 @@ import (
// domains with their DKIM/selector and application counts, plus the add-domain // 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. // form (spec 7.2.2). Applications and the send log arrive in later phases.
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
s.renderDashboard(w, r, http.StatusOK, "", "") s.renderDashboard(w, r, http.StatusOK, "", "", "")
} }
// renderDashboard renders the domain list. formErr and formName repopulate the // renderDashboard renders the domain list. formErr and formName repopulate the
// add-domain form after a rejected submission; flash surfaces a one-shot status // add-domain form after a rejected submission; importErr surfaces a failed
// message keyed by a redirect query flag (never reflected user input). // domain import (spec 7.5.B); flash surfaces a one-shot status message keyed by
func (s *Server) renderDashboard(w http.ResponseWriter, r *http.Request, status int, formErr, formName string) { // a redirect query flag (never reflected user input).
func (s *Server) renderDashboard(w http.ResponseWriter, r *http.Request, status int, formErr, formName, importErr string) {
domains, err := s.domains.List() domains, err := s.domains.List()
if err != nil { if err != nil {
logf("panel: dashboard: list domains: %v", err) logf("panel: dashboard: list domains: %v", err)
@@ -27,12 +28,13 @@ func (s *Server) renderDashboard(w http.ResponseWriter, r *http.Request, status
return return
} }
s.render(w, status, "dashboard", map[string]any{ s.render(w, status, "dashboard", map[string]any{
"Title": "SelfPost", "Title": "SelfPost",
"User": currentUser(r), "User": currentUser(r),
"Domains": domains, "Domains": domains,
"Error": formErr, "Error": formErr,
"FormName": formName, "FormName": formName,
"Flash": dashboardFlash(r), "ImportErr": importErr,
"Flash": dashboardFlash(r),
}) })
} }
@@ -54,25 +56,25 @@ func dashboardFlash(r *http.Request) string {
// publish is shown (spec 7.2.3). // publish is shown (spec 7.2.3).
func (s *Server) handleAddDomain(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAddDomain(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
s.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "") s.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "", "")
return return
} }
raw := r.PostFormValue("name") raw := r.PostFormValue("name")
name := normalizeDomain(raw) name := normalizeDomain(raw)
if err := validateDomain(name); err != nil { if err := validateDomain(name); err != nil {
s.renderDashboard(w, r, http.StatusBadRequest, err.Error(), raw) s.renderDashboard(w, r, http.StatusBadRequest, err.Error(), raw, "")
return return
} }
d, err := s.domains.Add(name) d, err := s.domains.Add(name)
if err != nil { if err != nil {
if errors.Is(err, store.ErrDomainExists) { if errors.Is(err, store.ErrDomainExists) {
s.renderDashboard(w, r, http.StatusConflict, "That domain is already configured.", raw) s.renderDashboard(w, r, http.StatusConflict, "That domain is already configured.", raw, "")
return return
} }
logf("panel: add domain %q: %v", name, err) logf("panel: add domain %q: %v", name, err)
s.renderDashboard(w, r, http.StatusInternalServerError, s.renderDashboard(w, r, http.StatusInternalServerError,
"Could not add the domain. Please check the logs and try again.", raw) "Could not add the domain. Please check the logs and try again.", raw, "")
return return
} }
http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther) http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther)
+27
View File
@@ -53,4 +53,31 @@
<p class="muted">No domains yet. Add one above to get started.</p> <p class="muted">No domains yet. Add one above to get started.</p>
{{end}} {{end}}
</div> </div>
<div class="card">
<h2>Backup &amp; migration</h2>
<p class="muted">Download a full backup of all persistent state — the database,
every domain's DKIM key and the application credentials. Use it to move the
whole server to a new machine: restore it into a container of the
<strong>same SelfPost version</strong>, with the same data mount, before first
start. TLS certificates and the mail queue are not included.</p>
<p class="muted"><strong>The backup file is a secret</strong> (it contains
private keys and credentials). Store and transfer it securely and delete it
once the restore succeeds.</p>
<form class="inline" method="post" action="/backup">
<button type="submit">Download full backup</button>
</form>
<h2>Import a domain</h2>
<p class="muted">Move a single domain here from another SelfPost instance using
a domain export file (from that domain's page). Its DKIM key and application
passwords come across, so the published DNS record needs no change. The export
file is a secret, like a full backup.</p>
{{if .ImportErr}}<p class="error">{{.ImportErr}}</p>{{end}}
<form method="post" action="/domains/import" enctype="multipart/form-data">
<label for="importfile">Domain export file</label>
<input id="importfile" name="file" type="file" accept=".json,application/json" required>
<button type="submit">Import domain</button>
</form>
</div>
{{end}} {{end}}
+13
View File
@@ -177,6 +177,19 @@
be unique across all domains and may contain letters, digits, '.', '-' and '_'.</p> be unique across all domains and may contain letters, digits, '.', '-' and '_'.</p>
</div> </div>
<div class="card">
<h2>Export domain</h2>
<p class="muted">Download this domain to move it to another SelfPost instance:
its DKIM key, selector and every application with its working password. On
import the DNS record stays the same, so no DNS change is needed.</p>
<p class="muted"><strong>The export file is a secret</strong> — it contains the
private DKIM key and application passwords. Transfer it securely and delete it
after the import.</p>
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/export">
<button type="submit">Export domain</button>
</form>
</div>
<div class="card"> <div class="card">
<h2>Danger zone</h2> <h2>Danger zone</h2>
<p class="muted">Deleting this domain also deletes its DKIM key and every <p class="muted">Deleting this domain also deletes its DKIM key and every
+11
View File
@@ -31,6 +31,12 @@ type Config struct {
// monitoring view (spec 7.2.13). It is the same path the log-tailer role // monitoring view (spec 7.2.13). It is the same path the log-tailer role
// follows in cmd/panel. // follows in cmd/panel.
MailLogPath string MailLogPath string
// DataDir and DBPath locate the persistent state a full backup archives
// (spec 7.5.A); Version is stamped into the backup manifest. They mirror the
// panel's own configuration.
DataDir string
DBPath string
Version string
} }
// Server is the panel HTTP application. // Server is the panel HTTP application.
@@ -102,17 +108,22 @@ func (s *Server) Handler() http.Handler {
authed := http.NewServeMux() authed := http.NewServeMux()
authed.HandleFunc("GET /{$}", s.handleDashboard) authed.HandleFunc("GET /{$}", s.handleDashboard)
authed.HandleFunc("POST /domains", s.handleAddDomain) authed.HandleFunc("POST /domains", s.handleAddDomain)
authed.HandleFunc("POST /domains/import", s.handleImportDomain)
authed.HandleFunc("GET /domains/{id}", s.handleDomainDetail) authed.HandleFunc("GET /domains/{id}", s.handleDomainDetail)
authed.HandleFunc("GET /domains/{id}/delete", s.handleDeleteConfirm) authed.HandleFunc("GET /domains/{id}/delete", s.handleDeleteConfirm)
authed.HandleFunc("POST /domains/{id}/delete", s.handleDeleteDomain) authed.HandleFunc("POST /domains/{id}/delete", s.handleDeleteDomain)
authed.HandleFunc("POST /domains/{id}/applications", s.handleAddApplication) authed.HandleFunc("POST /domains/{id}/applications", s.handleAddApplication)
authed.HandleFunc("POST /domains/{id}/ratelimit", s.handleDomainRateLimit) authed.HandleFunc("POST /domains/{id}/ratelimit", s.handleDomainRateLimit)
authed.HandleFunc("POST /domains/{id}/export", s.handleExportDomain)
authed.HandleFunc("POST /applications/{aid}/mode", s.handleUpdateAppMode) authed.HandleFunc("POST /applications/{aid}/mode", s.handleUpdateAppMode)
authed.HandleFunc("POST /applications/{aid}/password", s.handleRegenPassword) authed.HandleFunc("POST /applications/{aid}/password", s.handleRegenPassword)
authed.HandleFunc("POST /applications/{aid}/ratelimit", s.handleAppRateLimit) authed.HandleFunc("POST /applications/{aid}/ratelimit", s.handleAppRateLimit)
authed.HandleFunc("POST /applications/{aid}/delete", s.handleDeleteApplication) authed.HandleFunc("POST /applications/{aid}/delete", s.handleDeleteApplication)
authed.HandleFunc("POST /reload", s.handleReload) authed.HandleFunc("POST /reload", s.handleReload)
// Full-server backup download (spec 7.5.A).
authed.HandleFunc("POST /backup", s.handleBackup)
// Monitoring screens (spec 7.2.11-13): each page and its HTMX polling // Monitoring screens (spec 7.2.11-13): each page and its HTMX polling
// fragment (spec 7.1 — the /rows and /body endpoints return HTML, not JSON). // fragment (spec 7.1 — the /rows and /body endpoints return HTML, not JSON).
authed.HandleFunc("GET /sendlog", s.handleSendLog) authed.HandleFunc("GET /sendlog", s.handleSendLog)