Files
HY2XS_flamy/orchestrator/src/steps/firewall.ts
T
founder 2259f7c847 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-граф.
2026-08-31 16:44:20 +05:00

1311 lines
65 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 type { RuntimeContext } from "../types/context";
import { fileExists, readText, renderTemplate, writeText } from "../lib/fs";
import { fail, info } from "../lib/log";
import {
runMutatingArgv,
runMutatingStatus,
runMutatingVisible,
runReadOnly,
runReadOnlyArgv
} from "../lib/process";
import { runRollbackStages, type RollbackStage } from "../lib/rollback";
type NftEntrypointKind =
| "missing"
| "hy2xs-managed"
| "empty"
| "debian-empty-template"
| "include-compatible"
| "foreign";
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";
/**
* Точность транзиентного таймера guard'а.
*
* `OnActiveSec=` НЕ означает «ровно через столько». `systemd.timer` разрешает
* себе сработать в окне `[цель; цель + AccuracySec]`, объединяя пробуждения
* ради экономии энергии, и умолчание этого параметра — `1min`. То есть guard,
* объявленный как «45 секунд», без явного значения имел контракт «от 45 до 105
* секунд», а README, docs и текст отказа обещали первое число.
*
* Для аварийного guard'а коалесценция пробуждений не нужна: он взводится один
* раз за операцию и почти всегда снимается, не сработав. `1s` возвращает
* обещанному окну смысл — реальный интервал становится 45–46 секунд — и остаётся
* достаточно грубым, чтобы не будить ядро ради миллисекунд.
*/
const FIREWALL_ROLLBACK_ACCURACY = "1s";
/**
* Транзиентный таймер обязан исчезнуть, отработав.
*
* `systemd-run` выставляет `RemainAfterElapse=false` сам — это его умолчание для
* транзиентных таймеров, и на systemd 257 (Debian 13) оно действует. Значение
* повторяется здесь ЯВНО не из недоверия к systemd, а потому что от него зависит
* чужой инвариант: барьер покоя считает отсутствие юнита доказательством того,
* что откатывать firewall больше некому.
*
* Инвариант, который держится на чужом умолчании, не записан нигде и не
* проверяется ничем. С явным свойством он становится частью команды, которую
* видно в journal и которую проверяет тест.
*
* Второе плечо той же защиты — в барьере: отработавший таймер опознаётся ещё и
* по `SubState=elapsed`, поэтому даже таймер, созданный не нами, не блокирует
* `repair` навсегда.
*/
const FIREWALL_ROLLBACK_REMAIN_AFTER_ELAPSE = "no";
/**
* Маркер факта: автоматический откат firewall НАЧАЛ выполняться.
*
* Ключевое слово — «начал». Файл создаётся первым действием 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";
}
}
const ROLLBACK_UNIT_PREFIX = "hy2xs-fw-rollback-";
/**
* Состояния, в которых guard заведомо БОЛЬШЕ НИЧЕГО не сделает.
*
* Список именно такой — белый, а не чёрный, и это принципиально. Раньше
* перечислялись непокойные состояния (`active`, `activating`, `deactivating`,
* `reloading`), а покоем считалось «всё остальное». Такая формулировка
* доказывает не то, что нужно: она объявляет безопасным любое состояние,
* которого автор не перечислил, — включая те, которых он не знал. systemd 257
* знает `maintenance` и `refreshing` помимо перечисленных, и завтра список
* может пополниться снова.
*
* Перечислять же нужно ровно то, что мы УТВЕРЖДАЕМ: покой — это `inactive` и
* `failed`. Отработавший guard, успешно или нет, систему больше не меняет, а
* отказавший юнит — как раз повод запустить `repair`; барьер, отказывающий по
* `failed`, блокировал бы инструмент, которым чинят последствия.
*
* Всё прочее — непокой, и это соответствует политике, уже применённой к замку
* операций: сомнение трактуется в пользу отказа.
*/
const GUARD_QUIESCENT_ACTIVE_STATES = ["inactive", "failed"] as const;
/**
* Отработавший таймер — покой, даже если он остался `active`.
*
* `ActiveState` таймера отвечает на вопрос «юнит загружен и в строю», а не «он
* ещё может сработать»: в systemd `TIMER_ELAPSED` отображается в `UNIT_ACTIVE`
* так же, как `TIMER_WAITING`. Различает их только `SubState`.
*
* У наших guard'ов этой ситуации не возникает — `RemainAfterElapse=no`
* выгружает таймер сразу, — но инвариант «барьер не залипает» не должен
* зависеть от того, чем именно создан таймер. Без этой ветки одноразовый
* таймер, оставшийся в `elapsed`, запрещал бы install/reconfigure/repair/doctor
* навсегда, пока оператор не остановит его руками, — и запрещал бы ради
* отката, который уже произошёл.
*
* Ветка узкая намеренно: только `*.timer` и только состояния, из которых
* следующего срабатывания не будет. Триггернутый сервис при этом виден
* барьеру отдельным юнитом и остаётся непокоем, пока выполняется.
*/
const GUARD_QUIESCENT_TIMER_SUB_STATES = ["dead", "elapsed"] as const;
/** Свойства, по которым принимается решение о покое. */
const GUARD_STATE_PROPERTIES = ["ActiveState", "SubState"] as const;
/**
* Новую операцию начинать нельзя.
*
* База для двух разных причин отказа. Общий предок нужен потому, что вызывающий
* иногда обязан отличать «эта операция ничего не испортила, ей просто нельзя
* начинать» от собственных отказов, но не обязан различать конкретную причину.
*/
export class OperationBarrierError extends Error {}
/**
* Предыдущая операция мертва, но её асинхронный исполнитель ещё жив.
*
* Отдельный тип, потому что это единственный отказ, который не про текущую
* операцию: она не сделала ничего плохого, ей просто нельзя начинать.
*/
export class PendingRecoveryError extends OperationBarrierError {
constructor(message: string) {
super(message);
this.name = "PendingRecoveryError";
}
}
/**
* Покой недоказуем: systemd не ответил.
*
* Отдельный тип от `PendingRecoveryError`, потому что утверждения разные.
* «Guard вооружён» — наблюдение. «Состояние guard'а неизвестно» — отсутствие
* наблюдения, и оператору нужно другое действие: не подождать 45 секунд, а
* разобраться с systemd.
*
* Отказ здесь ничего не стоит: systemd-run и так требуется в preflight, поэтому
* без работающего systemd операция всё равно не пройдёт — просто позже и с
* менее внятной диагностикой.
*/
export class GuardStateUnknownError extends OperationBarrierError {
constructor(message: string) {
super(message);
this.name = "GuardStateUnknownError";
}
}
function rollbackRoot(opId: string): string {
return `/run/hy2xs/rollback/${opId}`;
}
function rollbackUnit(opId: string): string {
return `${ROLLBACK_UNIT_PREFIX}${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 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}`;
}
async function ensureRollbackRoot(opId: string): Promise<void> {
await runMutatingVisible`mkdir -p ${rollbackRoot(opId)}`;
}
async function cleanupFirewallBackupFiles(opId: string): Promise<void> {
await runMutatingVisible`rm -rf ${rollbackRoot(opId)}`;
}
/**
* Файлы firewall, которые операция обязана сохранить до первой мутации.
*
* Список явный: «скопировать всё, что найдём» и «доказать, что скопировали
* именно то, что нужно» — разные утверждения, и rollback опирается на второе.
*/
const FIREWALL_BACKUP_TARGETS = [
{ 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<string, string>();
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 };
}
/**
* Наблюдение за systemd, вынесенное в интерфейс.
*
* Не абстракция ради абстракции. Барьер покоя — единственное место продукта, где
* ОТКАЗ наблюдения меняет решение, а не только его обоснование, и до сих пор это
* поведение проверялось грепом по исходнику: тест утверждал, что в тексте
* функции есть `return [];`. Такой тест закрепляет строку, а не свойство, и
* ровно поэтому пережил инверсию смысла — строка была на месте, а решение стало
* неверным.
*
* С подставляемым probe те же сценарии («systemd не ответил», «таймер взведён»,
* «сервис упал») становятся обычными тестами на поведение, не требующими ни
* systemd, ни Linux.
*/
export type SystemdUnitProbe = {
/** Сырой вывод `systemctl list-units` по шаблонам guard-юнитов. */
listGuardUnits(): Promise<string>;
/** Значения свойств юнита; о непрочитанном свойстве возвращается пустая строка. */
showProperties(unit: string, properties: readonly string[]): Promise<Record<string, string>>;
};
export const defaultSystemdUnitProbe: SystemdUnitProbe = {
async listGuardUnits(): Promise<string> {
return await runReadOnly`systemctl list-units --all --plain --no-legend ${`${ROLLBACK_UNIT_PREFIX}*.timer`} ${`${ROLLBACK_UNIT_PREFIX}*.service`}`;
},
async showProperties(unit: string, properties: readonly string[]): Promise<Record<string, string>> {
// `--value` здесь не используется: при нескольких свойствах он печатает
// значения без имён, и разбор начинает зависеть от порядка вывода. Формат
// `Свойство=значение` самоописателен.
const raw = await runReadOnlyArgv([
"systemctl",
"show",
...properties.map((property) => `--property=${property}`),
unit
]);
const values: Record<string, string> = {};
for (const property of properties) {
values[property] = "";
}
for (const line of raw.split(/\r?\n/)) {
const separator = line.indexOf("=");
if (separator <= 0) {
continue;
}
values[line.slice(0, separator).trim()] = line.slice(separator + 1).trim();
}
return values;
}
};
async function readUnitProperty(
unit: string,
property: string,
probe: SystemdUnitProbe = defaultSystemdUnitProbe
): Promise<string> {
return ((await probe.showProperties(unit, [property]))[property] ?? "").trim();
}
/**
* Снимает резервные копии ДО первой мутации firewall — и доказывает, что снял.
*
* Что было:
*
* cp -a /etc/nftables.conf <backup> 2>/dev/null || true
*
* то есть отказ копирования (заполненный /run, ошибка ввода-вывода, права)
* молча игнорировался. Дальше выставлялся маркер `prepared`, и операция
* начинала переписывать firewall — уже НЕ имея резервной копии, на которую
* рассчитывает откат. Предпосылка отката нарушалась в самом его основании.
*
* Маркер `prepared` теперь ставится ПОСЛЕ проверенных копий, а не до них: он
* означает «данные для отката существуют», и раньше это было неправдой.
*
* Вместе с файлами сохраняется состояние юнита nftables.service. Восстановление
* одних только файлов оставляло на хосте системную мутацию: `applyFirewall`
* выполняет `systemctl enable --now nftables`, и после отката неудачной ПЕРВОЙ
* установки сервис оставался включённым в автозапуск, хотя до установки был
* выключен.
*/
async function backupFirewallState(opId: string): Promise<void> {
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);
if (!(await fileExists(target.path))) {
// Отсутствие файла — законное состояние, но оно обязано быть ЗАПИСАНО, а
// не выведено из неудачи копирования: откат по этому маркеру решает,
// восстанавливать файл или удалять его.
await runMutatingVisible`rm -f ${markerPath} ${backupPath}`;
continue;
}
await runMutatingVisible`cp -a ${target.path} ${backupPath}`;
if (!(await fileExists(backupPath))) {
fail(
`firewall backup was not created for ${target.path}: ${backupPath} is missing. ` +
"Отказ до первой мутации firewall: без резервной копии откат невозможен."
);
}
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<NftablesServiceState | null> {
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/)
.map((line) => line.replace(/#.*/, "").trim())
.filter(Boolean)
.join("\n");
}
function classifyNftEntrypoint(content: string): NftEntrypointKind {
if (!content.trim()) {
return "missing";
}
if (content.includes("HY2XS-MANAGED")) {
return "hy2xs-managed";
}
const withoutComments = stripNftComments(content);
const effective = withoutComments
.replace(/^#!\/usr\/sbin\/nft\s+-f\s*/m, "")
.trim();
if (!effective) {
return "empty";
}
const normalized = effective.replace(/\s+/g, " ").trim();
if (normalized === "flush ruleset") {
return "debian-empty-template";
}
if (/include\s+"\/etc\/nftables\.d\/hy2xs\.nft"/.test(effective)) {
return "include-compatible";
}
return "foreign";
}
export async function detectFirewallEntrypointKind(): Promise<FirewallEntrypointKind> {
if (!(await fileExists(NFTABLES_ENTRYPOINT_PATH))) {
return "missing";
}
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.
*
* 3. Создание маркера входит в учёт `rc`, и это не мелочь, а второе плечо
* инварианта фиксации. Инвариант
*
* маркер отсутствует И юниты inactive => guard не сработал
*
* верен только при дополнительном условии «guard способен записать маркер».
* Пока `rc=0` стояло ПОСЛЕ создания маркера, отказ записи (заполненный
* tmpfs /run, read-only ФС, ошибка ввода-вывода) не влиял ни на что: скрипт
* успешно восстанавливал прежний firewall и завершался кодом 0, юнит уходил
* в `inactive`, маркера не было — и операция фиксировала успех после
* РЕАЛЬНО сработавшего отката.
*
* Теперь у факта срабатывания два независимых канала:
*
* маркер — обычный;
* отказ юнита — аварийный, когда маркер записать не удалось.
*
* Второй работает потому, что на пути фиксации успеха допустим ровно один
* `ActiveState` — `inactive`, а `failed` фиксацию запрещает.
*
* Состояние 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}'
# rc объявляется ДО первой операции, включая создание маркера срабатывания.
#
# Иначе отказ записи маркера не влиял бы ни на что: скрипт успешно восстановил
# бы прежний firewall и завершился кодом 0, а операция, не увидев маркера и
# увидев inactive-юнит, зафиксировала бы успех после реально сработавшего
# отката. Отказ юнита — аварийный канал того же факта.
rc=0
# Маркер срабатывания — первым действием, до любой проверки. Операция обязана
# узнать, что guard сработал, даже если восстановление ниже не удалось.
if ! mkdir -p "$root"; then
echo "hy2xs auto-rollback: failed to access the recovery root $root" >&2
rc=1
fi
# touch, а НЕ \`: >file\`.
#
# Двоеточие — special builtin POSIX, и ошибка перенаправления на нём обязана
# завершить неинтерактивный shell целиком. В dash, который на Debian и есть
# /bin/sh, это означало бы, что при недоступном /run скрипт умирает ДО
# восстановления firewall — то есть guard перестаёт делать ровно то, ради чего
# существует. touch — обычная внешняя команда, её код возврата просто
# возвращается в if.
if ! touch "$root/${AUTO_ROLLBACK_FIRED_MARKER}"; then
echo "hy2xs auto-rollback: failed to create the fired marker in $root" >&2
rc=1
fi
if [ ! -f "$root/prepared" ]; then
echo 'hy2xs auto-rollback: prepared marker is absent, nothing to restore' >&2
exit "$rc"
fi
# $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<string> {
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<void> {
await runMutatingVisible`rm -f ${HY2XS_NFT_CANDIDATE} ${NFTABLES_ENTRYPOINT_CANDIDATE}`;
}
/**
* Команда взведения guard'а — как значение, а не как момент запуска.
*
* Чистая функция, потому что у этой команды есть контракт, от которого зависят
* два чужих утверждения:
*
* AccuracySec — обещанное оператору окно отката (README, docs, текст
* отказа барьера говорят «45 секунд»);
* RemainAfterElapse — право барьера считать отсутствие юнита покоем.
*
* Пока команда собиралась интерполяцией внутри вызова, оба свойства
* существовали только в момент запуска, и проверить их можно было лишь грепом
* по исходнику. Здесь они — обычное значение, которое сравнивает обычный тест.
*
* Имя юнита передаётся С суффиксом `.service`, а не голым. Голое имя systemd-run
* пропускает через unit_name_mangle_with_suffix, и тот сначала смотрит, не
* заканчивается ли оно уже известным типом юнита. Ключ операции —
* санитизированный ISO-timestamp вида `...T12-34-56.789Z`, то есть содержит
* точку, и корректность имени зависела бы от того, что `.789Z` случайно не
* совпало ни с одним типом. Явный суффикс убирает эту зависимость: systemd-run
* берёт имя как есть и создаёт рядом одноимённый `.timer`.
*/
export function buildArmGuardArgv(opId: string): string[] {
assertSafeOperationKey(opId);
return [
"systemd-run",
`--unit=${rollbackUnit(opId)}.service`,
`--on-active=${FIREWALL_ROLLBACK_DEADLINE}`,
`--timer-property=RemainAfterElapse=${FIREWALL_ROLLBACK_REMAIN_AFTER_ELAPSE}`,
`--timer-property=AccuracySec=${FIREWALL_ROLLBACK_ACCURACY}`,
"/bin/sh",
autoRollbackScriptPath(opId)
];
}
async function armRollbackGuard(opId: string): Promise<void> {
assertSafeOperationKey(opId);
await writeText(autoRollbackScriptPath(opId), buildAutoRollbackScript(opId), 0o700);
await runMutatingArgv(buildArmGuardArgv(opId));
info(
`firewall rollback guard armed: ${rollbackUnit(opId)} fires in ${FIREWALL_ROLLBACK_DEADLINE} ` +
`(timer accuracy ${FIREWALL_ROLLBACK_ACCURACY}) unless the operation disarms it`
);
}
export async function applyFirewall(context: RuntimeContext): Promise<void> {
const opId = operationKey(context);
if (context.options.skipFirewall || context.config.firewallMode === "off") {
info("firewall skipped by flag");
return;
}
if (context.config.firewallMode === "external") {
info("firewall mode is external: nftables is not modified");
return;
}
const rendered = await renderHy2xsNft(context);
const existing = await fileExists(NFTABLES_ENTRYPOINT_PATH)
? await readText(NFTABLES_ENTRYPOINT_PATH)
: "";
const entrypointKind = classifyNftEntrypoint(existing);
const managedAllowed = new Set<NftEntrypointKind>([
"missing",
"hy2xs-managed",
"empty",
"debian-empty-template",
"include-compatible"
]);
if (context.config.firewallMode === "managed" && !managedAllowed.has(entrypointKind)) {
fail("foreign nftables.conf detected; use HY2XS_FIREWALL_MODE=takeover|external|off");
}
// Резервные копии снимаются и ПРОВЕРЯЮТСЯ до первой записи в /etc.
await backupFirewallState(opId);
await writeText(HY2XS_NFT_CANDIDATE, rendered, 0o600);
await runMutatingVisible`nft -c -f ${HY2XS_NFT_CANDIDATE}`;
await writeText(NFTABLES_ENTRYPOINT_CANDIDATE, renderNftablesEntrypoint(HY2XS_NFT_CANDIDATE), 0o644);
await runMutatingVisible`nft -c -f ${NFTABLES_ENTRYPOINT_CANDIDATE}`;
await runMutatingVisible`mv ${HY2XS_NFT_CANDIDATE} ${HY2XS_NFT_PATH}`;
await writeText(NFTABLES_ENTRYPOINT_PATH, renderNftablesEntrypoint(HY2XS_NFT_PATH), 0o644);
await runMutatingVisible`nft -c -f ${NFTABLES_ENTRYPOINT_PATH}`;
if (context.config.firewallStagedApply) {
await armRollbackGuard(opId);
}
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<void> {
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)}`
);
}
}
/** Наблюдаемое состояние одного guard-юнита. */
export type GuardUnitState = {
unit: string;
activeState: string;
subState: string;
};
/**
* Что удалось УЗНАТЬ о guard'ах предыдущей операции.
*
* Три исхода, а не два, и третий здесь главный. Прежняя функция возвращала
* список юнитов, и «список пуст» одинаково означало и «guard'ов нет», и «спросить
* не удалось». Различие между ними — это различие между доказанным покоем и
* отсутствием доказательства, то есть ровно то, ради чего барьер существует.
*/
export type GuardInspection =
| { kind: "quiescent"; units: GuardUnitState[] }
| { kind: "pending"; units: GuardUnitState[] }
| { kind: "unknown"; reason: string };
function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/**
* Имена guard-юнитов из вывода `systemctl list-units`.
*
* Ищется первое поле строки, НАЧИНАЮЩЕЕСЯ с нашего префикса, а не просто первое
* поле. Разница не косметическая: у юнита в состоянии `failed` systemctl
* печатает первой колонкой маркер `●`, и разбор «первое поле — имя юнита»
* пропускал бы именно аварийно сработавший guard — тот единственный, ради
* которого проверка и написана. `--plain` этот маркер убирает, но разбор не
* должен зависеть от того, не потеряется ли флаг при следующей правке команды.
*/
export function parseRollbackGuardUnitNames(listed: string): string[] {
const names = new Set<string>();
for (const line of listed.split(/\r?\n/)) {
for (const field of line.trim().split(/\s+/)) {
if (field.startsWith(ROLLBACK_UNIT_PREFIX)) {
names.add(field);
break;
}
}
}
return [...names];
}
/** Юнит уже ничего не изменит: см. GUARD_QUIESCENT_ACTIVE_STATES. */
export function guardUnitIsQuiescent(state: GuardUnitState): boolean {
if ((GUARD_QUIESCENT_ACTIVE_STATES as readonly string[]).includes(state.activeState)) {
return true;
}
return (
state.unit.endsWith(".timer") &&
(GUARD_QUIESCENT_TIMER_SUB_STATES as readonly string[]).includes(state.subState)
);
}
export function describeGuardUnits(units: readonly GuardUnitState[]): string {
return units.map((state) => `${state.unit} (${state.activeState}/${state.subState})`).join(", ");
}
/**
* Состояние транзиентных guard'ов, известных systemd.
*
* Отказ запроса больше НЕ считается покоем, и это исправление, а не смена
* умолчания. Прежний комментарий обосновывал `return []` так: «без systemd не
* может быть и транзиентного таймера». Утверждение верное, но доказывает не то —
* отказ запроса к systemd не означает, что systemd нет:
*
* systemd жив, старый rollback timer взведён
* -> запрос к systemctl/D-Bus временно отказывает
* -> список пуст
* -> барьер считает систему спокойной
* -> новая операция начинает менять firewall
* -> таймер срабатывает поверх неё
*
* То есть механизм, обязанный ДОКАЗАТЬ отсутствие асинхронного исполнителя, при
* невозможности получить доказательство принимал результат как положительный.
* Это прямо противоположно политике, уже принятой для замка операций, где
* сомнение трактуется в пользу отказа.
*
* Практического выигрыша от прежнего поведения не было: systemd-run требуется в
* preflight, поэтому без работающего systemd операция всё равно откажет — просто
* позже и с менее внятным сообщением.
*/
export async function inspectRollbackGuard(
probe: SystemdUnitProbe = defaultSystemdUnitProbe
): Promise<GuardInspection> {
let listed: string;
try {
listed = await probe.listGuardUnits();
} catch (error) {
return { kind: "unknown", reason: `systemctl list-units failed: ${describeError(error)}` };
}
const units: GuardUnitState[] = [];
for (const unit of parseRollbackGuardUnitNames(listed)) {
let properties: Record<string, string>;
try {
properties = await probe.showProperties(unit, GUARD_STATE_PROPERTIES);
} catch (error) {
// Отказ на ОДНОМ юните тоже делает картину неполной: покой — утверждение
// обо всех guard'ах сразу, и «про этот не знаем» его опровергает.
return { kind: "unknown", reason: `systemctl show ${unit} failed: ${describeError(error)}` };
}
units.push({
unit,
activeState: (properties.ActiveState ?? "").trim(),
subState: (properties.SubState ?? "").trim()
});
}
const pending = units.filter((state) => !guardUnitIsQuiescent(state));
return pending.length > 0 ? { kind: "pending", units: pending } : { kind: "quiescent", units };
}
/**
* Барьер покоя: у предыдущей операции не осталось асинхронных исполнителей.
*
* Замок операций и rollback guard вводились по отдельности и по отдельности же
* оставляли дыру на своём стыке. Замок защищает production paths, пока ЖИВ
* процесс-держатель. Guard — это отдельный systemd-объект, который переживает
* свой процесс:
*
* A берёт замок -> применяет firewall -> взводит guard на 45s
* A аварийно умирает
* B берёт замок (либо снятый обработчиком сигнала, либо переиспользованный)
* B начинает менять production paths
* guard A срабатывает и возвращает firewall, который был ДО A
*
* Уникальные op-id здесь не помогают: каталоги копий разные, а
* /etc/nftables.conf, /etc/nftables.d/hy2xs.nft и ruleset в ядре — общие.
*
* Поэтому правильное условие для начала новой операции — не «PID предыдущей
* мёртв», а «у предыдущей не осталось исполнителей, способных изменить
* систему». Проверка обязательна при ЛЮБОМ захвате замка, а не только при
* переиспользовании устаревшего: обработчик сигналов снимает замок сам, и в
* этом случае stale-замка просто не будет, а таймер останется.
*
* Сознательно НЕ проверяются `hysteria-server`, `hy2xs-admin` и
* `nftables.service`: незавершённый `systemctl restart` ничего не откатывает,
* он лишь повторяет то, что новая операция сделает сама, а отказ по их
* переходным состояниям заблокировал бы `repair` ровно тогда, когда он нужен.
*/
export async function assertNoPendingRollbackGuard(
probe: SystemdUnitProbe = defaultSystemdUnitProbe
): Promise<void> {
const inspection = await inspectRollbackGuard(probe);
if (inspection.kind === "quiescent") {
return;
}
if (inspection.kind === "unknown") {
throw new GuardStateUnknownError(
"unable to verify firewall rollback guard state; systemd query failed, " +
`refusing to start a lifecycle operation: ${inspection.reason}. ` +
"Барьер обязан ДОКАЗАТЬ, что у предыдущей операции не осталось исполнителей, " +
"способных изменить firewall; без ответа systemd такого доказательства нет. " +
"Проверьте systemd (`systemctl status`, `journalctl -u 'hy2xs-fw-rollback-*'`) и повторите."
);
}
throw new PendingRecoveryError(
"previous HY2XS operation is no longer running, but its firewall rollback guard is still armed: " +
`${describeGuardUnits(inspection.units)}. ` +
"Такой guard способен вернуть прежний firewall уже посреди новой операции. " +
`Дождитесь его завершения (окно — ${FIREWALL_ROLLBACK_DEADLINE} с момента применения firewall, ` +
`точность таймера — ${FIREWALL_ROLLBACK_ACCURACY}) и повторите; ` +
"состояние guard видно в `hy2xs-orchestrator status` и в `journalctl -u 'hy2xs-fw-rollback-*'`."
);
}
function firewallRollbackIsInactive(context: RuntimeContext): boolean {
return (
context.options.skipFirewall ||
context.config.firewallMode === "off" ||
context.config.firewallMode === "external"
);
}
async function assertGuardHasNotFired(opId: string, when: string): Promise<void> {
if (!(await fileExists(autoRollbackFiredMarker(opId)))) {
return;
}
throw new FirewallGuardFiredError(
`automatic firewall rollback has already fired (${when}): marker ${autoRollbackFiredMarker(opId)} exists. ` +
"Правила firewall этой операции больше не действуют, поэтому фиксировать успех запрещено; " +
"выполняется обычный откат операции."
);
}
/**
* Останавливает guard и ДОКАЗЫВАЕТ, что остановил.
*
* Что было:
*
* systemctl stop <unit>.timer <unit>.service || true
* systemctl reset-failed <unit>.timer <unit>.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<void> {
if (!context.config.firewallStagedApply) {
info("staged firewall apply is disabled: no rollback guard was armed for this operation");
return;
}
const unit = rollbackUnit(opId);
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");
}
}
/**
* Снимает автоматический откат по таймеру, НО сохраняет резервные копии.
*
* Разделение на disarm и cleanup — исправление ошибки порядка фиксации.
* Единая `cancelFirewallRollback` делала и то и другое, а вызывалась ДО
* долговечной записи `phase: installed`. Получался разрыв:
*
* smoke PASS
* -> таймер снят, резервные копии УДАЛЕНЫ
* -> запись "installed" падает (ENOSPC/EIO/read-only)
* -> catch -> обязательный откат
* -> "no HY2XS rollback markers found"
*
* То есть ровно тот отказ записи маркера, который был специально сделан
* безопасным, случался после уничтожения единственных данных для отката.
* Откат запускался, но откатывать ему было нечем.
*
* Теперь между disarm и cleanup стоит долговечная фиксация успеха, и до неё
* ручное восстановление остаётся возможным. Сам disarm при этом стал
* доказательством, а не сообщением: см. `stopRollbackGuard`.
*/
export async function disarmFirewallRollback(context: RuntimeContext): Promise<void> {
if (firewallRollbackIsInactive(context)) {
return;
}
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"
);
}
/**
* Удаляет резервные копии firewall. Вызывается ТОЛЬКО после долговечной
* фиксации успеха операции.
*
* Неудача здесь — мусор в /run, а не причина объявить успешную установку
* неуспешной, поэтому вызывающий выполняет её best-effort.
*/
export async function cleanupFirewallRollback(context: RuntimeContext): Promise<void> {
if (firewallRollbackIsInactive(context)) {
return;
}
await cleanupFirewallBackupFiles(operationKey(context));
}
/**
* Немедленное восстановление firewall.
*
* Три правила, которых здесь раньше не было.
*
* 1. Ошибки восстановления НЕ скрываются. Было:
*
* cp <backup> /etc/nftables.conf 2>/dev/null || true
* nft -f /etc/nftables.conf >/dev/null 2>&1 || true
*
* то есть неудача копирования или применения правил давала функции
* завершиться успешно, и стадия отката отчитывалась как выполненная.
*
* 2. Резервные копии удаляются ТОЛЬКО после подтверждённого восстановления.
* Было — безусловный `rm -rf` в конце: худшая комбинация, при которой
* ошибка восстановления скрыта, а данные, по которым оператор мог бы
* поднять firewall вручную, уничтожены.
*
* 3. Остановка guard'а — такая же стадия, как остальные. Раньше она выполнялась
* отдельным вызовом с `|| true` внутри, поэтому откат мог начать
* восстановление, не остановив таймер, и не сообщить об этом.
*
* Стадии независимы и идут в порядке, в котором ошибка одной не портит
* результат другой. Порядок важен для nftables.service: на Debian у него
* `ExecStop=/usr/sbin/nft flush ruleset`, поэтому восстановление состояния
* сервиса обязано идти ДО применения ruleset — иначе остановка сервиса стёрла бы
* только что восстановленные правила.
*/
export async function rollbackFirewallNow(context: RuntimeContext): Promise<void> {
const opId = operationKey(context);
if (firewallRollbackIsInactive(context)) {
return;
}
if (!(await fileExists(rollbackMarker(opId)))) {
info("firewall rollback skipped: no HY2XS rollback markers found");
return;
}
const entrypointExisted = await fileExists(rollbackBackup("nftables.conf.existed", opId));
const serviceState = await readNftablesServiceState(opId);
const stages: RollbackStage[] = [];
stages.push({
name: "stop firewall rollback guard",
run: async () => {
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);
stages.push({
name: `restore ${target.path}`,
run: async () => {
if (await fileExists(markerPath)) {
await runMutatingVisible`cp -a ${backupPath} ${target.path}`;
return;
}
// Файла не было до операции — восстановление означает его удаление.
await runMutatingVisible`rm -f ${target.path}`;
}
});
}
stages.push({
name: "restore nftables.service unit file state",
run: async () => {
if (!serviceState) {
info("nftables.service state was not captured for this operation; unit file state is left as is");
return;
}
// Восстанавливается ровно то, что операция могла изменить, и ровно так,
// как это можно сделать достоверно.
//
// Единственная мутация этого юнита в applyFirewall — постоянный
// `systemctl enable --now nftables`. Её точная отмена существует для двух
// состояний: `enabled` (ничего менять не нужно) и `disabled` (убрать
// добавленную нами постоянную ссылку).
//
// Остальные состояния сознательно НЕ трогаются, и это исправление
// прежнего поведения, а не пропуск. `enable --runtime` не удаляет
// постоянную ссылку, поэтому «восстановление» `enabled-runtime` таким
// вызовом оставляло юнит включённым в обоих scope'ах — то есть обещало
// точность, которой не давало. `masked`/`masked-runtime` восстанавливать
// не нужно вовсе: на замаскированном юните `enable --now` отказывает, и
// операция падает, ничего не изменив.
switch (serviceState.unitFileState) {
case "enabled":
await runMutatingVisible`systemctl enable nftables`;
return;
case "disabled":
await runMutatingVisible`systemctl disable nftables`;
return;
default:
info(
`nftables.service unit file state "${serviceState.unitFileState || "(empty)"}" is left as is: ` +
"точное восстановление этого состояния не гарантируется, а операция не могла его изменить"
);
}
}
});
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)}`
);
return;
}
await cleanupFirewallBackupFiles(opId);
}