2259f7c847
Барьер, обязанный ДОКАЗАТЬ отсутствие асинхронного исполнителя, в трёх местах принимал за доказательство отсутствие наблюдения. - отказ `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-граф.
1311 lines
65 KiB
TypeScript
1311 lines
65 KiB
TypeScript
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);
|
||
}
|