Files
HY2XS_flamy/orchestrator/test/firewall-guard.test.ts
T
founder 0230f1ca99 fix(orchestrator): не полагаться на мангление имени транзиентного юнита
systemd-run пропускает голое имя через unit_name_mangle_with_suffix, который
сначала проверяет, не заканчивается ли оно уже известным типом юнита. Ключ
операции — санитизированный ISO-timestamp вида `...T12-34-56.789Z`, то есть
содержит точку, и корректность имени зависела бы от того, что `.789Z` случайно
не совпало ни с одним типом systemd.

Имя передаётся с явным суффиксом `.service`: systemd-run берёт его как есть и
создаёт рядом одноимённый `.timer`, на который и рассчитывают снятие guard и
status.
2026-08-31 01:32:35 +05:00

401 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, test } from "bun:test";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { classifyFailure } from "../src/commands/install";
import {
FirewallGuardFiredError,
buildAutoRollbackScript,
operationKeyFor,
parseNftablesServiceState,
renderNftablesEntrypoint,
renderNftablesServiceState
} from "../src/steps/firewall";
/**
* Транзакционность staged firewall guard.
*
* Дефект, который здесь закрепляется, — второй по счёту commit ordering, и он
* тоньше первого. Первый был про то, что данные для отката уничтожались до
* фиксации успеха. Этот — про то, что сама фиксация опиралась на недоказанное
* утверждение.
*
* systemctl stop <unit>.timer <unit>.service || true
* -> "firewall rollback timer disarmed"
* -> phase=installed
*
* Между «мы думаем, что guard снят» и «guard действительно снят» не было ни
* одной проверки. Отказ остановки стирался через `|| true`, и взведённый таймер
* мог вернуть прежний firewall уже ПОСЛЕ того, как установка объявлена
* успешной и долговечно записана.
*
* Соседний дефект того же корня: guard мог сработать ВО ВРЕМЯ успешного smoke.
* Окно 45 секунд заведомо короче худшего случая smoke (retry-бюджеты дают
* заметно больше), а единственной проверкой firewall в smoke был `nft -c` —
* разбор текущего файла, каким бы он ни был. Откатившийся прежний ruleset
* проходил её зелёным.
*
* Оба закрываются одним фактом: маркером `auto-rollback-fired`, который
* rollback-скрипт создаёт первым действием. Дальше инвариант становится
* детерминированным:
*
* маркер отсутствует И юниты inactive => можно фиксировать успех
*/
function source(relativeToSrc: string): string {
return readFileSync(new URL(`../src/${relativeToSrc}`, import.meta.url), "utf8");
}
function occurrences(haystack: string, needle: string): number {
return haystack.split(needle).length - 1;
}
const firewallSource = source("steps/firewall.ts");
const smokeSource = source("steps/smoke.ts");
const OP_ID = "2026-08-30T12-34-56.789Z";
/**
* Ищет shell, которому можно доверить разбор скрипта.
*
* Кандидат принимается только после ДВУСТОРОННЕЙ проверки: он обязан принять
* заведомо корректный скрипт и отвергнуть заведомо сломанный. Односторонней
* проверки мало — `C:\Windows\system32\bash.exe` без установленного WSL
* «проваливает» любой файл одинаково, и тест на таком парсере либо всегда
* зелёный, либо всегда красный, но в обоих случаях ничего не проверяет.
*/
function findShellParser(sandbox: string): string | null {
const good = join(sandbox, "good.sh");
const bad = join(sandbox, "bad.sh");
writeFileSync(good, "true\n");
writeFileSync(bad, "if then fi else\n");
const candidates = [
Bun.which("dash"),
Bun.which("sh"),
Bun.which("bash"),
"C:\\Program Files\\Git\\usr\\bin\\bash.exe",
"E:\\Git\\usr\\bin\\bash.exe"
].filter((candidate): candidate is string => Boolean(candidate) && existsSync(candidate as string));
for (const candidate of candidates) {
const accepts = Bun.spawnSync([candidate, "-n", good], { stdout: "pipe", stderr: "pipe" });
const rejects = Bun.spawnSync([candidate, "-n", bad], { stdout: "pipe", stderr: "pipe" });
if (accepts.exitCode === 0 && rejects.exitCode !== 0) {
return candidate;
}
}
return null;
}
describe("скрипт автоматического отката firewall", () => {
const script = buildAutoRollbackScript(OP_ID);
test("маркер срабатывания создаётся ПЕРВЫМ действием", () => {
const marker = script.indexOf(`: >"$root/auto-rollback-fired"`);
const prepared = script.indexOf(`if [ ! -f "$root/prepared" ]`);
const firstRestore = script.indexOf(`restore_file "$root/nftables.conf.existed"`);
expect(marker).toBeGreaterThan(-1);
expect(prepared).toBeGreaterThan(marker);
expect(firstRestore).toBeGreaterThan(prepared);
});
// Без этого «guard сработал» недоказуемо: транзиентные юниты systemd после
// выполнения исчезают, и `systemctl stop` для них неотличим от успешного
// снятия взведённого таймера.
test("маркер создаётся даже когда восстанавливать нечего", () => {
const marker = script.indexOf(`: >"$root/auto-rollback-fired"`);
const earlyExit = script.indexOf("nothing to restore");
expect(marker).toBeLessThan(earlyExit);
});
test("ошибки не маскируются", () => {
expect(script).not.toContain("|| true");
expect(script).not.toContain("2>/dev/null");
expect(script).not.toContain(">/dev/null 2>&1");
});
// set -e прервал бы восстановление на первой отказавшей части, а задача
// guard'а — восстановить ВСЁ, что получится.
test("непрерывность обеспечивается rc, а не set -e", () => {
expect(script).not.toMatch(/^set -e/m);
expect(script).toContain("rc=0");
expect(script).toContain('exit "$rc"');
});
test("каждый сообщённый отказ поднимает код возврата", () => {
expect(occurrences(script, "rc=1")).toBe(occurrences(script, "auto-rollback: failed"));
expect(occurrences(script, "rc=1")).toBeGreaterThan(0);
});
/**
* На Debian у nftables.service `ExecStop=/usr/sbin/nft flush ruleset`.
* Остановка сервиса внутри guard'а стёрла бы только что восстановленные
* правила — то есть сделала бы ровно противоположное задаче.
*/
test("состояние nftables.service guard не трогает", () => {
expect(script).not.toContain("systemctl");
});
test("пути привязаны к каталогу этой операции", () => {
expect(script).toContain(`root='/run/hy2xs/rollback/${OP_ID}'`);
expect(buildAutoRollbackScript("2026-09-01T00-00-00.000Z")).not.toContain(OP_ID);
});
// Ключ операции служит именем каталога, именем systemd-юнита и подставляется
// в текст скрипта. Прежний однострочник держался на том, что в нём не бывает
// пробелов; теперь это проверяемое утверждение, а не допущение.
test("небезопасный ключ операции отвергается", () => {
expect(() => buildAutoRollbackScript("op id")).toThrow(/unsafe operation key/);
expect(() => buildAutoRollbackScript("op'; rm -rf /")).toThrow(/unsafe operation key/);
expect(() => buildAutoRollbackScript("")).toThrow(/unsafe operation key/);
});
test("разбирается настоящим shell-парсером", () => {
const sandbox = mkdtempSync(join(tmpdir(), "hy2xs-guard-"));
try {
const parser = findShellParser(sandbox);
if (!parser) {
// Сборка идёт на Linux, где парсер есть всегда; на машине разработчика
// его может не быть, и это не повод объявлять скрипт корректным.
console.warn("shell parser is unavailable: skipping the syntax check of the rollback script");
return;
}
const scriptPath = join(sandbox, "auto-rollback.sh");
writeFileSync(scriptPath, script);
const parsed = Bun.spawnSync([parser, "-n", scriptPath], { stdout: "pipe", stderr: "pipe" });
expect(parsed.stderr.toString().trim()).toBe("");
expect(parsed.exitCode).toBe(0);
} finally {
rmSync(sandbox, { recursive: true, force: true });
}
});
});
describe("ключ операции один на все её артефакты", () => {
// Раньше формула была записана дважды, и копии разошлись: install писал в
// маркер сырой ISO-timestamp с двоеточиями, а каталог отката назывался
// санитизированным ключом. Runbook предлагает оператору открыть
// /run/hy2xs/rollback/<op_id> — по значению из маркера такого пути не было.
test("двоеточия ISO-времени заменяются", () => {
expect(operationKeyFor("2026-08-30T12:34:56.789Z")).toBe("2026-08-30T12-34-56.789Z");
});
test("install и reconfigure берут ключ из одного места", () => {
expect(source("commands/install.ts")).toContain("opId: operationKeyFor(context.installDate)");
expect(source("commands/reconfigure.ts")).toContain("return operationKeyFor(context.installDate)");
});
});
describe("состояние nftables.service переживает откат", () => {
test("запись и разбор согласованы", () => {
const state = { unitFileState: "disabled", activeState: "inactive" };
expect(parseNftablesServiceState(renderNftablesServiceState(state))).toEqual(state);
});
test("непонятый файл не притворяется состоянием", () => {
expect(parseNftablesServiceState("")).toBeNull();
expect(parseNftablesServiceState("мусор без разделителя\n")).toBeNull();
});
test("частичное состояние разбирается, а не отбрасывается", () => {
expect(parseNftablesServiceState("unit_file_state=enabled\n")).toEqual({
unitFileState: "enabled",
activeState: ""
});
});
test("состояние снимается до первой мутации firewall", () => {
const backup = firewallSource.indexOf("async function backupFirewallState");
const capture = firewallSource.indexOf("nftablesServiceStatePath(opId)", backup);
const prepared = firewallSource.indexOf("touch ${rollbackMarker(opId)}", backup);
expect(capture).toBeGreaterThan(backup);
expect(prepared).toBeGreaterThan(capture);
});
/**
* Порядок стадий отката. `systemctl stop nftables` выполняет
* `nft flush ruleset`, поэтому восстановление состояния сервиса обязано идти
* ДО применения ruleset — иначе остановка стёрла бы восстановленные правила.
*/
test("состояние сервиса восстанавливается до применения ruleset", () => {
const body = firewallSource.slice(firewallSource.indexOf("export async function rollbackFirewallNow"));
const unitFile = body.indexOf('name: "restore nftables.service unit file state"');
const inactive = body.indexOf('name: "restore nftables.service inactive state"');
const ruleset = body.indexOf('name: "apply restored ruleset"');
expect(unitFile).toBeGreaterThan(-1);
expect(inactive).toBeGreaterThan(unitFile);
expect(ruleset).toBeGreaterThan(inactive);
});
});
describe("disarm доказывает снятие guard'а, а не сообщает о нём", () => {
const body = firewallSource.slice(
firewallSource.indexOf("async function stopRollbackGuard"),
firewallSource.indexOf("export async function disarmFirewallRollback")
);
test("остановка guard'а больше не глушит свой код возврата", () => {
expect(body).not.toContain("|| true");
expect(body).toContain("runMutatingStatus`systemctl stop");
});
test("маркер срабатывания проверяется до и после остановки", () => {
const before = body.indexOf("before stopping the rollback guard");
const stop = body.indexOf("runMutatingStatus`systemctl stop");
const after = body.indexOf("after stopping the rollback guard");
expect(before).toBeGreaterThan(-1);
expect(stop).toBeGreaterThan(before);
expect(after).toBeGreaterThan(stop);
});
test("снятие подтверждается наблюдаемым состоянием юнитов", () => {
expect(body).toContain('readUnitProperty(target, "ActiveState")');
expect(body).toContain("`${unit}.timer`");
expect(body).toContain("`${unit}.service`");
});
// На пути фиксации успеха допустимо ровно одно состояние. `failed` там
// означает сработавший и упавший guard, то есть запрет на фиксацию.
test("на пути фиксации допустим только inactive", () => {
expect(firewallSource).toContain('const GUARD_STOPPED_STATES_FOR_COMMIT = ["inactive"] as const');
expect(firewallSource).toContain(
'const GUARD_STOPPED_STATES_FOR_RECOVERY = ["inactive", "failed"] as const'
);
});
/**
* Имя транзиентного юнита передаётся с явным суффиксом. Голое имя systemd-run
* пропускает через мангling, который смотрит, не заканчивается ли оно уже
* известным типом юнита; ключ операции содержит точку (`...T12-34-56.789Z`),
* и корректность зависела бы от того, что `.789Z` ни с чем не совпало.
*/
test("имя юнита guard'а не зависит от мангления systemd", () => {
expect(firewallSource).toContain("systemd-run --unit ${`${unit}.service`}");
});
test("остановка guard'а стала стадией отката с отчётом", () => {
const rollback = firewallSource.slice(firewallSource.indexOf("export async function rollbackFirewallNow"));
expect(rollback).toContain('name: "stop firewall rollback guard"');
expect(rollback).toContain("assertNotFired: false");
});
});
describe("сработавший guard запрещает фиксацию успеха", () => {
function ownership(overrides: Record<string, boolean> = {}) {
return {
stateTouched: true,
bootstrapTouched: true,
depsTouched: true,
filesystemTouched: true,
uiTouched: true,
hysteriaTouched: true,
configTouched: true,
unitsTouched: true,
firewallTouched: true,
postInstallTouched: true,
bootstrapSecretTouched: true,
servicesStarted: true,
...overrides
};
}
test("install классифицирует его отдельной причиной отказа", () => {
expect(classifyFailure(ownership(), "smoke_ok", new FirewallGuardFiredError("guard"))).toBe(
"firewall_guard_fired"
);
});
// Классификация по ТИПУ, а не по тексту: ошибка с теми же словами, но другим
// типом обязана классифицироваться по владению, как и прежде.
test("похожий текст чужой ошибки на классификацию не влияет", () => {
expect(
classifyFailure(ownership(), "smoke_ok", new Error("automatic firewall rollback has already fired"))
).toBe("service_start_failure");
});
test("маркер отказа получает собственную фазу", () => {
const installSource = source("commands/install.ts");
expect(installSource).toContain('? "firewall_guard_fired"');
expect(source("commands/reconfigure.ts")).toContain('return "firewall_guard_fired"');
});
});
describe("эффективный firewall проверяется семантически", () => {
test("smoke сверяет firewall, а не только разбирает файл", () => {
const syntax = smokeSource.indexOf("nft -c -f /etc/nftables.conf");
const semantic = smokeSource.indexOf("assertEffectiveFirewallIsOurs(context)");
expect(syntax).toBeGreaterThan(-1);
expect(semantic).toBeGreaterThan(syntax);
});
test("сверяется отрендеренный фрагмент, entrypoint и загруженная таблица", () => {
const body = firewallSource.slice(
firewallSource.indexOf("export async function assertEffectiveFirewallIsOurs"),
firewallSource.indexOf("function firewallRollbackIsInactive")
);
expect(body).toContain("await renderHy2xsNft(context)");
expect(body).toContain("effective !== expected");
expect(body).toContain('entrypointKind !== "hy2xs-managed"');
expect(body).toContain("nft list table inet hy2xs");
});
// Проверка обязана оставаться наблюдением: она выполняется и в doctor,
// который целиком идёт под read-only guard.
test("проверка выполняется read-only раннером", () => {
const body = firewallSource.slice(
firewallSource.indexOf("export async function assertEffectiveFirewallIsOurs"),
firewallSource.indexOf("function firewallRollbackIsInactive")
);
expect(body).not.toMatch(/runMutating[A-Za-z]*`/);
});
test("рендер фрагмента объявлен один раз и переиспользуется", () => {
expect(occurrences(firewallSource, "templates/nftables/hy2xs.nft.tpl")).toBe(1);
});
});
describe("candidate-файлы не переживают операцию", () => {
test("успешный apply убирает оба", () => {
expect(firewallSource).toContain(
"runMutatingVisible`rm -f ${HY2XS_NFT_CANDIDATE} ${NFTABLES_ENTRYPOINT_CANDIDATE}`"
);
const apply = firewallSource.slice(
firewallSource.indexOf("export async function applyFirewall"),
firewallSource.indexOf("export async function assertEffectiveFirewallIsOurs")
);
expect(apply).toContain("await cleanupFirewallCandidates()");
});
test("откат убирает их best-effort и не срывается на этом", () => {
const rollback = firewallSource.slice(firewallSource.indexOf("export async function rollbackFirewallNow"));
const cleanup = rollback.indexOf("await cleanupFirewallCandidates()");
const guarded = rollback.indexOf("catch (candidateError)");
expect(cleanup).toBeGreaterThan(-1);
expect(guarded).toBeGreaterThan(cleanup);
});
});
describe("entrypoint nftables", () => {
test("объявляет владение HY2XS и подключает переданный фрагмент", () => {
const rendered = renderNftablesEntrypoint("/etc/nftables.d/hy2xs.nft");
expect(rendered).toContain("HY2XS-MANAGED");
expect(rendered).toContain("flush ruleset");
expect(rendered).toContain('include "/etc/nftables.d/hy2xs.nft"');
});
test("candidate и production entrypoint строятся одной функцией", () => {
expect(occurrences(firewallSource, "HY2XS-MANAGED: root nftables entrypoint")).toBe(1);
});
});