diff --git a/build/Dockerfile b/build/Dockerfile index 432ed78..0f41e96 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -46,6 +46,7 @@ RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \ opendkim-tools \ sasl2-bin \ libsasl2-modules \ + db-util \ supervisor \ logrotate \ ca-certificates \ diff --git a/cmd/panel/httpserver.go b/cmd/panel/httpserver.go index 8973f05..fdf3e8c 100644 --- a/cmd/panel/httpserver.go +++ b/cmd/panel/httpserver.go @@ -8,6 +8,7 @@ import ( "time" "codeberg.org/mix/selfpost/internal/app" + "codeberg.org/mix/selfpost/internal/buildinfo" "codeberg.org/mix/selfpost/internal/domain" "codeberg.org/mix/selfpost/internal/postfix" "codeberg.org/mix/selfpost/internal/store" @@ -28,6 +29,9 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error { Hostname: cfg.hostname, CookieSecure: cfg.cookieSecure, MailLogPath: cfg.mailLog, + DataDir: cfg.dataDir, + DBPath: cfg.dbPath, + Version: buildinfo.Version, }, cfg.setupTokenPath) if err != nil { return err diff --git a/cmd/panel/main.go b/cmd/panel/main.go index 8a34170..81e0541 100644 --- a/cmd/panel/main.go +++ b/cmd/panel/main.go @@ -19,6 +19,7 @@ import ( "sync" "syscall" + "codeberg.org/mix/selfpost/internal/backup" "codeberg.org/mix/selfpost/internal/buildinfo" "codeberg.org/mix/selfpost/internal/logtail" "codeberg.org/mix/selfpost/internal/store" @@ -51,6 +52,7 @@ type config struct { dataDir string dbPath string + manifestPath string setupTokenPath string hostname string cookieSecure bool @@ -75,6 +77,7 @@ func loadConfig() config { dataDir: dataDir, 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")), hostname: os.Getenv("SELFPOST_HOSTNAME"), // 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) + // 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 // (MaxOpenConns(1)), so the HTTP panel, the journal-milter and the tailer // can all use it without stepping on each other under WAL. diff --git a/cmd/selfpost-backup/main.go b/cmd/selfpost-backup/main.go index 7dcdceb..a84434e 100644 --- a/cmd/selfpost-backup/main.go +++ b/cmd/selfpost-backup/main.go @@ -1,20 +1,29 @@ -// Command selfpost-backup produces (and helps restore) the full persistent-state -// archive from inside the container, invoked via `docker exec` for scripted/cron -// backups — the CLI equivalent of the panel's backup button (spec 7.5.A, 11.6). +// Command selfpost-backup produces the full persistent-state archive from inside +// the container, invoked via `docker exec` for scripted/cron backups — the CLI +// 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 -// logic lands in Phase 9. +// By default the gzip-compressed tar is written to stdout, so the usual form is: +// +// docker exec 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 import ( "flag" "fmt" + "os" + "path/filepath" + "codeberg.org/mix/selfpost/internal/backup" "codeberg.org/mix/selfpost/internal/buildinfo" ) func main() { showVersion := flag.Bool("version", false, "print version and exit") + out := flag.String("o", "", "write the archive to this file instead of stdout") flag.Parse() if *showVersion { @@ -22,5 +31,43 @@ func main() { 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 } diff --git a/docs/progress.md b/docs/progress.md index 2122677..44bb2eb 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -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]]. - **Прежняя фаза:** 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) идеальна. - **Тупик, который обошли (важно для будущих тестов доставки):** нельзя тестировать доставку, отправляя с домена, который хостит сам приёмник. `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 попыток (шум, можно удалить). +### Сделано в Фазе 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:`); при совпадении манифест **потребляется** (удаляется) — гвардит только первый старт после restore и не блокирует обычный in-place апгрейд образа; отсутствие манифеста = обычный старт. Restore — не отдельная ветка кода: состояние (Postfix/OpenDKIM) регенерируется из восстановленного SQLite тем же путём, что при любом старте. +- **CLI `selfpost-backup`** (`cmd/selfpost-backup/main.go`, ТЗ 11.6): по умолчанию пишет `tar.gz` в **stdout** (`docker exec 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 - **Уровень 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). @@ -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`/этот (доки). - **Фаза 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 выполнены. +- **Фаза 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, проверено на `