From 4e7f54b9ffda898e92466d9263745aec304464a1 Mon Sep 17 00:00:00 2001 From: Crimson Date: Sun, 30 Aug 2026 22:54:57 +0500 Subject: [PATCH] =?UTF-8?q?fix(orchestrator):=20=D1=81=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20staged=20firewall=20guard=20=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B7=D0=B0=D0=BA=D1=86=D0=B8=D0=BE=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Снятие автоматического отката firewall было утверждением, а не фактом: systemctl stop .timer .service || true -> "firewall rollback timer disarmed" -> phase=installed Отказ остановки стирался через `|| true`, и взведённый таймер мог вернуть прежний firewall уже ПОСЛЕ долговечной записи успеха. Просто убрать `|| true` нельзя: для транзиентного юнита, уже убранного systemd, `systemctl stop` возвращает 5 — законный исход, неотличимый от успеха. Соседний дефект того же корня: guard мог сработать ВО ВРЕМЯ успешного smoke. Окно 45 секунд короче худшего случая smoke, а единственной проверкой firewall был `nft -c` — разбор текущего файла, каким бы он ни был. Откатившийся прежний ruleset проходил её зелёным, и сервер объявлялся настроенным с firewall, который операция же и заменила. Оба закрываются маркером /run/hy2xs/rollback//auto-rollback-fired, который rollback-скрипт создаёт первым действием. Инвариант стал детерминированным: маркер отсутствует И timer/service inactive => можно фиксировать успех Остальное в том же проходе: - auto-rollback переехал из однострочного `sh -c` в сгенерированный скрипт. Прежний держался на склейке соседних кавычек и на том, что op-id не содержит пробелов; теперь ключ проверяется, а скрипт покрыт тестом и shell-парсером; - скрипт накапливает rc и уходит в failed вместо молчаливого 0 при частичном восстановлении. Состояние nftables.service он сознательно не трогает: ExecStop у него делает `nft flush ruleset`; - smoke сверяет ЭФФЕКТИВНЫЙ firewall: фрагмент на диске против отрендеренного, принадлежность entrypoint и загруженность таблицы inet hy2xs; - откат восстанавливает enabled/active nftables.service — стадиями, идущими до применения ruleset; - остановка guard'а в rollbackFirewallNow стала стадией с отчётом, а не вызовом с `|| true` внутри; - `*.candidate` больше не остаются на диске навсегда; - стадии восстановления reconfigure независимы по ОТДЕЛЬНОМУ ФАЙЛУ, а не по группе; - ключ операции считается одной функцией: install писал в маркер сырой ISO-timestamp, и путь /run/hy2xs/rollback/ из runbook не существовал. --- orchestrator/src/commands/install.ts | 50 +- orchestrator/src/commands/reconfigure.ts | 52 +- orchestrator/src/lib/process.ts | 47 +- orchestrator/src/steps/firewall.ts | 632 +++++++++++++++++-- orchestrator/src/steps/smoke.ts | 11 + orchestrator/test/commit-ordering.test.ts | 2 +- orchestrator/test/firewall-guard.test.ts | 390 ++++++++++++ orchestrator/test/install-sequence.test.ts | 22 +- orchestrator/test/rollback-mandatory.test.ts | 8 +- 9 files changed, 1127 insertions(+), 87 deletions(-) create mode 100644 orchestrator/test/firewall-guard.test.ts diff --git a/orchestrator/src/commands/install.ts b/orchestrator/src/commands/install.ts index 716e2a0..2d7209f 100644 --- a/orchestrator/src/commands/install.ts +++ b/orchestrator/src/commands/install.ts @@ -22,9 +22,11 @@ import { installHysteria } from "../steps/hysteria"; import { generateConfig } from "../steps/config"; import { deploySystemd } from "../steps/systemd"; import { + FirewallGuardFiredError, applyFirewall, cleanupFirewallRollback, disarmFirewallRollback, + operationKeyFor, rollbackFirewallNow } from "../steps/firewall"; import { ensureBootstrapAdminSecret, writePostInstallEnv } from "../steps/env"; @@ -49,6 +51,11 @@ type InstallPhase = | "smoke_running" | "smoke_ok" | "smoke_failed" + // Автоматический откат firewall сработал раньше, чем операция успела снять + // guard. Отдельная фаза, потому что оператору важно другое действие: сервер + // работает на ПРЕЖНЕМ firewall, и повторять установку нужно, разобравшись, + // почему проход не уложился в окно guard'а. + | "firewall_guard_fired" | "failed" | "installed"; @@ -100,6 +107,7 @@ type FailureKind = | "fatal_pre_apply" | "fatal_post_apply" | "firewall_connectivity_failure" + | "firewall_guard_fired" | "service_start_failure" | "smoke_readiness_timeout" | "postinstall_validation_failure"; @@ -150,7 +158,11 @@ async function writeInstallState( const record = buildInstallStateRecord({ productVersion: context.packageVersion, buildId: context.packageBuildId, - opId: context.installDate, + // Тот же ключ, что и у каталога отката /run/hy2xs/rollback/ и у + // юнита guard'а. Раньше здесь стоял сырой ISO-timestamp с двоеточиями, + // поэтому путь, который runbook предлагает открыть по op_id из маркера, + // на сервере не существовал. + opId: operationKeyFor(context.installDate), startedAt: context.installDate, phase, installed: phase === "installed", @@ -184,8 +196,26 @@ async function advanceInstallState( * * Порядок веток — от самой поздней стадии к самой ранней: она точнее * описывает, что именно чинить. + * + * Единственное исключение из «классификация по владению» — сработавший + * автоматический откат firewall, и оно опирается на ТИП ошибки, а не на её + * текст. Разница принципиальная: разбор сообщения регулярным выражением уже + * убирали и из install, и из reconfigure, потому что слово «firewall» в + * сообщении о недоступном DNS уводило классификацию не туда. Тип же + * производится ровно одним местом — проверкой маркера `auto-rollback-fired`. + * + * По владению этот отказ неотличим от обычного: флаги те же, фаза `smoke_ok`. + * Но чинить нужно другое — сервер уже работает на прежнем firewall, — поэтому + * и называться он обязан иначе. */ -export function classifyFailure(ownership: OperationOwnership, phase: InstallPhase): FailureKind { +export function classifyFailure( + ownership: OperationOwnership, + phase: InstallPhase, + error?: unknown +): FailureKind { + if (error instanceof FirewallGuardFiredError) { + return "firewall_guard_fired"; + } if (ownership.servicesStarted) { return phase === "smoke_running" || phase === "smoke_failed" ? "smoke_readiness_timeout" @@ -443,7 +473,7 @@ export async function install(options: InstallOptions): Promise { } } catch (error) { const message = error instanceof Error ? error.message : String(error); - const failureKind = classifyFailure(ownership, phase); + const failureKind = classifyFailure(ownership, phase, error); if (failureKind === "fatal_pre_apply") { // Хост не изменён: не пишем install-state и не собираем bundle в @@ -460,13 +490,15 @@ export async function install(options: InstallOptions): Promise { // дело не доходило вовсе — то есть применённый firewall и развёрнутые // сервисы оставались на сервере ровно в том сценарии, ради которого откат и // существует. Это тот же класс, что и с диагностикой ниже. + const failurePhase: InstallPhase = + failureKind === "smoke_readiness_timeout" + ? "smoke_failed" + : failureKind === "firewall_guard_fired" + ? "firewall_guard_fired" + : "failed"; + await persistFailureState(() => - advanceInstallState( - context, - ownership, - failureKind === "smoke_readiness_timeout" ? "smoke_failed" : "failed", - `${failureKind}: ${message}` - ) + advanceInstallState(context, ownership, failurePhase, `${failureKind}: ${message}`) ); // Диагностика — best effort, откат — обязателен. diff --git a/orchestrator/src/commands/reconfigure.ts b/orchestrator/src/commands/reconfigure.ts index c06998d..c7a719a 100644 --- a/orchestrator/src/commands/reconfigure.ts +++ b/orchestrator/src/commands/reconfigure.ts @@ -15,9 +15,11 @@ import { preflight } from "../steps/preflight"; import { generateConfig } from "../steps/config"; import { deploySystemd } from "../steps/systemd"; import { + FirewallGuardFiredError, applyFirewall, cleanupFirewallRollback, disarmFirewallRollback, + operationKeyFor, rollbackFirewallNow } from "../steps/firewall"; import { @@ -47,6 +49,7 @@ type ReconfigurePhase = | "smoke_ok" | "reconfigure_failed" | "firewall_connectivity_failure" + | "firewall_guard_fired" | "smoke_failed" | "installed"; @@ -81,8 +84,20 @@ function newReconfigureOwnership(): ReconfigureOwnership { }; } -/** Порядок веток — от самой поздней стадии к самой ранней. */ -export function classifyReconfigureFailure(ownership: ReconfigureOwnership): ReconfigurePhase { +/** + * Порядок веток — от самой поздней стадии к самой ранней. + * + * Сработавший автоматический откат firewall классифицируется по ТИПУ ошибки, а + * не по её тексту: по владению он неотличим от обычного отказа smoke, но чинить + * надо другое — сервер уже вернулся на прежний firewall. + */ +export function classifyReconfigureFailure( + ownership: ReconfigureOwnership, + error?: unknown +): ReconfigurePhase { + if (error instanceof FirewallGuardFiredError) { + return "firewall_guard_fired"; + } if (ownership.servicesRestarted) { return "smoke_failed"; } @@ -93,11 +108,13 @@ export function classifyReconfigureFailure(ownership: ReconfigureOwnership): Rec } /** - * Ключ операции. Санитизируется так же, как в steps/firewall.ts: он служит - * ИМЕНЕМ КАТАЛОГА резервной копии, а не только полем в маркере. + * Ключ операции. Санитизация живёт в steps/firewall.ts одной функцией: ключ + * служит именем каталога резервной копии, именем каталога в /run, именем + * systemd-юнита и полем `op_id` в маркере установки. Пока копий формулы было + * две, они успели разойтись — install писал в маркер сырой ISO-timestamp. */ function operationKey(context: ReconfigureContext): string { - return context.installDate.replace(/[^a-zA-Z0-9_.-]/g, "-"); + return operationKeyFor(context.installDate); } function ownedPaths(context: ReconfigureContext): string[] { @@ -218,16 +235,23 @@ async function readBackupManifest(opId: string): Promise { * отчитывался успешным, что бы ни произошло. * * Непрерывность обеспечивает стадийный раннер, а не подавление ошибок. + * + * Независимость доведена до ОТДЕЛЬНОГО ФАЙЛА. Раньше стадия соответствовала + * группе, и группа «restore systemd units» восстанавливала два юнита одним + * телом: отказ `cp` для hy2xs-admin.service отменял восстановление + * hysteria-server.service. Внешне это выглядело честно — стадия честно попадала + * в список отказавших, — но принцип «восстановить максимум» на уровне файлов не + * выполнялся. Группы остались как порядок восстановления и как префикс имени. */ function restoreStages(manifest: BackupManifest, opId: string): RollbackStage[] { const byPath = new Map(manifest.entries.map((entry) => [entry.path, entry])); const stages: RollbackStage[] = []; for (const group of BACKUP_GROUPS) { - stages.push({ - name: group.stage, - run: async () => { - for (const path of group.paths) { + for (const path of group.paths) { + stages.push({ + name: `${group.stage}: ${path}`, + run: async () => { const entry = byPath.get(path); if (!entry) { throw new Error(`манифест резервной копии не описывает ${path}`); @@ -235,12 +259,12 @@ function restoreStages(manifest: BackupManifest, opId: string): RollbackStage[] if (!entry.present) { // Файла не существовало до операции: восстановление — удаление. await runMutatingVisible`rm -f ${path}`; - continue; + return; } await runMutatingVisible`cp -a ${backupFilePath(opId, entry.stored as string)} ${path}`; } - } - }); + }); + } } const entrypoint = byPath.get("/etc/nftables.conf"); @@ -463,7 +487,9 @@ export async function reconfigure(options: ReconfigureOptions): Promise { // стояла обычным await, её отказ уносил управление наружу мимо обоих // восстановлений — и снятия firewall, и возврата конфигов из // /etc/hy2xs/backups. - await persistFailureState(() => markPhase(context, classifyReconfigureFailure(ownership), message)); + await persistFailureState(() => + markPhase(context, classifyReconfigureFailure(ownership, error), message) + ); // Диагностика — best effort, откат — обязателен. // diff --git a/orchestrator/src/lib/process.ts b/orchestrator/src/lib/process.ts index 73683ef..d713e1d 100644 --- a/orchestrator/src/lib/process.ts +++ b/orchestrator/src/lib/process.ts @@ -15,7 +15,8 @@ * runReadOnly / runReadOnlySecret * наблюдение за системой. Guard не трогает — они разрешены в любой фазе. * - * runMutating / runMutatingVisible / runMutatingHidden / runMutatingRaw + * runMutating / runMutatingVisible / runMutatingHidden / runMutatingRaw / + * runMutatingStatus * всё, что может изменить хост. Каждый спрашивает разрешения у guard'а. * * Выбор набора — сознательное решение на месте вызова, а не умолчание. @@ -115,6 +116,50 @@ export async function runMutatingRaw(command: string): Promise { } } +export type MutationResult = { + exitCode: number; + stdout: string; + stderr: string; +}; + +/** + * Мутация, которая ВОЗВРАЩАЕТ код возврата вместо броска. + * + * Существует ровно ради одного класса команд: тех, у которых ненулевой код — + * законный исход, а не отказ. Канонический пример — остановка транзиентного + * юнита: `systemctl stop hy2xs-fw-rollback-.timer` возвращает 5 («unit not + * loaded»), если таймер уже отработал и был убран systemd, и это ровно то же + * наблюдаемое состояние, что и успешная остановка. + * + * Раньше такие места писались как `systemctl stop ... || true`. Разница + * принципиальная: `|| true` стирает ВСЕ коды возврата, включая настоящий отказ, + * и вызывающий не может отличить «юнита уже нет» от «остановить не удалось». + * Именно из-за этого снятие rollback guard рапортовало об успехе, ничего не + * сняв. + * + * Контракт раннера: он ничего не решает сам. Решение — «этот код возврата + * допустим» или «состояние надо доказать отдельно» — принимает вызывающий, + * на месте, где известен смысл команды. + */ +export async function runMutatingStatus( + command: TemplateStringsArray, + ...args: unknown[] +): Promise { + const rendered = renderCommand(command, args); + assertMutationAllowed(`runMutatingStatus(${rendered})`); + info(`running: ${rendered}`); + const process = Bun.spawn(["sh", "-c", rendered], { + stdout: "pipe", + stderr: "pipe" + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(process.stdout).text(), + new Response(process.stderr).text(), + process.exited + ]); + return { exitCode, stdout, stderr }; +} + /** Мутация без эха команды: аргументы могут содержать секреты. */ export async function runMutatingHidden(command: TemplateStringsArray, ...args: unknown[]): Promise { const rendered = renderCommand(command, args); diff --git a/orchestrator/src/steps/firewall.ts b/orchestrator/src/steps/firewall.ts index de02e8a..f892581 100644 --- a/orchestrator/src/steps/firewall.ts +++ b/orchestrator/src/steps/firewall.ts @@ -1,7 +1,7 @@ import type { RuntimeContext } from "../types/context"; import { fileExists, readText, renderTemplate, writeText } from "../lib/fs"; import { fail, info } from "../lib/log"; -import { runMutatingVisible } from "../lib/process"; +import { runMutatingStatus, runMutatingVisible, runReadOnly } from "../lib/process"; import { runRollbackStages, type RollbackStage } from "../lib/rollback"; type NftEntrypointKind = @@ -14,6 +14,63 @@ type NftEntrypointKind = export type FirewallEntrypointKind = NftEntrypointKind; +export const NFTABLES_ENTRYPOINT_PATH = "/etc/nftables.conf"; +export const HY2XS_NFT_PATH = "/etc/nftables.d/hy2xs.nft"; +const NFTABLES_ENTRYPOINT_CANDIDATE = `${NFTABLES_ENTRYPOINT_PATH}.candidate`; +const HY2XS_NFT_CANDIDATE = `${HY2XS_NFT_PATH}.candidate`; + +/** + * Окно, в течение которого автоматический откат firewall остаётся взведённым. + * + * Значение НЕ является таймаутом smoke и не обязано его покрывать. Наоборот: + * smoke заведомо может идти дольше, и это учтено маркером `auto-rollback-fired` + * — сработавший guard запрещает фиксацию успеха, каким бы зелёным ни оказался + * smoke. Увеличение окна лечило бы гонку расширением, а не устранением. + */ +const FIREWALL_ROLLBACK_DEADLINE = "45s"; + +/** + * Маркер факта: автоматический откат firewall НАЧАЛ выполняться. + * + * Ключевое слово — «начал». Файл создаётся первым действием rollback-скрипта, + * до любой проверки и до первой попытки восстановления, поэтому его наличие + * означает «правила этой операции больше нельзя считать действующими», + * независимо от того, чем скрипт закончился. + * + * Без этого маркера у операции не было способа отличить «guard снят» от «guard + * успел сработать»: транзиентные юниты systemd после выполнения исчезают, и + * `systemctl stop` для них возвращает такой же результат, как для успешно + * остановленного таймера. + */ +const AUTO_ROLLBACK_FIRED_MARKER = "auto-rollback-fired"; + +/** + * Состояния юнита, допустимые после остановки guard'а. + * + * На пути фиксации успеха допустимо ровно одно: `inactive`. Всё остальное — + * `active`, `activating`, `failed` — означает, что автоматический откат либо всё + * ещё может сработать, либо уже сработал, и фиксировать успех нельзя. + * + * На пути восстановления `failed` тоже допустим: там сработавший и упавший guard + * — ожидаемая часть картины, а не причина объявить откат несостоявшимся. + */ +const GUARD_STOPPED_STATES_FOR_COMMIT = ["inactive"] as const; +const GUARD_STOPPED_STATES_FOR_RECOVERY = ["inactive", "failed"] as const; + +/** + * Автоматический откат firewall уже сработал. + * + * Отдельный тип, а не текст ошибки: классификация отказа обязана опираться на + * тип, а не на разбор сообщения — ровно по той причине, по которой из install и + * reconfigure убрали regexp'ы по тексту ошибки. + */ +export class FirewallGuardFiredError extends Error { + constructor(message: string) { + super(message); + this.name = "FirewallGuardFiredError"; + } +} + function rollbackRoot(opId: string): string { return `/run/hy2xs/rollback/${opId}`; } @@ -22,14 +79,44 @@ function rollbackUnit(opId: string): string { return `hy2xs-fw-rollback-${opId}`; } +/** + * Ключ операции. + * + * Санитизация здесь не косметическая: значение служит ИМЕНЕМ каталога в /run, + * ИМЕНЕМ systemd-юнита и подставляется в текст rollback-скрипта. Класс символов + * сознательно узкий, и `assertSafeOperationKey` превращает это из допущения в + * проверяемое утверждение. + */ +export function operationKeyFor(installDate: string): string { + return installDate.replace(/[^a-zA-Z0-9_.-]/g, "-"); +} + function operationKey(context: RuntimeContext): string { - return context.installDate.replace(/[^a-zA-Z0-9_.-]/g, "-"); + return operationKeyFor(context.installDate); +} + +function assertSafeOperationKey(opId: string): void { + if (!/^[a-zA-Z0-9_.-]+$/.test(opId)) { + fail(`unsafe operation key for the firewall rollback guard: ${JSON.stringify(opId)}`); + } } function rollbackMarker(opId: string): string { return `${rollbackRoot(opId)}/prepared`; } +function autoRollbackFiredMarker(opId: string): string { + return `${rollbackRoot(opId)}/${AUTO_ROLLBACK_FIRED_MARKER}`; +} + +function autoRollbackScriptPath(opId: string): string { + return `${rollbackRoot(opId)}/auto-rollback.sh`; +} + +function nftablesServiceStatePath(opId: string): string { + return `${rollbackRoot(opId)}/nftables.service.state`; +} + function rollbackBackup(path: string, opId: string): string { return `${rollbackRoot(opId)}/${path}`; } @@ -49,10 +136,50 @@ async function cleanupFirewallBackupFiles(opId: string): Promise { * именно то, что нужно» — разные утверждения, и rollback опирается на второе. */ const FIREWALL_BACKUP_TARGETS = [ - { path: "/etc/nftables.conf", backup: "nftables.conf.bak", marker: "nftables.conf.existed" }, - { path: "/etc/nftables.d/hy2xs.nft", backup: "hy2xs.nft.bak", marker: "hy2xs.nft.existed" } + { path: NFTABLES_ENTRYPOINT_PATH, backup: "nftables.conf.bak", marker: "nftables.conf.existed" }, + { path: HY2XS_NFT_PATH, backup: "hy2xs.nft.bak", marker: "hy2xs.nft.existed" } ] as const; +/** Состояние юнита nftables.service на момент снятия резервной копии. */ +export type NftablesServiceState = { + /** `enabled` | `disabled` | `masked` | `static` | ... — вывод `systemctl show UnitFileState`. */ + unitFileState: string; + /** `active` | `inactive` | `failed` | ... — вывод `systemctl show ActiveState`. */ + activeState: string; +}; + +export function renderNftablesServiceState(state: NftablesServiceState): string { + return `unit_file_state=${state.unitFileState}\nactive_state=${state.activeState}\n`; +} + +/** + * Разбор терпимый, и это осознанно: файл состояния — вспомогательные метаданные. + * Непонятое значение приводит к пропуску стадии восстановления с записью в + * журнал, а не к отказу от восстановления firewall, ради которого всё и + * затевалось. + */ +export function parseNftablesServiceState(raw: string): NftablesServiceState | null { + const values = new Map(); + for (const line of raw.split(/\r?\n/)) { + const separator = line.indexOf("="); + if (separator <= 0) { + continue; + } + values.set(line.slice(0, separator).trim(), line.slice(separator + 1).trim()); + } + + const unitFileState = values.get("unit_file_state") ?? ""; + const activeState = values.get("active_state") ?? ""; + if (!unitFileState && !activeState) { + return null; + } + return { unitFileState, activeState }; +} + +async function readUnitProperty(unit: string, property: string): Promise { + return (await runReadOnly`systemctl show --property=${property} --value ${unit}`).trim(); +} + /** * Снимает резервные копии ДО первой мутации firewall — и доказывает, что снял. * @@ -67,10 +194,21 @@ const FIREWALL_BACKUP_TARGETS = [ * * Маркер `prepared` теперь ставится ПОСЛЕ проверенных копий, а не до них: он * означает «данные для отката существуют», и раньше это было неправдой. + * + * Вместе с файлами сохраняется состояние юнита nftables.service. Восстановление + * одних только файлов оставляло на хосте системную мутацию: `applyFirewall` + * выполняет `systemctl enable --now nftables`, и после отката неудачной ПЕРВОЙ + * установки сервис оставался включённым в автозапуск, хотя до установки был + * выключен. */ async function backupFirewallState(opId: string): Promise { + assertSafeOperationKey(opId); await ensureRollbackRoot(opId); + // Маркер срабатывания принадлежит ЭТОЙ операции. Оставшийся от предыдущей он + // запретил бы фиксацию успеха на ровном месте. + await runMutatingVisible`rm -f ${autoRollbackFiredMarker(opId)}`; + for (const target of FIREWALL_BACKUP_TARGETS) { const backupPath = rollbackBackup(target.backup, opId); const markerPath = rollbackBackup(target.marker, opId); @@ -93,9 +231,27 @@ async function backupFirewallState(opId: string): Promise { await runMutatingVisible`printf 1 > ${markerPath}`; } + const serviceState: NftablesServiceState = { + unitFileState: await readUnitProperty("nftables.service", "UnitFileState"), + activeState: await readUnitProperty("nftables.service", "ActiveState") + }; + await writeText(nftablesServiceStatePath(opId), renderNftablesServiceState(serviceState), 0o600); + info( + `nftables.service state before the operation: unit_file_state=${serviceState.unitFileState || "(empty)"}, ` + + `active_state=${serviceState.activeState || "(empty)"}` + ); + await runMutatingVisible`touch ${rollbackMarker(opId)}`; } +async function readNftablesServiceState(opId: string): Promise { + const path = nftablesServiceStatePath(opId); + if (!(await fileExists(path))) { + return null; + } + return parseNftablesServiceState(await readText(path)); +} + function stripNftComments(content: string): string { return content .split(/\r?\n/) @@ -135,10 +291,166 @@ function classifyNftEntrypoint(content: string): NftEntrypointKind { } export async function detectFirewallEntrypointKind(): Promise { - if (!(await fileExists("/etc/nftables.conf"))) { + if (!(await fileExists(NFTABLES_ENTRYPOINT_PATH))) { return "missing"; } - return classifyNftEntrypoint(await readText("/etc/nftables.conf")); + return classifyNftEntrypoint(await readText(NFTABLES_ENTRYPOINT_PATH)); +} + +/** + * Скрипт автоматического отката firewall. + * + * Функция чистая и экспортируется намеренно: раньше этот скрипт существовал + * только как однострочный литерал внутри `systemd-run ... /bin/sh -c '...'` с + * интерполяциями. Интерполяции проходили через shell-квотирование и + * подставлялись ВНУТРЬ уже закавыченной строки, поэтому корректность держалась + * на склейке соседних кавычек и на том, что op-id не содержит пробелов. Такой + * код нельзя ни прочитать, ни проверить парсером, ни покрыть тестом. + * + * Два свойства, ради которых он переписан. + * + * 1. Маркер `auto-rollback-fired` создаётся ПЕРВЫМ действием — до проверки + * `prepared` и до первой попытки восстановления. Иначе «guard сработал» было + * бы недоказуемо: транзиентные юниты systemd после выполнения исчезают. + * + * 2. Ошибки не маскируются, но и не прерывают восстановление. Было: + * + * cp ... || true; cp ... || true; nft -f ... || true + * + * то есть при частичном восстановлении юнит завершался кодом 0, и в journal + * оставалась успешная запись. Теперь каждая стадия независима, её отказ + * поднимает `rc`, и юнит честно уходит в `failed` с диагностикой в journal. + * + * Состояние nftables.service скрипт СОЗНАТЕЛЬНО не восстанавливает: на Debian у + * этого юнита `ExecStop=/usr/sbin/nft flush ruleset`, то есть остановка сервиса + * стёрла бы только что восстановленные правила — прямо противоположно задаче + * guard'а. Enable/active восстанавливает обычный откат в процессе оркестратора, + * где порядок стадий контролируется. + */ +export function buildAutoRollbackScript(opId: string): string { + assertSafeOperationKey(opId); + const root = rollbackRoot(opId); + + return `#!/bin/sh +# HY2XS: автоматический откат firewall для операции ${opId}. +# +# Запускается транзиентным юнитом systemd, если операция не сняла guard за +# отведённое окно. Единственная задача — вернуть сервер к firewall, который был +# на нём до операции, чтобы не потерять доступ по SSH. +# +# set -e здесь НЕ используется: восстановить нужно ВСЕ части, а не остановиться +# на первой отказавшей. Непрерывность обеспечивается независимыми стадиями, +# честность — накоплением rc. + +root='${root}' + +# Маркер срабатывания — первым действием, до любой проверки. Операция обязана +# узнать, что guard сработал, даже если восстановление ниже не удалось. +mkdir -p "$root" +: >"$root/${AUTO_ROLLBACK_FIRED_MARKER}" + +if [ ! -f "$root/prepared" ]; then + echo 'hy2xs auto-rollback: prepared marker is absent, nothing to restore' >&2 + exit 0 +fi + +rc=0 + +# $1 — маркер существования, $2 — резервная копия, $3 — целевой путь. +restore_file() { + if [ -f "$1" ]; then + if ! cp -a "$2" "$3"; then + echo "hy2xs auto-rollback: failed to restore $3 from $2" >&2 + rc=1 + fi + return + fi + if ! rm -f "$3"; then + echo "hy2xs auto-rollback: failed to remove $3" >&2 + rc=1 + fi +} + +restore_file "$root/nftables.conf.existed" "$root/nftables.conf.bak" '${NFTABLES_ENTRYPOINT_PATH}' +restore_file "$root/hy2xs.nft.existed" "$root/hy2xs.nft.bak" '${HY2XS_NFT_PATH}' + +if [ -f "$root/nftables.conf.existed" ]; then + if ! nft -f '${NFTABLES_ENTRYPOINT_PATH}'; then + echo 'hy2xs auto-rollback: failed to apply ${NFTABLES_ENTRYPOINT_PATH}' >&2 + rc=1 + fi +else + if ! nft flush ruleset; then + echo 'hy2xs auto-rollback: failed to flush ruleset' >&2 + rc=1 + fi +fi + +if [ "$rc" -ne 0 ]; then + echo "hy2xs auto-rollback: recovery data is preserved in $root" >&2 +fi + +exit "$rc" +`; +} + +/** Корневой entrypoint nftables, который разворачивает HY2XS. */ +export function renderNftablesEntrypoint(includePath: string): string { + return `#!/usr/sbin/nft -f +# HY2XS-MANAGED: root nftables entrypoint +# Generated by hy2xs-orchestrator. Do not edit manually; edit /etc/hy2xs/hy2xs.env and run reconfigure. + +flush ruleset + +include "${includePath}" +`; +} + +/** + * Отрендеренный фрагмент правил HY2XS. + * + * Вынесен из applyFirewall, потому что у него появился второй потребитель: + * smoke сверяет ЭФФЕКТИВНЫЙ firewall с тем, который должна была получить эта + * конфигурация. Две копии логики рендера означали бы, что проверка сверяет файл + * сам с собой. + */ +export async function renderHy2xsNft(context: RuntimeContext): Promise { + const acmeChallengePort = context.config.acmeType === "tls" ? 443 : 80; + const acmeRule = context.config.tlsMode === "acme" + ? `meta nfproto ipv4 tcp dport ${acmeChallengePort} accept` + : "# acme challenge port disabled"; + + return renderTemplate( + await readText(`${context.options.packageDir}/templates/nftables/hy2xs.nft.tpl`), + { + SSH_PORT: context.config.sshPort, + HYSTERIA_PORT: context.config.hysteriaPort, + ACME_RULE: acmeRule + } + ); +} + +/** + * Промежуточные `*.candidate` не имеют права пережить операцию. + * + * `/etc/nftables.conf.candidate` не удалялся вообще: успешная установка + * оставляла его на диске навсегда. Для продукта с контрактом чистого хоста это + * означало файл, который никто не создавал повторно и никто не убирал. + */ +async function cleanupFirewallCandidates(): Promise { + await runMutatingVisible`rm -f ${HY2XS_NFT_CANDIDATE} ${NFTABLES_ENTRYPOINT_CANDIDATE}`; +} + +async function armRollbackGuard(opId: string): Promise { + assertSafeOperationKey(opId); + const scriptPath = autoRollbackScriptPath(opId); + await writeText(scriptPath, buildAutoRollbackScript(opId), 0o700); + + const unit = rollbackUnit(opId); + await runMutatingVisible`systemd-run --unit ${unit} --on-active=${FIREWALL_ROLLBACK_DEADLINE} /bin/sh ${scriptPath}`; + info( + `firewall rollback guard armed: ${unit} fires in ${FIREWALL_ROLLBACK_DEADLINE} unless the operation disarms it` + ); } export async function applyFirewall(context: RuntimeContext): Promise { @@ -153,19 +465,10 @@ export async function applyFirewall(context: RuntimeContext): Promise { return; } - const acmeChallengePort = context.config.acmeType === "tls" ? 443 : 80; - const acmeRule = context.config.tlsMode === "acme" - ? `meta nfproto ipv4 tcp dport ${acmeChallengePort} accept` - : "# acme challenge port disabled"; + const rendered = await renderHy2xsNft(context); - const rendered = renderTemplate(await readText(`${context.options.packageDir}/templates/nftables/hy2xs.nft.tpl`), { - SSH_PORT: context.config.sshPort, - HYSTERIA_PORT: context.config.hysteriaPort, - ACME_RULE: acmeRule - }); - - const existing = await fileExists("/etc/nftables.conf") - ? await readText("/etc/nftables.conf") + const existing = await fileExists(NFTABLES_ENTRYPOINT_PATH) + ? await readText(NFTABLES_ENTRYPOINT_PATH) : ""; const entrypointKind = classifyNftEntrypoint(existing); @@ -183,46 +486,83 @@ export async function applyFirewall(context: RuntimeContext): Promise { // Резервные копии снимаются и ПРОВЕРЯЮТСЯ до первой записи в /etc. await backupFirewallState(opId); - await writeText("/etc/nftables.d/hy2xs.nft.candidate", rendered, 0o600); - await runMutatingVisible`nft -c -f /etc/nftables.d/hy2xs.nft.candidate`; + await writeText(HY2XS_NFT_CANDIDATE, rendered, 0o600); + await runMutatingVisible`nft -c -f ${HY2XS_NFT_CANDIDATE}`; - const nftablesConfCandidate = `#!/usr/sbin/nft -f -# HY2XS-MANAGED: root nftables entrypoint -# Generated by hy2xs-orchestrator. Do not edit manually; edit /etc/hy2xs/hy2xs.env and run reconfigure. + await writeText(NFTABLES_ENTRYPOINT_CANDIDATE, renderNftablesEntrypoint(HY2XS_NFT_CANDIDATE), 0o644); + await runMutatingVisible`nft -c -f ${NFTABLES_ENTRYPOINT_CANDIDATE}`; -flush ruleset + await runMutatingVisible`mv ${HY2XS_NFT_CANDIDATE} ${HY2XS_NFT_PATH}`; -include "/etc/nftables.d/hy2xs.nft.candidate" -`; - await writeText("/etc/nftables.conf.candidate", nftablesConfCandidate, 0o644); - await runMutatingVisible`nft -c -f /etc/nftables.conf.candidate`; - - await runMutatingVisible`mv /etc/nftables.d/hy2xs.nft.candidate /etc/nftables.d/hy2xs.nft`; - - const nftablesConf = `#!/usr/sbin/nft -f -# HY2XS-MANAGED: root nftables entrypoint -# Generated by hy2xs-orchestrator. Do not edit manually; edit /etc/hy2xs/hy2xs.env and run reconfigure. - -flush ruleset - -include "/etc/nftables.d/hy2xs.nft" -`; - await writeText("/etc/nftables.conf", nftablesConf, 0o644); - await runMutatingVisible`nft -c -f /etc/nftables.conf`; + await writeText(NFTABLES_ENTRYPOINT_PATH, renderNftablesEntrypoint(HY2XS_NFT_PATH), 0o644); + await runMutatingVisible`nft -c -f ${NFTABLES_ENTRYPOINT_PATH}`; if (context.config.firewallStagedApply) { - const unit = rollbackUnit(opId); - await runMutatingVisible`systemd-run --unit ${unit} --on-active=45s /bin/sh -c 'if [ -f ${rollbackMarker(opId)} ]; then if [ -f ${rollbackBackup("nftables.conf.existed", opId)} ]; then cp -a ${rollbackBackup("nftables.conf.bak", opId)} /etc/nftables.conf 2>/dev/null || true; else rm -f /etc/nftables.conf; fi; if [ -f ${rollbackBackup("hy2xs.nft.existed", opId)} ]; then cp -a ${rollbackBackup("hy2xs.nft.bak", opId)} /etc/nftables.d/hy2xs.nft 2>/dev/null || true; else rm -f /etc/nftables.d/hy2xs.nft; fi; if [ -f ${rollbackBackup("nftables.conf.existed", opId)} ]; then nft -f /etc/nftables.conf >/dev/null 2>&1 || true; else nft flush ruleset >/dev/null 2>&1 || true; fi; fi'`; + await armRollbackGuard(opId); } - await runMutatingVisible`nft -f /etc/nftables.conf`; + await runMutatingVisible`nft -f ${NFTABLES_ENTRYPOINT_PATH}`; await runMutatingVisible`systemctl enable --now nftables`; await runMutatingVisible`ss -H -ltn | grep -q ':${context.config.sshPort} ' || (echo 'ssh port check failed' >&2; exit 1)`; + await cleanupFirewallCandidates(); + info("firewall applied with rollback guard; guard will be cancelled only after successful smoke checks"); } +/** + * Эффективный firewall обязан быть ТЕМ, который сгенерировала эта операция. + * + * Проверка закрывает вторую половину гонки со сработавшим guard'ом. Раньше + * единственной проверкой firewall в smoke был + * + * nft -c -f /etc/nftables.conf + * + * то есть РАЗБОР текущего файла, каким бы он ни был. Если автоматический откат + * успевал вернуть прежний — валидный — ruleset, эта проверка проходила зелёной, + * и операция объявляла успешной установку, работающую на firewall, который она + * же только что заменила. Особенно дорого это стоило при смене порта Hysteria, + * SSH или ACME. + * + * Сверяется три независимых утверждения: + * 1. фрагмент правил на диске совпадает с отрендеренным для этой конфигурации; + * 2. корневой entrypoint принадлежит HY2XS и подключает именно его; + * 3. таблица `inet hy2xs` реально загружена в ядро, а не только описана файлом. + */ +export async function assertEffectiveFirewallIsOurs(context: RuntimeContext): Promise { + if (firewallRollbackIsInactive(context)) { + info("effective firewall check skipped: nftables is not managed by HY2XS in this configuration"); + return; + } + + const expected = await renderHy2xsNft(context); + const effective = (await fileExists(HY2XS_NFT_PATH)) ? await readText(HY2XS_NFT_PATH) : ""; + if (effective !== expected) { + throw new Error( + `effective firewall fragment ${HY2XS_NFT_PATH} does not match the ruleset generated for this configuration. ` + + "Возможные причины: сработал автоматический откат firewall, файл изменён вручную " + + "или ruleset принадлежит другой операции." + ); + } + + const entrypointKind = await detectFirewallEntrypointKind(); + if (entrypointKind !== "hy2xs-managed") { + throw new Error( + `effective nftables entrypoint ${NFTABLES_ENTRYPOINT_PATH} is not HY2XS-managed (kind=${entrypointKind})` + ); + } + + try { + await runReadOnly`nft list table inet hy2xs`; + } catch (error) { + throw new Error( + "HY2XS nftables table is not loaded into the kernel: " + + `${error instanceof Error ? error.message : String(error)}` + ); + } +} + function firewallRollbackIsInactive(context: RuntimeContext): boolean { return ( context.options.skipFirewall || @@ -231,13 +571,104 @@ function firewallRollbackIsInactive(context: RuntimeContext): boolean { ); } -async function stopRollbackTimer(context: RuntimeContext, opId: string): Promise { - if (!context.config.firewallStagedApply) { +async function assertGuardHasNotFired(opId: string, when: string): Promise { + if (!(await fileExists(autoRollbackFiredMarker(opId)))) { return; } + throw new FirewallGuardFiredError( + `automatic firewall rollback has already fired (${when}): marker ${autoRollbackFiredMarker(opId)} exists. ` + + "Правила firewall этой операции больше не действуют, поэтому фиксировать успех запрещено; " + + "выполняется обычный откат операции." + ); +} + +/** + * Останавливает guard и ДОКАЗЫВАЕТ, что остановил. + * + * Что было: + * + * systemctl stop .timer .service || true + * systemctl reset-failed .timer .service || true + * + * и сразу за этим — сообщение «timer disarmed» и долговечная запись + * `phase: installed`. То есть порядок фиксации успеха опирался на утверждение, + * которого никто не проверял: отказ остановки стирался через `|| true`, и + * взведённый таймер мог вернуть прежний firewall уже ПОСЛЕ того, как установка + * объявлена успешной. + * + * Просто убрать `|| true` нельзя: для транзиентного юнита, который уже + * отработал и был убран systemd, `systemctl stop` возвращает 5 («unit not + * loaded») — законный исход. Поэтому код возврата уходит в журнал как + * диагностика, а решение принимается по НАБЛЮДАЕМОМУ состоянию юнитов и по + * маркеру срабатывания. + * + * Инвариант, который здесь устанавливается: + * + * маркер auto-rollback-fired отсутствует + * И timer/service находятся в состоянии inactive + * => автоматический откат больше не может сработать + * + * `reset-failed` остаётся уборкой: он ничего не доказывает и не имеет права + * отменить уже доказанное снятие guard'а. + */ +async function stopRollbackGuard( + context: RuntimeContext, + opId: string, + options: { assertNotFired: boolean } +): Promise { + if (!context.config.firewallStagedApply) { + info("staged firewall apply is disabled: no rollback guard was armed for this operation"); + return; + } + const unit = rollbackUnit(opId); - await runMutatingVisible`systemctl stop ${unit}.timer ${unit}.service || true`; - await runMutatingVisible`systemctl reset-failed ${unit}.timer ${unit}.service || true`; + const allowedStates = options.assertNotFired + ? GUARD_STOPPED_STATES_FOR_COMMIT + : GUARD_STOPPED_STATES_FOR_RECOVERY; + + if (options.assertNotFired) { + await assertGuardHasNotFired(opId, "before stopping the rollback guard"); + } + + const stop = await runMutatingStatus`systemctl stop ${unit}.timer ${unit}.service`; + if (stop.exitCode !== 0) { + // Не отказ сам по себе: транзиентный юнит мог быть уже убран systemd. + // Решает проверка состояния ниже. + info( + `systemctl stop ${unit}.timer ${unit}.service exited with ${stop.exitCode}: ` + + `${stop.stderr.trim() || "(no stderr)"}` + ); + } + + for (const target of [`${unit}.timer`, `${unit}.service`]) { + const state = await readUnitProperty(target, "ActiveState"); + if ((allowedStates as readonly string[]).includes(state)) { + continue; + } + if (options.assertNotFired) { + // Сработавший guard обязан быть опознан как таковой, а не как + // безымянная неудача остановки: от типа ошибки зависит классификация + // отказа операции. + await assertGuardHasNotFired(opId, `unit ${target} is in state "${state}"`); + } + throw new Error( + `firewall rollback guard ${target} is still in state "${state}" after systemctl stop ` + + `(exit ${stop.exitCode}${stop.stderr.trim() ? `: ${stop.stderr.trim()}` : ""}). ` + + "Автоматический откат firewall не снят." + ); + } + + const resetFailed = await runMutatingStatus`systemctl reset-failed ${unit}.timer ${unit}.service`; + if (resetFailed.exitCode !== 0) { + info( + `systemctl reset-failed ${unit}.timer ${unit}.service exited with ${resetFailed.exitCode}: ` + + `${resetFailed.stderr.trim() || "(no stderr)"}` + ); + } + + if (options.assertNotFired) { + await assertGuardHasNotFired(opId, "after stopping the rollback guard"); + } } /** @@ -258,14 +689,20 @@ async function stopRollbackTimer(context: RuntimeContext, opId: string): Promise * Откат запускался, но откатывать ему было нечем. * * Теперь между disarm и cleanup стоит долговечная фиксация успеха, и до неё - * ручное восстановление остаётся возможным. + * ручное восстановление остаётся возможным. Сам disarm при этом стал + * доказательством, а не сообщением: см. `stopRollbackGuard`. */ export async function disarmFirewallRollback(context: RuntimeContext): Promise { if (firewallRollbackIsInactive(context)) { return; } - await stopRollbackTimer(context, operationKey(context)); - info("firewall rollback timer disarmed; backups are kept until the installation is durably committed"); + await stopRollbackGuard(context, operationKey(context), { assertNotFired: true }); + if (!context.config.firewallStagedApply) { + return; + } + info( + "firewall rollback guard disarmed and proven inactive; backups are kept until the installation is durably committed" + ); } /** @@ -285,7 +722,7 @@ export async function cleanupFirewallRollback(context: RuntimeContext): Promise< /** * Немедленное восстановление firewall. * - * Два правила, которых здесь раньше не было. + * Три правила, которых здесь раньше не было. * * 1. Ошибки восстановления НЕ скрываются. Было: * @@ -300,9 +737,15 @@ export async function cleanupFirewallRollback(context: RuntimeContext): Promise< * ошибка восстановления скрыта, а данные, по которым оператор мог бы * поднять firewall вручную, уничтожены. * - * Стадии независимы: файл nftables.conf и файл hy2xs.nft восстанавливаются - * порознь, применение правил выполняется в любом случае — частично - * восстановленный firewall лучше полностью отсутствующего. + * 3. Остановка guard'а — такая же стадия, как остальные. Раньше она выполнялась + * отдельным вызовом с `|| true` внутри, поэтому откат мог начать + * восстановление, не остановив таймер, и не сообщить об этом. + * + * Стадии независимы и идут в порядке, в котором ошибка одной не портит + * результат другой. Порядок важен для nftables.service: на Debian у него + * `ExecStop=/usr/sbin/nft flush ruleset`, поэтому восстановление состояния + * сервиса обязано идти ДО применения ruleset — иначе остановка сервиса стёрла бы + * только что восстановленные правила. */ export async function rollbackFirewallNow(context: RuntimeContext): Promise { const opId = operationKey(context); @@ -315,11 +758,17 @@ export async function rollbackFirewallNow(context: RuntimeContext): Promise { + await stopRollbackGuard(context, opId, { assertNotFired: false }); + } + }); + for (const target of FIREWALL_BACKUP_TARGETS) { const backupPath = rollbackBackup(target.backup, opId); const markerPath = rollbackBackup(target.marker, opId); @@ -338,18 +787,79 @@ export async function rollbackFirewallNow(context: RuntimeContext): Promise { - if (entrypointExisted) { - await runMutatingVisible`nft -f /etc/nftables.conf`; + if (!serviceState) { + info("nftables.service state was not captured for this operation; unit file state is left as is"); return; } - await runMutatingVisible`nft flush ruleset`; + switch (serviceState.unitFileState) { + case "enabled": + await runMutatingVisible`systemctl enable nftables`; + return; + case "enabled-runtime": + await runMutatingVisible`systemctl enable --runtime nftables`; + return; + case "disabled": + await runMutatingVisible`systemctl disable nftables`; + return; + case "masked": + case "masked-runtime": + await runMutatingVisible`systemctl mask nftables`; + return; + default: + // static/indirect/generated/transient/пусто: у таких юнитов + // enable/disable либо бессмысленны, либо отказывают. + info( + `nftables.service unit file state "${serviceState.unitFileState || "(empty)"}" is not restorable explicitly; skipped` + ); + } + } + }); + + stages.push({ + name: "restore nftables.service inactive state", + run: async () => { + if (!serviceState) { + return; + } + if (serviceState.activeState === "inactive" || serviceState.activeState === "failed") { + // Остановка выполняет `nft flush ruleset`, поэтому она обязана + // предшествовать применению восстановленного ruleset. + await runMutatingVisible`systemctl stop nftables`; + return; + } + info(`nftables.service was "${serviceState.activeState || "(empty)"}" before the operation; it is restored by the ruleset stage`); + } + }); + + stages.push({ + name: "apply restored ruleset", + run: async () => { + if (!entrypointExisted) { + await runMutatingVisible`nft flush ruleset`; + return; + } + if (serviceState?.activeState === "active") { + // Ровно один flush+load из восстановленного файла: у активного + // nftables.service перезапуск и есть штатное применение ruleset. + await runMutatingVisible`systemctl restart nftables`; + return; + } + await runMutatingVisible`nft -f ${NFTABLES_ENTRYPOINT_PATH}`; } }); const failures = await runRollbackStages(stages); + try { + await cleanupFirewallCandidates(); + } catch (candidateError) { + info( + `firewall candidate files were not removed: ${candidateError instanceof Error ? candidateError.message : String(candidateError)}` + ); + } + if (failures.length > 0) { info( `firewall rollback did not complete; manual recovery data preserved at ${rollbackRoot(opId)}` diff --git a/orchestrator/src/steps/smoke.ts b/orchestrator/src/steps/smoke.ts index 3226c25..5ba25f9 100644 --- a/orchestrator/src/steps/smoke.ts +++ b/orchestrator/src/steps/smoke.ts @@ -4,6 +4,7 @@ import { readText } from "../lib/fs"; import { runReadOnly, runReadOnlySecret, runMutatingVisible } from "../lib/process"; import { HYSTERIA_MACHINE_AUTH_PATH, hysteriaMachineAuthUrl } from "../config/profile"; import { assertHysteriaConfigMatchesProfile } from "./configAssertions"; +import { assertEffectiveFirewallIsOurs } from "./firewall"; function parseLocalAddress(line: string): string { const cols = line.trim().split(/\s+/); @@ -225,6 +226,16 @@ export async function smoke(context: RuntimeContext): Promise { // синтаксиса, а не изменение ruleset. await runReadOnly`nft -c -f /etc/nftables.conf`; + // Разбор файла ничего не говорит о том, ЧЕЙ это файл. + // + // Пока единственной проверкой firewall был `nft -c`, сработавший за время + // smoke автоматический откат оставался незамеченным: он возвращал прежний — + // синтаксически валидный — ruleset, проверка проходила зелёной, и операция + // объявляла успешной установку, работающую на firewall, который она же + // только что заменила. + info("verifying that the effective firewall is the one generated for this configuration"); + await assertEffectiveFirewallIsOurs(context); + // Семантическая проверка установленного конфига: разбираем YAML и сверяем // с production-профилем, а не ищем подстроки. info("verifying effective Hysteria config against HY2XS production profile"); diff --git a/orchestrator/test/commit-ordering.test.ts b/orchestrator/test/commit-ordering.test.ts index 1963801..7757f6a 100644 --- a/orchestrator/test/commit-ordering.test.ts +++ b/orchestrator/test/commit-ordering.test.ts @@ -52,7 +52,7 @@ describe("снятие таймера и удаление копий — раз const start = firewallSource.indexOf("export async function disarmFirewallRollback"); const body = firewallSource.slice(start, firewallSource.indexOf("export async function cleanupFirewallRollback")); expect(body).not.toContain("cleanupFirewallBackupFiles"); - expect(body).toContain("stopRollbackTimer"); + expect(body).toContain("stopRollbackGuard"); }); test("cleanup удаляет копии и ничего больше", () => { diff --git a/orchestrator/test/firewall-guard.test.ts b/orchestrator/test/firewall-guard.test.ts new file mode 100644 index 0000000..0cd4e9a --- /dev/null +++ b/orchestrator/test/firewall-guard.test.ts @@ -0,0 +1,390 @@ +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 .timer .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/ — по значению из маркера такого пути не было. + 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' + ); + }); + + 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 = {}) { + 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); + }); +}); diff --git a/orchestrator/test/install-sequence.test.ts b/orchestrator/test/install-sequence.test.ts index 9859944..a7837d5 100644 --- a/orchestrator/test/install-sequence.test.ts +++ b/orchestrator/test/install-sequence.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { preflight } from "../src/steps/preflight"; import { classifyReconfigureFailure } from "../src/commands/reconfigure"; +import { FirewallGuardFiredError } from "../src/steps/firewall"; import { baselineConfig, testContext } from "./fixtures"; /** @@ -134,7 +135,26 @@ describe("классификация отказа reconfigure/repair", () => { // классификация по тексту ошибки убрана, должен быть разрешён. test("текст ошибки на классификацию не влияет", () => { expect(reconfigureSource).not.toContain(".test(message)"); - expect(reconfigureSource).toContain("classifyReconfigureFailure(ownership)"); + expect(reconfigureSource).toContain("classifyReconfigureFailure(ownership, error)"); + }); + + /** + * Сработавший guard классифицируется по ТИПУ ошибки. + * + * Разница с прежним разбором сообщения принципиальна, и проверяется она + * именно так: ошибка, в тексте которой есть все нужные слова, но у которой + * другой тип, обязана классифицироваться по владению, как и раньше. + */ + test("сработавший guard опознаётся по типу ошибки, а не по её тексту", () => { + const late = ownership({ firewallTouched: true, servicesRestarted: true }); + + expect(classifyReconfigureFailure(late, new FirewallGuardFiredError("guard"))).toBe( + "firewall_guard_fired" + ); + expect( + classifyReconfigureFailure(late, new Error("automatic firewall rollback has already fired")) + ).toBe("smoke_failed"); + expect(classifyReconfigureFailure(late)).toBe("smoke_failed"); }); test("отказ диагностики не отменяет откат firewall и конфигов", () => { diff --git a/orchestrator/test/rollback-mandatory.test.ts b/orchestrator/test/rollback-mandatory.test.ts index 0f81a30..19cb9d4 100644 --- a/orchestrator/test/rollback-mandatory.test.ts +++ b/orchestrator/test/rollback-mandatory.test.ts @@ -198,7 +198,13 @@ describe("reconfigure: откат обязателен после операци const reconfigureSource = source("commands/reconfigure.ts"); test("запись состояния отказа обёрнута в persistFailureState", () => { - expect(reconfigureSource).toContain("await persistFailureState(() => markPhase(context"); + // Проверяется вложенность вызовов, а не форматирование: markPhase обязан + // выполняться ВНУТРИ persistFailureState, в какую бы строку его ни перенёс + // редактор. + const wrapped = reconfigureSource.match( + /await persistFailureState\(\(\) =>\s*markPhase\(context,\s*classifyReconfigureFailure\(ownership, error\), message\)\s*\)/ + ); + expect(wrapped).not.toBeNull(); }); test("в обработчике ошибки нет незащищённого markPhase", () => {