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:
@@ -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/<op-id>/` с манифестом, где
|
||||
отсутствие файла — записанный факт (`"present": false`), а не вывод из
|
||||
неудачи `cp`. Разбор манифеста строгий, включая проверку `opId`.
|
||||
|
||||
- **Ошибка восстановления скрывалась, а копии после неё удалялись.**
|
||||
`rollbackFirewallNow` выполняла `cp` и `nft -f` с `|| true`, а затем
|
||||
безусловно удаляла `/run/hy2xs/rollback/<op>`. Худшая возможная комбинация:
|
||||
неудача восстановления не видна, стадия отчитывается успешной, а данные, по
|
||||
которым оператор мог бы поднять 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 по инварианту рантайма, а не по соглашению.**
|
||||
|
||||
@@ -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/<op-id>/` с
|
||||
манифестом:
|
||||
|
||||
```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 ведёт **на этот сервер**. Так как
|
||||
|
||||
@@ -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/<op>` между применением
|
||||
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/<op>/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`
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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/<op>` | 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` означает, что откат обязан был файл **удалить**, а не
|
||||
восстановить.
|
||||
|
||||
Наружу оркестратор всегда пробрасывает **исходную** ошибку операции, а не
|
||||
проблему внутри отката: последняя — это информация о том, что осталось не
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
|
||||
@@ -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<void> {
|
||||
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/<op-id>/ и манифест в
|
||||
* нём. Раньше копии всех операций лежали одним общим набором `*.bak`, и при
|
||||
* неудачном копировании в операции B откат B восстанавливал файлы, сохранённые
|
||||
* операцией A, — то есть возвращал сервер в более старое состояние и выглядел
|
||||
* при этом успешным.
|
||||
*
|
||||
* Любая ошибка здесь — отказ ДО первой мутации: у reconfigure ещё ничего не
|
||||
* изменено, и остановиться дешевле, чем начать менять систему без копии.
|
||||
*/
|
||||
async function backupCurrentState(context: ReconfigureContext): Promise<void> {
|
||||
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<void> {
|
||||
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<BackupManifest> {
|
||||
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<string[]> {
|
||||
const opId = operationKey(context);
|
||||
const manifest = await readBackupManifest(opId);
|
||||
return await runRollbackStages(restoreStages(manifest, opId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Убирает копии предыдущих операций после долговечной фиксации успеха.
|
||||
*
|
||||
* Копия текущей операции остаётся: это состояние «до последнего успешного
|
||||
* reconfigure», ровно та же семантика, что была у прежнего единственного
|
||||
* набора `*.bak`, но теперь без смешения операций. Рост каталога при этом
|
||||
* ограничен — лишние каталоги удаляются.
|
||||
*/
|
||||
async function pruneOldBackups(context: ReconfigureContext): Promise<void> {
|
||||
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<InstallStateRecord | null> {
|
||||
@@ -239,7 +385,7 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
|
||||
}
|
||||
|
||||
step("backup");
|
||||
await backupCurrentState();
|
||||
await backupCurrentState(context);
|
||||
stepDone("backup");
|
||||
|
||||
const ownership = newReconfigureOwnership();
|
||||
@@ -277,15 +423,35 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
|
||||
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<void> {
|
||||
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))}`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -79,13 +79,32 @@ export async function ensureDir(
|
||||
owner?: string;
|
||||
group?: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
): 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<void> {
|
||||
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 <backup> 2>/dev/null || true
|
||||
*
|
||||
* то есть отказ копирования (заполненный /run, ошибка ввода-вывода, права)
|
||||
* молча игнорировался. Дальше выставлялся маркер `prepared`, и операция
|
||||
* начинала переписывать firewall — уже НЕ имея резервной копии, на которую
|
||||
* рассчитывает откат. Предпосылка отката нарушалась в самом его основании.
|
||||
*
|
||||
* Маркер `prepared` теперь ставится ПОСЛЕ проверенных копий, а не до них: он
|
||||
* означает «данные для отката существуют», и раньше это было неправдой.
|
||||
*/
|
||||
async function backupFirewallState(opId: string): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const opId = operationKey(context);
|
||||
if (context.options.skipFirewall || context.config.firewallMode === "off" || context.config.firewallMode === "external") {
|
||||
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<void> {
|
||||
if (!context.config.firewallStagedApply) {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Снимает автоматический откат по таймеру, НО сохраняет резервные копии.
|
||||
*
|
||||
* Разделение на 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<void> {
|
||||
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<void> {
|
||||
if (firewallRollbackIsInactive(context)) {
|
||||
return;
|
||||
}
|
||||
await cleanupFirewallBackupFiles(operationKey(context));
|
||||
}
|
||||
|
||||
/**
|
||||
* Немедленное восстановление firewall.
|
||||
*
|
||||
* Два правила, которых здесь раньше не было.
|
||||
*
|
||||
* 1. Ошибки восстановления НЕ скрываются. Было:
|
||||
*
|
||||
* cp <backup> /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<void> {
|
||||
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<void
|
||||
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 stopRollbackTimer(context, opId);
|
||||
|
||||
const entrypointExisted = await fileExists(rollbackBackup("nftables.conf.existed", opId));
|
||||
const stages: RollbackStage[] = [];
|
||||
|
||||
for (const target of FIREWALL_BACKUP_TARGETS) {
|
||||
const backupPath = rollbackBackup(target.backup, opId);
|
||||
const markerPath = rollbackBackup(target.marker, opId);
|
||||
|
||||
stages.push({
|
||||
name: `restore ${target.path}`,
|
||||
run: async () => {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
+132
-13
@@ -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 давали одному
|
||||
|
||||
Reference in New Issue
Block a user