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
+34 -5
View File
@@ -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<void> {
}
}
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<void> {
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<void> {
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)}`);
+72 -2
View File
@@ -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<string> {
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<string> {
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<void> {
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<void> {
assertMutationAllowed("runMutatingRaw(...)");
+332 -48
View File
@@ -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<string> {
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<string>;
/** Значения свойств юнита; о непрочитанном свойстве возвращается пустая строка. */
showProperties(unit: string, properties: readonly string[]): Promise<Record<string, string>>;
};
export const defaultSystemdUnitProbe: SystemdUnitProbe = {
async listGuardUnits(): Promise<string> {
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<Record<string, string>> {
// `--value` здесь не используется: при нескольких свойствах он печатает
// значения без имён, и разбор начинает зависеть от порядка вывода. Формат
// `Свойство=значение` самоописателен.
const raw = await runReadOnlyArgv([
"systemctl",
"show",
...properties.map((property) => `--property=${property}`),
unit
]);
const values: Record<string, string> = {};
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<string> {
return ((await probe.showProperties(unit, [property]))[property] ?? "").trim();
}
/**
@@ -506,23 +665,49 @@ async function cleanupFirewallCandidates(): Promise<void> {
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<void> {
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<string[]> {
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<string>();
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<GuardInspection> {
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<string, string>;
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<string[]> {
* он лишь повторяет то, что новая операция сделает сама, а отказ по их
* переходным состояниям заблокировал бы `repair` ровно тогда, когда он нужен.
*/
export async function assertNoPendingRollbackGuard(): Promise<void> {
const pending: string[] = [];
export async function assertNoPendingRollbackGuard(
probe: SystemdUnitProbe = defaultSystemdUnitProbe
): Promise<void> {
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-*'`."
);
}
+241 -21
View File
@@ -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<string, { ActiveState: string; SubState: string }>): 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<string> {
throw new Error("Failed to connect to bus: No such file or directory");
},
async showProperties(): Promise<Record<string, string>> {
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("барьер проверяется при любом захвате замка, а не только при устаревшем", () => {