diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d1e234..ee870d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 было утверждением, а не фактом.** Порядок фиксации diff --git a/README.md b/README.md index 30a98f8..5b1074f 100644 --- a/README.md +++ b/README.md @@ -675,6 +675,8 @@ hy2xs-orchestrator status \ При `HY2XS_FIREWALL_STAGED_APPLY=true` (значение по умолчанию) перед применением новых правил HY2XS взводит rollback guard — транзиентный systemd‑юнит с окном 45 секунд. Если операция не снимет его вовремя, guard вернёт прежний firewall, и SSH останется доступным. +Таймеру явно задаётся `AccuracySec=1s`, поэтому «45 секунд» — это реальный контракт, а не приблизительный: по умолчанию `systemd.timer` разрешает себе сработать в окне `[цель; цель + AccuracySec]`, где `AccuracySec` — одна минута, и обещанное окно превращалось бы в 45–105 секунд. Вторым свойством задаётся `RemainAfterElapse=no`: отработавший таймер обязан выгрузиться, иначе он навсегда блокировал бы следующую операцию (см. [«Одна операция за раз»](#одна-операция-за-раз)). + Окно намеренно короткое и **не** обязано покрывать smoke‑checks: на медленном сервере они идут дольше. Вместо этого guard оставляет за собой факт срабатывания в `/run/hy2xs/rollback//auto-rollback-fired`, и операция не имеет права объявить себя успешной, если этот файл появился, — сервер в такой момент работает на прежнем firewall, а не на том, который она сгенерировала. Установка завершится отказом с `phase: firewall_guard_fired`, и её нужно повторить после устранения причины медленного прохода. Дополнительно smoke сверяет, что действующий firewall — именно тот, который сгенерирован для текущей конфигурации: разбора `/etc/nftables.conf` для этого недостаточно, потому что прежний ruleset тоже валиден. @@ -697,10 +699,21 @@ another HY2XS operation is already in progress: reconfigure (pid 4242, started a ```text previous HY2XS operation is no longer running, but its firewall rollback guard -is still armed: hy2xs-fw-rollback-.timer (active) +is still armed: hy2xs-fw-rollback-.timer (active/waiting) ``` -Ждать в этом случае нужно не дольше 45 секунд с момента применения firewall. +Ждать в этом случае нужно не дольше 45–46 секунд с момента применения firewall. + +Покоем считаются ровно два состояния юнита — `inactive` и `failed`: отработавший guard больше ничего не сделает, а отказ по `failed` заблокировал бы `repair`, которым чинят последствия. Всё остальное, включая незнакомые барьеру состояния systemd, операцию запрещает. + +Отдельный случай — когда состояние guard'а вообще не удалось выяснить: + +```text +unable to verify firewall rollback guard state; systemd query failed, +refusing to start a lifecycle operation +``` + +Здесь ждать нечего: отсутствие ответа systemd — это отсутствие доказательства, а не доказательство покоя, и разбираться нужно с systemd. Барьер обязан **доказать**, что у предыдущей операции не осталось исполнителей, способных изменить firewall; молчаливое «наверное, всё в порядке» однажды означало бы срабатывание старого таймера поверх новой операции. ## Реконфигурация diff --git a/docs/07-systemd-and-firewall.md b/docs/07-systemd-and-firewall.md index c6f354d..cf7b143 100644 --- a/docs/07-systemd-and-firewall.md +++ b/docs/07-systemd-and-firewall.md @@ -90,6 +90,14 @@ IPv4-only policy: 3. Подставить candidate в production-пути. 4. Взвести rollback guard: транзиентный юнит `hy2xs-fw-rollback-` с окном 45 секунд. + ```bash + systemd-run \ + --unit=hy2xs-fw-rollback-.service \ + --on-active=45s \ + --timer-property=RemainAfterElapse=no \ + --timer-property=AccuracySec=1s \ + /bin/sh /run/hy2xs/rollback//auto-rollback.sh + ``` 5. Применить ruleset и проверить SSH/Hysteria/UI. 6. Снять guard и **доказать**, что он снят (см. ниже). 7. Долговечно зафиксировать успех. @@ -109,6 +117,29 @@ Guard — это защита от потери доступа к серверу smoke: smoke на медленном, но исправном сервере может идти заметно дольше. Увеличение окна лечило бы гонку расширением, а не устранением. +#### Почему у таймера заданы `AccuracySec` и `RemainAfterElapse` + +`OnActiveSec=45s` сам по себе **не** означает «ровно через 45 секунд». +`systemd.timer` разрешает себе сработать в окне + +```text +цель ... цель + AccuracySec +``` + +объединяя пробуждения ради экономии энергии, и умолчание `AccuracySec=` — +`1min`. То есть без явного значения guard, про который эта страница и текст +отказа говорят «45 секунд», по контракту systemd мог сработать и через 105. +Поэтому точность задаётся явно: `AccuracySec=1s`, и реальное окно — **45–46 +секунд**. Коалесценция пробуждений аварийному guard'у не нужна: он взводится +один раз за операцию и почти всегда снимается, не сработав. + +`RemainAfterElapse=no` задаётся по другой причине. Отработавший одноразовый +таймер обязан выгрузиться — на этом стоит право барьера покоя считать +отсутствие юнита доказательством того, что откатывать firewall больше некому. +`systemd-run` выставляет это свойство транзиентным таймерам сам, но инвариант, +который держится на чужом умолчании, нигде не записан и ничем не проверяется; +в явном виде он попадает и в journal, и в тест. + Вместо этого guard оставляет за собой факт: ```text @@ -158,18 +189,81 @@ Guard — объект systemd, а не часть процесса оркест Поэтому условие начала новой операции — не «PID предыдущей мёртв», а «у предыдущей не осталось исполнителей, способных изменить систему». Каждый захват -замка проходит через барьер покоя: если хоть один `hy2xs-fw-rollback-*` находится -в состоянии `active`, `activating`, `deactivating` или `reloading`, операция -отказывает. +замка проходит через барьер покоя. -`inactive` и `failed` считаются покоем: отработавший guard больше ничего не -сделает, а отказ по `failed` заблокировал бы `repair` — ровно тот инструмент, -которым чинят последствия. +#### Покой перечисляется белым списком + +Покой — это `inactive` и `failed`, и **только** они. Отработавший guard больше +ничего не сделает, а отказ по `failed` заблокировал бы `repair` — ровно тот +инструмент, которым чинят последствия. Всё остальное считается непокоем и +запрещает операцию. + +Список именно белый, а не чёрный. Перечисление непокойных состояний +(`active`, `activating`, `deactivating`, `reloading`) объявляло бы безопасным +любое состояние, которого автор не назвал, — включая те, которых он не знал: +systemd 257 знает ещё `maintenance` и `refreshing`, и список может пополниться +снова. Незнакомое состояние systemd обязано блокировать операцию, а не +проходить молча. + +Единственное исключение — `*.timer` в `SubState=elapsed` или `dead`. `ActiveState` +таймера отвечает на вопрос «юнит загружен и в строю», а не «он ещё может +сработать»: в systemd `TIMER_ELAPSED` отображается в `UNIT_ACTIVE` так же, как +`TIMER_WAITING`, и различает их только `SubState`. У наших guard'ов такого не +бывает (`RemainAfterElapse=no`), но инвариант «барьер не залипает» не должен +зависеть от того, чем именно создан таймер: иначе отработавший таймер запрещал +бы install/reconfigure/repair/doctor навсегда — и запрещал бы ради отката, +который уже произошёл. Триггернутый сервис при этом виден барьеру отдельным +юнитом и остаётся непокоем, пока выполняется. + +#### Отказ запроса к systemd — это отказ операции + +Если `systemctl` не ответил, барьер **не** считает систему спокойной: + +```text +unable to verify firewall rollback guard state; +systemd query failed, refusing to start a lifecycle operation +``` + +Отсутствие ответа — отсутствие наблюдения, а не наблюдение покоя. Обратная +трактовка давала реальный сценарий потери firewall: + +```text +systemd жив, старый rollback timer взведён +-> запрос к systemctl/D-Bus временно отказывает +-> список guard'ов пуст +-> барьер считает систему спокойной +-> новая операция начинает менять firewall +-> старый таймер срабатывает поверх неё +``` + +Механизм, обязанный **доказать** отсутствие асинхронного исполнителя, принимал +невозможность получить доказательство за положительный результат. Это прямо +противоположно политике замка операций, где сомнение трактуется в пользу +отказа. + +Практического выигрыша у прежнего поведения не было: `systemd-run` требуется в +preflight, поэтому без работающего systemd операция всё равно откажет — просто +позже и с менее внятной диагностикой. Состояния `hysteria-server`, `hy2xs-admin` и `nftables.service` барьер сознательно не проверяет: незавершённый `systemctl restart` ничего не откатывает, он лишь повторяет то, что новая операция сделает сама. +#### Один наблюдатель на барьер и на отчёт + +Состояние guard'ов читает одна функция, и `hy2xs-orchestrator status` берёт его +у неё же. Раньше у status была своя копия листинга, и она расходилась с +барьером по трём пунктам сразу: без `--plain` (у `failed`-юнита первой колонкой +идёт маркер `●`), с `|| true` (отказ systemd превращался в «guard'ов нет») и +без разбора состояний — вооружённым считался любой найденный юнит. На практике +это означало, что аварийно сработавший guard оставлял `failed`-сервис +загруженным до `reset-failed`, status вечно показывал `firewall_state: +guard_active`, а барьер тот же самый юнит считал покоем и разрешал `repair`. + +`status` при этом остаётся отчётом: он не берёт замок и существует в том числе +для сломанного хоста, поэтому «спросить не удалось» попадает в JSON значением +`rollback_guard_state: "unknown"`, а не отказом команды. + ### Проверка эффективного firewall `nft -c -f /etc/nftables.conf` разбирает текущий файл, каким бы он ни был, и diff --git a/docs/11-testing-and-acceptance.md b/docs/11-testing-and-acceptance.md index dbbe051..0127a97 100644 --- a/docs/11-testing-and-acceptance.md +++ b/docs/11-testing-and-acceptance.md @@ -288,7 +288,33 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh стадиями, идущими **до** применения ruleset; - candidate-файлы убираются после успеха и best-effort при откате; - ключ операции считается одной функцией: install писал в маркер сырой - ISO-timestamp, и путь `/run/hy2xs/rollback/` из runbook не существовал. + ISO-timestamp, и путь `/run/hy2xs/rollback/` из runbook не существовал; +- команда взведения guard проверяется как **значение**, а не грепом по + исходнику: `buildArmGuardArgv` возвращает готовый argv, и тест сверяет его + целиком — имя юнита с явным суффиксом, `--on-active=45s`, + `--timer-property=AccuracySec=1s`, `--timer-property=RemainAfterElapse=no`. + Небезопасный ключ операции отвергается до запуска: shell в этой команде не + участвует, поэтому единственная защита — отказ; +- барьер покоя проверяется **поведенчески**, с подставляемым наблюдателем + systemd (`SystemdUnitProbe`), без systemd и без Linux: + - взведённый таймер (`active/waiting`) и идущий прямо сейчас откат + (`activating`) запрещают операцию с `PendingRecoveryError`; + - отказ `list-units` **и** отказ `show` на любом отдельном юните дают + `GuardStateUnknownError`: отсутствие ответа systemd — отсутствие + наблюдения, а не наблюдение покоя. Прежний тест закреплял обратное + (`return [];` в тексте функции) и потому пережил инверсию смысла: строка + была на месте, а решение стало неверным; + - оба отказа имеют общего предка `OperationBarrierError`; + - покой — ровно `inactive` и `failed`; `maintenance`, `refreshing` и любое + незнакомое состояние блокируют операцию, потому что политика перечисляет + безопасные состояния, а не опасные; + - `*.timer` в `SubState=elapsed` считается покоем: `TIMER_ELAPSED` в systemd + отображается в `UNIT_ACTIVE`, и без этой ветки отработавший таймер + блокировал бы `repair` навсегда. У сервиса тот же `SubState` ничего не + значит; + - разбор вывода `list-units` находит имя юнита и когда первой колонкой идёт + маркер `●` (состояние `failed`) — иначе терялся бы именно аварийно + сработавший guard. ## A5f. Взаимное исключение операций (unit) @@ -1040,7 +1066,19 @@ firewall и успешным smoke. Сценарий: 1. установка доходит до шага `firewall`, в журнале появляется - `firewall rollback guard armed`; + `firewall rollback guard armed: … fires in 45s (timer accuracy 1s)`. + Пока guard ждёт, свойства таймера проверяются напрямую — обещанное окно + обязано быть контрактом systemd, а не намерением: + + ```bash + systemctl show hy2xs-fw-rollback-.timer \ + -p ActiveState -p SubState -p AccuracyUSec -p RemainAfterElapse + ``` + + Ожидается `ActiveState=active`, `SubState=waiting`, `AccuracyUSec=1s`, + `RemainAfterElapse=no`. Без явной точности systemd вправе сработать в окне + `[45s; 45s + AccuracySec]`, а умолчание `AccuracySec=` — одна минута, то + есть реальное окно было бы 45–105 секунд; 2. smoke искусственно замедляется дольше 45 секунд. Проще всего задержать один из сервисов — например, добавить в `hy2xs-admin.service` временный `ExecStartPre=/bin/sleep 60` и выполнить `systemctl daemon-reload` до запуска @@ -1142,18 +1180,56 @@ guard A срабатывает и возвращает firewall, который ```text previous HY2XS operation is no longer running, but its firewall rollback guard -is still armed: hy2xs-fw-rollback-.timer (active) +is still armed: hy2xs-fw-rollback-.timer (active/waiting) ``` 5. отказ происходит **до** снятия резервной копии и до первой мутации; 6. `install.sh` в том же окне отказывает на PHASE 0 по той же причине; -7. после срабатывания guard (`hy2xs-fw-rollback-*` больше не `active`) - `repair` проходит. +7. после срабатывания guard транзиентный таймер выгружается + (`RemainAfterElapse=no`), и `repair` проходит. Проверяется наблюдением, а не + ожиданием на глаз: + + ```bash + systemctl show hy2xs-fw-rollback-.timer -p LoadState -p ActiveState + systemctl list-units --all --plain 'hy2xs-fw-rollback-*' + ``` + + Ожидается, что таймера в списке больше нет; оставшийся `.service` в + состоянии `failed` (частичное восстановление) операцию не блокирует. Обратная проверка: на сервере без вооружённого guard барьер молчит и ни одну операцию не задерживает, а `failed` от уже отработавшего guard **не** считается непокоем — иначе он заблокировал бы `repair`, которым и чинят последствия. +Отдельная проверка того же барьера — недоказуемое состояние. Барьер обязан +различать «guard вооружён» и «спросить не удалось»: это разные утверждения, и +оператору по ним нужны разные действия. + +1. на рабочей установке без вооружённого guard делается недоступным запрос к + systemd — проще всего временно подложить в `PATH` оркестратора `systemctl`, + завершающийся ненулевым кодом; +2. любая операция жизненного цикла (`repair`, `reconfigure --apply`, `doctor`, + `install.sh` на PHASE 0) обязана отказать: + +```text +unable to verify firewall rollback guard state; systemd query failed, +refusing to start a lifecycle operation +``` + +3. отказ происходит **до** первой мутации, и тип ошибки — + `GuardStateUnknownError`, а не `PendingRecoveryError`: ждать окна отката + здесь бессмысленно; +4. `hy2xs-orchestrator status` при этом **не** падает: он замок не берёт и + существует в том числе для сломанного хоста, поэтому сообщает + `rollback_guard_state: "unknown"` и `firewall_state: "guard_unknown"`; +5. после возврата рабочего `systemctl` операция проходит без дополнительных + действий. + +Смысл проверки — в том, что прежнее поведение было противоположным: отказ +запроса давал пустой список guard'ов, барьер считал систему спокойной и +пропускал операцию, а взведённый таймер предыдущей операции срабатывал уже +посреди неё. + ## D1g. Успешная установка не оставляет следов транзакции Проверяется на чистом хосте, обычной успешной установкой. Это обратная проверка @@ -1163,7 +1239,7 @@ is still armed: hy2xs-fw-rollback-.timer (active) После `installed`: ```text -systemctl list-units --all 'hy2xs-fw-rollback-*' → пусто +systemctl list-units --all --plain 'hy2xs-fw-rollback-*' → пусто ls /run/hy2xs/rollback/ → пусто ls /run/lock/hy2xs-orchestrator.lock → отсутствует ls /etc/nftables.conf.candidate → отсутствует @@ -1366,3 +1442,8 @@ hy2xs-orchestrator doctor 68. новая операция не начинается, пока у предыдущей остаётся вооружённый rollback guard: условие старта — «у предыдущей нет исполнителей, способных изменить систему», а не «её PID мёртв» 69. отказ записи маркера `auto-rollback-fired` не может привести к фиксации успеха: он переводит юнит guard в `failed`, а `failed` фиксацию запрещает 70. восстановление `UnitFileState` у `nftables.service` не обещает точности, которой не даёт: восстанавливаются `enabled`/`disabled`, остальные состояния называются оператору и не трогаются +71. отказ запроса к systemd не выдаётся за покой: барьер обязан **доказать** отсутствие исполнителей предыдущей операции, а при невозможности получить доказательство отказывает с `GuardStateUnknownError`, а не разрешает операцию +72. покой guard перечисляется белым списком (`inactive`, `failed`): незнакомое состояние systemd блокирует операцию, а не проходит молча по принципу «его нет в списке опасных» +73. отработавший таймер не блокирует операцию навсегда: `RemainAfterElapse=no` выгружает его, а барьер дополнительно опознаёт `SubState=elapsed` у `*.timer` как покой +74. обещанное окно отката — контракт systemd, а не намерение: у транзиентного таймера явно задан `AccuracySec=1s`, иначе умолчание `AccuracySec=1min` превращало «45 секунд» в 45–105 +75. состояние guard читает один наблюдатель: `status` берёт его у того же кода, что и барьер, и сообщает `unknown` вместо тихого «guard'ов нет» при отказе systemd diff --git a/docs/12-operations-and-troubleshooting.md b/docs/12-operations-and-troubleshooting.md index 2611f82..6a7a85b 100644 --- a/docs/12-operations-and-troubleshooting.md +++ b/docs/12-operations-and-troubleshooting.md @@ -201,7 +201,7 @@ operation lock … exists but is not a valid HY2XS lock record ```text previous HY2XS operation is no longer running, but its firewall rollback guard -is still armed: hy2xs-fw-rollback-.timer (active) +is still armed: hy2xs-fw-rollback-.timer (active/waiting) ``` Предыдущая операция умерла аварийно **после** применения firewall. Её процесса @@ -211,18 +211,50 @@ systemd, и он переживает свой процесс. Если нача операции. Ничего делать не нужно, кроме как подождать: окно guard — 45 секунд с момента -применения firewall. +применения firewall плюс точность таймера (`AccuracySec=1s`), то есть не больше +46 секунд. ```bash # сколько ещё ждать и что именно висит -systemctl list-units --all 'hy2xs-fw-rollback-*' +systemctl list-units --all --plain 'hy2xs-fw-rollback-*' hy2xs-orchestrator status --package-dir /usr/local/lib/hy2xs/package ``` -Когда guard сработает, юнит перестанет быть `active`, и операция пройдёт. -Состояние `failed` у него покою не мешает: оно означает, что откат отработал -не полностью, и это как раз повод запустить `repair`, а не ждать дальше — -подробности в `journalctl -u 'hy2xs-fw-rollback-*'`. +`--plain` здесь не для красоты: у юнита в состоянии `failed` systemctl печатает +первой колонкой маркер `●`, и без флага его легко не заметить в списке. + +Когда guard сработает, отработавший таймер выгрузится (`RemainAfterElapse=no`), +и операция пройдёт. Состояние `failed` у сервиса покою не мешает: оно означает, +что откат отработал не полностью, и это как раз повод запустить `repair`, а не +ждать дальше — подробности в `journalctl -u 'hy2xs-fw-rollback-*'`. Такой +`failed`-юнит остаётся загруженным до `systemctl reset-failed`, но операцию не +блокирует. + +### Операция отказывает: состояние guard'а не удалось выяснить + +```text +unable to verify firewall rollback guard state; systemd query failed, +refusing to start a lifecycle operation: systemctl list-units failed: … +``` + +Это **не** «guard вооружён», и ждать здесь нечего. Барьер обязан доказать, что у +предыдущей операции не осталось исполнителей, способных изменить firewall; +`systemctl` не ответил, доказательства нет, и операция отказывает до первой +мутации. + +Отсутствие ответа не равно отсутствию guard'а: systemd мог быть жив, а взведённый +таймер — существовать. Разрешить операцию в этой ситуации означало бы допустить +срабатывание старого таймера поверх новой операции. + +```bash +systemctl status +systemctl list-units --all --plain 'hy2xs-fw-rollback-*' +journalctl -u 'hy2xs-fw-rollback-*' --no-pager +``` + +Разбирайтесь с systemd/D-Bus и повторяйте операцию. Отдельно ускорять ничего не +нужно: `systemd-run` требуется в preflight, поэтому без работающего systemd +операция всё равно не прошла бы — барьер лишь сообщает об этом раньше и точнее. ### Установка отказала с `firewall_guard_fired` @@ -237,8 +269,8 @@ phase: firewall_guard_fired даже если smoke успел сойтись, — иначе сервер считался бы настроенным с чужими правилами, что особенно дорого при смене порта Hysteria, SSH или ACME. -Окно guard — 45 секунд, и оно не обязано покрывать smoke. Причину ищите в том, -почему проход в него не уложился: +Окно guard — 45 секунд (плюс точность таймера, `AccuracySec=1s`), и оно не +обязано покрывать smoke. Причину ищите в том, почему проход в него не уложился: ```bash journalctl -u 'hy2xs-fw-rollback-*' --no-pager diff --git a/docs/13-production-runbook.md b/docs/13-production-runbook.md index b10ef8a..2742b9f 100644 --- a/docs/13-production-runbook.md +++ b/docs/13-production-runbook.md @@ -81,10 +81,11 @@ sudo -u hy2xs-admin test ! -r /etc/hy2xs/hy2xs.env | `rollback finished with N failed stage(s); manual recovery may be required` | итог: перечисленные стадии требуют ручной проверки | | `rollback completed: N stage(s) succeeded` | восстановление отработало полностью | | `manual recovery data preserved at /run/hy2xs/rollback/` | firewall восстановлен не полностью; прежние `nftables.conf` и `hy2xs.nft` лежат по этому пути | -| `firewall rollback guard armed: … fires in 45s` | guard взведён; с этого момента операция обязана снять его до фиксации успеха | +| `firewall rollback guard armed: … fires in 45s (timer accuracy 1s)` | guard взведён; с этого момента операция обязана снять его до фиксации успеха | | `firewall rollback guard disarmed and proven inactive` | guard снят, и это подтверждено состоянием юнитов и отсутствием маркера срабатывания | | `automatic firewall rollback has already fired` | guard успел сработать; сервер работает на **прежнем** firewall, операция обязана завершиться отказом | | `firewall rollback guard is still in state "…"` | остановить guard не удалось; фиксация успеха запрещена, разбирайтесь с systemd | +| `unable to verify firewall rollback guard state; systemd query failed` | состояние guard'а недоказуемо; операция не начата, чинить нужно systemd, а не ждать | Отдельно про сработавший guard. Окно 45 секунд намеренно короче худшего случая smoke и не обязано его покрывать: доказательством служит не время, а маркер @@ -169,14 +170,26 @@ operation lock … is held by install (pid 1234), which is no longer running; re ```text previous HY2XS operation is no longer running, but its firewall rollback guard -is still armed: hy2xs-fw-rollback-.timer (active) +is still armed: hy2xs-fw-rollback-.timer (active/waiting) ``` Условие старта — не «PID предыдущей мёртв», а «у предыдущей не осталось -исполнителей, способных изменить систему». Ждать нужно не больше 45 секунд с +исполнителей, способных изменить систему». Ждать нужно не больше 45–46 секунд с момента применения firewall; `failed` у guard покою не мешает и означает, что пора смотреть `journalctl -u 'hy2xs-fw-rollback-*'` и запускать `repair`. +Второй отказ того же барьера выглядит иначе и требует другого действия: + +```text +unable to verify firewall rollback guard state; systemd query failed, +refusing to start a lifecycle operation +``` + +Здесь ждать бессмысленно. Барьер обязан **доказать** покой, а не предположить +его: отсутствие ответа systemd — это отсутствие наблюдения, а не наблюдение +отсутствия guard'а. Смотрите `systemctl status` и повторяйте операцию после того, +как systemd отвечает. + `/run/lock` — это tmpfs, поэтому перезагрузка снимает замок в любом случае. Удалять файл руками нужно только если в нём оказалось непонятное содержимое: такой замок сознательно не переиспользуется автоматически — непонятый файл не diff --git a/docs/14-legacy-cleanup.md b/docs/14-legacy-cleanup.md index e62b520..b31936d 100644 --- a/docs/14-legacy-cleanup.md +++ b/docs/14-legacy-cleanup.md @@ -186,10 +186,14 @@ sudo systemctl stop hysteria-server hy2xs-admin h-ui sudo systemctl disable hysteria-server hy2xs-admin h-ui sudo systemctl reset-failed hysteria-server hy2xs-admin h-ui -# таймеры отката firewall от незавершённой установки -sudo systemctl list-units --all 'hy2xs-fw-rollback-*' +# таймеры отката firewall от незавершённой установки. +# --plain обязателен: у юнита в состоянии failed первой колонкой идёт маркер `●`, +# и без флага такой юнит легко пропустить — а это ровно те, что остались после +# аварийной установки. +sudo systemctl list-units --all --plain 'hy2xs-fw-rollback-*' # для каждого найденного юнита: # sudo systemctl stop && sudo systemctl disable +# sudo systemctl reset-failed # sudo rm -f /etc/systemd/system/ sudo rm -f /etc/systemd/system/hysteria-server.service \ diff --git a/orchestrator/src/commands/status.ts b/orchestrator/src/commands/status.ts index ebab9dd..a5de33f 100644 --- a/orchestrator/src/commands/status.ts +++ b/orchestrator/src/commands/status.ts @@ -3,7 +3,7 @@ import { fileExists, readText } from "../lib/fs"; import { info, setOperationContext } from "../lib/log"; import { runReadOnly } from "../lib/process"; import { getPlatformProfile } from "../platform/profile"; -import { detectFirewallEntrypointKind } from "../steps/firewall"; +import { detectFirewallEntrypointKind, inspectRollbackGuard } from "../steps/firewall"; import { INSTALL_STATE_PATH, detectGenerationProblems } from "../lib/installState"; import { describeOperationInProgress } from "../lib/operationLock"; @@ -51,7 +51,20 @@ export async function status(_options: CommonOptions): Promise { } } - const rollbackGuardUnits = (await runReadOnly`sh -c 'systemctl list-units --all --no-legend "hy2xs-fw-rollback-*.timer" "hy2xs-fw-rollback-*.service" 2>/dev/null || true'`).trim(); + // Состояние guard'а берётся у того же наблюдателя, что и у барьера покоя. + // + // Здесь стояла вторая копия листинга, и она расходилась с барьером по трём + // пунктам сразу: без `--plain` (у `failed`-юнита первой колонкой идёт `●`), + // с `|| true` (отказ systemd превращался в «guard'ов нет») и без разбора + // состояний — вооружённым считался любой найденный юнит. Практический эффект: + // аварийно сработавший guard оставляет `failed`-сервис загруженным до + // `reset-failed`, и status вечно показывал `firewall_state: guard_active`, + // пока барьер тот же самый юнит считал покоем и разрешал `repair`. + // + // Отчёт при этом остаётся отчётом: status замок не берёт и существует в том + // числе для сломанного хоста, поэтому «спросить не удалось» попадает в JSON + // значением `unknown`, а не отказом команды. + const rollbackGuard = await inspectRollbackGuard(); // Идущая операция обязана быть видна в отчёте: без неё оператор разбирает // промежуточное состояние транзакции как окончательное — например, читает @@ -66,7 +79,11 @@ export async function status(_options: CommonOptions): Promise { const installStateEffective = installState?.installed ? "installed" : (installPhase === "unknown" ? "failed" : installPhase); - const rollbackGuardActive = rollbackGuardUnits.length > 0; + // «Вооружён» — это ровно `pending`. Отработавший guard (`inactive`/`failed`, + // а для таймера ещё и `elapsed`) систему уже не изменит, и объявлять его + // активным значит противоречить барьеру, который в этот момент разрешает + // операцию. + const rollbackGuardActive = rollbackGuard.kind === "pending"; // Отдельное поле: маркер может присутствовать и быть «installed», но // принадлежать другому поколению продукта. const generationProblems = installState ? detectGenerationProblems(installState) : []; @@ -99,10 +116,22 @@ export async function status(_options: CommonOptions): Promise { install_state_generation_problems: generationProblems, operation_in_progress: operationInProgress, rollback_guard_active: rollbackGuardActive, - rollback_guard_units: rollbackGuardUnits ? rollbackGuardUnits.split("\n") : [], + rollback_guard_state: rollbackGuard.kind, + rollback_guard_reason: rollbackGuard.kind === "unknown" ? rollbackGuard.reason : null, + rollback_guard_units: rollbackGuard.kind === "unknown" + ? [] + : rollbackGuard.units.map((unit) => ({ + unit: unit.unit, + active_state: unit.activeState, + sub_state: unit.subState + })), runtime_state: runtimeState, install_state_effective: installStateEffective, - firewall_state: rollbackGuardActive ? "guard_active" : firewall, + // Недоказуемое состояние guard'а называется своим именем: «guard'а нет» — + // это утверждение, и выдавать за него отсутствие ответа systemd нельзя. + firewall_state: rollbackGuardActive + ? "guard_active" + : (rollbackGuard.kind === "unknown" ? "guard_unknown" : firewall), human_status: humanStatus }; info(`status report: ${JSON.stringify(result)}`); diff --git a/orchestrator/src/lib/process.ts b/orchestrator/src/lib/process.ts index d713e1d..77d1a0f 100644 --- a/orchestrator/src/lib/process.ts +++ b/orchestrator/src/lib/process.ts @@ -12,14 +12,26 @@ * * Поэтому здесь нет универсального раннера. Есть два набора: * - * runReadOnly / runReadOnlySecret + * runReadOnly / runReadOnlySecret / runReadOnlyArgv * наблюдение за системой. Guard не трогает — они разрешены в любой фазе. * * runMutating / runMutatingVisible / runMutatingHidden / runMutatingRaw / - * runMutatingStatus + * runMutatingStatus / runMutatingArgv * всё, что может изменить хост. Каждый спрашивает разрешения у guard'а. * * Выбор набора — сознательное решение на месте вызова, а не умолчание. + * + * Внутри каждого набора есть две ФОРМЫ, и различие между ними тоже + * содержательное. Tagged template собирает строку для `sh -c`: аргументы + * проходят через shellQuote, а сама команда остаётся shell-строкой, поэтому + * ей доступны конвейеры и перенаправления. Форма `*Argv` shell не запускает + * вовсе: argv уходит в exec как есть. + * + * Вторая форма появилась не ради экономии процесса. Команда, собранная + * интерполяцией, существует только в момент запуска, и её контракт («у + * транзиентного таймера заданы именно эти свойства») проверяется грепом по + * исходнику. Готовый argv строит чистая функция, и тот же контракт становится + * обычным тестом на значение. */ import { assertMutationAllowed } from "./guard"; @@ -80,6 +92,38 @@ export async function runReadOnlySecret(command: TemplateStringsArray, ...args: return capture(renderCommand(command, args), false); } +function describeArgv(argv: string[]): string { + return argv.join(" "); +} + +function assertArgv(argv: string[], runner: string): void { + if (argv.length === 0) { + throw new Error(`${runner}: argv is empty`); + } +} + +/** + * Наблюдение готовым argv, без shell. + * + * Нужно там, где список аргументов вычисляется (набор `--property=` зависит от + * того, что именно спрашивают у юнита). Собирать такой список интерполяцией в + * shell-строку означало бы либо потерять квотирование, либо склеить весь набор + * в один аргумент. + */ +export async function runReadOnlyArgv(argv: string[]): Promise { + assertArgv(argv, "runReadOnlyArgv"); + const subprocess = Bun.spawn(argv, { stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(subprocess.stdout).text(), + new Response(subprocess.stderr).text(), + subprocess.exited + ]); + if (exitCode !== 0) { + throw new Error(`command failed (${exitCode}): ${describeArgv(argv)}\n${stderr.trim()}`); + } + return stdout.trim(); +} + /** Мутация с захватом вывода (`mktemp -d`, `install -d`, ...). */ export async function runMutating(command: TemplateStringsArray, ...args: unknown[]): Promise { const rendered = renderCommand(command, args); @@ -102,6 +146,32 @@ export async function runMutatingVisible(command: TemplateStringsArray, ...args: } } +/** + * Мутация готовым argv, без shell. + * + * Существует ради команд, у которых важен ТОЧНЫЙ набор аргументов, а не удобство + * записи. Канонический пример — взведение транзиентного guard'а: у него есть + * контракт («заданы `RemainAfterElapse` и `AccuracySec`»), от которого зависит + * поведение барьера покоя и обещанное оператору окно отката. + * + * Пока команда собиралась интерполяцией в shell-строку, этот контракт нельзя + * было проверить иначе как грепом по исходнику: значения существовали только + * внутри вызова. Чистая функция, возвращающая argv, делает его обычным + * значением, а отсутствие shell заодно убирает вопрос о квотировании из команды, + * которая создаёт systemd-юнит с именем из данных операции. + */ +export async function runMutatingArgv(argv: string[]): Promise { + assertArgv(argv, "runMutatingArgv"); + const rendered = describeArgv(argv); + assertMutationAllowed(`runMutatingArgv(${rendered})`); + info(`running: ${rendered}`); + const subprocess = Bun.spawn(argv, { stdout: "inherit", stderr: "inherit" }); + const exitCode = await subprocess.exited; + if (exitCode !== 0) { + throw new Error(`command failed (${exitCode}): ${rendered}`); + } +} + /** Мутация многострочным скриптом (`sh -eu -c`). */ export async function runMutatingRaw(command: string): Promise { assertMutationAllowed("runMutatingRaw(...)"); diff --git a/orchestrator/src/steps/firewall.ts b/orchestrator/src/steps/firewall.ts index 6127519..8914623 100644 --- a/orchestrator/src/steps/firewall.ts +++ b/orchestrator/src/steps/firewall.ts @@ -1,7 +1,13 @@ import type { RuntimeContext } from "../types/context"; import { fileExists, readText, renderTemplate, writeText } from "../lib/fs"; import { fail, info } from "../lib/log"; -import { runMutatingStatus, runMutatingVisible, runReadOnly } from "../lib/process"; +import { + runMutatingArgv, + runMutatingStatus, + runMutatingVisible, + runReadOnly, + runReadOnlyArgv +} from "../lib/process"; import { runRollbackStages, type RollbackStage } from "../lib/rollback"; type NftEntrypointKind = @@ -29,6 +35,41 @@ const HY2XS_NFT_CANDIDATE = `${HY2XS_NFT_PATH}.candidate`; */ const FIREWALL_ROLLBACK_DEADLINE = "45s"; +/** + * Точность транзиентного таймера guard'а. + * + * `OnActiveSec=` НЕ означает «ровно через столько». `systemd.timer` разрешает + * себе сработать в окне `[цель; цель + AccuracySec]`, объединяя пробуждения + * ради экономии энергии, и умолчание этого параметра — `1min`. То есть guard, + * объявленный как «45 секунд», без явного значения имел контракт «от 45 до 105 + * секунд», а README, docs и текст отказа обещали первое число. + * + * Для аварийного guard'а коалесценция пробуждений не нужна: он взводится один + * раз за операцию и почти всегда снимается, не сработав. `1s` возвращает + * обещанному окну смысл — реальный интервал становится 45–46 секунд — и остаётся + * достаточно грубым, чтобы не будить ядро ради миллисекунд. + */ +const FIREWALL_ROLLBACK_ACCURACY = "1s"; + +/** + * Транзиентный таймер обязан исчезнуть, отработав. + * + * `systemd-run` выставляет `RemainAfterElapse=false` сам — это его умолчание для + * транзиентных таймеров, и на systemd 257 (Debian 13) оно действует. Значение + * повторяется здесь ЯВНО не из недоверия к systemd, а потому что от него зависит + * чужой инвариант: барьер покоя считает отсутствие юнита доказательством того, + * что откатывать firewall больше некому. + * + * Инвариант, который держится на чужом умолчании, не записан нигде и не + * проверяется ничем. С явным свойством он становится частью команды, которую + * видно в journal и которую проверяет тест. + * + * Второе плечо той же защиты — в барьере: отработавший таймер опознаётся ещё и + * по `SubState=elapsed`, поэтому даже таймер, созданный не нами, не блокирует + * `repair` навсегда. + */ +const FIREWALL_ROLLBACK_REMAIN_AFTER_ELAPSE = "no"; + /** * Маркер факта: автоматический откат firewall НАЧАЛ выполняться. * @@ -74,14 +115,57 @@ export class FirewallGuardFiredError extends Error { const ROLLBACK_UNIT_PREFIX = "hy2xs-fw-rollback-"; /** - * Состояния, в которых guard ещё СПОСОБЕН изменить систему. + * Состояния, в которых guard заведомо БОЛЬШЕ НИЧЕГО не сделает. * - * `failed` и `inactive` сюда не входят намеренно. Guard, который уже отработал - * (успешно или нет), больше ничего не сделает, а отказавший юнит — это как раз - * повод запустить `repair`. Барьер, отказывающий по `failed`, блокировал бы - * ровно тот инструмент, которым чинят последствия. + * Список именно такой — белый, а не чёрный, и это принципиально. Раньше + * перечислялись непокойные состояния (`active`, `activating`, `deactivating`, + * `reloading`), а покоем считалось «всё остальное». Такая формулировка + * доказывает не то, что нужно: она объявляет безопасным любое состояние, + * которого автор не перечислил, — включая те, которых он не знал. systemd 257 + * знает `maintenance` и `refreshing` помимо перечисленных, и завтра список + * может пополниться снова. + * + * Перечислять же нужно ровно то, что мы УТВЕРЖДАЕМ: покой — это `inactive` и + * `failed`. Отработавший guard, успешно или нет, систему больше не меняет, а + * отказавший юнит — как раз повод запустить `repair`; барьер, отказывающий по + * `failed`, блокировал бы инструмент, которым чинят последствия. + * + * Всё прочее — непокой, и это соответствует политике, уже применённой к замку + * операций: сомнение трактуется в пользу отказа. */ -const GUARD_PENDING_STATES = ["active", "activating", "deactivating", "reloading"] as const; +const GUARD_QUIESCENT_ACTIVE_STATES = ["inactive", "failed"] as const; + +/** + * Отработавший таймер — покой, даже если он остался `active`. + * + * `ActiveState` таймера отвечает на вопрос «юнит загружен и в строю», а не «он + * ещё может сработать»: в systemd `TIMER_ELAPSED` отображается в `UNIT_ACTIVE` + * так же, как `TIMER_WAITING`. Различает их только `SubState`. + * + * У наших guard'ов этой ситуации не возникает — `RemainAfterElapse=no` + * выгружает таймер сразу, — но инвариант «барьер не залипает» не должен + * зависеть от того, чем именно создан таймер. Без этой ветки одноразовый + * таймер, оставшийся в `elapsed`, запрещал бы install/reconfigure/repair/doctor + * навсегда, пока оператор не остановит его руками, — и запрещал бы ради + * отката, который уже произошёл. + * + * Ветка узкая намеренно: только `*.timer` и только состояния, из которых + * следующего срабатывания не будет. Триггернутый сервис при этом виден + * барьеру отдельным юнитом и остаётся непокоем, пока выполняется. + */ +const GUARD_QUIESCENT_TIMER_SUB_STATES = ["dead", "elapsed"] as const; + +/** Свойства, по которым принимается решение о покое. */ +const GUARD_STATE_PROPERTIES = ["ActiveState", "SubState"] as const; + +/** + * Новую операцию начинать нельзя. + * + * База для двух разных причин отказа. Общий предок нужен потому, что вызывающий + * иногда обязан отличать «эта операция ничего не испортила, ей просто нельзя + * начинать» от собственных отказов, но не обязан различать конкретную причину. + */ +export class OperationBarrierError extends Error {} /** * Предыдущая операция мертва, но её асинхронный исполнитель ещё жив. @@ -89,13 +173,32 @@ const GUARD_PENDING_STATES = ["active", "activating", "deactivating", "reloading * Отдельный тип, потому что это единственный отказ, который не про текущую * операцию: она не сделала ничего плохого, ей просто нельзя начинать. */ -export class PendingRecoveryError extends Error { +export class PendingRecoveryError extends OperationBarrierError { constructor(message: string) { super(message); this.name = "PendingRecoveryError"; } } +/** + * Покой недоказуем: systemd не ответил. + * + * Отдельный тип от `PendingRecoveryError`, потому что утверждения разные. + * «Guard вооружён» — наблюдение. «Состояние guard'а неизвестно» — отсутствие + * наблюдения, и оператору нужно другое действие: не подождать 45 секунд, а + * разобраться с systemd. + * + * Отказ здесь ничего не стоит: systemd-run и так требуется в preflight, поэтому + * без работающего systemd операция всё равно не пройдёт — просто позже и с + * менее внятной диагностикой. + */ +export class GuardStateUnknownError extends OperationBarrierError { + constructor(message: string) { + super(message); + this.name = "GuardStateUnknownError"; + } +} + function rollbackRoot(opId: string): string { return `/run/hy2xs/rollback/${opId}`; } @@ -201,8 +304,64 @@ export function parseNftablesServiceState(raw: string): NftablesServiceState | n return { unitFileState, activeState }; } -async function readUnitProperty(unit: string, property: string): Promise { - return (await runReadOnly`systemctl show --property=${property} --value ${unit}`).trim(); +/** + * Наблюдение за systemd, вынесенное в интерфейс. + * + * Не абстракция ради абстракции. Барьер покоя — единственное место продукта, где + * ОТКАЗ наблюдения меняет решение, а не только его обоснование, и до сих пор это + * поведение проверялось грепом по исходнику: тест утверждал, что в тексте + * функции есть `return [];`. Такой тест закрепляет строку, а не свойство, и + * ровно поэтому пережил инверсию смысла — строка была на месте, а решение стало + * неверным. + * + * С подставляемым probe те же сценарии («systemd не ответил», «таймер взведён», + * «сервис упал») становятся обычными тестами на поведение, не требующими ни + * systemd, ни Linux. + */ +export type SystemdUnitProbe = { + /** Сырой вывод `systemctl list-units` по шаблонам guard-юнитов. */ + listGuardUnits(): Promise; + /** Значения свойств юнита; о непрочитанном свойстве возвращается пустая строка. */ + showProperties(unit: string, properties: readonly string[]): Promise>; +}; + +export const defaultSystemdUnitProbe: SystemdUnitProbe = { + async listGuardUnits(): Promise { + return await runReadOnly`systemctl list-units --all --plain --no-legend ${`${ROLLBACK_UNIT_PREFIX}*.timer`} ${`${ROLLBACK_UNIT_PREFIX}*.service`}`; + }, + + async showProperties(unit: string, properties: readonly string[]): Promise> { + // `--value` здесь не используется: при нескольких свойствах он печатает + // значения без имён, и разбор начинает зависеть от порядка вывода. Формат + // `Свойство=значение` самоописателен. + const raw = await runReadOnlyArgv([ + "systemctl", + "show", + ...properties.map((property) => `--property=${property}`), + unit + ]); + + const values: Record = {}; + for (const property of properties) { + values[property] = ""; + } + for (const line of raw.split(/\r?\n/)) { + const separator = line.indexOf("="); + if (separator <= 0) { + continue; + } + values[line.slice(0, separator).trim()] = line.slice(separator + 1).trim(); + } + return values; + } +}; + +async function readUnitProperty( + unit: string, + property: string, + probe: SystemdUnitProbe = defaultSystemdUnitProbe +): Promise { + return ((await probe.showProperties(unit, [property]))[property] ?? "").trim(); } /** @@ -506,23 +665,49 @@ async function cleanupFirewallCandidates(): Promise { await runMutatingVisible`rm -f ${HY2XS_NFT_CANDIDATE} ${NFTABLES_ENTRYPOINT_CANDIDATE}`; } +/** + * Команда взведения guard'а — как значение, а не как момент запуска. + * + * Чистая функция, потому что у этой команды есть контракт, от которого зависят + * два чужих утверждения: + * + * AccuracySec — обещанное оператору окно отката (README, docs, текст + * отказа барьера говорят «45 секунд»); + * RemainAfterElapse — право барьера считать отсутствие юнита покоем. + * + * Пока команда собиралась интерполяцией внутри вызова, оба свойства + * существовали только в момент запуска, и проверить их можно было лишь грепом + * по исходнику. Здесь они — обычное значение, которое сравнивает обычный тест. + * + * Имя юнита передаётся С суффиксом `.service`, а не голым. Голое имя systemd-run + * пропускает через unit_name_mangle_with_suffix, и тот сначала смотрит, не + * заканчивается ли оно уже известным типом юнита. Ключ операции — + * санитизированный ISO-timestamp вида `...T12-34-56.789Z`, то есть содержит + * точку, и корректность имени зависела бы от того, что `.789Z` случайно не + * совпало ни с одним типом. Явный суффикс убирает эту зависимость: systemd-run + * берёт имя как есть и создаёт рядом одноимённый `.timer`. + */ +export function buildArmGuardArgv(opId: string): string[] { + assertSafeOperationKey(opId); + return [ + "systemd-run", + `--unit=${rollbackUnit(opId)}.service`, + `--on-active=${FIREWALL_ROLLBACK_DEADLINE}`, + `--timer-property=RemainAfterElapse=${FIREWALL_ROLLBACK_REMAIN_AFTER_ELAPSE}`, + `--timer-property=AccuracySec=${FIREWALL_ROLLBACK_ACCURACY}`, + "/bin/sh", + autoRollbackScriptPath(opId) + ]; +} + async function armRollbackGuard(opId: string): Promise { assertSafeOperationKey(opId); - const scriptPath = autoRollbackScriptPath(opId); - await writeText(scriptPath, buildAutoRollbackScript(opId), 0o700); + await writeText(autoRollbackScriptPath(opId), buildAutoRollbackScript(opId), 0o700); - const unit = rollbackUnit(opId); - // Имя передаётся С суффиксом `.service`, а не голым. - // - // Голое имя systemd-run пропускает через unit_name_mangle_with_suffix, и тот - // сначала смотрит, не заканчивается ли оно уже известным типом юнита. Ключ - // операции — санитизированный ISO-timestamp вида `...T12-34-56.789Z`, то есть - // содержит точку, и корректность имени зависела бы от того, что `.789Z` - // случайно не совпало ни с одним типом. Явный суффикс убирает эту зависимость: - // systemd-run берёт имя как есть и создаёт рядом одноимённый `.timer`. - await runMutatingVisible`systemd-run --unit ${`${unit}.service`} --on-active=${FIREWALL_ROLLBACK_DEADLINE} /bin/sh ${scriptPath}`; + await runMutatingArgv(buildArmGuardArgv(opId)); info( - `firewall rollback guard armed: ${unit} fires in ${FIREWALL_ROLLBACK_DEADLINE} unless the operation disarms it` + `firewall rollback guard armed: ${rollbackUnit(opId)} fires in ${FIREWALL_ROLLBACK_DEADLINE} ` + + `(timer accuracy ${FIREWALL_ROLLBACK_ACCURACY}) unless the operation disarms it` ); } @@ -636,28 +821,121 @@ export async function assertEffectiveFirewallIsOurs(context: RuntimeContext): Pr } } +/** Наблюдаемое состояние одного guard-юнита. */ +export type GuardUnitState = { + unit: string; + activeState: string; + subState: string; +}; + /** - * Транзиентные юниты guard, которые сейчас известны systemd. + * Что удалось УЗНАТЬ о guard'ах предыдущей операции. * - * Отказ самого запроса не считается доказательством наличия guard: без systemd - * не может быть и транзиентного таймера, а требование systemd живёт в - * preflight, где отказ будет понятнее и точнее. + * Три исхода, а не два, и третий здесь главный. Прежняя функция возвращала + * список юнитов, и «список пуст» одинаково означало и «guard'ов нет», и «спросить + * не удалось». Различие между ними — это различие между доказанным покоем и + * отсутствием доказательства, то есть ровно то, ради чего барьер существует. */ -export async function listRollbackGuardUnits(): Promise { +export type GuardInspection = + | { kind: "quiescent"; units: GuardUnitState[] } + | { kind: "pending"; units: GuardUnitState[] } + | { kind: "unknown"; reason: string }; + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Имена guard-юнитов из вывода `systemctl list-units`. + * + * Ищется первое поле строки, НАЧИНАЮЩЕЕСЯ с нашего префикса, а не просто первое + * поле. Разница не косметическая: у юнита в состоянии `failed` systemctl + * печатает первой колонкой маркер `●`, и разбор «первое поле — имя юнита» + * пропускал бы именно аварийно сработавший guard — тот единственный, ради + * которого проверка и написана. `--plain` этот маркер убирает, но разбор не + * должен зависеть от того, не потеряется ли флаг при следующей правке команды. + */ +export function parseRollbackGuardUnitNames(listed: string): string[] { + const names = new Set(); + for (const line of listed.split(/\r?\n/)) { + for (const field of line.trim().split(/\s+/)) { + if (field.startsWith(ROLLBACK_UNIT_PREFIX)) { + names.add(field); + break; + } + } + } + return [...names]; +} + +/** Юнит уже ничего не изменит: см. GUARD_QUIESCENT_ACTIVE_STATES. */ +export function guardUnitIsQuiescent(state: GuardUnitState): boolean { + if ((GUARD_QUIESCENT_ACTIVE_STATES as readonly string[]).includes(state.activeState)) { + return true; + } + return ( + state.unit.endsWith(".timer") && + (GUARD_QUIESCENT_TIMER_SUB_STATES as readonly string[]).includes(state.subState) + ); +} + +export function describeGuardUnits(units: readonly GuardUnitState[]): string { + return units.map((state) => `${state.unit} (${state.activeState}/${state.subState})`).join(", "); +} + +/** + * Состояние транзиентных guard'ов, известных systemd. + * + * Отказ запроса больше НЕ считается покоем, и это исправление, а не смена + * умолчания. Прежний комментарий обосновывал `return []` так: «без systemd не + * может быть и транзиентного таймера». Утверждение верное, но доказывает не то — + * отказ запроса к systemd не означает, что systemd нет: + * + * systemd жив, старый rollback timer взведён + * -> запрос к systemctl/D-Bus временно отказывает + * -> список пуст + * -> барьер считает систему спокойной + * -> новая операция начинает менять firewall + * -> таймер срабатывает поверх неё + * + * То есть механизм, обязанный ДОКАЗАТЬ отсутствие асинхронного исполнителя, при + * невозможности получить доказательство принимал результат как положительный. + * Это прямо противоположно политике, уже принятой для замка операций, где + * сомнение трактуется в пользу отказа. + * + * Практического выигрыша от прежнего поведения не было: systemd-run требуется в + * preflight, поэтому без работающего systemd операция всё равно откажет — просто + * позже и с менее внятным сообщением. + */ +export async function inspectRollbackGuard( + probe: SystemdUnitProbe = defaultSystemdUnitProbe +): Promise { let listed: string; try { - listed = await runReadOnly`systemctl list-units --all --plain --no-legend ${`${ROLLBACK_UNIT_PREFIX}*.timer`} ${`${ROLLBACK_UNIT_PREFIX}*.service`}`; + listed = await probe.listGuardUnits(); } catch (error) { - info( - `unable to list firewall rollback guard units: ${error instanceof Error ? error.message : String(error)}` - ); - return []; + return { kind: "unknown", reason: `systemctl list-units failed: ${describeError(error)}` }; } - return listed - .split("\n") - .map((line) => line.trim().split(/\s+/)[0] ?? "") - .filter((unit) => unit.startsWith(ROLLBACK_UNIT_PREFIX)); + const units: GuardUnitState[] = []; + for (const unit of parseRollbackGuardUnitNames(listed)) { + let properties: Record; + try { + properties = await probe.showProperties(unit, GUARD_STATE_PROPERTIES); + } catch (error) { + // Отказ на ОДНОМ юните тоже делает картину неполной: покой — утверждение + // обо всех guard'ах сразу, и «про этот не знаем» его опровергает. + return { kind: "unknown", reason: `systemctl show ${unit} failed: ${describeError(error)}` }; + } + units.push({ + unit, + activeState: (properties.ActiveState ?? "").trim(), + subState: (properties.SubState ?? "").trim() + }); + } + + const pending = units.filter((state) => !guardUnitIsQuiescent(state)); + return pending.length > 0 ? { kind: "pending", units: pending } : { kind: "quiescent", units }; } /** @@ -688,25 +966,31 @@ export async function listRollbackGuardUnits(): Promise { * он лишь повторяет то, что новая операция сделает сама, а отказ по их * переходным состояниям заблокировал бы `repair` ровно тогда, когда он нужен. */ -export async function assertNoPendingRollbackGuard(): Promise { - const pending: string[] = []; +export async function assertNoPendingRollbackGuard( + probe: SystemdUnitProbe = defaultSystemdUnitProbe +): Promise { + const inspection = await inspectRollbackGuard(probe); - for (const unit of await listRollbackGuardUnits()) { - const state = await readUnitProperty(unit, "ActiveState"); - if ((GUARD_PENDING_STATES as readonly string[]).includes(state)) { - pending.push(`${unit} (${state})`); - } + if (inspection.kind === "quiescent") { + return; } - if (pending.length === 0) { - return; + if (inspection.kind === "unknown") { + throw new GuardStateUnknownError( + "unable to verify firewall rollback guard state; systemd query failed, " + + `refusing to start a lifecycle operation: ${inspection.reason}. ` + + "Барьер обязан ДОКАЗАТЬ, что у предыдущей операции не осталось исполнителей, " + + "способных изменить firewall; без ответа systemd такого доказательства нет. " + + "Проверьте systemd (`systemctl status`, `journalctl -u 'hy2xs-fw-rollback-*'`) и повторите." + ); } throw new PendingRecoveryError( "previous HY2XS operation is no longer running, but its firewall rollback guard is still armed: " + - `${pending.join(", ")}. ` + + `${describeGuardUnits(inspection.units)}. ` + "Такой guard способен вернуть прежний firewall уже посреди новой операции. " + - "Дождитесь его завершения (окно — 45 секунд с момента применения firewall) и повторите; " + + `Дождитесь его завершения (окно — ${FIREWALL_ROLLBACK_DEADLINE} с момента применения firewall, ` + + `точность таймера — ${FIREWALL_ROLLBACK_ACCURACY}) и повторите; ` + "состояние guard видно в `hy2xs-orchestrator status` и в `journalctl -u 'hy2xs-fw-rollback-*'`." ); } diff --git a/orchestrator/test/firewall-guard.test.ts b/orchestrator/test/firewall-guard.test.ts index 250911d..71b1367 100644 --- a/orchestrator/test/firewall-guard.test.ts +++ b/orchestrator/test/firewall-guard.test.ts @@ -5,9 +5,19 @@ import { delimiter, dirname, join } from "node:path"; import { classifyFailure } from "../src/commands/install"; import { FirewallGuardFiredError, + GuardStateUnknownError, + OperationBarrierError, + PendingRecoveryError, + type SystemdUnitProbe, + assertNoPendingRollbackGuard, + buildArmGuardArgv, buildAutoRollbackScript, + describeGuardUnits, + guardUnitIsQuiescent, + inspectRollbackGuard, operationKeyFor, parseNftablesServiceState, + parseRollbackGuardUnitNames, renderNftablesEntrypoint, renderNftablesServiceState } from "../src/steps/firewall"; @@ -447,7 +457,7 @@ describe("disarm доказывает снятие guard'а, а не сообщ * и корректность зависела бы от того, что `.789Z` ни с чем не совпало. */ test("имя юнита guard'а не зависит от мангления systemd", () => { - expect(firewallSource).toContain("systemd-run --unit ${`${unit}.service`}"); + expect(buildArmGuardArgv(OP_ID)).toContain(`--unit=hy2xs-fw-rollback-${OP_ID}.service`); }); test("остановка guard'а стала стадией отката с отчётом", () => { @@ -457,39 +467,249 @@ describe("disarm доказывает снятие guard'а, а не сообщ }); }); +describe("взведение guard'а задаёт свойства таймера явно", () => { + /** + * У этой команды есть контракт, от которого зависят два чужих утверждения, и + * оба до сих пор проверялись грепом по исходнику. + * + * `AccuracySec` — обещанное оператору окно. `systemd.timer` разрешает себе + * сработать в интервале `[цель; цель + AccuracySec]`, а умолчание — `1min`. + * То есть guard, про который README, docs и текст отказа барьера говорят «45 + * секунд», по контракту systemd мог сработать через 105. + * + * `RemainAfterElapse` — право барьера считать исчезновение юнита покоем. + * systemd-run выставляет `false` сам, но инвариант, который держится на чужом + * умолчании, нигде не записан и ничем не проверяется. + */ + const argv = buildArmGuardArgv(OP_ID); + + test("окно отката задано вместе с точностью таймера", () => { + expect(argv).toContain("--on-active=45s"); + expect(argv).toContain("--timer-property=AccuracySec=1s"); + }); + + test("отработавший таймер обязан выгрузиться", () => { + expect(argv).toContain("--timer-property=RemainAfterElapse=no"); + }); + + test("команда собрана целиком, а не по кускам", () => { + expect(argv).toEqual([ + "systemd-run", + `--unit=hy2xs-fw-rollback-${OP_ID}.service`, + "--on-active=45s", + "--timer-property=RemainAfterElapse=no", + "--timer-property=AccuracySec=1s", + "/bin/sh", + `/run/hy2xs/rollback/${OP_ID}/auto-rollback.sh` + ]); + }); + + // Ключ операции подставляется в имя systemd-юнита и в путь скрипта. Без shell + // квотирование не спасает — спасает отказ. + test("небезопасный ключ операции отвергается до запуска", () => { + expect(() => buildArmGuardArgv("op id")).toThrow(/unsafe operation key/); + expect(() => buildArmGuardArgv("op'; rm -rf /")).toThrow(/unsafe operation key/); + }); + + // Готовый argv уходит в exec как есть: shell в этой команде не участвует. + test("взведение идёт без shell", () => { + const arm = firewallSource.slice( + firewallSource.indexOf("async function armRollbackGuard"), + firewallSource.indexOf("export async function applyFirewall") + ); + expect(arm).toContain("runMutatingArgv(buildArmGuardArgv(opId))"); + expect(arm).not.toMatch(/runMutatingVisible`/); + }); +}); + describe("барьер покоя между операциями", () => { /** * Стык двух защитных механизмов. Замок защищает production paths, пока жив * процесс-держатель; rollback guard — отдельный systemd-объект, переживающий * свой процесс. Аварийно умершая операция оставляет вооружённый guard, * который возвращает прежний firewall уже посреди следующей операции. + * + * Проверки здесь поведенческие. Прежние сверяли ТЕКСТ функции, и именно + * поэтому пропустили инверсию смысла: тест утверждал, что в теле есть + * `return [];`, строка была на месте, а решение при этом стало неверным. */ - const body = firewallSource.slice( - firewallSource.indexOf("export async function assertNoPendingRollbackGuard"), - firewallSource.indexOf("function firewallRollbackIsInactive") - ); + const TIMER = "hy2xs-fw-rollback-2026-08-30T12-34-56.789Z.timer"; + const SERVICE = "hy2xs-fw-rollback-2026-08-30T12-34-56.789Z.service"; - test("вооружённый guard предыдущей операции запрещает новую", () => { - expect(body).toContain("PendingRecoveryError"); - expect(body).toContain("readUnitProperty(unit, \"ActiveState\")"); + /** Probe, отвечающий заранее заданными состояниями. */ + function probeWith(states: Record): SystemdUnitProbe { + return { + async listGuardUnits() { + return Object.keys(states) + .map((unit) => `${unit} loaded active running HY2XS firewall rollback guard`) + .join("\n"); + }, + async showProperties(unit) { + const state = states[unit]; + if (!state) { + throw new Error(`unexpected unit: ${unit}`); + } + return { ...state }; + } + }; + } + + const failingProbe: SystemdUnitProbe = { + async listGuardUnits(): Promise { + throw new Error("Failed to connect to bus: No such file or directory"); + }, + async showProperties(): Promise> { + throw new Error("Failed to connect to bus: No such file or directory"); + } + }; + + test("guard'ов нет — операция разрешена", async () => { + const inspection = await inspectRollbackGuard(probeWith({})); + expect(inspection.kind).toBe("quiescent"); + await expect(assertNoPendingRollbackGuard(probeWith({}))).resolves.toBeUndefined(); }); - // `failed` и `inactive` — покой: guard уже отработал и больше ничего не - // сделает. Отказ по `failed` заблокировал бы `repair` ровно тогда, когда он - // нужен для устранения последствий. - test("покоем считаются inactive и failed, а не только inactive", () => { - expect(firewallSource).toContain( - 'const GUARD_PENDING_STATES = ["active", "activating", "deactivating", "reloading"] as const' - ); + test("взведённый таймер предыдущей операции запрещает новую", async () => { + const probe = probeWith({ [TIMER]: { ActiveState: "active", SubState: "waiting" } }); + + const inspection = await inspectRollbackGuard(probe); + expect(inspection.kind).toBe("pending"); + + const error = await assertNoPendingRollbackGuard(probe).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(PendingRecoveryError); + expect((error as Error).message).toContain(TIMER); + expect((error as Error).message).toContain("active/waiting"); }); - test("отказ запроса к systemd не выдаётся за наличие guard", () => { - const listing = firewallSource.slice( - firewallSource.indexOf("export async function listRollbackGuardUnits"), - firewallSource.indexOf("export async function assertNoPendingRollbackGuard") + test("выполняющийся прямо сейчас откат — тоже непокой", async () => { + const probe = probeWith({ + [TIMER]: { ActiveState: "active", SubState: "running" }, + [SERVICE]: { ActiveState: "activating", SubState: "start" } + }); + await expect(assertNoPendingRollbackGuard(probe)).rejects.toBeInstanceOf(PendingRecoveryError); + }); + + /** + * Отказ запроса к systemd — ОТСУТСТВИЕ наблюдения, а не наблюдение покоя. + * + * Прежний код возвращал пустой список и тем самым принимал невозможность + * получить доказательство за положительный результат: + * + * systemd жив, старый таймер взведён + * -> systemctl временно отказывает + * -> список пуст -> барьер считает систему спокойной + * -> новая операция меняет firewall, старый таймер срабатывает поверх + */ + test("отказ запроса к systemd запрещает операцию, а не разрешает её", async () => { + const inspection = await inspectRollbackGuard(failingProbe); + expect(inspection.kind).toBe("unknown"); + + const error = await assertNoPendingRollbackGuard(failingProbe).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(GuardStateUnknownError); + expect((error as Error).message).toContain("refusing to start a lifecycle operation"); + }); + + test("отказ на одном юните тоже делает картину неполной", async () => { + const probe: SystemdUnitProbe = { + async listGuardUnits() { + return `${TIMER} loaded active waiting guard\n${SERVICE} loaded inactive dead guard`; + }, + async showProperties(unit) { + if (unit === SERVICE) { + throw new Error("Connection timed out"); + } + return { ActiveState: "inactive", SubState: "dead" }; + } + }; + + await expect(assertNoPendingRollbackGuard(probe)).rejects.toBeInstanceOf(GuardStateUnknownError); + }); + + // Обе причины отказа — про то, что операцию нельзя начинать, и вызывающий + // вправе не различать их по конкретному типу. + test("оба отказа барьера имеют общего предка", async () => { + await expect(assertNoPendingRollbackGuard(failingProbe)).rejects.toBeInstanceOf(OperationBarrierError); + await expect( + assertNoPendingRollbackGuard(probeWith({ [TIMER]: { ActiveState: "active", SubState: "waiting" } })) + ).rejects.toBeInstanceOf(OperationBarrierError); + }); + + // Отработавший guard больше ничего не сделает. Отказ по `failed` заблокировал + // бы `repair` ровно тогда, когда им чинят последствия. + test("покой — это inactive и failed", async () => { + await expect( + assertNoPendingRollbackGuard( + probeWith({ + [TIMER]: { ActiveState: "inactive", SubState: "dead" }, + [SERVICE]: { ActiveState: "failed", SubState: "failed" } + }) + ) + ).resolves.toBeUndefined(); + }); + + /** + * Политика покоя — белый список, а не чёрный. + * + * Прежняя перечисляла непокойные состояния, то есть объявляла безопасным + * любое, которого автор не назвал. systemd 257 знает `maintenance` и + * `refreshing` помимо перечисленных, и список может пополниться снова. + */ + test("незнакомое состояние systemd блокирует операцию, а не проходит молча", async () => { + for (const activeState of ["maintenance", "refreshing", "some-future-state"]) { + await expect( + assertNoPendingRollbackGuard(probeWith({ [SERVICE]: { ActiveState: activeState, SubState: "x" } })) + ).rejects.toBeInstanceOf(PendingRecoveryError); + } + }); + + /** + * Отработавший таймер не должен блокировать операцию навсегда. + * + * `TIMER_ELAPSED` в systemd отображается в `UNIT_ACTIVE` так же, как + * `TIMER_WAITING`, — различает их только SubState. У наших guard'ов такого не + * бывает (`RemainAfterElapse=no`), но инвариант «барьер не залипает» не + * должен зависеть от того, чем именно создан таймер. + */ + test("таймер в elapsed — покой, а не вечная блокировка", async () => { + await expect( + assertNoPendingRollbackGuard(probeWith({ [TIMER]: { ActiveState: "active", SubState: "elapsed" } })) + ).resolves.toBeUndefined(); + }); + + // Исключение узкое: у сервиса тот же SubState ничего не значит. + test("исключение по SubState действует только для таймера", async () => { + expect(guardUnitIsQuiescent({ unit: TIMER, activeState: "active", subState: "elapsed" })).toBe(true); + expect(guardUnitIsQuiescent({ unit: SERVICE, activeState: "active", subState: "elapsed" })).toBe(false); + expect(guardUnitIsQuiescent({ unit: TIMER, activeState: "active", subState: "waiting" })).toBe(false); + }); + + /** + * Разбор вывода `systemctl list-units`. + * + * У юнита в состоянии `failed` первой колонкой идёт маркер `●`, поэтому + * «имя юнита — первое поле строки» теряло бы именно аварийно сработавший + * guard. Ищется первое поле, начинающееся с префикса. + */ + test("имя юнита находится и при маркере failed в первой колонке", () => { + const listed = [ + `${TIMER} loaded active waiting HY2XS firewall rollback guard`, + `● ${SERVICE} loaded failed failed HY2XS firewall rollback guard`, + "", + "hysteria-server.service loaded active running Hysteria" + ].join("\n"); + + expect(parseRollbackGuardUnitNames(listed)).toEqual([TIMER, SERVICE]); + }); + + test("посторонние юниты и пустой вывод не попадают в разбор", () => { + expect(parseRollbackGuardUnitNames("")).toEqual([]); + expect(parseRollbackGuardUnitNames("nftables.service loaded active exited nftables\n")).toEqual([]); + }); + + test("описание юнита называет и состояние, и подсостояние", () => { + expect(describeGuardUnits([{ unit: TIMER, activeState: "active", subState: "waiting" }])).toBe( + `${TIMER} (active/waiting)` ); - expect(listing).toContain("unable to list firewall rollback guard units"); - expect(listing).toContain("return [];"); }); test("барьер проверяется при любом захвате замка, а не только при устаревшем", () => { diff --git a/tools/build/lib/acceptance.sh b/tools/build/lib/acceptance.sh index b5acf32..19bad65 100644 --- a/tools/build/lib/acceptance.sh +++ b/tools/build/lib/acceptance.sh @@ -380,9 +380,15 @@ run_clean_install_acceptance() { || fail "acceptance: process.ts must expose an explicit mutating runner" ! grep -rEq '(^|[^A-Za-z0-9_])(run|runVisible|runHidden|runSecret|runRawVisible)`' orchestrator/src \ || fail "acceptance: the pre-split runner names must not come back" - local unguarded_runner - unguarded_runner="$(grep -c 'assertMutationAllowed' orchestrator/src/lib/process.ts || true)" - [ "$unguarded_runner" -ge 4 ] \ + # Порог считается от числа мутирующих раннеров, а не задан константой: новый + # раннер обязан приносить с собой и проверку guard'а, а не проходить под + # запасом, оставленным предыдущей правкой. + local mutating_runners guarded_runners + mutating_runners="$(grep -c 'export async function runMutating' orchestrator/src/lib/process.ts || true)" + guarded_runners="$(grep -c 'assertMutationAllowed' orchestrator/src/lib/process.ts || true)" + [ "$mutating_runners" -ge 5 ] \ + || fail "acceptance: набор мутирующих раннеров неожиданно сократился ($mutating_runners)" + [ "$guarded_runners" -gt "$mutating_runners" ] \ || fail "acceptance: every mutating runner must ask the read-only guard for permission" log_step "Acceptance: doctor is read-only by runtime invariant, not by convention" @@ -413,7 +419,7 @@ run_clean_install_acceptance() { const source = require("node:fs").readFileSync("orchestrator/src/steps/smoke.ts", "utf8"); const calls = source.split(/\r?\n/) .filter((line) => !line.trimStart().startsWith("//")) - .filter((line) => /\brunMutating[A-Za-z]*`/.test(line)); + .filter((line) => /\brunMutating[A-Za-z]*[`(]/.test(line)); if (calls.length !== 1) { throw new Error("мутирующих вызовов в smoke: " + calls.length + "\n" + calls.join("\n")); } @@ -1421,7 +1427,7 @@ run_transaction_boundary_acceptance() { if (!body.includes(claim)) throw new Error("проверка эффективного firewall не сверяет: " + claim); } // Проверка выполняется и в doctor, то есть под read-only guard. - if (/runMutating[A-Za-z]*`/.test(body)) throw new Error("проверка эффективного firewall мутирует систему"); + if (/runMutating[A-Za-z]*[`(]/.test(body)) throw new Error("проверка эффективного firewall мутирует систему"); ' || fail "acceptance: проверка эффективного firewall нарушает свой контракт" log_step "Acceptance: firewall candidates and nftables.service state do not outlive the operation" @@ -1508,6 +1514,11 @@ run_transaction_boundary_acceptance() { || fail "acceptance: нет барьера покоя перед новой операцией жизненного цикла" grep -q 'export class PendingRecoveryError' orchestrator/src/steps/firewall.ts \ || fail "acceptance: у отказа по незавершённому восстановлению нет собственного типа" + # «Guard вооружён» и «состояние guard'а неизвестно» — разные утверждения, и + # оператору по ним нужны разные действия: подождать окно отката либо чинить + # systemd. Один тип на оба означал бы, что различие существует только в тексте. + grep -q 'export class GuardStateUnknownError' orchestrator/src/steps/firewall.ts \ + || fail "acceptance: у недоказуемого состояния guard'а нет собственного типа" "$BUN_BIN" -e ' const fs = require("node:fs"); const cli = fs.readFileSync("orchestrator/src/cli.ts", "utf8"); @@ -1535,15 +1546,69 @@ run_transaction_boundary_acceptance() { throw new Error("отказ второй проверки оставляет замок за собой"); } - // Покой — это inactive и failed: отработавший guard больше ничего не - // сделает, а отказ по failed заблокировал бы repair, которым чинят - // последствия. + // Покой перечисляется БЕЛЫМ списком. Чёрный объявлял безопасным любое + // состояние, которого автор не назвал, — включая те, которых он не знал: + // systemd 257 знает maintenance и refreshing помимо перечислявшихся. const firewall = fs.readFileSync("orchestrator/src/steps/firewall.ts", "utf8"); - if (!firewall.includes("const GUARD_PENDING_STATES = [\"active\", \"activating\", \"deactivating\", \"reloading\"] as const")) { - throw new Error("набор непокойных состояний guard изменился без пересмотра барьера"); + if (!firewall.includes("const GUARD_QUIESCENT_ACTIVE_STATES = [\"inactive\", \"failed\"] as const")) { + throw new Error("политика покоя guard изменилась без пересмотра барьера"); + } + if (firewall.includes("GUARD_PENDING_STATES")) { + throw new Error("вернулся чёрный список состояний: неизвестное состояние снова считается покоем"); + } + + // Отказ запроса к systemd — отсутствие наблюдения, а не наблюдение покоя. + // Прежний код возвращал пустой список, то есть принимал невозможность + // получить доказательство за положительный результат. + const inspectStart = firewall.indexOf("export async function inspectRollbackGuard"); + const inspectEnd = firewall.indexOf("export async function assertNoPendingRollbackGuard"); + if (inspectStart < 0 || inspectEnd < inspectStart) { + throw new Error("нет единого наблюдателя состояния guard перед барьером"); + } + const inspect = firewall.slice(inspectStart, inspectEnd); + if (inspect.includes("return [];")) { + throw new Error("барьер снова fail-open при отказе systemctl"); + } + for (const outcome of ["kind: \"unknown\"", "kind: \"pending\"", "kind: \"quiescent\""]) { + if (!inspect.includes(outcome)) throw new Error("наблюдение не различает исход: " + outcome); + } + + // Второй копии листинга быть не должно: status расходился с барьером по + // --plain, по `|| true` и по трактовке failed-юнита. + const status = fs.readFileSync("orchestrator/src/commands/status.ts", "utf8"); + if (status.includes("list-units")) { + throw new Error("status снова листит guard-юниты сам, мимо барьерного наблюдателя"); + } + if (!status.includes("await inspectRollbackGuard()")) { + throw new Error("status не берёт состояние guard у общего наблюдателя"); } ' || fail "acceptance: барьер покоя между операциями нарушен" + log_step "Acceptance: the transient guard timer states its window and its cleanup" + # `OnActiveSec=` не означает «ровно через столько»: systemd.timer разрешает + # себе сработать в окне [цель; цель + AccuracySec], а умолчание — 1min. Guard, + # про который README и docs говорят «45 секунд», без явного значения имел + # контракт «от 45 до 105». RemainAfterElapse задаётся по другой причине: от + # выгрузки отработавшего таймера зависит право барьера считать его отсутствие + # покоем, и держать этот инвариант на чужом умолчании нельзя. + "$BUN_BIN" -e ' + const { buildArmGuardArgv } = await import("./orchestrator/src/steps/firewall.ts"); + const argv = buildArmGuardArgv("2026-01-01T00-00-00.000Z"); + for (const expected of [ + "--unit=hy2xs-fw-rollback-2026-01-01T00-00-00.000Z.service", + "--on-active=45s", + "--timer-property=RemainAfterElapse=no", + "--timer-property=AccuracySec=1s" + ]) { + if (!argv.includes(expected)) throw new Error("взведение guard не задаёт " + expected); + } + // Ключ операции подставляется в имя юнита и в путь скрипта; shell в этой + // команде не участвует, поэтому единственная защита — отказ. + let rejected = false; + try { buildArmGuardArgv("op id"); } catch { rejected = true; } + if (!rejected) throw new Error("небезопасный ключ операции принят взведением guard"); + ' || fail "acceptance: контракт транзиентного таймера guard нарушен" + log_step "Acceptance: nftables.service restore claims only what it can guarantee" # `enable --runtime` не удаляет постоянную ссылку, поэтому "восстановление" # enabled-runtime таким вызовом обещало точность, которой не давало. diff --git a/tools/legacy/purge-v0.sh b/tools/legacy/purge-v0.sh index 54d53f0..5e9062e 100644 --- a/tools/legacy/purge-v0.sh +++ b/tools/legacy/purge-v0.sh @@ -111,10 +111,17 @@ require_root() { # Таймеры отката firewall переживают неудачную установку и продолжат менять # ruleset уже после очистки, если их не снять. +# +# `--plain` обязателен, а разбор идёт по ЛЮБОМУ полю строки, а не по первому. +# У юнита в состоянии `failed` systemctl печатает первой колонкой маркер `●`, +# поэтому прежний `awk '{print $1}' | grep -E '^hy2xs-fw-rollback-'` отбрасывал +# строку целиком — и purge не снимал именно аварийно сработавший guard, тот +# единственный, ради которого эта функция написана. rollback_guard_units() { - systemctl list-units --all --no-legend 'hy2xs-fw-rollback-*.timer' 'hy2xs-fw-rollback-*.service' 2>/dev/null \ - | awk '{print $1}' \ - | grep -E '^hy2xs-fw-rollback-' || true + systemctl list-units --all --plain --no-legend 'hy2xs-fw-rollback-*.timer' 'hy2xs-fw-rollback-*.service' 2>/dev/null \ + | tr -s '[:space:]' '\n' \ + | grep -E '^hy2xs-fw-rollback-.+\.(timer|service)$' \ + | sort -u || true } show_plan() { diff --git a/versions.env b/versions.env index e6d6efa..8658168 100644 --- a/versions.env +++ b/versions.env @@ -86,7 +86,11 @@ PNPM_VERSION=9.15.9 # нужно: пин версии инструмента не должен превращаться в пин знаний о мире. GOVULNCHECK_VERSION=v1.7.0 -# Порог pnpm audit для production-зависимостей frontend. +# Порог pnpm audit по ВСЕМУ lock-графу frontend, а не только по production- +# зависимостям. `--prod` здесь стоял с обоснованием «devDependencies в артефакт +# не попадают», и для frontend build tooling это обоснование неверно: vite и +# rollup формируют production-бандл, который уезжает в артефакт, поэтому +# уязвимость в них — уязвимость в том, что мы выпускаем. # Значения: low | moderate | high | critical. PNPM_AUDIT_LEVEL=high