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:
+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]].
|
||||
- **Прежняя фаза:** 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:<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
|
||||
- **Уровень 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, проверено на `<script>` в теме письма). Новое: `store.QuerySendLog/CountSendLog/ListApplicationLogins`, `postfix.Queue()`, `logtail.TailLines` (точечное обратное чтение хвоста, независимо от фонового `follow()`). Юниты/vet/gofmt зелёные; контейнерный e2e (фильтры, пагинация на 60 строках, экранирование, `postqueue -p`, реальные строки `mail.log`, существующий Reload не сломан) — зелёный.
|
||||
|
||||
Reference in New Issue
Block a user