From 330a63b050f0387e5fa7862cd1bb0232160473aa Mon Sep 17 00:00:00 2001 From: Crimson Date: Sun, 30 Aug 2026 19:32:43 +0500 Subject: [PATCH] =?UTF-8?q?fix(v1):=20=D1=81=D0=B4=D0=B5=D0=BB=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BD=D0=B0=D0=B4=D1=91=D0=B6=D0=BD=D1=8B=D0=BC=20?= =?UTF-8?q?=D0=BD=D0=B8=D0=B6=D0=BD=D0=B8=D0=B9=20=D1=81=D0=BB=D0=BE=D0=B9?= =?UTF-8?q?=20=D0=BE=D1=82=D0=BA=D0=B0=D1=82=D0=B0,=20=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B5=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Верхнеуровневый откат стал неотменяемым в прошлом проходе, и на этом фоне проявилось, что его substrate этой надёжности не соответствует: откат гарантированно запускался, но отдельные его шаги могли молча не выполнить восстановление, отчитаться успехом и уничтожить резервную копию. 1. Данные для отката уничтожались ДО фиксации успеха (commit ordering). cancelFirewallRollback снимала таймер автоотката И удаляла резервные копии firewall, а вызывалась до долговечной записи phase=installed. Отказ этой записи (ENOSPC/EIO/read-only ФС) приводил в обработчик ошибки, обязательный откат честно запускался и сообщал "no HY2XS rollback markers found": откатывать было нечем. Причём отказ записи маркера — ровно тот сценарий, который прошлый проход специально сделал безопасным. Разделено на disarmFirewallRollback (снять таймер, копии оставить) и cleanupFirewallRollback (удалить копии). Порядок в install и reconfigure: smoke_ok -> disarm -> durable installed -> cleanup best-effort. 2. Резервные копии снимались без доказательства. И firewall, и reconfigure копировали как `cp ... || true`: отказ игнорировался, операция шла менять систему без копии, на которую рассчитывает откат. У firewall маркер prepared («данные для отката существуют») выставлялся вообще ДО копирования. Копирование строгое, факт создания проверяется, маркер ставится после. 3. Копии reconfigure смешивались между операциями. Общий набор *.bak в /etc/hy2xs/backups не был привязан к проходу. Если у операции B копирование падало, B всё равно менял систему, а его откат восстанавливал файлы операции A — сервер возвращался в более старое состояние и это выглядело успешным откатом. Копия стала операционной: /etc/hy2xs/backups// с манифестом, где отсутствие файла записано явно ("present": false), а не выведено из неудачи cp. Разбор строгий, включая проверку opId. 4. Ошибка восстановления скрывалась, и после неё копии удалялись. rollbackFirewallNow выполняла cp и nft -f с `|| true`, затем безусловно удаляла /run/hy2xs/rollback/. Худшая комбинация: неудача не видна, стадия успешна, данные для ручной починки уничтожены. Теперь копии удаляются только после подтверждённого успеха, иначе сохраняются с сообщением manual recovery data preserved at ... 5. Команды отката глушили собственный код возврата. До стадийного раннера `|| true` был единственной защитой от обрыва цепочки; после его появления стал маскировкой — стадия не могла сообщить, что ничего не сделала. Убран; rollbackCurrentState разбита на семь независимых стадий. 6. Долговечность записи каталога маркера. writeTextAtomic синхронизирует файл и его каталог, но при первой установке /var/lib/hy2xs создаётся тут же, и запись "hy2xs" в /var/lib оставалась несинхронизированной. ensureDir сообщает о фактическом создании и синхронизирует родителя только тогда. Отдельно про doctor. Утверждение аудита, что doctor вызывает UpdatePeerLastConnectionAt через успешную machine-auth, кодом не подтверждается: проба с действующим паролем ограничена `context.mode === "install"`, а doctor работает в режиме reconfigure. Инвариант, однако, ничем не охранялся — добавлены тест и приёмка. Документация уточнена: guard действует внутри процесса, а границу «что doctor шлёт по сети» держит состав проб; единственный остающийся след — записи в журнале админки, и это сказано прямо. Тесты: backup-integrity.test.ts (манифест, строгий разбор, копия до мутации, сохранение копий при неудачном восстановлении), commit-ordering.test.ts (disarm/cleanup разделены, порядок фиксации в обеих командах). Три теста, закреплявших прежний инвариант «каждая команда отката несёт || true», переписаны на обратный: команды обязаны сообщать о своих отказах. --- CHANGELOG.md | 61 +++++ docs/08-orchestrator-spec.md | 100 +++++++ docs/11-testing-and-acceptance.md | 113 +++++++- docs/12-operations-and-troubleshooting.md | 26 ++ docs/13-production-runbook.md | 15 ++ orchestrator/src/commands/install.ts | 53 +++- orchestrator/src/commands/reconfigure.ts | 243 ++++++++++++++--- orchestrator/src/lib/backupManifest.ts | 154 +++++++++++ orchestrator/src/lib/fs.ts | 21 +- orchestrator/src/steps/firewall.ts | 205 ++++++++++++-- orchestrator/test/atomic-write.test.ts | 11 + orchestrator/test/backup-integrity.test.ts | 265 +++++++++++++++++++ orchestrator/test/commit-ordering.test.ts | 118 +++++++++ orchestrator/test/doctor-readonly.test.ts | 33 +++ orchestrator/test/install-sequence.test.ts | 2 +- orchestrator/test/rollback-mandatory.test.ts | 67 ++++- tools/build/lib/acceptance.sh | 145 +++++++++- 17 files changed, 1534 insertions(+), 98 deletions(-) create mode 100644 orchestrator/src/lib/backupManifest.ts create mode 100644 orchestrator/test/backup-integrity.test.ts create mode 100644 orchestrator/test/commit-ordering.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f361435..759594f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,67 @@ Hardening-проход перед релизом `1.0.0`. Основная те проверявшие не то, что обещали, и два свойства, которые были описаны, но не обеспечены — read-only у `doctor` и различение отказа базы у `reset-admin`. +Пятый проход — нижний слой того же механизма. Верхнеуровневый откат стал +надёжным раньше, чем его storage/firewall substrate: откат гарантированно +запускался, но отдельные его шаги могли молча не выполнить восстановление, +отчитаться успехом и уничтожить резервную копию. + +### Исправлено — целостность отката + +- **Данные для отката уничтожались до фиксации успеха.** Успешный install + заканчивался вызовом, который снимал таймер автоотката **и удалял резервные + копии firewall**, — и стоял этот вызов ДО долговечной записи + `phase: installed`. Если запись падала (`ENOSPC`, `EIO`, read-only ФС), + управление уходило в обработчик ошибки, обязательный откат честно запускался + и сообщал `no HY2XS rollback markers found`. Откат нельзя было отменить, но + откатывать ему было нечем — причём отказ записи маркера это ровно тот + сценарий, который был специально сделан безопасным прошлым проходом. + + Операция разделена на `disarmFirewallRollback` (снять таймер, копии + оставить) и `cleanupFirewallRollback` (удалить копии). Порядок теперь: + `smoke_ok` → disarm → durable `installed` → cleanup best-effort. То же в + `reconfigure`. + +- **Резервные копии снимались без доказательства.** И firewall, и + `reconfigure` копировали файлы как `cp ... || true`, поэтому отказ + копирования игнорировался, а операция начинала менять систему, не имея + копии, на которую рассчитывает откат. У firewall маркер `prepared` + («данные для отката существуют») выставлялся вообще до копирования. + Копирование стало строгим, факт создания проверяется, маркер ставится после. + +- **Копии `reconfigure` смешивались между операциями.** Они лежали одним общим + набором `*.bak` в `/etc/hy2xs/backups`, не привязанным к проходу. Если у + операции B копирование падало, B всё равно менял систему, а его откат + восстанавливал файлы, сохранённые операцией A: сервер возвращался не в + состояние «до B», а в более старое — и это выглядело успешным откатом. + Копия стала операционной: `/etc/hy2xs/backups//` с манифестом, где + отсутствие файла — записанный факт (`"present": false`), а не вывод из + неудачи `cp`. Разбор манифеста строгий, включая проверку `opId`. + +- **Ошибка восстановления скрывалась, а копии после неё удалялись.** + `rollbackFirewallNow` выполняла `cp` и `nft -f` с `|| true`, а затем + безусловно удаляла `/run/hy2xs/rollback/`. Худшая возможная комбинация: + неудача восстановления не видна, стадия отчитывается успешной, а данные, по + которым оператор мог бы поднять firewall вручную, уничтожены. Теперь копии + удаляются только после подтверждённого успеха, иначе сохраняются с + сообщением `manual recovery data preserved at …`. + +- **Команды отката глушили собственный код возврата.** `systemctl stop`, + `disable`, `reset-failed`, `cp`, `nft -f`, `daemon-reload`, `restart` — все + несли `|| true`. До появления стадийного раннера это была единственная защита + от обрыва цепочки; после его появления — маскировка: стадия физически не + могла сообщить, что ничего не сделала, и обещание «отказавшие стадии + перечисляются» для них не выполнялось никогда. `|| true` убран, + непрерывность обеспечивает раннер, `rollbackCurrentState` разбита на семь + независимых стадий. + +- **Долговечность записи каталога маркера.** `writeTextAtomic` синхронизирует + файл и каталог, в котором файл лежит, но при первой установке + `/var/lib/hy2xs` создаётся тут же, и запись «hy2xs» в `/var/lib` оставалась + несинхронизированной: после потери питания мог исчезнуть весь каталог вместе + с маркером. `ensureDir` сообщает о фактическом создании и синхронизирует + родителя только тогда. + ### Изменено — свойства, ставшие инвариантами - **`doctor` read-only по инварианту рантайма, а не по соглашению.** diff --git a/docs/08-orchestrator-spec.md b/docs/08-orchestrator-spec.md index 4da10cb..8a365c4 100644 --- a/docs/08-orchestrator-spec.md +++ b/docs/08-orchestrator-spec.md @@ -201,6 +201,14 @@ machine token или пароль пира, а сообщение уходит перезагрузки ext4 штатно отдаёт по этому пути нулевой файл или отсутствие файла. Для метаданных восстановления это неприемлемо. +Есть ещё один уровень: при первой установке сам каталог `/var/lib/hy2xs` +создаётся прямо сейчас, и запись «hy2xs» в `/var/lib` тоже обязана быть +долговечной. Иначе возможно состояние, в котором и файл, и его каталог сброшены +на носитель, а каталог из родителя исчез — то есть маркер пропал целиком. +Поэтому `ensureDir` сообщает, был ли каталог **фактически создан**, и при +создании синхронизирует родителя. На последующих обновлениях маркера каталог уже +существует, и лишний `fsync` родителя не выполняется. + ## Ownership и rollback Операция ведёт учёт того, к чему она **могла прикоснуться**: @@ -286,6 +294,98 @@ preflight-ошибка со словом `nftables` приводила к отк ошибка операции: проблема внутри отката — это дополнительная информация о том, что осталось не восстановленным, а не замена диагноза. +Команды внутри стадий **не глушат собственные ошибки**. Это правило обратно +тому, что действовало раньше. Пока непрерывность держалась на `|| true` в каждой +команде, стадия физически не могла сообщить, что восстановление не выполнилось: +`cp`, `nft -f`, `systemctl daemon-reload` и `systemctl restart` возвращали ноль +при любом исходе, и «restore configuration» никогда не попадала в список +отказавших. Непрерывность обеспечивает стадийный раннер; подавление кода +возврата после его появления стало не защитой, а маскировкой. + +### Порядок фиксации успеха + +Данные, по которым выполняется откат, обязаны пережить долговечную запись +успеха: + +```text +smoke PASS + ↓ +durable phase = smoke_ok + ↓ +disarm автоматического отката по таймеру ← резервные копии ОСТАЮТСЯ + ↓ +durable phase = installed ← точка фиксации + ↓ +cleanup резервных копий ← best effort +``` + +Раньше снятие таймера и удаление копий выполнял один вызов, стоявший **до** +записи `installed`. Отсюда следовал разрыв: + +```text +smoke PASS +→ таймер снят, резервные копии УДАЛЕНЫ +→ запись "installed" падает (ENOSPC / EIO / read-only ФС) +→ обработчик ошибки → обязательный откат +→ "firewall rollback skipped: no HY2XS rollback markers found" +``` + +То есть ровно тот отказ записи маркера, который был специально сделан +безопасным, случался после уничтожения единственных данных для отката: откат +запускался, но откатывать ему было нечем. + +Уборка после точки фиксации выполняется best-effort намеренно: невозможность +удалить временные данные в `/run` — мусор, а не причина объявить успешную +установку неуспешной. + +### Резервные копии: строгие и привязанные к операции + +Две отдельные гарантии, которых раньше не было ни у firewall, ни у +`reconfigure`. + +**Копия обязана существовать до первой мутации.** Копирование выполнялось как +`cp ... || true`, поэтому отказ (заполненный `/run`, ошибка ввода-вывода, права) +игнорировался, а операция шла менять систему, не имея того, на что рассчитывает +откат. Теперь копирование строгое, факт создания проверяется, а маркер +готовности `prepared` ставится **после** проверенных копий, а не до них. + +**Копия принадлежит конкретной операции.** `reconfigure` хранил копии всех +операций одним общим набором `*.bak` в `/etc/hy2xs/backups`. Отсюда сценарий: + +```text +reconfigure A → config.yaml.bak создан +reconfigure B → создание копии упало, ошибка скрыта + → B меняет конфигурацию + → B падает → откат восстанавливает копию, снятую операцией A +``` + +Сервер возвращался не в состояние «до B», а в более старое — и это выглядело +успешным откатом. Теперь копия лежит в `/etc/hy2xs/backups//` с +манифестом: + +```json +{ + "version": 1, + "opId": "2026-08-30T10-00-00.000Z", + "entries": [ + { "path": "/etc/hysteria/config.yaml", "present": true, "stored": "etc_hysteria_config.yaml" }, + { "path": "/etc/nftables.d/hy2xs.nft", "present": false, "stored": null } + ] +} +``` + +Отсутствие файла — **записанный факт**, а не вывод из неудачи `cp`: по этому +полю откат решает, восстанавливать файл или удалять его. Разбор манифеста +строгий, включая проверку `opId`: восстановление по частично понятому манифесту +или по копии чужой операции опаснее отказа. + +**Артефакты восстановления удаляются только после подтверждённого +восстановления.** `rollbackFirewallNow` раньше скрывала ошибки `cp` и `nft`, а +затем безусловно удаляла копии — худшая комбинация, при которой неудача +восстановления не видна, а данные для ручной починки уничтожены. Теперь при +любом отказе стадии копии сохраняются, и в журнале появляется +`manual recovery data preserved at …`. + ## Инвариант публичного endpoint `preflight` проверяет, что публичный endpoint ведёт **на этот сервер**. Так как diff --git a/docs/11-testing-and-acceptance.md b/docs/11-testing-and-acceptance.md index 2c3b75d..0a09a37 100644 --- a/docs/11-testing-and-acceptance.md +++ b/docs/11-testing-and-acceptance.md @@ -210,6 +210,41 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh `systemctl daemon-reload` отменял перезапуск сервисов строкой ниже, то есть восстановленные unit-файлы так и не применялись. +## A5c. Целостность резервных копий (unit) + +`orchestrator/test/backup-integrity.test.ts`: + +- копия каждой операции адресуется своим каталогом, разные `op-id` не + пересекаются; +- разные пути дают разные имена файлов копии, и имя не выходит за пределы + каталога; +- отсутствовавший файл записан **явно** (`present: false`), а не выведен из + неудачи `cp`; +- манифест переживает сериализацию без потерь; +- разбор строгий: манифест чужой операции, неизвестная версия, битый JSON, + запись без пути, без признака существования или без имени копии — + отклоняются. «Поле не разобралось, будем считать, что файла не было» означало + бы удаление существующего файла при откате; +- копирование в `reconfigure` и в `firewall` не глушит ошибки, факт создания + копии проверяется, а копия снимается **до** первой мутации; +- маркер готовности firewall (`prepared`) ставится после проверенных копий; +- восстановление firewall не глушит ошибки `cp`/`nft`, а резервные копии + удаляются только после подтверждённого успеха — иначе сохраняются вместе с + сообщением `manual recovery data preserved at …`. + +## A5d. Порядок фиксации успеха (unit) + +`orchestrator/test/commit-ordering.test.ts`: + +- снятие таймера автоотката и удаление резервных копий — **разные** операции + (`disarmFirewallRollback` / `cleanupFirewallRollback`), объединённая + `cancelFirewallRollback` не вернулась ни в один вызов; +- `disarm` не удаляет копии; +- порядок в `install` и `reconfigure` одинаков: `disarm` → долговечная запись + `installed` → `cleanup`; +- успешный smoke фиксируется отдельной фазой до снятия таймера; +- уборка после точки фиксации выполняется best-effort. + ## A5b. Долговечная запись маркера (unit) `orchestrator/test/atomic-write.test.ts`: @@ -227,6 +262,11 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh `/var/lib/hy2xs`, и «read-only» перестало бы быть правдой ровно для того файла, по которому clean-host принимает решение. +- `ensureDir` сообщает, был ли каталог **фактически создан**: родитель + синхронизируется только при создании, иначе долговечность записи `hy2xs` в + `/var/lib` осталась бы необеспеченной, и после потери питания мог исчезнуть + весь каталог вместе с маркером. + Наличие самих `fsync` проверяется приёмкой сборки по исходнику: из пользовательского процесса их не наблюдать, а без них `rename()` даёт атомарность видимости без долговечности. @@ -823,15 +863,29 @@ runtime-пакета выполнял `install.sh`, эти пути не при Проверяется на чистом хосте. Это доказательство того, что телеметрия состояния больше не стоит перед восстановлением. -Подготовка: `/var/lib/hy2xs` делается недоступным для записи именно к моменту -обработки ошибки. Практичнее всего смонтировать поверх него крошечный `tmpfs` -и заполнить его до отказа: +**Тайминг здесь — часть сценария, и его легко испортить.** + +`tmpfs` НЕЛЬЗЯ монтировать заранее: первая же запись маркера (`preflight_ok`) +получит `ENOSPC`, установка отвалится до firewall, и проверяться будет совсем +другой путь — обычный `fatal_post_apply` на ранней стадии. + +Порядок строго такой: + +1. запустить установку и дождаться в журнале `step=firewall status=done`; +2. **только теперь**, во втором терминале: ```bash mount -t tmpfs -o size=16k tmpfs /var/lib/hy2xs dd if=/dev/zero of=/var/lib/hy2xs/filler bs=1k count=64 2>/dev/null || true ``` +3. вызвать искусственный отказ следующего шага установки. + +Ловить это окно руками неудобно, поэтому тот же сценарий имеет смысл прогнать и +через отказ на более длинном шаге (`smoke`), где времени заметно больше: +дождаться `step=smoke checks`, смонтировать `tmpfs` и остановить один из +сервисов, чтобы smoke не сошёлся. + Сценарий: 1. установка доходит **дальше** шага firewall (то есть `firewallTouched` @@ -856,9 +910,51 @@ dd if=/dev/zero of=/var/lib/hy2xs/filler bs=1k count=64 2>/dev/null || true отменялись разом. Дополнительно проверяется независимость стадий: если сделать неработоспособной -первую стадию (например, удалить `/run/hy2xs/rollback/` между применением -firewall и отказом), восстановление конфигов и остановка сервисов обязаны -выполниться всё равно. +первую стадию (например, сделать `/etc/nftables.conf` неперезаписываемым через +`chattr +i` между применением firewall и отказом), восстановление конфигов и +остановка сервисов обязаны выполниться всё равно, а в журнале обязаны появиться +`rollback stage "…" failed, continuing with the remaining stages` и итоговое +`rollback finished with N failed stage(s)`. + +## D1c. Данные отката переживают отказ фиксации успеха + +Проверяется на чистом хосте. Это второй сценарий того же класса, но на +противоположном конце операции: отказывает не промежуточный шаг, а **запись +успеха**. + +1. установка доходит до успешного `smoke`, в маркере появляется + `phase: smoke_ok`; +2. сразу после этого `/var/lib/hy2xs` делается недоступным для записи (тот же + `tmpfs`, смонтированный по появлению `step=smoke checks status=done`); +3. запись `phase: installed` падает; +4. `/run/hy2xs/rollback//prepared` и обе резервные копии firewall **всё ещё + существуют** — это и есть проверяемое свойство; +5. откат выполняется полностью: `/etc/nftables.conf` возвращается к прежнему + содержимому, развёрнутые юниты останавливаются; +6. в журнале **нет** строки `no HY2XS rollback markers found`. + +До исправления пункты 4–6 давали противоположный результат: снятие таймера и +удаление копий выполнял один вызов, стоявший до записи `installed`, поэтому +откат запускался, но откатывать ему было нечем. + +Обратная проверка — успешный путь: после нормально завершённой установки +`/run/hy2xs/rollback/` пуст, а `phase: installed` записан. + +## D1d. Отказ снятия резервной копии останавливает reconfigure до мутации + +Проверяется на рабочей установке. + +1. `/etc/hy2xs/backups` делается недоступным для записи (`chattr +i` или + заполненный `tmpfs`); +2. запускается `reconfigure --apply`; +3. операция отказывает на шаге `backup` с сообщением про несозданную копию; +4. `/etc/hysteria/config.yaml`, unit-файлы и `/etc/nftables.conf` **не + изменены**, сервисы не перезапускались. + +Отдельно проверяется привязка копии к операции: после успешного `reconfigure` +в `/etc/hy2xs/backups/` остаётся ровно один каталог — текущей операции — с +`manifest.json`, и в нём перечислены все семь путей, включая отсутствовавшие с +`"present": false`. ## D1a. Проход установки не спотыкается о собственный маркер @@ -1017,3 +1113,8 @@ hy2xs-orchestrator doctor 53. ownership-флаг маркера установки взводится **до** записи, поэтому отказ на `chown` не даёт `fatal_pre_apply` при уже созданном файле 54. тесты и проверка типов не имеют обходов ни в сборке, ни в документации; `metadata/package.env` содержит `tests_gate=true`, и это утверждение опирается на фактический прогон 55. `reset-admin` при недоступной базе отказывает, а не создаёт вторую учётную запись администратора; ошибка хеширования не приводит к пустому `password_hash` +56. данные для отката переживают долговечную фиксацию успеха: снятие таймера автоотката и удаление резервных копий разделены записью `phase: installed` +57. резервная копия снимается строго и до первой мутации; несозданная копия останавливает операцию, а не игнорируется +58. копия привязана к операции: откат восстанавливает состояние непосредственно перед текущим проходом, а не сохранённое предыдущим +59. ни одна команда отката не глушит свой код возврата; отказавшие стадии перечисляются, а артефакты восстановления удаляются только после подтверждённого успеха +60. `doctor` не выполняет проб, изменяющих данные в админке: авторизация действующим паролем пира ограничена режимом `install` diff --git a/docs/12-operations-and-troubleshooting.md b/docs/12-operations-and-troubleshooting.md index 7d17927..9ab6535 100644 --- a/docs/12-operations-and-troubleshooting.md +++ b/docs/12-operations-and-troubleshooting.md @@ -270,6 +270,32 @@ hy2xs-admin`, и диагностика подозрения на проблем auth, `trafficStats`, версия бинаря, семантика `/etc/hysteria/config.yaml` и синтаксис `nft` проверяются полностью. +### Где проходит граница read-only + +Guard действует внутри процесса оркестратора. Он не способен запретить +побочный эффект, который вызвал бы HTTP-запрос в **другом** процессе, поэтому +эта половина границы держится не им, а составом проб. + +Существенный случай — machine-auth. Успешная авторизация пира заставляет админку +выполнить `UPDATE peer.last_connection_at`, то есть диагностика изменила бы +отображаемое «последнее подключение» у `bootstrap-admin-peer`. Поэтому проба с +**действующим** паролем выполняется только в режиме `install`; `doctor` работает +в режиме `reconfigure` и до неё не доходит. Полный happy-path авторизации +проверяют установка и E2E, а не диагностика. + +`doctor` отправляет только пробы, которые заведомо не проходят авторизацию +(отсутствующий machine token, неверные учётные данные, некорректный тип поля) и +читающие запросы (`/healthz`, `trafficStats /online`). Ни одна из них не +изменяет данные. + +Честная формулировка гарантии: + +> `doctor` не изменяет конфигурацию, состояние сервисов, firewall и данные. + +Единственный след, который он оставляет, — записи в журнале админки: пробы +проходят через обычный обработчик логирования, как любой запрос. Это не +состояние системы, но и не «совсем ничего», поэтому сказано прямо. + Вариант `server public IPv4:` пустой означает, что на интерфейсах нет ни одного публичного маршрутизируемого IPv4 — сервер за NAT. Это топология вне baseline; осознанное решение оформляется через `HY2XS_PUBLIC_ENDPOINT_POLICY=warn`. diff --git a/docs/13-production-runbook.md b/docs/13-production-runbook.md index 4cf56b7..7094b31 100644 --- a/docs/13-production-runbook.md +++ b/docs/13-production-runbook.md @@ -80,6 +80,21 @@ sudo -u hy2xs-admin test ! -r /etc/hy2xs/hy2xs.env | `rollback stage "<имя>" failed, continuing with the remaining stages` | конкретная половина восстановления не отработала; остальные выполнены | | `rollback finished with N failed stage(s); manual recovery may be required` | итог: перечисленные стадии требуют ручной проверки | | `rollback completed: N stage(s) succeeded` | восстановление отработало полностью | +| `manual recovery data preserved at /run/hy2xs/rollback/` | firewall восстановлен не полностью; прежние `nftables.conf` и `hy2xs.nft` лежат по этому пути | + +Резервные копии не удаляются, пока восстановление не подтверждено, и переживают +долговечную запись `phase: installed`. Поэтому при разборе неудачи всегда +осмысленно посмотреть: + +```bash +ls -la /run/hy2xs/rollback/ # копии firewall текущей операции +ls -la /etc/hy2xs/backups/ # копия состояния до последнего reconfigure +cat /etc/hy2xs/backups/*/manifest.json +``` + +Манифест прямо говорит, какие файлы существовали до операции, а какие нет: +запись `"present": false` означает, что откат обязан был файл **удалить**, а не +восстановить. Наружу оркестратор всегда пробрасывает **исходную** ошибку операции, а не проблему внутри отката: последняя — это информация о том, что осталось не diff --git a/orchestrator/src/commands/install.ts b/orchestrator/src/commands/install.ts index 97ed268..716e2a0 100644 --- a/orchestrator/src/commands/install.ts +++ b/orchestrator/src/commands/install.ts @@ -21,7 +21,12 @@ import { deployUi } from "../steps/ui"; import { installHysteria } from "../steps/hysteria"; import { generateConfig } from "../steps/config"; import { deploySystemd } from "../steps/systemd"; -import { applyFirewall, cancelFirewallRollback, rollbackFirewallNow } from "../steps/firewall"; +import { + applyFirewall, + cleanupFirewallRollback, + disarmFirewallRollback, + rollbackFirewallNow +} from "../steps/firewall"; import { ensureBootstrapAdminSecret, writePostInstallEnv } from "../steps/env"; import { smoke } from "../steps/smoke"; import { diagnosticsCollect } from "./diagnostics"; @@ -42,6 +47,7 @@ type InstallPhase = | "bootstrap_secret_written" | "services_started" | "smoke_running" + | "smoke_ok" | "smoke_failed" | "failed" | "installed"; @@ -244,23 +250,33 @@ async function rollbackFailedInstall( } if (ownership.unitsTouched) { + // Без `|| true`, и это осознанно. + // + // Раньше каждая из этих команд глушила собственный код возврата, поэтому + // стадия физически не могла сообщить runRollbackStages, что systemctl + // ничего не сделал. Обещание «отказавшие стадии перечисляются» для них не + // выполнялось никогда. + // + // Непрерывность отката теперь обеспечивает стадийный раннер, а не + // подавление ошибок в каждой команде: отказ остановки не мешает + // выполниться disable, отказ disable — выполниться reset-failed. stages.push( { name: "stop services", run: async () => { - await runMutatingVisible`systemctl stop hysteria-server hy2xs-admin || true`; + await runMutatingVisible`systemctl stop hysteria-server hy2xs-admin`; } }, { name: "disable services", run: async () => { - await runMutatingVisible`systemctl disable hysteria-server hy2xs-admin || true`; + await runMutatingVisible`systemctl disable hysteria-server hy2xs-admin`; } }, { name: "reset failed services", run: async () => { - await runMutatingVisible`systemctl reset-failed hysteria-server hy2xs-admin || true`; + await runMutatingVisible`systemctl reset-failed hysteria-server hy2xs-admin`; } } ); @@ -396,12 +412,35 @@ export async function install(options: InstallOptions): Promise { phase = "smoke_running"; await smoke(context); stepDone("smoke checks"); - step("finalize firewall rollback guard"); - await cancelFirewallRollback(context); - stepDone("finalize firewall rollback guard"); + await advanceInstallState(context, ownership, "smoke_ok"); + phase = "smoke_ok"; + + // Порядок фиксации успеха. + // + // Раньше здесь стоял один вызов, который снимал таймер автоотката И удалял + // резервные копии firewall, — и стоял он ДО долговечной записи + // `phase: installed`. Отказ этой записи (ENOSPC, EIO, read-only ФС) + // приводил в обработчик ошибки, обязательный откат запускался и сообщал + // «no HY2XS rollback markers found»: единственные данные для отката были + // уничтожены секундой раньше. + // + // Теперь между снятием таймера и удалением копий стоит сама фиксация + // успеха, и до неё откат остаётся выполнимым. + step("disarm firewall rollback timer"); + await disarmFirewallRollback(context); + stepDone("disarm firewall rollback timer"); step("mark install successful"); await advanceInstallState(context, ownership, "installed"); stepDone("mark install successful"); + + // Установка уже зафиксирована. Неудача уборки — мусор в /run, а не причина + // объявить успешную установку неуспешной. + try { + await cleanupFirewallRollback(context); + } catch (cleanupError) { + const cleanupMessage = cleanupError instanceof Error ? cleanupError.message : String(cleanupError); + info(`firewall rollback data cleanup failed after a successful install: ${cleanupMessage}`); + } } catch (error) { const message = error instanceof Error ? error.message : String(error); const failureKind = classifyFailure(ownership, phase); diff --git a/orchestrator/src/commands/reconfigure.ts b/orchestrator/src/commands/reconfigure.ts index fc1046e..c06998d 100644 --- a/orchestrator/src/commands/reconfigure.ts +++ b/orchestrator/src/commands/reconfigure.ts @@ -14,7 +14,23 @@ import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env"; import { preflight } from "../steps/preflight"; import { generateConfig } from "../steps/config"; import { deploySystemd } from "../steps/systemd"; -import { applyFirewall, cancelFirewallRollback, rollbackFirewallNow } from "../steps/firewall"; +import { + applyFirewall, + cleanupFirewallRollback, + disarmFirewallRollback, + rollbackFirewallNow +} from "../steps/firewall"; +import { + BACKUP_ROOT, + backupDir, + backupFilePath, + backupManifestPath, + buildManifest, + parseManifest, + renderManifest, + storedNameFor, + type BackupManifest +} from "../lib/backupManifest"; import { ensureBootstrapAdminSecret, writePostInstallEnv } from "../steps/env"; import { smoke } from "../steps/smoke"; import { runMutatingVisible } from "../lib/process"; @@ -76,8 +92,12 @@ export function classifyReconfigureFailure(ownership: ReconfigureOwnership): Rec return "reconfigure_failed"; } +/** + * Ключ операции. Санитизируется так же, как в steps/firewall.ts: он служит + * ИМЕНЕМ КАТАЛОГА резервной копии, а не только полем в маркере. + */ function operationKey(context: ReconfigureContext): string { - return context.installDate; + return context.installDate.replace(/[^a-zA-Z0-9_.-]/g, "-"); } function ownedPaths(context: ReconfigureContext): string[] { @@ -114,38 +134,164 @@ async function markPhase(context: ReconfigureContext, phase: ReconfigurePhase, l await persistInstallState(record); } -async function backupCurrentState(): Promise { - await runMutatingVisible`mkdir -p /etc/hy2xs/backups`; - await runMutatingVisible`cp -a /etc/hysteria/config.yaml /etc/hy2xs/backups/config.yaml.bak 2>/dev/null || true`; - await runMutatingVisible`cp -a /etc/systemd/system/hy2xs-admin.service /etc/hy2xs/backups/hy2xs-admin.service.bak 2>/dev/null || true`; - await runMutatingVisible`cp -a /etc/systemd/system/hysteria-server.service /etc/hy2xs/backups/hysteria-server.service.bak 2>/dev/null || true`; - await runMutatingVisible`cp -a /etc/hy2xs/hy2xs.env /etc/hy2xs/backups/hy2xs.env.bak 2>/dev/null || true`; - await runMutatingVisible`cp -a /etc/hysteria/post-install.env /etc/hy2xs/backups/post-install.env.bak 2>/dev/null || true`; - await runMutatingVisible`cp -a /etc/nftables.conf /etc/hy2xs/backups/nftables.conf.bak 2>/dev/null || true`; - await runMutatingVisible`cp -a /etc/nftables.d/hy2xs.nft /etc/hy2xs/backups/hy2xs.nft.bak 2>/dev/null || true`; +/** + * Файлы, которые reconfigure/repair обязаны сохранить до первой мутации. + * + * Порядок групп задаёт и порядок восстановления: конфиг Hysteria, юниты, + * env-артефакты, файлы nftables. + */ +const BACKUP_GROUPS = [ + { stage: "restore hysteria config", paths: ["/etc/hysteria/config.yaml"] }, + { + stage: "restore systemd units", + paths: ["/etc/systemd/system/hy2xs-admin.service", "/etc/systemd/system/hysteria-server.service"] + }, + { stage: "restore runtime env", paths: ["/etc/hy2xs/hy2xs.env", "/etc/hysteria/post-install.env"] }, + { stage: "restore nftables files", paths: ["/etc/nftables.conf", "/etc/nftables.d/hy2xs.nft"] } +] as const; - await runMutatingVisible`test -f /etc/hy2xs/hy2xs.env && echo 1 > /etc/hy2xs/backups/hy2xs.env.existed || rm -f /etc/hy2xs/backups/hy2xs.env.existed`; - await runMutatingVisible`test -f /etc/hysteria/post-install.env && echo 1 > /etc/hy2xs/backups/post-install.env.existed || rm -f /etc/hy2xs/backups/post-install.env.existed`; - await runMutatingVisible`test -f /etc/nftables.conf && echo 1 > /etc/hy2xs/backups/nftables.conf.existed || rm -f /etc/hy2xs/backups/nftables.conf.existed`; - await runMutatingVisible`test -f /etc/nftables.d/hy2xs.nft && echo 1 > /etc/hy2xs/backups/hy2xs.nft.existed || rm -f /etc/hy2xs/backups/hy2xs.nft.existed`; +const BACKED_UP_PATHS = BACKUP_GROUPS.flatMap((group) => group.paths); + +/** + * Снимает полную резервную копию текущей операции и ДОКАЗЫВАЕТ, что снял. + * + * Копия привязана к op-id: каталог /etc/hy2xs/backups// и манифест в + * нём. Раньше копии всех операций лежали одним общим набором `*.bak`, и при + * неудачном копировании в операции B откат B восстанавливал файлы, сохранённые + * операцией A, — то есть возвращал сервер в более старое состояние и выглядел + * при этом успешным. + * + * Любая ошибка здесь — отказ ДО первой мутации: у reconfigure ещё ничего не + * изменено, и остановиться дешевле, чем начать менять систему без копии. + */ +async function backupCurrentState(context: ReconfigureContext): Promise { + const opId = operationKey(context); + const dir = backupDir(opId); + + await runMutatingVisible`install -d -m 0700 -o root -g root ${dir}`; + await runMutatingVisible`install -d -m 0700 -o root -g root ${dir}/files`; + + const files: { path: string; present: boolean }[] = []; + + for (const path of BACKED_UP_PATHS) { + const present = await fileExists(path); + files.push({ path, present }); + if (!present) { + continue; + } + + const target = backupFilePath(opId, storedNameFor(path)); + await runMutatingVisible`cp -a ${path} ${target}`; + if (!(await fileExists(target))) { + throw new Error( + `резервная копия ${path} не создана: ${target} отсутствует. ` + + "Reconfigure остановлен до первой мутации: без полной копии откат недостоверен." + ); + } + } + + const manifest = buildManifest(opId, context.installDate, files); + await writeTextAtomic(backupManifestPath(opId), renderManifest(manifest), { + mode: 0o600, + owner: "root", + group: "root" + }); + + info(`backup of the current state is complete: ${dir}`); } -async function rollbackCurrentState(): Promise { - await runMutatingVisible`cp -a /etc/hy2xs/backups/config.yaml.bak /etc/hysteria/config.yaml 2>/dev/null || true`; - await runMutatingVisible`cp -a /etc/hy2xs/backups/hy2xs-admin.service.bak /etc/systemd/system/hy2xs-admin.service 2>/dev/null || true`; - await runMutatingVisible`cp -a /etc/hy2xs/backups/hysteria-server.service.bak /etc/systemd/system/hysteria-server.service 2>/dev/null || true`; +async function readBackupManifest(opId: string): Promise { + const path = backupManifestPath(opId); + if (!(await fileExists(path))) { + throw new Error(`манифест резервной копии не найден: ${path}`); + } + return parseManifest(await readText(path), opId); +} - await runMutatingVisible`if [ -f /etc/hy2xs/backups/hy2xs.env.existed ]; then cp -a /etc/hy2xs/backups/hy2xs.env.bak /etc/hy2xs/hy2xs.env 2>/dev/null || true; else rm -f /etc/hy2xs/hy2xs.env; fi`; - await runMutatingVisible`if [ -f /etc/hy2xs/backups/post-install.env.existed ]; then cp -a /etc/hy2xs/backups/post-install.env.bak /etc/hysteria/post-install.env 2>/dev/null || true; else rm -f /etc/hysteria/post-install.env; fi`; - await runMutatingVisible`if [ -f /etc/hy2xs/backups/nftables.conf.existed ]; then cp -a /etc/hy2xs/backups/nftables.conf.bak /etc/nftables.conf 2>/dev/null || true; else rm -f /etc/nftables.conf; fi`; - await runMutatingVisible`if [ -f /etc/hy2xs/backups/hy2xs.nft.existed ]; then cp -a /etc/hy2xs/backups/hy2xs.nft.bak /etc/nftables.d/hy2xs.nft 2>/dev/null || true; else rm -f /etc/nftables.d/hy2xs.nft; fi`; +/** + * Стадии восстановления состояния до текущей операции. + * + * Каждая стадия независима и НЕ глушит собственные ошибки. Раньше здесь были + * `cp ... || true`, `nft -f ... || true`, `daemon-reload || true` и + * `restart ... || true`, поэтому высокоуровневая стадия «restore + * configuration» физически не могла попасть в список отказавших: откат всегда + * отчитывался успешным, что бы ни произошло. + * + * Непрерывность обеспечивает стадийный раннер, а не подавление ошибок. + */ +function restoreStages(manifest: BackupManifest, opId: string): RollbackStage[] { + const byPath = new Map(manifest.entries.map((entry) => [entry.path, entry])); + const stages: RollbackStage[] = []; - await runMutatingVisible`nft -f /etc/nftables.conf >/dev/null 2>&1 || true`; - // `|| true` здесь по той же причине, что и у остальных команд отката: без - // него отказ daemon-reload отменял бы перезапуск сервисов строкой ниже, то - // есть восстановленные из backups unit-файлы так и не были бы применены. - await runMutatingVisible`systemctl daemon-reload || true`; - await runMutatingVisible`systemctl restart hysteria-server hy2xs-admin || true`; + for (const group of BACKUP_GROUPS) { + stages.push({ + name: group.stage, + run: async () => { + for (const path of group.paths) { + const entry = byPath.get(path); + if (!entry) { + throw new Error(`манифест резервной копии не описывает ${path}`); + } + if (!entry.present) { + // Файла не существовало до операции: восстановление — удаление. + await runMutatingVisible`rm -f ${path}`; + continue; + } + await runMutatingVisible`cp -a ${backupFilePath(opId, entry.stored as string)} ${path}`; + } + } + }); + } + + const entrypoint = byPath.get("/etc/nftables.conf"); + stages.push({ + name: "apply restored ruleset", + run: async () => { + if (entrypoint?.present) { + await runMutatingVisible`nft -f /etc/nftables.conf`; + return; + } + await runMutatingVisible`nft flush ruleset`; + } + }); + + stages.push({ + name: "reload systemd units", + run: async () => { + await runMutatingVisible`systemctl daemon-reload`; + } + }); + + stages.push({ + name: "restart services", + run: async () => { + await runMutatingVisible`systemctl restart hysteria-server hy2xs-admin`; + } + }); + + return stages; +} + +async function rollbackCurrentState(context: ReconfigureContext): Promise { + const opId = operationKey(context); + const manifest = await readBackupManifest(opId); + return await runRollbackStages(restoreStages(manifest, opId)); +} + +/** + * Убирает копии предыдущих операций после долговечной фиксации успеха. + * + * Копия текущей операции остаётся: это состояние «до последнего успешного + * reconfigure», ровно та же семантика, что была у прежнего единственного + * набора `*.bak`, но теперь без смешения операций. Рост каталога при этом + * ограничен — лишние каталоги удаляются. + */ +async function pruneOldBackups(context: ReconfigureContext): Promise { + const opId = operationKey(context); + await runMutatingVisible`find ${BACKUP_ROOT} -mindepth 1 -maxdepth 1 -type d ! -name ${opId} -exec rm -rf {} +`; + // Наследие общего каталога: одиночные *.bak и *.existed от прежней схемы. + await runMutatingVisible`find ${BACKUP_ROOT} -mindepth 1 -maxdepth 1 -type f -name '*.bak' -delete`; + await runMutatingVisible`find ${BACKUP_ROOT} -mindepth 1 -maxdepth 1 -type f -name '*.existed' -delete`; } async function readInstallState(): Promise { @@ -239,7 +385,7 @@ export async function reconfigure(options: ReconfigureOptions): Promise { } step("backup"); - await backupCurrentState(); + await backupCurrentState(context); stepDone("backup"); const ownership = newReconfigureOwnership(); @@ -277,15 +423,35 @@ export async function reconfigure(options: ReconfigureOptions): Promise { await smoke(context); stepDone("smoke checks"); await markPhase(context, "smoke_ok"); - step("finalize firewall rollback guard"); - await cancelFirewallRollback(context); - stepDone("finalize firewall rollback guard"); + + // Порядок фиксации успеха — тот же, что и в install: снять таймер + // автоотката, ЗАФИКСИРОВАТЬ успех долговечно и только потом удалять данные + // для отката. Раньше единственный вызов снимал таймер и удалял резервные + // копии firewall до записи `installed`, поэтому отказ этой записи приводил + // в обработчик ошибки, где откатывать было уже нечем. + step("disarm firewall rollback timer"); + await disarmFirewallRollback(context); + stepDone("disarm firewall rollback timer"); await markPhase(context, "installed"); const finalState = await readInstallState(); if (!finalState?.installed || finalState.phase !== "installed") { throw new Error("deterministic state violation: reconfigure/repair finished without installed phase"); } + + // Операция зафиксирована. Уборка — best effort: невозможность удалить + // временные данные не делает успешный reconfigure неуспешным. + for (const [what, cleanup] of [ + ["firewall rollback data", () => cleanupFirewallRollback(context)], + ["previous state backups", () => pruneOldBackups(context)] + ] as const) { + try { + await cleanup(); + } catch (cleanupError) { + const cleanupMessage = cleanupError instanceof Error ? cleanupError.message : String(cleanupError); + info(`cleanup of ${what} failed after a successful reconfigure: ${cleanupMessage}`); + } + } } catch (error) { info("reconfigure failed, rollback in progress"); const message = error instanceof Error ? error.message : String(error); @@ -333,7 +499,16 @@ export async function reconfigure(options: ReconfigureOptions): Promise { stages.push({ name: "restore configuration", run: async () => { - await rollbackCurrentState(); + // rollbackCurrentState сама разбита на независимые стадии и возвращает + // список отказавших. Здесь он превращается в отказ внешней стадии, + // чтобы итоговая сводка отката не объявила восстановление успешным. + const restoreFailures = await rollbackCurrentState(context); + if (restoreFailures.length > 0) { + throw new Error( + `восстановление состояния выполнено не полностью: ${restoreFailures.join("; ")}. ` + + `Данные для ручного восстановления сохранены в ${backupDir(operationKey(context))}` + ); + } } }); diff --git a/orchestrator/src/lib/backupManifest.ts b/orchestrator/src/lib/backupManifest.ts new file mode 100644 index 0000000..90e6fc0 --- /dev/null +++ b/orchestrator/src/lib/backupManifest.ts @@ -0,0 +1,154 @@ +/** + * Манифест резервной копии одной операции reconfigure/repair. + * + * Зачем он вообще нужен. + * + * Резервная копия раньше жила в общем каталоге /etc/hy2xs/backups одним + * набором `*.bak`, не привязанным к операции, и снималась так: + * + * cp -a /etc/hysteria/config.yaml /etc/hy2xs/backups/config.yaml.bak || true + * + * Из этого следовали две разные беды. + * + * 1. Отказ копирования игнорировался, и reconfigure шёл менять систему, не имея + * копии, на которую рассчитывает откат. + * + * 2. Копии от РАЗНЫХ операций смешивались. Если reconfigure A успешно записал + * config.yaml.bak, а у reconfigure B копирование упало, то B всё равно менял + * конфигурацию, а его откат восстанавливал файл, сохранённый операцией A. То + * есть сервер возвращался не в состояние «до B», а в более старое — и это + * выглядело как успешный откат. + * + * Манифест закрывает обе: он привязан к op-id и содержит ЯВНУЮ запись о каждом + * файле, включая отсутствовавшие. «Файла не было» становится записанным фактом, + * а не выводом из неудачи `cp`. + */ + +export const BACKUP_ROOT = "/etc/hy2xs/backups"; + +export const BACKUP_MANIFEST_VERSION = 1; + +export type BackupEntry = { + /** Путь на сервере, который эта запись описывает. */ + path: string; + /** Существовал ли файл на момент снятия копии. */ + present: boolean; + /** Имя файла внутри каталога копии; null, если файла не было. */ + stored: string | null; +}; + +export type BackupManifest = { + version: number; + opId: string; + createdAt: string; + entries: BackupEntry[]; +}; + +export function backupDir(opId: string): string { + return `${BACKUP_ROOT}/${opId}`; +} + +export function backupManifestPath(opId: string): string { + return `${backupDir(opId)}/manifest.json`; +} + +export function backupFilePath(opId: string, stored: string): string { + return `${backupDir(opId)}/files/${stored}`; +} + +/** + * Имя файла внутри копии. + * + * Плоское, потому что каталог копии — не зеркало файловой системы, а набор + * артефактов; при этом имя обязано быть однозначным, иначе + * /etc/nftables.conf и /etc/hy2xs/nftables.conf легли бы в одно место. + */ +export function storedNameFor(path: string): string { + const normalized = path.replace(/^\/+/, "").replace(/[^A-Za-z0-9._-]+/g, "_"); + return normalized || "unnamed"; +} + +export function buildManifest( + opId: string, + createdAt: string, + files: readonly { path: string; present: boolean }[] +): BackupManifest { + return { + version: BACKUP_MANIFEST_VERSION, + opId, + createdAt, + entries: files.map((file) => ({ + path: file.path, + present: file.present, + stored: file.present ? storedNameFor(file.path) : null + })) + }; +} + +export function renderManifest(manifest: BackupManifest): string { + return `${JSON.stringify(manifest, null, 2)}\n`; +} + +/** + * Разбор манифеста строгий. + * + * Откат по частично понятому манифесту опаснее отказа: «поле не разобралось, + * будем считать, что файла не было» означает удаление существующего файла. + */ +export function parseManifest(raw: string, expectedOpId: string): BackupManifest { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new Error( + `манифест резервной копии не разбирается: ${error instanceof Error ? error.message : String(error)}` + ); + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("манифест резервной копии не является объектом"); + } + + const record = parsed as Record; + if (record.version !== BACKUP_MANIFEST_VERSION) { + throw new Error( + `неизвестная версия манифеста резервной копии: ${JSON.stringify(record.version)}, ожидалась ${BACKUP_MANIFEST_VERSION}` + ); + } + if (record.opId !== expectedOpId) { + throw new Error( + `манифест относится к другой операции: ${JSON.stringify(record.opId)}, ожидалась ${JSON.stringify(expectedOpId)}` + ); + } + if (!Array.isArray(record.entries)) { + throw new Error("в манифесте резервной копии нет списка файлов"); + } + + const entries: BackupEntry[] = record.entries.map((item, index) => { + if (!item || typeof item !== "object" || Array.isArray(item)) { + throw new Error(`запись ${index} манифеста не является объектом`); + } + const entry = item as Record; + if (typeof entry.path !== "string" || entry.path === "") { + throw new Error(`запись ${index} манифеста не содержит пути`); + } + if (typeof entry.present !== "boolean") { + throw new Error(`запись ${index} манифеста не сообщает, существовал ли файл`); + } + if (entry.present && (typeof entry.stored !== "string" || entry.stored === "")) { + throw new Error(`запись ${index} манифеста объявляет файл существующим, но не называет копию`); + } + return { + path: entry.path, + present: entry.present, + stored: entry.present ? (entry.stored as string) : null + }; + }); + + return { + version: BACKUP_MANIFEST_VERSION, + opId: expectedOpId, + createdAt: typeof record.createdAt === "string" ? record.createdAt : "", + entries + }; +} diff --git a/orchestrator/src/lib/fs.ts b/orchestrator/src/lib/fs.ts index 672fc29..df649a4 100644 --- a/orchestrator/src/lib/fs.ts +++ b/orchestrator/src/lib/fs.ts @@ -79,13 +79,32 @@ export async function ensureDir( owner?: string; group?: string; } -): Promise { +): Promise<{ created: boolean }> { assertMutationAllowed(`ensureDir(${path})`); + const existed = await dirExists(path); + await mkdir(path, { recursive: true, mode: options.mode }); await chmod(path, options.mode); if (options.owner && options.group) { chownByName(path, options.owner, options.group); } + + // Долговечность самой записи каталога в РОДИТЕЛЕ. + // + // writeTextAtomic синхронизирует файл и каталог, в котором файл лежит. Но + // при первой установке /var/lib/hy2xs создаётся прямо сейчас, и запись + // «hy2xs» в /var/lib остаётся несинхронизированной. После потери питания + // возможно состояние, в котором install-state.json и его каталог были + // сброшены на носитель, а сам каталог из /var/lib исчез — то есть маркер + // восстановления пропал целиком. + // + // Синхронизируется только при фактическом создании: на каждом обновлении + // маркера каталог уже существует, и лишний fsync родителя ничего не даёт. + if (!existed) { + await syncDirectory(dirname(path)); + } + + return { created: !existed }; } /** diff --git a/orchestrator/src/steps/firewall.ts b/orchestrator/src/steps/firewall.ts index cd2635d..de02e8a 100644 --- a/orchestrator/src/steps/firewall.ts +++ b/orchestrator/src/steps/firewall.ts @@ -2,6 +2,7 @@ import type { RuntimeContext } from "../types/context"; import { fileExists, readText, renderTemplate, writeText } from "../lib/fs"; import { fail, info } from "../lib/log"; import { runMutatingVisible } from "../lib/process"; +import { runRollbackStages, type RollbackStage } from "../lib/rollback"; type NftEntrypointKind = | "missing" @@ -41,6 +42,60 @@ async function cleanupFirewallBackupFiles(opId: string): Promise { await runMutatingVisible`rm -rf ${rollbackRoot(opId)}`; } +/** + * Файлы firewall, которые операция обязана сохранить до первой мутации. + * + * Список явный: «скопировать всё, что найдём» и «доказать, что скопировали + * именно то, что нужно» — разные утверждения, и rollback опирается на второе. + */ +const FIREWALL_BACKUP_TARGETS = [ + { path: "/etc/nftables.conf", backup: "nftables.conf.bak", marker: "nftables.conf.existed" }, + { path: "/etc/nftables.d/hy2xs.nft", backup: "hy2xs.nft.bak", marker: "hy2xs.nft.existed" } +] as const; + +/** + * Снимает резервные копии ДО первой мутации firewall — и доказывает, что снял. + * + * Что было: + * + * cp -a /etc/nftables.conf 2>/dev/null || true + * + * то есть отказ копирования (заполненный /run, ошибка ввода-вывода, права) + * молча игнорировался. Дальше выставлялся маркер `prepared`, и операция + * начинала переписывать firewall — уже НЕ имея резервной копии, на которую + * рассчитывает откат. Предпосылка отката нарушалась в самом его основании. + * + * Маркер `prepared` теперь ставится ПОСЛЕ проверенных копий, а не до них: он + * означает «данные для отката существуют», и раньше это было неправдой. + */ +async function backupFirewallState(opId: string): Promise { + await ensureRollbackRoot(opId); + + for (const target of FIREWALL_BACKUP_TARGETS) { + const backupPath = rollbackBackup(target.backup, opId); + const markerPath = rollbackBackup(target.marker, opId); + + if (!(await fileExists(target.path))) { + // Отсутствие файла — законное состояние, но оно обязано быть ЗАПИСАНО, а + // не выведено из неудачи копирования: откат по этому маркеру решает, + // восстанавливать файл или удалять его. + await runMutatingVisible`rm -f ${markerPath} ${backupPath}`; + continue; + } + + await runMutatingVisible`cp -a ${target.path} ${backupPath}`; + if (!(await fileExists(backupPath))) { + fail( + `firewall backup was not created for ${target.path}: ${backupPath} is missing. ` + + "Отказ до первой мутации firewall: без резервной копии откат невозможен." + ); + } + await runMutatingVisible`printf 1 > ${markerPath}`; + } + + await runMutatingVisible`touch ${rollbackMarker(opId)}`; +} + function stripNftComments(content: string): string { return content .split(/\r?\n/) @@ -125,12 +180,9 @@ export async function applyFirewall(context: RuntimeContext): Promise { fail("foreign nftables.conf detected; use HY2XS_FIREWALL_MODE=takeover|external|off"); } - await ensureRollbackRoot(opId); - await runMutatingVisible`touch ${rollbackMarker(opId)}`; - await runMutatingVisible`cp -a /etc/nftables.conf ${rollbackBackup("nftables.conf.bak", opId)} 2>/dev/null || true`; - await runMutatingVisible`cp -a /etc/nftables.d/hy2xs.nft ${rollbackBackup("hy2xs.nft.bak", opId)} 2>/dev/null || true`; - await runMutatingVisible`test -f /etc/nftables.conf && echo 1 > ${rollbackBackup("nftables.conf.existed", opId)} || rm -f ${rollbackBackup("nftables.conf.existed", opId)}`; - await runMutatingVisible`test -f /etc/nftables.d/hy2xs.nft && echo 1 > ${rollbackBackup("hy2xs.nft.existed", opId)} || rm -f ${rollbackBackup("hy2xs.nft.existed", opId)}`; + // Резервные копии снимаются и ПРОВЕРЯЮТСЯ до первой записи в /etc. + await backupFirewallState(opId); + await writeText("/etc/nftables.d/hy2xs.nft.candidate", rendered, 0o600); await runMutatingVisible`nft -c -f /etc/nftables.d/hy2xs.nft.candidate`; @@ -171,24 +223,90 @@ include "/etc/nftables.d/hy2xs.nft" info("firewall applied with rollback guard; guard will be cancelled only after successful smoke checks"); } -export async function cancelFirewallRollback(context: RuntimeContext): Promise { - const opId = operationKey(context); - if (context.options.skipFirewall || context.config.firewallMode === "off" || context.config.firewallMode === "external") { - return; - } - - if (context.config.firewallStagedApply) { - const unit = rollbackUnit(opId); - await runMutatingVisible`systemctl stop ${unit}.timer ${unit}.service || true`; - await runMutatingVisible`systemctl reset-failed ${unit}.timer ${unit}.service || true`; - } - - await cleanupFirewallBackupFiles(opId); +function firewallRollbackIsInactive(context: RuntimeContext): boolean { + return ( + context.options.skipFirewall || + context.config.firewallMode === "off" || + context.config.firewallMode === "external" + ); } +async function stopRollbackTimer(context: RuntimeContext, opId: string): Promise { + if (!context.config.firewallStagedApply) { + return; + } + const unit = rollbackUnit(opId); + await runMutatingVisible`systemctl stop ${unit}.timer ${unit}.service || true`; + await runMutatingVisible`systemctl reset-failed ${unit}.timer ${unit}.service || true`; +} + +/** + * Снимает автоматический откат по таймеру, НО сохраняет резервные копии. + * + * Разделение на disarm и cleanup — исправление ошибки порядка фиксации. + * Единая `cancelFirewallRollback` делала и то и другое, а вызывалась ДО + * долговечной записи `phase: installed`. Получался разрыв: + * + * smoke PASS + * -> таймер снят, резервные копии УДАЛЕНЫ + * -> запись "installed" падает (ENOSPC/EIO/read-only) + * -> catch -> обязательный откат + * -> "no HY2XS rollback markers found" + * + * То есть ровно тот отказ записи маркера, который был специально сделан + * безопасным, случался после уничтожения единственных данных для отката. + * Откат запускался, но откатывать ему было нечем. + * + * Теперь между disarm и cleanup стоит долговечная фиксация успеха, и до неё + * ручное восстановление остаётся возможным. + */ +export async function disarmFirewallRollback(context: RuntimeContext): Promise { + if (firewallRollbackIsInactive(context)) { + return; + } + await stopRollbackTimer(context, operationKey(context)); + info("firewall rollback timer disarmed; backups are kept until the installation is durably committed"); +} + +/** + * Удаляет резервные копии firewall. Вызывается ТОЛЬКО после долговечной + * фиксации успеха операции. + * + * Неудача здесь — мусор в /run, а не причина объявить успешную установку + * неуспешной, поэтому вызывающий выполняет её best-effort. + */ +export async function cleanupFirewallRollback(context: RuntimeContext): Promise { + if (firewallRollbackIsInactive(context)) { + return; + } + await cleanupFirewallBackupFiles(operationKey(context)); +} + +/** + * Немедленное восстановление firewall. + * + * Два правила, которых здесь раньше не было. + * + * 1. Ошибки восстановления НЕ скрываются. Было: + * + * cp /etc/nftables.conf 2>/dev/null || true + * nft -f /etc/nftables.conf >/dev/null 2>&1 || true + * + * то есть неудача копирования или применения правил давала функции + * завершиться успешно, и стадия отката отчитывалась как выполненная. + * + * 2. Резервные копии удаляются ТОЛЬКО после подтверждённого восстановления. + * Было — безусловный `rm -rf` в конце: худшая комбинация, при которой + * ошибка восстановления скрыта, а данные, по которым оператор мог бы + * поднять firewall вручную, уничтожены. + * + * Стадии независимы: файл nftables.conf и файл hy2xs.nft восстанавливаются + * порознь, применение правил выполняется в любом случае — частично + * восстановленный firewall лучше полностью отсутствующего. + */ export async function rollbackFirewallNow(context: RuntimeContext): Promise { const opId = operationKey(context); - if (context.options.skipFirewall || context.config.firewallMode === "off" || context.config.firewallMode === "external") { + if (firewallRollbackIsInactive(context)) { return; } @@ -197,14 +315,47 @@ export async function rollbackFirewallNow(context: RuntimeContext): Promise { + if (await fileExists(markerPath)) { + await runMutatingVisible`cp -a ${backupPath} ${target.path}`; + return; + } + // Файла не было до операции — восстановление означает его удаление. + await runMutatingVisible`rm -f ${target.path}`; + } + }); + } + + stages.push({ + name: "apply restored ruleset", + run: async () => { + if (entrypointExisted) { + await runMutatingVisible`nft -f /etc/nftables.conf`; + return; + } + await runMutatingVisible`nft flush ruleset`; + } + }); + + const failures = await runRollbackStages(stages); + + if (failures.length > 0) { + info( + `firewall rollback did not complete; manual recovery data preserved at ${rollbackRoot(opId)}` + ); + return; } - await runMutatingVisible`if [ -f ${rollbackBackup("nftables.conf.existed", opId)} ]; then cp -a ${rollbackBackup("nftables.conf.bak", opId)} /etc/nftables.conf 2>/dev/null || true; else rm -f /etc/nftables.conf; fi`; - await runMutatingVisible`if [ -f ${rollbackBackup("hy2xs.nft.existed", opId)} ]; then cp -a ${rollbackBackup("hy2xs.nft.bak", opId)} /etc/nftables.d/hy2xs.nft 2>/dev/null || true; else rm -f /etc/nftables.d/hy2xs.nft; fi`; - await runMutatingVisible`if [ -f ${rollbackBackup("nftables.conf.existed", opId)} ]; then nft -f /etc/nftables.conf >/dev/null 2>&1 || true; else nft flush ruleset >/dev/null 2>&1 || true; fi`; await cleanupFirewallBackupFiles(opId); } diff --git a/orchestrator/test/atomic-write.test.ts b/orchestrator/test/atomic-write.test.ts index d2243a6..1113a90 100644 --- a/orchestrator/test/atomic-write.test.ts +++ b/orchestrator/test/atomic-write.test.ts @@ -135,6 +135,17 @@ describe("ensureDir", () => { expect(((await stat(target)).mode & 0o777).toString(8)).toBe("755"); }); + // Долговечность записи каталога в РОДИТЕЛЕ имеет смысл только при + // фактическом создании, поэтому ensureDir обязана его различать: на каждом + // обновлении маркера каталог уже существует, и лишний fsync родителя ничего + // не даёт. + test("сообщает, был ли каталог создан", async () => { + const target = join(dir, "hy2xs"); + + expect((await ensureDir(target, { mode: 0o755 })).created).toBe(true); + expect((await ensureDir(target, { mode: 0o755 })).created).toBe(false); + }); + test("создание каталога проходит через read-only guard", async () => { enableReadOnlyGuard("the read-only install preflight (PHASE 0)"); await expect(ensureDir(join(dir, "hy2xs"), { mode: 0o755 })).rejects.toThrow( diff --git a/orchestrator/test/backup-integrity.test.ts b/orchestrator/test/backup-integrity.test.ts new file mode 100644 index 0000000..9a0c4f7 --- /dev/null +++ b/orchestrator/test/backup-integrity.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + BACKUP_MANIFEST_VERSION, + backupDir, + backupFilePath, + backupManifestPath, + buildManifest, + parseManifest, + renderManifest, + storedNameFor +} from "../src/lib/backupManifest"; + +/** + * Целостность резервной копии — предпосылка отката, а не его деталь. + * + * Два дефекта, которые здесь закрепляются. + * + * 1. Копии снимались как `cp ... || true`, поэтому операция начинала менять + * систему, не имея копии, на которую рассчитывает откат. + * + * 2. Копии всех операций лежали одним общим набором `*.bak` в + * /etc/hy2xs/backups. Если у операции B копирование падало, её откат + * восстанавливал файл, сохранённый операцией A, — то есть возвращал сервер + * в более старое состояние и выглядел при этом успешным. + * + * Манифест отвечает на оба: он привязан к op-id и содержит ЯВНУЮ запись о + * каждом файле, включая отсутствовавшие. + */ + +function source(relativeToSrc: string): string { + return readFileSync(join(import.meta.dir, "..", "src", relativeToSrc), "utf8"); +} + +describe("манифест резервной копии", () => { + test("копия каждой операции лежит в своём каталоге", () => { + expect(backupDir("2026-08-30T10-00-00.000Z")).toBe("/etc/hy2xs/backups/2026-08-30T10-00-00.000Z"); + expect(backupDir("a")).not.toBe(backupDir("b")); + }); + + test("манифест и файлы адресуются внутри каталога операции", () => { + const opId = "op-1"; + expect(backupManifestPath(opId)).toStartWith(backupDir(opId)); + expect(backupFilePath(opId, "etc_nftables.conf")).toStartWith(backupDir(opId)); + }); + + // Имя обязано быть однозначным: /etc/nftables.conf и /etc/hy2xs/nftables.conf + // не имеют права лечь в один файл копии. + test("разные пути дают разные имена файлов копии", () => { + const names = new Set( + [ + "/etc/nftables.conf", + "/etc/hy2xs/nftables.conf", + "/etc/nftables.d/hy2xs.nft", + "/etc/hysteria/config.yaml", + "/etc/systemd/system/hy2xs-admin.service" + ].map(storedNameFor) + ); + expect(names.size).toBe(5); + }); + + test("имя файла копии не выходит за пределы каталога", () => { + for (const path of ["/etc/../../evil", "/etc/hy2xs/../../x", "//etc//x"]) { + const stored = storedNameFor(path); + expect(stored).not.toContain("/"); + expect(stored).not.toContain("../"); + } + }); + + // Ключевое: «файла не было» — записанный факт, а не вывод из неудачи `cp`. + test("отсутствовавший файл записывается явно", () => { + const manifest = buildManifest("op-1", "2026-08-30T10:00:00.000Z", [ + { path: "/etc/hysteria/config.yaml", present: true }, + { path: "/etc/nftables.d/hy2xs.nft", present: false } + ]); + + const missing = manifest.entries.find((entry) => entry.path === "/etc/nftables.d/hy2xs.nft"); + expect(missing?.present).toBe(false); + expect(missing?.stored).toBeNull(); + + const kept = manifest.entries.find((entry) => entry.path === "/etc/hysteria/config.yaml"); + expect(kept?.present).toBe(true); + expect(kept?.stored).toBe(storedNameFor("/etc/hysteria/config.yaml")); + }); + + test("манифест переживает сериализацию без потерь", () => { + const manifest = buildManifest("op-1", "2026-08-30T10:00:00.000Z", [ + { path: "/etc/hysteria/config.yaml", present: true }, + { path: "/etc/hy2xs/hy2xs.env", present: false } + ]); + + expect(parseManifest(renderManifest(manifest), "op-1")).toEqual(manifest); + }); +}); + +describe("разбор манифеста строгий", () => { + const valid = renderManifest( + buildManifest("op-1", "2026-08-30T10:00:00.000Z", [{ path: "/etc/hysteria/config.yaml", present: true }]) + ); + + // Откат по манифесту ЧУЖОЙ операции — это ровно тот дефект, ради которого + // копия стала привязанной к op-id. + test("манифест другой операции отклоняется", () => { + expect(() => parseManifest(valid, "op-2")).toThrow(/относится к другой операции/); + }); + + test("непонятная версия отклоняется", () => { + const foreign = valid.replace(`"version": ${BACKUP_MANIFEST_VERSION}`, '"version": 99'); + expect(() => parseManifest(foreign, "op-1")).toThrow(/неизвестная версия/); + }); + + test("битый JSON отклоняется, а не считается пустой копией", () => { + expect(() => parseManifest("{ не json", "op-1")).toThrow(/не разбирается/); + }); + + // «Поле не разобралось, будем считать, что файла не было» означает удаление + // существующего файла при откате. Поэтому отказ, а не умолчание. + test("запись без признака существования отклоняется", () => { + const broken = JSON.stringify({ + version: BACKUP_MANIFEST_VERSION, + opId: "op-1", + createdAt: "", + entries: [{ path: "/etc/hysteria/config.yaml" }] + }); + expect(() => parseManifest(broken, "op-1")).toThrow(/не сообщает, существовал ли файл/); + }); + + test("существующий файл без имени копии отклоняется", () => { + const broken = JSON.stringify({ + version: BACKUP_MANIFEST_VERSION, + opId: "op-1", + createdAt: "", + entries: [{ path: "/etc/hysteria/config.yaml", present: true, stored: "" }] + }); + expect(() => parseManifest(broken, "op-1")).toThrow(/не называет копию/); + }); + + test("запись без пути отклоняется", () => { + const broken = JSON.stringify({ + version: BACKUP_MANIFEST_VERSION, + opId: "op-1", + createdAt: "", + entries: [{ present: false }] + }); + expect(() => parseManifest(broken, "op-1")).toThrow(/не содержит пути/); + }); +}); + +describe("reconfigure: копия снимается строго и до первой мутации", () => { + const reconfigureSource = source("commands/reconfigure.ts"); + + test("копирование не глушит ошибки", () => { + const start = reconfigureSource.indexOf("async function backupCurrentState"); + const body = reconfigureSource.slice(start, reconfigureSource.indexOf("async function readBackupManifest")); + const muted = body + .split(/\r?\n/) + .filter((line) => line.includes("runMutatingVisible`")) + .filter((line) => line.includes("|| true") || line.includes("2>/dev/null")); + + expect(muted, `копирование снова скрывает ошибки: ${muted.join("; ")}`).toEqual([]); + }); + + // Создание копии обязано быть ДОКАЗАНО, а не предположено по коду возврата + // одного лишь `cp`. + test("существование копии проверяется после копирования", () => { + expect(reconfigureSource).toContain("резервная копия"); + expect(reconfigureSource).toContain("await fileExists(target)"); + }); + + test("копия снимается до первой мутации системы", () => { + const backup = reconfigureSource.indexOf("await backupCurrentState(context)"); + const generate = reconfigureSource.indexOf("await generateConfig(context)"); + const firewall = reconfigureSource.indexOf("await applyFirewall(context)"); + + expect(backup).toBeGreaterThan(-1); + expect(backup).toBeLessThan(generate); + expect(backup).toBeLessThan(firewall); + }); + + test("откат читает манифест текущей операции, а не общий каталог", () => { + expect(reconfigureSource).toContain("readBackupManifest(opId)"); + expect(reconfigureSource).toContain("parseManifest(await readText(path), opId)"); + // Прежняя схема общего набора *.bak не должна вернуться. + expect(reconfigureSource).not.toContain("/etc/hy2xs/backups/config.yaml.bak"); + + // Решение «восстанавливать или удалять» принимается по манифесту, а не по + // маркерам-файлам прежней схемы. Уборка их наследия в pruneOldBackups + // допустима и проверяется отдельно. + const restoreStart = reconfigureSource.indexOf("function restoreStages("); + const restoreBody = reconfigureSource.slice( + restoreStart, + reconfigureSource.indexOf("async function rollbackCurrentState") + ); + expect(restoreBody).not.toContain(".existed"); + expect(restoreBody).toContain("entry.present"); + }); + + // Каталог копий не имеет права расти неограниченно, но уборка обязана идти + // ПОСЛЕ долговечной фиксации успеха — иначе это тот же дефект порядка, что и + // с firewall. + test("копии предыдущих операций убираются после фиксации успеха", () => { + const installed = reconfigureSource.indexOf('await markPhase(context, "installed")'); + const prune = reconfigureSource.indexOf("pruneOldBackups(context)"); + expect(installed).toBeGreaterThan(-1); + expect(prune).toBeGreaterThan(installed); + }); +}); + +describe("firewall: копия снимается строго и переживает неудачный откат", () => { + const firewallSource = source("steps/firewall.ts"); + + test("копирование firewall не глушит ошибки", () => { + const start = firewallSource.indexOf("async function backupFirewallState"); + const body = firewallSource.slice(start, firewallSource.indexOf("function stripNftComments")); + const muted = body + .split(/\r?\n/) + .filter((line) => line.includes("runMutatingVisible`")) + .filter((line) => line.includes("|| true") || line.includes("2>/dev/null")); + + expect(muted, `копирование firewall снова скрывает ошибки: ${muted.join("; ")}`).toEqual([]); + }); + + // Маркер `prepared` означает «данные для отката существуют». Пока он + // ставился ДО копирования, это было неправдой. + test("маркер готовности ставится после проверенных копий", () => { + const start = firewallSource.indexOf("async function backupFirewallState"); + const body = firewallSource.slice(start, firewallSource.indexOf("function stripNftComments")); + const copy = body.indexOf("cp -a ${target.path}"); + const verify = body.indexOf("await fileExists(backupPath)"); + const marker = body.indexOf("touch ${rollbackMarker(opId)}"); + + expect(copy).toBeGreaterThan(-1); + expect(verify).toBeGreaterThan(copy); + expect(marker).toBeGreaterThan(verify); + }); + + test("восстановление firewall не глушит ошибки cp и nft", () => { + const start = firewallSource.indexOf("export async function rollbackFirewallNow"); + const body = firewallSource.slice(start); + const muted = body + .split(/\r?\n/) + .filter((line) => line.includes("runMutatingVisible`")) + .filter((line) => line.includes("|| true") || line.includes("2>/dev/null")) + // Снятие таймера остаётся best-effort: отсутствующий юнит — не отказ + // восстановления, а нормальное состояние при firewallStagedApply=false. + .filter((line) => !line.includes("systemctl")); + + expect(muted, `восстановление firewall снова скрывает ошибки: ${muted.join("; ")}`).toEqual([]); + }); + + // Худшая прежняя комбинация: ошибка восстановления скрыта, а данные, по + // которым оператор мог бы поднять firewall вручную, уничтожены. + test("резервные копии удаляются только после успешного восстановления", () => { + const start = firewallSource.indexOf("export async function rollbackFirewallNow"); + const body = firewallSource.slice(start); + const failures = body.indexOf("if (failures.length > 0)"); + const preserved = body.indexOf("manual recovery data preserved at"); + const cleanup = body.indexOf("await cleanupFirewallBackupFiles(opId)"); + + expect(failures).toBeGreaterThan(-1); + expect(preserved).toBeGreaterThan(failures); + expect(cleanup).toBeGreaterThan(preserved); + }); +}); diff --git a/orchestrator/test/commit-ordering.test.ts b/orchestrator/test/commit-ordering.test.ts new file mode 100644 index 0000000..1963801 --- /dev/null +++ b/orchestrator/test/commit-ordering.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Порядок фиксации успеха операции. + * + * Дефект, который здесь закрепляется, — commit ordering. Успешный install + * заканчивался так: + * + * smoke PASS + * -> cancelFirewallRollback() // снимала таймер И УДАЛЯЛА резервные копии + * -> advanceInstallState("installed") + * + * То есть единственные данные для отката firewall (marker, прежний + * nftables.conf, прежний hy2xs.nft) уничтожались ДО долговечной записи успеха. + * Если эта запись падала — ENOSPC, EIO, read-only ФС, — управление уходило в + * обработчик ошибки, обязательный откат честно запускался и сообщал: + * + * firewall rollback skipped: no HY2XS rollback markers found + * + * Откат нельзя было отменить, но откатывать ему было нечем. Причём падение + * записи маркера — ровно тот сценарий, который был специально сделан + * безопасным предыдущим патчем. + * + * Правильный порядок: снять таймер (копии остаются) -> зафиксировать успех + * долговечно -> и только потом убрать копии, best-effort. + */ + +function source(relativeToSrc: string): string { + return readFileSync(join(import.meta.dir, "..", "src", relativeToSrc), "utf8"); +} + +const firewallSource = source("steps/firewall.ts"); + +describe("снятие таймера и удаление копий — разные операции", () => { + test("firewall предоставляет disarm и cleanup по отдельности", () => { + expect(firewallSource).toContain("export async function disarmFirewallRollback"); + expect(firewallSource).toContain("export async function cleanupFirewallRollback"); + }); + + // Прежнее единое имя не должно вернуться: оно и означало «снять таймер и + // удалить копии одним действием». + test("объединённая cancelFirewallRollback не вернулась", () => { + expect(firewallSource).not.toContain("export async function cancelFirewallRollback"); + for (const file of ["commands/install.ts", "commands/reconfigure.ts"]) { + expect(source(file)).not.toContain("cancelFirewallRollback"); + } + }); + + test("disarm не удаляет резервные копии", () => { + const start = firewallSource.indexOf("export async function disarmFirewallRollback"); + const body = firewallSource.slice(start, firewallSource.indexOf("export async function cleanupFirewallRollback")); + expect(body).not.toContain("cleanupFirewallBackupFiles"); + expect(body).toContain("stopRollbackTimer"); + }); + + test("cleanup удаляет копии и ничего больше", () => { + const start = firewallSource.indexOf("export async function cleanupFirewallRollback"); + const body = firewallSource.slice(start, firewallSource.indexOf("export async function rollbackFirewallNow")); + expect(body).toContain("cleanupFirewallBackupFiles"); + }); +}); + +describe("install: успех фиксируется до уничтожения данных отката", () => { + const installSource = source("commands/install.ts"); + + test("порядок: disarm -> durable installed -> cleanup", () => { + const disarm = installSource.indexOf("await disarmFirewallRollback(context)"); + const installed = installSource.indexOf('await advanceInstallState(context, ownership, "installed")'); + const cleanup = installSource.indexOf("await cleanupFirewallRollback(context)"); + + expect(disarm).toBeGreaterThan(-1); + expect(installed).toBeGreaterThan(disarm); + expect(cleanup).toBeGreaterThan(installed); + }); + + test("успешный smoke фиксируется отдельной фазой до снятия таймера", () => { + const smokeOk = installSource.indexOf('await advanceInstallState(context, ownership, "smoke_ok")'); + const disarm = installSource.indexOf("await disarmFirewallRollback(context)"); + + expect(smokeOk).toBeGreaterThan(-1); + expect(smokeOk).toBeLessThan(disarm); + }); + + // Уборка после фиксации успеха — мусор в /run, а не причина объявить + // успешную установку неуспешной. + test("уборка после фиксации выполняется best-effort", () => { + const cleanup = installSource.indexOf("await cleanupFirewallRollback(context)"); + const tryAt = installSource.lastIndexOf("try {", cleanup); + const catchAt = installSource.indexOf("catch (cleanupError)", cleanup); + + expect(tryAt).toBeGreaterThan(-1); + expect(tryAt).toBeLessThan(cleanup); + expect(catchAt).toBeGreaterThan(cleanup); + }); +}); + +describe("reconfigure: тот же порядок фиксации", () => { + const reconfigureSource = source("commands/reconfigure.ts"); + + test("порядок: disarm -> durable installed -> cleanup", () => { + const disarm = reconfigureSource.indexOf("await disarmFirewallRollback(context)"); + const installed = reconfigureSource.indexOf('await markPhase(context, "installed")'); + const cleanup = reconfigureSource.indexOf("cleanupFirewallRollback(context)"); + + expect(disarm).toBeGreaterThan(-1); + expect(installed).toBeGreaterThan(disarm); + expect(cleanup).toBeGreaterThan(installed); + }); + + test("уборка после фиксации выполняется best-effort", () => { + expect(reconfigureSource).toContain("catch (cleanupError)"); + const cleanup = reconfigureSource.indexOf("cleanupFirewallRollback(context)"); + const catchAt = reconfigureSource.indexOf("catch (cleanupError)", cleanup); + expect(catchAt).toBeGreaterThan(cleanup); + }); +}); diff --git a/orchestrator/test/doctor-readonly.test.ts b/orchestrator/test/doctor-readonly.test.ts index 98d1d2e..0e05b70 100644 --- a/orchestrator/test/doctor-readonly.test.ts +++ b/orchestrator/test/doctor-readonly.test.ts @@ -130,6 +130,39 @@ describe("read-only — инвариант doctor, а не свойство те } }); + // Guard защищает ТОЛЬКО процесс оркестратора. Он физически не способен + // запретить побочный эффект, вызванный HTTP-запросом в другой процесс, + // поэтому граница «что doctor имеет право трогать по сети» держится здесь. + // + // Конкретный риск: успешная machine-auth реального пира заставляет админку + // выполнить UPDATE peer.last_connection_at, то есть диагностика изменила бы + // отображаемое «последнее подключение» bootstrap-пира. Проба с ВАЛИДНЫМ + // паролем поэтому ограничена режимом install; doctor работает в режиме + // reconfigure и до неё не доходит. + test("проба с валидными учётными данными выполняется только при install", () => { + const smokeSource = source("steps/smoke.ts"); + + const guard = smokeSource.indexOf('if (context.mode === "install")'); + const validAuth = smokeSource.indexOf('"auth valid credentials"'); + // Именно ЧТЕНИЕ значения пароля, а не проверка наличия строки в файле: + // последняя выполняется и в doctor и ничего наружу не отправляет. + const readsSecret = smokeSource.indexOf("cut -d= -f2-"); + + expect(guard).toBeGreaterThan(-1); + expect(validAuth).toBeGreaterThan(guard); + expect(readsSecret).toBeGreaterThan(guard); + + // Ровно одна такая проба, и она внутри блока install: вторая, добавленная + // мимо условия, вернула бы запись в БД в диагностику. + expect(smokeSource.split('"auth valid credentials"').length - 1).toBe(1); + }); + + test("doctor работает в режиме, который не запускает пробу успешной авторизации", () => { + const doctorSource = source("commands/doctor.ts"); + expect(doctorSource).toContain('mode: "reconfigure"'); + expect(doctorSource).not.toContain('mode: "install"'); + }); + // Поведенческая часть: механизм, на который опирается doctor, действительно // отказывает любой мутации и называет причину. test("под guard'ом doctor любая мутация отказывает с его причиной", async () => { diff --git a/orchestrator/test/install-sequence.test.ts b/orchestrator/test/install-sequence.test.ts index d8044db..9859944 100644 --- a/orchestrator/test/install-sequence.test.ts +++ b/orchestrator/test/install-sequence.test.ts @@ -140,7 +140,7 @@ describe("классификация отказа reconfigure/repair", () => { test("отказ диагностики не отменяет откат firewall и конфигов", () => { const diagnosticsAt = reconfigureSource.indexOf("await diagnosticsCollect(options)"); const firewallRollbackAt = reconfigureSource.indexOf("await rollbackFirewallNow(context)"); - const stateRollbackAt = reconfigureSource.indexOf("await rollbackCurrentState()"); + const stateRollbackAt = reconfigureSource.indexOf("await rollbackCurrentState(context)"); const catchAt = reconfigureSource.indexOf("catch (diagnosticsError)"); expect(catchAt).toBeGreaterThan(diagnosticsAt); diff --git a/orchestrator/test/rollback-mandatory.test.ts b/orchestrator/test/rollback-mandatory.test.ts index 03f34da..0f81a30 100644 --- a/orchestrator/test/rollback-mandatory.test.ts +++ b/orchestrator/test/rollback-mandatory.test.ts @@ -217,22 +217,71 @@ describe("reconfigure: откат обязателен после операци // Регрессия: отказ rollbackFirewallNow отменял rollbackCurrentState целиком. test("порядок сохранён: сначала firewall, затем конфиги", () => { const firewall = reconfigureSource.indexOf("await rollbackFirewallNow(context)"); - const restore = reconfigureSource.indexOf("await rollbackCurrentState()"); + const restore = reconfigureSource.indexOf("await rollbackCurrentState(context)"); const stages = reconfigureSource.indexOf("await runRollbackStages(stages)"); expect(firewall).toBeGreaterThan(-1); expect(firewall).toBeLessThan(restore); expect(restore).toBeLessThan(stages); }); - // Внутри самого восстановления конфигов дефект был тот же: единственная - // команда без `|| true` отменяла перезапуск сервисов строкой ниже. - test("ни одна команда восстановления конфигов не обрывает следующие", () => { - const start = reconfigureSource.indexOf("async function rollbackCurrentState"); - const body = reconfigureSource.slice(start, reconfigureSource.indexOf("async function readInstallState")); - const offenders = body + // Инвариант СМЕНИЛСЯ, и это принципиально. + // + // Раньше здесь требовалось, чтобы каждая команда восстановления несла + // `|| true`: тогда это была единственная защита от того, что отказ одной + // команды оборвёт следующие. После появления runRollbackStages непрерывность + // обеспечивает раннер, а `|| true` стал ВРЕДЕН: он не даёт стадии сообщить, + // что восстановление на самом деле не выполнилось, и «restore configuration» + // никогда не попадала в список отказавших. + test("команды восстановления не глушат собственные ошибки", () => { + const start = reconfigureSource.indexOf("function restoreStages("); + const body = reconfigureSource.slice( + start, + reconfigureSource.indexOf("async function rollbackCurrentState") + ); + const muted = body .split(/\r?\n/) .filter((line) => line.includes("runMutatingVisible`")) - .filter((line) => !line.includes("|| true") && !line.includes("; fi`")); - expect(offenders, `команды отката без защиты: ${offenders.join("; ")}`).toEqual([]); + .filter((line) => line.includes("|| true") || line.includes("2>/dev/null")); + + expect(muted, `восстановление снова скрывает ошибки: ${muted.join("; ")}`).toEqual([]); + }); + + test("восстановление разбито на независимые стадии", () => { + for (const stage of [ + "restore hysteria config", + "restore systemd units", + "restore runtime env", + "restore nftables files", + "apply restored ruleset", + "reload systemd units", + "restart services" + ]) { + expect(reconfigureSource).toContain(`"${stage}"`); + } + }); + + // Неполное восстановление обязано доходить до итоговой сводки как отказ. + test("частичное восстановление не выдаётся за успешное", () => { + expect(reconfigureSource).toContain("restoreFailures.length > 0"); + expect(reconfigureSource).toContain("восстановление состояния выполнено не полностью"); + }); +}); + +describe("install: команды отката не глушат собственные ошибки", () => { + const installSource = source("commands/install.ts"); + + // После появления runRollbackStages `|| true` в стадиях отката перестал быть + // защитой и стал маскировкой: стадия не могла сообщить, что systemctl + // реально ничего не сделал, и обещание «отказавшие стадии перечисляются» для + // них не выполнялось никогда. + test("стадии остановки сервисов не подавляют код возврата", () => { + const start = installSource.indexOf("async function rollbackFailedInstall"); + const body = installSource.slice(start, installSource.indexOf("export async function install")); + const muted = body + .split(/\r?\n/) + .filter((line) => line.includes("runMutatingVisible`")) + .filter((line) => line.includes("|| true")); + + expect(muted, `стадии отката снова скрывают ошибки: ${muted.join("; ")}`).toEqual([]); }); }); diff --git a/tools/build/lib/acceptance.sh b/tools/build/lib/acceptance.sh index 0b52428..7c31574 100644 --- a/tools/build/lib/acceptance.sh +++ b/tools/build/lib/acceptance.sh @@ -969,22 +969,141 @@ run_single_owner_acceptance() { grep -q 'await runRollbackStages(stages)' "$rollback_command" \ || fail "acceptance: откат в $rollback_command снова выполняется отменяемой цепочкой" done - # Внутри восстановления конфигов дефект был тот же: единственная команда без - # `|| true` отменяла перезапуск сервисов строкой ниже. + # Команды восстановления не имеют права ГЛУШИТЬ собственные ошибки. + # + # Инвариант здесь обратный тому, что стоял раньше. До появления + # runRollbackStages каждая команда несла `|| true` — это была единственная + # защита от того, что отказ одной оборвёт следующие. Теперь непрерывность + # обеспечивает стадийный раннер, а `|| true` стал маскировкой: стадия не + # могла сообщить, что восстановление на самом деле не выполнилось, и + # «restore configuration» никогда не попадала в список отказавших. + "$BUN_BIN" -e ' + const fs = require("node:fs"); + const checks = [ + ["orchestrator/src/commands/reconfigure.ts", "function restoreStages(", "async function rollbackCurrentState"], + ["orchestrator/src/commands/reconfigure.ts", "async function backupCurrentState", "async function readBackupManifest"], + ["orchestrator/src/commands/install.ts", "async function rollbackFailedInstall", "export async function install"], + ["orchestrator/src/steps/firewall.ts", "async function backupFirewallState", "function stripNftComments"] + ]; + for (const [file, from, to] of checks) { + const source = fs.readFileSync(file, "utf8"); + const start = source.indexOf(from); + const end = source.indexOf(to); + if (start < 0 || end < 0) throw new Error("не найдены границы " + from + " в " + file); + const muted = source.slice(start, end).split(/\r?\n/) + .filter((line) => line.includes("runMutatingVisible`")) + .filter((line) => line.includes("|| true") || line.includes("2>/dev/null")); + if (muted.length) { + throw new Error(from + " в " + file + " скрывает ошибки:\n" + muted.join("\n")); + } + } + ' || fail "acceptance: команды отката и резервного копирования обязаны сообщать о своих отказах" + + log_step "Acceptance: rollback artifacts outlive the durable commit" + # Ошибка порядка фиксации. cancelFirewallRollback снимала таймер И удаляла + # резервные копии, а вызывалась ДО долговечной записи `phase: installed`. + # Отказ этой записи приводил в обработчик ошибки, обязательный откат честно + # запускался и сообщал «no HY2XS rollback markers found»: откатывать было + # уже нечем. + grep -q 'export async function disarmFirewallRollback' orchestrator/src/steps/firewall.ts \ + || fail "acceptance: снятие таймера автоотката не отделено от удаления резервных копий" + grep -q 'export async function cleanupFirewallRollback' orchestrator/src/steps/firewall.ts \ + || fail "acceptance: удаление резервных копий firewall не выделено в отдельную операцию" + ! grep -rq 'cancelFirewallRollback' orchestrator/src \ + || fail "acceptance: объединённая cancelFirewallRollback вернулась; она удаляла копии до фиксации успеха" + "$BUN_BIN" -e ' + const fs = require("node:fs"); + for (const [file, installed] of [ + ["orchestrator/src/commands/install.ts", "await advanceInstallState(context, ownership, \"installed\")"], + ["orchestrator/src/commands/reconfigure.ts", "await markPhase(context, \"installed\")"] + ]) { + const source = fs.readFileSync(file, "utf8"); + const disarm = source.indexOf("disarmFirewallRollback(context)"); + const commit = source.indexOf(installed); + const cleanup = source.indexOf("cleanupFirewallRollback(context)"); + if (disarm < 0 || commit < 0 || cleanup < 0) throw new Error("не найдены шаги фиксации в " + file); + if (!(disarm < commit && commit < cleanup)) { + throw new Error("порядок обязан быть disarm -> durable installed -> cleanup в " + file); + } + } + ' || fail "acceptance: данные отката обязаны переживать долговечную фиксацию успеха" + # Восстановление firewall не имеет права удалить копии, не восстановив. + "$BUN_BIN" -e ' + const source = require("node:fs").readFileSync("orchestrator/src/steps/firewall.ts", "utf8"); + const body = source.slice(source.indexOf("export async function rollbackFirewallNow")); + const failures = body.indexOf("if (failures.length > 0)"); + const preserved = body.indexOf("manual recovery data preserved at"); + const cleanup = body.indexOf("await cleanupFirewallBackupFiles(opId)"); + if (failures < 0 || preserved < 0 || cleanup < 0) throw new Error("не найдены ветки восстановления"); + if (!(failures < preserved && preserved < cleanup)) { + throw new Error("резервные копии удаляются раньше проверки успеха восстановления"); + } + ' || fail "acceptance: копии удаляются только после подтверждённого восстановления firewall" + + log_step "Acceptance: reconfigure backups are operation-scoped and proven" + # Копии всех операций лежали одним общим набором *.bak. При неудачном + # копировании в операции B её откат восстанавливал файлы, сохранённые + # операцией A, — возвращал сервер в более старое состояние и выглядел + # успешным. + [ -f orchestrator/src/lib/backupManifest.ts ] \ + || fail "acceptance: манифест резервной копии отсутствует" + grep -q 'backupDir(opId)' orchestrator/src/commands/reconfigure.ts \ + || fail "acceptance: копия reconfigure снова не привязана к операции" + grep -q 'parseManifest(await readText(path), opId)' orchestrator/src/commands/reconfigure.ts \ + || fail "acceptance: откат читает копию без проверки принадлежности операции" + ! grep -qF '/etc/hy2xs/backups/config.yaml.bak' orchestrator/src/commands/reconfigure.ts \ + || fail "acceptance: вернулся общий набор *.bak, смешивающий операции" "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("orchestrator/src/commands/reconfigure.ts", "utf8"); - const start = source.indexOf("async function rollbackCurrentState"); - if (start < 0) throw new Error("rollbackCurrentState отсутствует"); - const rest = source.slice(start + 1); - const end = rest.search(/\n(export )?(async )?function /); - const body = end < 0 ? rest : rest.slice(0, end); - const offenders = body.split(/\r?\n/) - .filter((line) => line.includes("runMutatingVisible`")) - .filter((line) => !line.includes("|| true") && !line.includes("; fi`")); - if (offenders.length) { - throw new Error("команды восстановления конфигов обрывают следующие:\n" + offenders.join("\n")); + const backup = source.indexOf("await backupCurrentState(context)"); + for (const mutation of ["await generateConfig(context)", "await applyFirewall(context)"]) { + const at = source.indexOf(mutation); + if (at < 0) throw new Error("не найден шаг " + mutation); + if (backup > at) throw new Error("копия снимается после мутации " + mutation); } - ' || fail "acceptance: ни одна команда отката не имеет права отменить остальные" + const verify = source.indexOf("await fileExists(target)"); + if (verify < 0) throw new Error("создание копии не проверяется"); + ' || fail "acceptance: полная проверенная копия обязана предшествовать первой мутации" + + log_step "Acceptance: the install-state directory entry is durable too" + # writeTextAtomic синхронизирует файл и его каталог. Но при первой установке + # /var/lib/hy2xs создаётся прямо сейчас, и запись «hy2xs» в /var/lib + # остаётся несинхронизированной: после потери питания мог исчезнуть весь + # каталог вместе с маркером. + grep -q 'return { created: !existed }' orchestrator/src/lib/fs.ts \ + || fail "acceptance: ensureDir не сообщает о фактическом создании каталога" + "$BUN_BIN" -e ' + const source = require("node:fs").readFileSync("orchestrator/src/lib/fs.ts", "utf8"); + const start = source.indexOf("export async function ensureDir"); + const body = source.slice(start, source.indexOf("export async function writeTextAtomic")); + if (!/if \(!existed\) \{\s*\n\s*await syncDirectory\(dirname\(path\)\)/.test(body)) { + throw new Error("родительский каталог не синхронизируется при создании"); + } + ' || fail "acceptance: создание каталога маркера обязано быть долговечным" + + log_step "Acceptance: doctor never triggers a peer state write over HTTP" + # Guard защищает только процесс оркестратора и не способен запретить побочный + # эффект в другом процессе. Успешная machine-auth заставляет админку + # выполнить UPDATE peer.last_connection_at, поэтому проба с ВАЛИДНЫМ паролем + # ограничена режимом install, а doctor работает в режиме reconfigure. + "$BUN_BIN" -e ' + const fs = require("node:fs"); + const smoke = fs.readFileSync("orchestrator/src/steps/smoke.ts", "utf8"); + const guard = smoke.indexOf("if (context.mode === \"install\")"); + const validAuth = smoke.indexOf("\"auth valid credentials\""); + const readsSecret = smoke.indexOf("cut -d= -f2-"); + if (guard < 0 || validAuth < 0 || readsSecret < 0) throw new Error("не найдена проба успешной авторизации"); + if (validAuth < guard || readsSecret < guard) { + throw new Error("проба с валидными учётными данными вышла за пределы режима install"); + } + if (smoke.split("\"auth valid credentials\"").length - 1 !== 1) { + throw new Error("проб успешной авторизации больше одной"); + } + const doctor = fs.readFileSync("orchestrator/src/commands/doctor.ts", "utf8"); + if (!doctor.includes("mode: \"reconfigure\"") || doctor.includes("mode: \"install\"")) { + throw new Error("doctor работает в режиме, который запускает пробу успешной авторизации"); + } + ' || fail "acceptance: диагностика не имеет права менять состояние пира через HTTP" log_step "Acceptance: the install-state marker has exactly one durable writer" # Раньше writeText в install и writeTextAtomic в reconfigure давали одному