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:
@@ -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 \
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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@mixdelta.ru → selfpost@mixeme.ru` доставлено и принято `mc.mixfed.ru`, заголовок `Authentication-Results: dkim=pass (d=mixdelta.ru s=selfpost) ... spf=pass ... dmarc=none` (прочитано по IMAP). Попало в Junk из-за репутации нового IP/домена (`IP_REPUTATION_SPAM`, Bayes, `MX_INVALID` — у mixdelta.ru только A без MX) — это прогрев IP/DNS уровня деплоя (ТЗ 10), не дефект релея; аутентификация (зона ответственности SelfPost) идеальна.
|
- **Финальное подтверждение доставки** (2026-07-13): реальное письмо `dtester@mixdelta.ru → selfpost@mixeme.ru` доставлено и принято `mc.mixfed.ru`, заголовок `Authentication-Results: dkim=pass (d=mixdelta.ru s=selfpost) ... spf=pass ... dmarc=none` (прочитано по IMAP). Попало в Junk из-за репутации нового IP/домена (`IP_REPUTATION_SPAM`, Bayes, `MX_INVALID` — у mixdelta.ru только A без MX) — это прогрев IP/DNS уровня деплоя (ТЗ 10), не дефект релея; аутентификация (зона ответственности SelfPost) идеальна.
|
||||||
- **Тупик, который обошли (важно для будущих тестов доставки):** нельзя тестировать доставку, отправляя с домена, который хостит сам приёмник. `mc.mixfed.ru` хостит `mixeme.ru`, поэтому письма `mixeme.ru → mixeme.ru` он жёстко отбивал `554 does not meet our delivery requirements` (own-domain anti-spoofing) при валидном DKIM+SPF. Решение: отдельный домен-отправитель `mixdelta.ru` (не на `mc`), которому приёмник доверяет как обычной входящей почте. Первый контакт был `451 Greylisted` (норма) → принят после авто-ретраев Postfix.
|
- **Тупик, который обошли (важно для будущих тестов доставки):** нельзя тестировать доставку, отправляя с домена, который хостит сам приёмник. `mc.mixfed.ru` хостит `mixeme.ru`, поэтому письма `mixeme.ru → mixeme.ru` он жёстко отбивал `554 does not meet our delivery requirements` (own-domain anti-spoofing) при валидном DKIM+SPF. Решение: отдельный домен-отправитель `mixdelta.ru` (не на `mc`), которому приёмник доверяет как обычной входящей почте. Первый контакт был `451 Greylisted` (норма) → принят после авто-ретраев Postfix.
|
||||||
- **Артефакты теста на сервере:** контейнер `p5` (домены mixeme.ru id1 / mixdelta.ru id2), скрипт/лог `/tmp/p5retry.sh`+`/root/p5retry.log`, IMAP-читалка `/tmp/imapread5.py`. DNS `mixdelta.ru` (A/SPF/DKIM) можно снять после Фазы 6-тестов; в ящике `selfpost@mixeme.ru` остались bounce-письма от ранних mixeme.ru→mixeme.ru попыток (шум, можно удалить).
|
- **Артефакты теста на сервере:** контейнер `p5` (домены mixeme.ru id1 / mixdelta.ru id2), скрипт/лог `/tmp/p5retry.sh`+`/root/p5retry.log`, IMAP-читалка `/tmp/imapread5.py`. DNS `mixdelta.ru` (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.mixfed.ru, образ `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-сокет). **Реальная доставка подтверждена:** `mixdelta.ru → 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-сокет). **Реальная доставка подтверждена:** `mixdelta.ru → 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
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 & 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}}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user