@@ -1,7 +1,7 @@
import type { RuntimeContext } from "../types/context" ;
import { fileExists , readText , renderTemplate , writeText } from "../lib/fs" ;
import { fail , info } from "../lib/log" ;
import { runMutatingVisible } from "../lib/process" ;
import { runMutatingStatus , runMutatingVisible , runReadOnly } from "../lib/process" ;
import { runRollbackStages , type RollbackStage } from "../lib/rollback" ;
type NftEntrypointKind =
@@ -14,6 +14,63 @@ type NftEntrypointKind =
export type FirewallEntrypointKind = NftEntrypointKind ;
export const NFTABLES_ENTRYPOINT_PATH = "/etc/nftables.conf" ;
export const HY2XS_NFT_PATH = "/etc/nftables.d/hy2xs.nft" ;
const NFTABLES_ENTRYPOINT_CANDIDATE = ` ${ NFTABLES_ENTRYPOINT_PATH } .candidate ` ;
const HY2XS_NFT_CANDIDATE = ` ${ HY2XS_NFT_PATH } .candidate ` ;
/**
* Окно, в течение которого автоматический откат firewall остаётся взведённым.
*
* Значение НЕ является таймаутом smoke и не обязано его покрывать. Наоборот:
* smoke заведомо может идти дольше, и это учтено маркером `auto-rollback-fired`
* — сработавший guard запрещает фиксацию успеха, каким бы зелёным ни оказался
* smoke. Увеличение окна лечило бы гонку расширением, а не устранением.
*/
const FIREWALL_ROLLBACK_DEADLINE = "45s" ;
/**
* Маркер факта: автоматический откат firewall НАЧАЛ выполняться.
*
* Ключевое слово — «начал». Файл создаётся первым действием rollback-скрипта,
* до любой проверки и до первой попытки восстановления, поэтому его наличие
* означает «правила этой операции больше нельзя считать действующими»,
* независимо от того, чем скрипт закончился.
*
* Без этого маркера у операции не было способа отличить «guard снят» от «guard
* успел сработать»: транзиентные юниты systemd после выполнения исчезают, и
* `systemctl stop` для них возвращает такой же результат, как для успешно
* остановленного таймера.
*/
const AUTO_ROLLBACK_FIRED_MARKER = "auto-rollback-fired" ;
/**
* Состояния юнита, допустимые после остановки guard'а .
*
* На пути фиксации успеха допустимо ровно одно: `inactive`. Всё остальное —
* `active`, `activating`, `failed` — означает, что автоматический откат либо всё
* ещё может сработать, либо уже сработал, и фиксировать успех нельзя.
*
* На пути восстановления `failed` тоже допустим: там сработавший и упавший guard
* — ожидаемая часть картины, а не причина объявить откат несостоявшимся.
*/
const GUARD_STOPPED_STATES_FOR_COMMIT = [ "inactive" ] as const ;
const GUARD_STOPPED_STATES_FOR_RECOVERY = [ "inactive" , "failed" ] as const ;
/**
* Автоматический откат firewall уже сработал.
*
* Отдельный тип, а не текст ошибки: классификация отказа обязана опираться на
* тип, а не на разбор сообщения — ровно по той причине, по которой из install и
* reconfigure убрали regexp'ы по тексту ошибки.
*/
export class FirewallGuardFiredError extends Error {
constructor ( message : string ) {
super ( message ) ;
this . name = "FirewallGuardFiredError" ;
}
}
function rollbackRoot ( opId : string ) : string {
return ` /run/hy2xs/rollback/ ${ opId } ` ;
}
@@ -22,14 +79,44 @@ function rollbackUnit(opId: string): string {
return ` hy2xs-fw-rollback- ${ opId } ` ;
}
/**
* Ключ операции.
*
* Санитизация здесь не косметическая: значение служит ИМЕНЕМ каталога в /run,
* ИМЕНЕМ systemd-юнита и подставляется в текст rollback-скрипта. Класс символов
* сознательно узкий, и `assertSafeOperationKey` превращает это из допущения в
* проверяемое утверждение.
*/
export function operationKeyFor ( installDate : string ) : string {
return installDate . replace ( /[^a-zA-Z0-9_.-]/g , "-" ) ;
}
function operationKey ( context : RuntimeContext ) : string {
return context . installDate . replace ( /[^a-zA-Z0-9_.-]/g , "-" ) ;
return operationKeyFor ( context . installDate ) ;
}
function assertSafeOperationKey ( opId : string ) : void {
if ( ! /^[a-zA-Z0-9_.-]+$/ . test ( opId ) ) {
fail ( ` unsafe operation key for the firewall rollback guard: ${ JSON . stringify ( opId ) } ` ) ;
}
}
function rollbackMarker ( opId : string ) : string {
return ` ${ rollbackRoot ( opId ) } /prepared ` ;
}
function autoRollbackFiredMarker ( opId : string ) : string {
return ` ${ rollbackRoot ( opId ) } / ${ AUTO_ROLLBACK_FIRED_MARKER } ` ;
}
function autoRollbackScriptPath ( opId : string ) : string {
return ` ${ rollbackRoot ( opId ) } /auto-rollback.sh ` ;
}
function nftablesServiceStatePath ( opId : string ) : string {
return ` ${ rollbackRoot ( opId ) } /nftables.service.state ` ;
}
function rollbackBackup ( path : string , opId : string ) : string {
return ` ${ rollbackRoot ( opId ) } / ${ path } ` ;
}
@@ -49,10 +136,50 @@ async function cleanupFirewallBackupFiles(opId: string): Promise<void> {
* именно то, что нужно» — разные утверждения, и rollback опирается на второе.
*/
const FIREWALL_BACKUP_TARGETS = [
{ path : "/etc/nftables.conf" , backup : "nftables.conf.bak" , marker : "nftables.conf.existed" } ,
{ path : "/etc/nftables.d/hy2xs.nft" , backup : "hy2xs.nft.bak" , marker : "hy2xs.nft.existed" }
{ path : NFTABLES_ENTRYPOINT_PATH , backup : "nftables.conf.bak" , marker : "nftables.conf.existed" } ,
{ path : HY2XS_NFT_PATH , backup : "hy2xs.nft.bak" , marker : "hy2xs.nft.existed" }
] as const ;
/** Состояние юнита nftables.service на момент снятия резервной копии. */
export type NftablesServiceState = {
/** `enabled` | `disabled` | `masked` | `static` | ... — вывод `systemctl show UnitFileState`. */
unitFileState : string ;
/** `active` | `inactive` | `failed` | ... — вывод `systemctl show ActiveState`. */
activeState : string ;
} ;
export function renderNftablesServiceState ( state : NftablesServiceState ) : string {
return ` unit_file_state= ${ state . unitFileState } \ nactive_state= ${ state . activeState } \ n ` ;
}
/**
* Разбор терпимый, и это осознанно: файл состояния — вспомогательные метаданные.
* Непонятое значение приводит к пропуску стадии восстановления с записью в
* журнал, а не к отказу от восстановления firewall, ради которого всё и
* затевалось.
*/
export function parseNftablesServiceState ( raw : string ) : NftablesServiceState | null {
const values = new Map < 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 } ;
}
async function readUnitProperty ( unit : string , property : string ) : Promise < string > {
return ( await runReadOnly ` systemctl show --property= ${ property } --value ${ unit } ` ) . trim ( ) ;
}
/**
* Снимает резервные копии ДО первой мутации firewall — и доказывает, что снял.
*
@@ -67,10 +194,21 @@ const FIREWALL_BACKUP_TARGETS = [
*
* Маркер `prepared` теперь ставится ПОСЛЕ проверенных копий, а не до них: он
* означает «данные для отката существуют», и раньше это было неправдой.
*
* Вместе с файлами сохраняется состояние юнита nftables.service. Восстановление
* одних только файлов оставляло на хосте системную мутацию: `applyFirewall`
* выполняет `systemctl enable --now nftables`, и после отката неудачной ПЕРВОЙ
* установки сервис оставался включённым в автозапуск, хотя до установки был
* выключен.
*/
async function backupFirewallState ( opId : string ) : Promise < 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 ) ;
@@ -93,9 +231,27 @@ async function backupFirewallState(opId: string): Promise<void> {
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 ) , 0 o600 ) ;
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/ )
@@ -135,10 +291,166 @@ function classifyNftEntrypoint(content: string): NftEntrypointKind {
}
export async function detectFirewallEntrypointKind ( ) : Promise < FirewallEntrypointKind > {
if ( ! ( await fileExists ( "/etc/nftables.conf" ) ) ) {
if ( ! ( await fileExists ( NFTABLES_ENTRYPOINT_PATH ) ) ) {
return "missing" ;
}
return classifyNftEntrypoint ( await readText ( "/etc/nftables.conf" ) ) ;
return classifyNftEntrypoint ( await readText ( NFTABLES_ENTRYPOINT_PATH ) ) ;
}
/**
* Скрипт автоматического отката firewall.
*
* Функция чистая и экспортируется намеренно: раньше этот скрипт существовал
* только как однострочный литерал внутри `systemd-run ... /bin/sh -c '...'` с
* интерполяциями. Интерполяции проходили через shell-квотирование и
* подставлялись ВНУТРЬ уже закавыченной строки, поэтому корректность держалась
* на склейке соседних кавычек и на том, что op-id не содержит пробелов. Такой
* код нельзя ни прочитать, ни проверить парсером, ни покрыть тестом.
*
* Два свойства, ради которых он переписан.
*
* 1. Маркер `auto-rollback-fired` создаётся ПЕРВЫМ действием — до проверки
* `prepared` и до первой попытки восстановления. Иначе «guard сработал» было
* бы недоказуемо: транзиентные юниты systemd после выполнения исчезают.
*
* 2. Ошибки не маскируются, но и не прерывают восстановление. Было:
*
* cp ... || true; cp ... || true; nft -f ... || true
*
* то есть при частичном восстановлении юнит завершался кодом 0, и в journal
* оставалась успешная запись. Теперь каждая стадия независима, её отказ
* поднимает `rc`, и юнит честно уходит в `failed` с диагностикой в journal.
*
* Состояние nftables.service скрипт СОЗНАТЕЛЬНО не восстанавливает: на Debian у
* этого юнита `ExecStop=/usr/sbin/nft flush ruleset`, то есть остановка сервиса
* стёрла бы только что восстановленные правила — прямо противоположно задаче
* guard'а . Enable/active восстанавливает обычный откат в процессе оркестратора,
* где порядок стадий контролируется.
*/
export function buildAutoRollbackScript ( opId : string ) : string {
assertSafeOperationKey ( opId ) ;
const root = rollbackRoot ( opId ) ;
return ` #!/bin/sh
# HY2XS: автоматический откат firewall для операции ${ opId } .
#
# Запускается транзиентным юнитом systemd, если операция не сняла guard за
# отведённое окно. Единственная задача — вернуть сервер к firewall, который был
# на нём до операции, чтобы не потерять доступ по SSH.
#
# set -e здесь НЕ используется: восстановить нужно ВСЕ части, а не остановиться
# на первой отказавшей. Непрерывность обеспечивается независимыми стадиями,
# честность — накоплением rc.
root=' ${ root } '
# Маркер срабатывания — первым действием, до любой проверки. Операция обязана
# узнать, что guard сработал, даже если восстановление ниже не удалось.
mkdir -p " $ root"
: >" $ root/ ${ AUTO_ROLLBACK_FIRED_MARKER } "
if [ ! -f " $ root/prepared" ]; then
echo 'hy2xs auto-rollback: prepared marker is absent, nothing to restore' >&2
exit 0
fi
rc=0
# $ 1 — маркер существования, $ 2 — резервная копия, $ 3 — целевой путь.
restore_file() {
if [ -f " $ 1" ]; then
if ! cp -a " $ 2" " $ 3"; then
echo "hy2xs auto-rollback: failed to restore $ 3 from $ 2" >&2
rc=1
fi
return
fi
if ! rm -f " $ 3"; then
echo "hy2xs auto-rollback: failed to remove $ 3" >&2
rc=1
fi
}
restore_file " $ root/nftables.conf.existed" " $ root/nftables.conf.bak" ' ${ NFTABLES_ENTRYPOINT_PATH } '
restore_file " $ root/hy2xs.nft.existed" " $ root/hy2xs.nft.bak" ' ${ HY2XS_NFT_PATH } '
if [ -f " $ root/nftables.conf.existed" ]; then
if ! nft -f ' ${ NFTABLES_ENTRYPOINT_PATH } '; then
echo 'hy2xs auto-rollback: failed to apply ${ NFTABLES_ENTRYPOINT_PATH } ' >&2
rc=1
fi
else
if ! nft flush ruleset; then
echo 'hy2xs auto-rollback: failed to flush ruleset' >&2
rc=1
fi
fi
if [ " $ rc" -ne 0 ]; then
echo "hy2xs auto-rollback: recovery data is preserved in $ root" >&2
fi
exit " $ rc"
` ;
}
/** Корневой entrypoint nftables, который разворачивает HY2XS. */
export function renderNftablesEntrypoint ( includePath : string ) : string {
return ` #!/usr/sbin/nft -f
# HY2XS-MANAGED: root nftables entrypoint
# Generated by hy2xs-orchestrator. Do not edit manually; edit /etc/hy2xs/hy2xs.env and run reconfigure.
flush ruleset
include " ${ includePath } "
` ;
}
/**
* Отрендеренный фрагмент правил HY2XS.
*
* Вынесен из applyFirewall, потому что у него появился второй потребитель:
* smoke сверяет ЭФФЕКТИВНЫЙ firewall с тем, который должна была получить эта
* конфигурация. Две копии логики рендера означали бы, что проверка сверяет файл
* сам с собой.
*/
export async function renderHy2xsNft ( context : RuntimeContext ) : Promise < 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 } ` ;
}
async function armRollbackGuard ( opId : string ) : Promise < void > {
assertSafeOperationKey ( opId ) ;
const scriptPath = autoRollbackScriptPath ( opId ) ;
await writeText ( scriptPath , buildAutoRollbackScript ( opId ) , 0 o700 ) ;
const unit = rollbackUnit ( opId ) ;
await runMutatingVisible ` systemd-run --unit ${ unit } --on-active= ${ FIREWALL_ROLLBACK_DEADLINE } /bin/sh ${ scriptPath } ` ;
info (
` firewall rollback guard armed: ${ unit } fires in ${ FIREWALL_ROLLBACK_DEADLINE } unless the operation disarms it `
) ;
}
export async function applyFirewall ( context : RuntimeContext ) : Promise < void > {
@@ -153,19 +465,10 @@ export async function applyFirewall(context: RuntimeContext): Promise<void> {
return ;
}
const acmeChallengePort = context . config . acmeType === "tls" ? 443 : 80 ;
const acmeRule = context . config . tlsMode === "acme"
? ` meta nfproto ipv4 tcp dport ${ acmeChallengePort } accept `
: "# acme challenge port disabled" ;
const rendered = await renderHy2xsNft ( context ) ;
const rendered = renderTemplate ( await readText ( ` ${ context . options . packageDir } /templates/nftables/hy2xs.nft.tpl ` ) , {
SSH_PORT : context.config.sshPort ,
HYSTERIA_PORT : context.config.hysteriaPort ,
ACME_RULE : acmeRule
} ) ;
const existing = await fileExists ( "/etc/nftables.conf" )
? await readText ( "/etc/nftables.conf" )
const existing = await fileExists ( NFTABLES_ENTRYPOINT_PATH )
? await readText ( NFTABLES_ENTRYPOINT_PATH )
: "" ;
const entrypointKind = classifyNftEntrypoint ( existing ) ;
@@ -183,46 +486,83 @@ export async function applyFirewall(context: RuntimeContext): Promise<void> {
// Резервные копии снимаются и ПРОВЕРЯЮТСЯ до первой записи в /etc.
await backupFirewallState ( opId ) ;
await writeText ( "/etc/nftables.d/hy2xs.nft.candidate" , rendered , 0 o600 ) ;
await runMutatingVisible ` nft -c -f /etc/nftables.d/hy2xs.nft.candidate ` ;
await writeText ( HY2XS_NFT_CANDIDATE , rendered , 0 o600 ) ;
await runMutatingVisible ` nft -c -f ${ HY2XS_NFT_CANDIDATE } ` ;
const nftablesConfCandidate = ` #!/usr/sbin/nft -f
# HY2XS-MANAGED: root nftables entrypoint
# Generated by hy2xs-orchestrator. Do not edit manually; edit /etc/hy2xs/hy2xs.env and run reconfigure.
await writeText ( NFTABLES_ENTRYPOINT_CANDIDATE , renderNftablesEntrypoint ( HY2XS_NFT_CANDIDATE ) , 0 o644 ) ;
await runMutatingVisible ` nft -c -f ${ NFTABLES_ENTRYPOINT_CANDIDATE } ` ;
flush ruleset
await runMutatingVisible ` mv ${ HY2XS_NFT_CANDIDATE } ${ HY2XS_NFT_PATH } ` ;
include "/etc/nftables.d/hy2xs.nft.candidate"
` ;
await writeText ( "/etc/nftables.conf.candidate" , nftablesConfCandidate , 0 o644 ) ;
await runMutatingVisible ` nft -c -f /etc/nftables.conf.candidate ` ;
await runMutatingVisible ` mv /etc/nftables.d/hy2xs.nft.candidate /etc/nftables.d/hy2xs.nft ` ;
const nftablesConf = ` #!/usr/sbin/nft -f
# HY2XS-MANAGED: root nftables entrypoint
# Generated by hy2xs-orchestrator. Do not edit manually; edit /etc/hy2xs/hy2xs.env and run reconfigure.
flush ruleset
include "/etc/nftables.d/hy2xs.nft"
` ;
await writeText ( "/etc/nftables.conf" , nftablesConf , 0 o644 ) ;
await runMutatingVisible ` nft -c -f /etc/nftables.conf ` ;
await writeText ( NFTABLES_ENTRYPOINT_PATH , renderNftablesEntrypoint ( HY2XS_NFT_PATH ) , 0 o644 ) ;
await runMutatingVisible ` nft -c -f ${ NFTABLES_ENTRYPOINT_PATH } ` ;
if ( context . config . firewallStagedApply ) {
const unit = rollbackUnit ( opId ) ;
await runMutatingVisible ` systemd-run --unit ${ unit } --on-active=45s /bin/sh -c 'if [ -f ${ rollbackMarker ( opId ) } ]; then if [ -f ${ rollbackBackup ( "nftables.conf.existed" , opId ) } ]; then cp -a ${ rollbackBackup ( "nftables.conf.bak" , opId ) } /etc/nftables.conf 2>/dev/null || true; else rm -f /etc/nftables.conf; fi; if [ -f ${ rollbackBackup ( "hy2xs.nft.existed" , opId ) } ]; then cp -a ${ rollbackBackup ( "hy2xs.nft.bak" , opId ) } /etc/nftables.d/hy2xs.nft 2>/dev/null || true; else rm -f /etc/nftables.d/hy2xs.nft; fi; if [ -f ${ rollbackBackup ( "nftables.conf.existed" , opId ) } ]; then nft -f /etc/nftables.conf >/dev/null 2>&1 || true; else nft flush ruleset >/dev/null 2>&1 || true; fi; fi' ` ;
await armRollbackGuard ( opId ) ;
}
await runMutatingVisible ` nft -f /etc/nftables.conf ` ;
await runMutatingVisible ` nft -f ${ NFTABLES_ENTRYPOINT_PATH } ` ;
await runMutatingVisible ` systemctl enable --now nftables ` ;
await runMutatingVisible ` ss -H -ltn | grep -q ': ${ context . config . sshPort } ' || (echo 'ssh port check failed' >&2; exit 1) ` ;
await cleanupFirewallCandidates ( ) ;
info ( "firewall applied with rollback guard; guard will be cancelled only after successful smoke checks" ) ;
}
/**
* Эффективный firewall обязан быть ТЕМ, который сгенерировала эта операция.
*
* Проверка закрывает вторую половину гонки со сработавшим guard'ом. Раньше
* единственной проверкой firewall в smoke был
*
* nft -c -f /etc/nftables.conf
*
* то есть РАЗБОР текущего файла, каким бы он ни был. Если автоматический откат
* успевал вернуть прежний — валидный — ruleset, эта проверка проходила зелёной,
* и операция объявляла успешной установку, работающую на firewall, который она
* же только что заменила. Особенно дорого это стоило при смене порта Hysteria,
* SSH или ACME.
*
* Сверяется три независимых утверждения:
* 1. фрагмент правил на диске совпадает с отрендеренным для этой конфигурации;
* 2. корневой entrypoint принадлежит HY2XS и подключает именно его;
* 3. таблица `inet hy2xs` реально загружена в ядро, а не только описана файлом.
*/
export async function assertEffectiveFirewallIsOurs ( context : RuntimeContext ) : Promise < 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 ) } `
) ;
}
}
function firewallRollbackIsInactive ( context : RuntimeContext ) : boolean {
return (
context . options . skipFirewall ||
@@ -231,13 +571,104 @@ function firewallRollbackIsInactive(context: RuntimeContext): boolean {
) ;
}
async function stopRollbackTimer ( context : RuntimeContext , opId : string ) : Promise < void > {
if ( ! context . config . firewallStagedApply ) {
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 ) ;
await runMutatingVisible ` systemctl stop ${ unit } .timer ${ unit } .service || true ` ;
await runMutatingVisible ` systemctl reset-failed ${ unit } .timer ${ unit } .service || true ` ;
const allowedStates = options . assertNotFired
? GUARD_STOPPED_STATES_FOR_COMMIT
: GUARD_STOPPED_STATES_FOR_RECOVERY ;
if ( options . assertNotFired ) {
await assertGuardHasNotFired ( opId , "before stopping the rollback guard" ) ;
}
const stop = await runMutatingStatus ` systemctl stop ${ unit } .timer ${ unit } .service ` ;
if ( stop . exitCode !== 0 ) {
// Не отказ сам по себе: транзиентный юнит мог быть уже убран systemd.
// Решает проверка состояния ниже.
info (
` systemctl stop ${ unit } .timer ${ unit } .service exited with ${ stop . exitCode } : ` +
` ${ stop . stderr . trim ( ) || "(no stderr)" } `
) ;
}
for ( const target of [ ` ${ unit } .timer ` , ` ${ unit } .service ` ] ) {
const state = await readUnitProperty ( target , "ActiveState" ) ;
if ( ( allowedStates as readonly string [ ] ) . includes ( state ) ) {
continue ;
}
if ( options . assertNotFired ) {
// Сработавший guard обязан быть опознан как таковой, а не как
// безымянная неудача остановки: от типа ошибки зависит классификация
// отказа операции.
await assertGuardHasNotFired ( opId , ` unit ${ target } is in state " ${ state } " ` ) ;
}
throw new Error (
` firewall rollback guard ${ target } is still in state " ${ state } " after systemctl stop ` +
` (exit ${ stop . exitCode } ${ stop . stderr . trim ( ) ? ` : ${ stop . stderr . trim ( ) } ` : "" } ). ` +
"Автоматический откат firewall не снят."
) ;
}
const resetFailed = await runMutatingStatus ` systemctl reset-failed ${ unit } .timer ${ unit } .service ` ;
if ( resetFailed . exitCode !== 0 ) {
info (
` systemctl reset-failed ${ unit } .timer ${ unit } .service exited with ${ resetFailed . exitCode } : ` +
` ${ resetFailed . stderr . trim ( ) || "(no stderr)" } `
) ;
}
if ( options . assertNotFired ) {
await assertGuardHasNotFired ( opId , "after stopping the rollback guard" ) ;
}
}
/**
@@ -258,14 +689,20 @@ async function stopRollbackTimer(context: RuntimeContext, opId: string): Promise
* Откат запускался, но откатывать ему было нечем.
*
* Теперь между disarm и cleanup стоит долговечная фиксация успеха, и до неё
* ручное восстановление остаётся возможным.
* ручное восстановление остаётся возможным. Сам disarm при этом стал
* доказательством, а не сообщением: см. `stopRollbackGuard`.
*/
export async function disarmFirewallRollback ( context : RuntimeContext ) : Promise < void > {
if ( firewallRollbackIsInactive ( context ) ) {
return ;
}
await stopRollbackTimer ( context , operationKey ( context ) ) ;
info ( "firewall rollback timer disarmed; backups are kept until the installation is durably committed" ) ;
await stopRollbackGuard ( context , operationKey ( context ) , { assertNotFired : true } ) ;
if ( ! context . config . firewallStagedApply ) {
return ;
}
info (
"firewall rollback guard disarmed and proven inactive; backups are kept until the installation is durably committed"
) ;
}
/**
@@ -285,7 +722,7 @@ export async function cleanupFirewallRollback(context: RuntimeContext): Promise<
/**
* Немедленное восстановление firewall.
*
* Два правила, которых здесь раньше не было.
* Три правила, которых здесь раньше не было.
*
* 1. Ошибки восстановления НЕ скрываются. Было:
*
@@ -300,9 +737,15 @@ export async function cleanupFirewallRollback(context: RuntimeContext): Promise<
* ошибка восстановления скрыта, а данные, по которым оператор мог бы
* поднять firewall вручную, уничтожены.
*
* Стадии независимы: файл nftables.conf и файл hy2xs.nft в осс танавливаются
* порознь, применение правил выполняется в любом случае — частично
* восстановленный firewall лучше полнос тью о тсутствующего .
* 3. Остановка guard'а — такая же стадия, как остальные. Раньше она выполнялась
* отдельным вызовом с `|| true` внутри, поэтому откат мог начать
* восстановление, не остановив таймер, и не сообщи ть о б этом .
*
* Стадии независимы и идут в порядке, в котором ошибка одной не портит
* результат другой. Порядок важен для nftables.service: на Debian у него
* `ExecStop=/usr/sbin/nft flush ruleset`, поэтому восстановление состояния
* сервиса обязано идти ДО применения ruleset — иначе остановка сервиса стёрла бы
* только что восстановленные правила.
*/
export async function rollbackFirewallNow ( context : RuntimeContext ) : Promise < void > {
const opId = operationKey ( context ) ;
@@ -315,11 +758,17 @@ export async function rollbackFirewallNow(context: RuntimeContext): Promise<void
return ;
}
await stopRollbackTimer ( context , opId ) ;
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 ) ;
@@ -338,18 +787,79 @@ export async function rollbackFirewallNow(context: RuntimeContext): Promise<void
}
stages . push ( {
name : "apply restored ruleset " ,
name : "restore nftables.service unit file state " ,
run : async ( ) = > {
if ( entrypointExisted ) {
await runMutatingVisible ` nft -f /etc/nftables.conf ` ;
if ( ! serviceState ) {
info ( "nftables.service state was not captured for this operation; unit file state is left as is" ) ;
return ;
}
await runMutatingVisible ` nft flush ruleset ` ;
switch ( serviceState . unitFileState ) {
case "enabled" :
await runMutatingVisible ` systemctl enable nftables ` ;
return ;
case "enabled-runtime" :
await runMutatingVisible ` systemctl enable --runtime nftables ` ;
return ;
case "disabled" :
await runMutatingVisible ` systemctl disable nftables ` ;
return ;
case "masked" :
case "masked-runtime" :
await runMutatingVisible ` systemctl mask nftables ` ;
return ;
default :
// static/indirect/generated/transient/пусто: у таких юнитов
// enable/disable либо бессмысленны, либо отказывают.
info (
` nftables.service unit file state " ${ serviceState . unitFileState || "(empty)" } " is not restorable explicitly; skipped `
) ;
}
}
} ) ;
stages . push ( {
name : "restore nftables.service inactive state" ,
run : async ( ) = > {
if ( ! serviceState ) {
return ;
}
if ( serviceState . activeState === "inactive" || serviceState . activeState === "failed" ) {
// Остановка выполняет `nft flush ruleset`, поэтому она обязана
// предшествовать применению восстановленного ruleset.
await runMutatingVisible ` systemctl stop nftables ` ;
return ;
}
info ( ` nftables.service was " ${ serviceState . activeState || "(empty)" } " before the operation; it is restored by the ruleset stage ` ) ;
}
} ) ;
stages . push ( {
name : "apply restored ruleset" ,
run : async ( ) = > {
if ( ! entrypointExisted ) {
await runMutatingVisible ` nft flush ruleset ` ;
return ;
}
if ( serviceState ? . activeState === "active" ) {
// Ровно один flush+load из восстановленного файла: у активного
// nftables.service перезапуск и есть штатное применение ruleset.
await runMutatingVisible ` systemctl restart nftables ` ;
return ;
}
await runMutatingVisible ` nft -f ${ NFTABLES_ENTRYPOINT_PATH } ` ;
}
} ) ;
const failures = await runRollbackStages ( stages ) ;
try {
await cleanupFirewallCandidates ( ) ;
} catch ( candidateError ) {
info (
` firewall candidate files were not removed: ${ candidateError instanceof Error ? candidateError.message : String ( candidateError ) } `
) ;
}
if ( failures . length > 0 ) {
info (
` firewall rollback did not complete; manual recovery data preserved at ${ rollbackRoot ( opId ) } `