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:
@@ -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("барьер проверяется при любом захвате замка, а не только при устаревшем", () => {
|
||||
|
||||
Reference in New Issue
Block a user