fix(v1): сделать надёжным нижний слой отката, а не только его запуск

Верхнеуровневый откат стал неотменяемым в прошлом проходе, и на этом фоне
проявилось, что его 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/<op-id>/ с манифестом, где отсутствие файла записано
   явно ("present": false), а не выведено из неудачи cp. Разбор строгий,
   включая проверку opId.

4. Ошибка восстановления скрывалась, и после неё копии удалялись.

   rollbackFirewallNow выполняла cp и nft -f с `|| true`, затем безусловно
   удаляла /run/hy2xs/rollback/<op>. Худшая комбинация: неудача не видна,
   стадия успешна, данные для ручной починки уничтожены. Теперь копии
   удаляются только после подтверждённого успеха, иначе сохраняются с
   сообщением 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»,
переписаны на обратный: команды обязаны сообщать о своих отказах.
This commit is contained in:
2026-08-30 19:32:43 +05:00
parent df73459ea5
commit 330a63b050
17 changed files with 1534 additions and 98 deletions
+26
View File
@@ -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`.