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:
@@ -90,6 +90,14 @@ IPv4-only policy:
|
||||
3. Подставить candidate в production-пути.
|
||||
4. Взвести rollback guard: транзиентный юнит `hy2xs-fw-rollback-<op-id>`
|
||||
с окном 45 секунд.
|
||||
```bash
|
||||
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
|
||||
```
|
||||
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` разбирает текущий файл, каким бы он ни был, и
|
||||
|
||||
Reference in New Issue
Block a user