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
+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("барьер проверяется при любом захвате замка, а не только при устаревшем", () => {