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 { await runMutatingVisible`mkdir -p ${rollbackRoot(opId)}`; } async function cleanupFirewallBackupFiles(opId: string): Promise { 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(); 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; /** Значения свойств юнита; о непрочитанном свойстве возвращается пустая строка. */ showProperties(unit: string, properties: readonly string[]): Promise>; }; export const defaultSystemdUnitProbe: SystemdUnitProbe = { async listGuardUnits(): Promise { 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> { // `--value` здесь не используется: при нескольких свойствах он печатает // значения без имён, и разбор начинает зависеть от порядка вывода. Формат // `Свойство=значение` самоописателен. const raw = await runReadOnlyArgv([ "systemctl", "show", ...properties.map((property) => `--property=${property}`), unit ]); const values: Record = {}; 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 { return ((await probe.showProperties(unit, [property]))[property] ?? "").trim(); } /** * Снимает резервные копии ДО первой мутации firewall — и доказывает, что снял. * * Что было: * * cp -a /etc/nftables.conf 2>/dev/null || true * * то есть отказ копирования (заполненный /run, ошибка ввода-вывода, права) * молча игнорировался. Дальше выставлялся маркер `prepared`, и операция * начинала переписывать firewall — уже НЕ имея резервной копии, на которую * рассчитывает откат. Предпосылка отката нарушалась в самом его основании. * * Маркер `prepared` теперь ставится ПОСЛЕ проверенных копий, а не до них: он * означает «данные для отката существуют», и раньше это было неправдой. * * Вместе с файлами сохраняется состояние юнита nftables.service. Восстановление * одних только файлов оставляло на хосте системную мутацию: `applyFirewall` * выполняет `systemctl enable --now nftables`, и после отката неудачной ПЕРВОЙ * установки сервис оставался включённым в автозапуск, хотя до установки был * выключен. */ async function backupFirewallState(opId: string): Promise { assertSafeOperationKey(opId); await ensureRollbackRoot(opId); // Маркер срабатывания принадлежит ЭТОЙ операции. Оставшийся от предыдущей он // запретил бы фиксацию успеха на ровном месте. await runMutatingVisible`rm -f ${autoRollbackFiredMarker(opId)}`; for (const target of FIREWALL_BACKUP_TARGETS) { const backupPath = rollbackBackup(target.backup, opId); const markerPath = rollbackBackup(target.marker, opId); 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 { 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 { 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 { const acmeChallengePort = context.config.acmeType === "tls" ? 443 : 80; const acmeRule = context.config.tlsMode === "acme" ? `meta nfproto ipv4 tcp dport ${acmeChallengePort} accept` : "# acme challenge port disabled"; return renderTemplate( await readText(`${context.options.packageDir}/templates/nftables/hy2xs.nft.tpl`), { SSH_PORT: context.config.sshPort, HYSTERIA_PORT: context.config.hysteriaPort, ACME_RULE: acmeRule } ); } /** * Промежуточные `*.candidate` не имеют права пережить операцию. * * `/etc/nftables.conf.candidate` не удалялся вообще: успешная установка * оставляла его на диске навсегда. Для продукта с контрактом чистого хоста это * означало файл, который никто не создавал повторно и никто не убирал. */ async function cleanupFirewallCandidates(): Promise { await runMutatingVisible`rm -f ${HY2XS_NFT_CANDIDATE} ${NFTABLES_ENTRYPOINT_CANDIDATE}`; } /** * Команда взведения 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 { 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 { 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([ "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 { 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(); 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 { 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; 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 { 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 { if (!(await fileExists(autoRollbackFiredMarker(opId)))) { return; } throw new FirewallGuardFiredError( `automatic firewall rollback has already fired (${when}): marker ${autoRollbackFiredMarker(opId)} exists. ` + "Правила firewall этой операции больше не действуют, поэтому фиксировать успех запрещено; " + "выполняется обычный откат операции." ); } /** * Останавливает guard и ДОКАЗЫВАЕТ, что остановил. * * Что было: * * systemctl stop .timer .service || true * systemctl reset-failed .timer .service || true * * и сразу за этим — сообщение «timer disarmed» и долговечная запись * `phase: installed`. То есть порядок фиксации успеха опирался на утверждение, * которого никто не проверял: отказ остановки стирался через `|| true`, и * взведённый таймер мог вернуть прежний firewall уже ПОСЛЕ того, как установка * объявлена успешной. * * Просто убрать `|| true` нельзя: для транзиентного юнита, который уже * отработал и был убран systemd, `systemctl stop` возвращает 5 («unit not * loaded») — законный исход. Поэтому код возврата уходит в журнал как * диагностика, а решение принимается по НАБЛЮДАЕМОМУ состоянию юнитов и по * маркеру срабатывания. * * Инвариант, который здесь устанавливается: * * маркер auto-rollback-fired отсутствует * И timer/service находятся в состоянии inactive * => автоматический откат больше не может сработать * * `reset-failed` остаётся уборкой: он ничего не доказывает и не имеет права * отменить уже доказанное снятие guard'а. */ async function stopRollbackGuard( context: RuntimeContext, opId: string, options: { assertNotFired: boolean } ): Promise { if (!context.config.firewallStagedApply) { info("staged firewall apply is disabled: no rollback guard was armed for this operation"); return; } const unit = rollbackUnit(opId); 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 { 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 { if (firewallRollbackIsInactive(context)) { return; } await cleanupFirewallBackupFiles(operationKey(context)); } /** * Немедленное восстановление firewall. * * Три правила, которых здесь раньше не было. * * 1. Ошибки восстановления НЕ скрываются. Было: * * cp /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 { 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); }