firewall guard: барьер покоя fail-closed и явный контракт транзиентного таймера

Барьер, обязанный ДОКАЗАТЬ отсутствие асинхронного исполнителя, в трёх местах
принимал за доказательство отсутствие наблюдения.

- отказ `systemctl` больше не выдаётся за отсутствие guard: вместо `return []`
  введён единый наблюдатель inspectRollbackGuard с исходами quiescent/pending/
  unknown и отдельным типом отказа GuardStateUnknownError;
- покой перечисляется белым списком (inactive, failed): maintenance,
  refreshing и любое незнакомое состояние systemd блокируют операцию;
- у транзиентного таймера явно заданы AccuracySec=1s (умолчание 1min
  превращало обещанные 45 секунд в 45-105) и RemainAfterElapse=no; барьер
  дополнительно опознаёт SubState=elapsed у *.timer как покой;
- команда взведения строится чистой buildArmGuardArgv и выполняется новым
  runMutatingArgv без shell, поэтому её контракт проверяется значением, а не
  грепом по исходнику;
- status перестал листить guard-юниты своей копией кода: без --plain, с
  `|| true` и с трактовкой failed как «вооружён» отчёт вечно противоречил
  барьеру. Добавлены rollback_guard_state и firewall_state=guard_unknown;
- purge-v0.sh пропускал failed-юниты из-за маркера в первой колонке.

Барьер покрыт поведенческими тестами через подставляемый SystemdUnitProbe:
прежние проверки грепом по тексту функции пережили инверсию смысла - строка
`return [];` была на месте, а решение стало неверным.

Документация (README, docs/07, 11, 12, 13, 14, CHANGELOG) приведена к реальному
окну 45-46 секунд и к новому тексту отказа. Отдельно исправлен комментарий
PNPM_AUDIT_LEVEL в versions.env: гейт давно проверяет весь lock-граф.
This commit is contained in:
2026-08-31 16:44:20 +05:00
parent 76d78ac71f
commit 2259f7c847
14 changed files with 1115 additions and 118 deletions
+81
View File
@@ -37,6 +37,87 @@ Hardening-проход перед релизом `1.0.0`. Основная те
допущения, на которых держалась операция целиком: что снятие защиты от отката
действительно произошло и что операция на сервере ровно одна.
Седьмой проход — приведение барьера покоя к реальной семантике systemd 257,
который стоит на Debian 13. Механизм, обязанный **доказать** отсутствие
асинхронного исполнителя, в трёх местах принимал за доказательство отсутствие
наблюдения.
### Исправлено — барьер покоя и контракт транзиентного таймера
- **Отказ запроса к systemd выдавался за отсутствие guard'а.** Листинг
guard-юнитов при исключении возвращал пустой список, и пустой список означал
«можно начинать»:
```text
systemd жив, старый rollback timer взведён
-> запрос к systemctl/D-Bus временно отказывает
-> список guard'ов пуст
-> барьер считает систему спокойной
-> новая операция начинает менять firewall
-> старый таймер срабатывает поверх неё
```
Обоснование в комментарии («без systemd не может быть и транзиентного
таймера») доказывало не то: отказ запроса не означает, что systemd нет.
Барьер стал fail-closed и получил отдельный тип отказа
`GuardStateUnknownError` — «guard вооружён» и «спросить не удалось» требуют
от оператора разных действий. Практического выигрыша у прежнего поведения не
было: `systemd-run` требуется в preflight, поэтому без работающего systemd
операция всё равно отказывала — просто позже и менее внятно.
- **Покой перечислялся чёрным списком.** Непокойными считались `active`,
`activating`, `deactivating`, `reloading`, а покоем — «всё остальное», то
есть любое состояние, которого автор не назвал. systemd 257 знает ещё
`maintenance` и `refreshing`. Политика инвертирована: покой — это `inactive`
и `failed`, всё прочее блокирует операцию.
- **Обещанные «45 секунд» не были контрактом systemd.** `OnActiveSec=45s` не
означает «ровно через 45 секунд»: таймер вправе сработать в окне
`[цель; цель + AccuracySec]`, а умолчание `AccuracySec=` — одна минута. То
есть README, docs и текст отказа обещали 45 секунд, а контракт допускал 105.
Теперь точность задаётся явно (`AccuracySec=1s`), и реальное окно — 45–46
секунд.
- **Выгрузка отработавшего таймера держалась на чужом умолчании.** Право
барьера считать исчезновение юнита покоем опирается на
`RemainAfterElapse=no`. `systemd-run` выставляет это свойство транзиентным
таймерам сам, но инвариант, который нигде не записан и ничем не проверяется,
инвариантом не является. Свойство задаётся явно, а барьер дополнительно
опознаёт `SubState=elapsed` у `*.timer` как покой: `TIMER_ELAPSED` в systemd
отображается в `UNIT_ACTIVE`, и без этой ветки отработавший таймер,
созданный не нами, блокировал бы `repair` навсегда.
- **`status` расходился с барьером в трактовке того же самого guard'а.** У него
была своя копия листинга — без `--plain` (у `failed`-юнита первой колонкой
идёт маркер ``), с `|| true` (отказ systemd превращался в «guard'ов нет») и
без разбора состояний: вооружённым считался любой найденный юнит. Аварийно
сработавший guard оставляет `failed`-сервис загруженным до `reset-failed`,
поэтому `status` вечно показывал `firewall_state: guard_active`, пока барьер
тот же юнит считал покоем и разрешал `repair`. Копия убрана: отчёт берёт
состояние у барьерного наблюдателя и сообщает `rollback_guard_state`
(`quiescent` / `pending` / `unknown`) с `active_state` и `sub_state` каждого
юнита.
- **`purge-v0.sh` не снимал именно аварийно сработавший guard.** Разбор
`awk '{print $1}' | grep -E '^hy2xs-fw-rollback-'` отбрасывал строку целиком,
когда первой колонкой стоял маркер ``, то есть пропускал `failed`-юниты —
единственные, ради которых эта проверка написана.
- **Контракт команды взведения проверялся грепом по исходнику.** Команда
собиралась интерполяцией в shell-строку и существовала только в момент
запуска. Теперь её строит чистая `buildArmGuardArgv`, а выполняет
`runMutatingArgv` — без shell вообще, что заодно убирает вопрос о
квотировании из команды, создающей systemd-юнит с именем из данных операции.
Свойства таймера стали обычным значением, которое сравнивает обычный тест.
- **Барьер не имел ни одного поведенческого теста.** Все проверки были грепами
по тексту функций, и один из них закреплял как раз небезопасное поведение:
тест утверждал, что в теле есть `return [];`. Строка была на месте — а
решение при этом стало неверным. Наблюдение за systemd вынесено в
подставляемый `SystemdUnitProbe`, и сценарии («systemd не ответил», «таймер
взведён», «сервис упал», «незнакомое состояние», «таймер отработал»)
проверяются поведением, без systemd и без Linux.
### Исправлено — границы транзакции
- **Снятие rollback guard было утверждением, а не фактом.** Порядок фиксации