fix(orchestrator): сделать staged firewall guard транзакционным
Снятие автоматического отката firewall было утверждением, а не фактом:
systemctl stop <unit>.timer <unit>.service || true
-> "firewall rollback timer disarmed"
-> phase=installed
Отказ остановки стирался через `|| true`, и взведённый таймер мог вернуть
прежний firewall уже ПОСЛЕ долговечной записи успеха. Просто убрать `|| true`
нельзя: для транзиентного юнита, уже убранного systemd, `systemctl stop`
возвращает 5 — законный исход, неотличимый от успеха.
Соседний дефект того же корня: guard мог сработать ВО ВРЕМЯ успешного smoke.
Окно 45 секунд короче худшего случая smoke, а единственной проверкой firewall
был `nft -c` — разбор текущего файла, каким бы он ни был. Откатившийся прежний
ruleset проходил её зелёным, и сервер объявлялся настроенным с firewall,
который операция же и заменила.
Оба закрываются маркером /run/hy2xs/rollback/<op>/auto-rollback-fired, который
rollback-скрипт создаёт первым действием. Инвариант стал детерминированным:
маркер отсутствует И timer/service inactive => можно фиксировать успех
Остальное в том же проходе:
- auto-rollback переехал из однострочного `sh -c` в сгенерированный скрипт.
Прежний держался на склейке соседних кавычек и на том, что op-id не содержит
пробелов; теперь ключ проверяется, а скрипт покрыт тестом и shell-парсером;
- скрипт накапливает rc и уходит в failed вместо молчаливого 0 при частичном
восстановлении. Состояние nftables.service он сознательно не трогает:
ExecStop у него делает `nft flush ruleset`;
- smoke сверяет ЭФФЕКТИВНЫЙ firewall: фрагмент на диске против отрендеренного,
принадлежность entrypoint и загруженность таблицы inet hy2xs;
- откат восстанавливает enabled/active nftables.service — стадиями, идущими до
применения ruleset;
- остановка guard'а в rollbackFirewallNow стала стадией с отчётом, а не вызовом
с `|| true` внутри;
- `*.candidate` больше не остаются на диске навсегда;
- стадии восстановления reconfigure независимы по ОТДЕЛЬНОМУ ФАЙЛУ, а не по
группе;
- ключ операции считается одной функцией: install писал в маркер сырой
ISO-timestamp, и путь /run/hy2xs/rollback/<op_id> из runbook не существовал.
This commit is contained in:
@@ -22,9 +22,11 @@ import { installHysteria } from "../steps/hysteria";
|
|||||||
import { generateConfig } from "../steps/config";
|
import { generateConfig } from "../steps/config";
|
||||||
import { deploySystemd } from "../steps/systemd";
|
import { deploySystemd } from "../steps/systemd";
|
||||||
import {
|
import {
|
||||||
|
FirewallGuardFiredError,
|
||||||
applyFirewall,
|
applyFirewall,
|
||||||
cleanupFirewallRollback,
|
cleanupFirewallRollback,
|
||||||
disarmFirewallRollback,
|
disarmFirewallRollback,
|
||||||
|
operationKeyFor,
|
||||||
rollbackFirewallNow
|
rollbackFirewallNow
|
||||||
} from "../steps/firewall";
|
} from "../steps/firewall";
|
||||||
import { ensureBootstrapAdminSecret, writePostInstallEnv } from "../steps/env";
|
import { ensureBootstrapAdminSecret, writePostInstallEnv } from "../steps/env";
|
||||||
@@ -49,6 +51,11 @@ type InstallPhase =
|
|||||||
| "smoke_running"
|
| "smoke_running"
|
||||||
| "smoke_ok"
|
| "smoke_ok"
|
||||||
| "smoke_failed"
|
| "smoke_failed"
|
||||||
|
// Автоматический откат firewall сработал раньше, чем операция успела снять
|
||||||
|
// guard. Отдельная фаза, потому что оператору важно другое действие: сервер
|
||||||
|
// работает на ПРЕЖНЕМ firewall, и повторять установку нужно, разобравшись,
|
||||||
|
// почему проход не уложился в окно guard'а.
|
||||||
|
| "firewall_guard_fired"
|
||||||
| "failed"
|
| "failed"
|
||||||
| "installed";
|
| "installed";
|
||||||
|
|
||||||
@@ -100,6 +107,7 @@ type FailureKind =
|
|||||||
| "fatal_pre_apply"
|
| "fatal_pre_apply"
|
||||||
| "fatal_post_apply"
|
| "fatal_post_apply"
|
||||||
| "firewall_connectivity_failure"
|
| "firewall_connectivity_failure"
|
||||||
|
| "firewall_guard_fired"
|
||||||
| "service_start_failure"
|
| "service_start_failure"
|
||||||
| "smoke_readiness_timeout"
|
| "smoke_readiness_timeout"
|
||||||
| "postinstall_validation_failure";
|
| "postinstall_validation_failure";
|
||||||
@@ -150,7 +158,11 @@ async function writeInstallState(
|
|||||||
const record = buildInstallStateRecord({
|
const record = buildInstallStateRecord({
|
||||||
productVersion: context.packageVersion,
|
productVersion: context.packageVersion,
|
||||||
buildId: context.packageBuildId,
|
buildId: context.packageBuildId,
|
||||||
opId: context.installDate,
|
// Тот же ключ, что и у каталога отката /run/hy2xs/rollback/<op-id> и у
|
||||||
|
// юнита guard'а. Раньше здесь стоял сырой ISO-timestamp с двоеточиями,
|
||||||
|
// поэтому путь, который runbook предлагает открыть по op_id из маркера,
|
||||||
|
// на сервере не существовал.
|
||||||
|
opId: operationKeyFor(context.installDate),
|
||||||
startedAt: context.installDate,
|
startedAt: context.installDate,
|
||||||
phase,
|
phase,
|
||||||
installed: phase === "installed",
|
installed: phase === "installed",
|
||||||
@@ -184,8 +196,26 @@ async function advanceInstallState(
|
|||||||
*
|
*
|
||||||
* Порядок веток — от самой поздней стадии к самой ранней: она точнее
|
* Порядок веток — от самой поздней стадии к самой ранней: она точнее
|
||||||
* описывает, что именно чинить.
|
* описывает, что именно чинить.
|
||||||
|
*
|
||||||
|
* Единственное исключение из «классификация по владению» — сработавший
|
||||||
|
* автоматический откат firewall, и оно опирается на ТИП ошибки, а не на её
|
||||||
|
* текст. Разница принципиальная: разбор сообщения регулярным выражением уже
|
||||||
|
* убирали и из install, и из reconfigure, потому что слово «firewall» в
|
||||||
|
* сообщении о недоступном DNS уводило классификацию не туда. Тип же
|
||||||
|
* производится ровно одним местом — проверкой маркера `auto-rollback-fired`.
|
||||||
|
*
|
||||||
|
* По владению этот отказ неотличим от обычного: флаги те же, фаза `smoke_ok`.
|
||||||
|
* Но чинить нужно другое — сервер уже работает на прежнем firewall, — поэтому
|
||||||
|
* и называться он обязан иначе.
|
||||||
*/
|
*/
|
||||||
export function classifyFailure(ownership: OperationOwnership, phase: InstallPhase): FailureKind {
|
export function classifyFailure(
|
||||||
|
ownership: OperationOwnership,
|
||||||
|
phase: InstallPhase,
|
||||||
|
error?: unknown
|
||||||
|
): FailureKind {
|
||||||
|
if (error instanceof FirewallGuardFiredError) {
|
||||||
|
return "firewall_guard_fired";
|
||||||
|
}
|
||||||
if (ownership.servicesStarted) {
|
if (ownership.servicesStarted) {
|
||||||
return phase === "smoke_running" || phase === "smoke_failed"
|
return phase === "smoke_running" || phase === "smoke_failed"
|
||||||
? "smoke_readiness_timeout"
|
? "smoke_readiness_timeout"
|
||||||
@@ -443,7 +473,7 @@ export async function install(options: InstallOptions): Promise<void> {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
const failureKind = classifyFailure(ownership, phase);
|
const failureKind = classifyFailure(ownership, phase, error);
|
||||||
|
|
||||||
if (failureKind === "fatal_pre_apply") {
|
if (failureKind === "fatal_pre_apply") {
|
||||||
// Хост не изменён: не пишем install-state и не собираем bundle в
|
// Хост не изменён: не пишем install-state и не собираем bundle в
|
||||||
@@ -460,13 +490,15 @@ export async function install(options: InstallOptions): Promise<void> {
|
|||||||
// дело не доходило вовсе — то есть применённый firewall и развёрнутые
|
// дело не доходило вовсе — то есть применённый firewall и развёрнутые
|
||||||
// сервисы оставались на сервере ровно в том сценарии, ради которого откат и
|
// сервисы оставались на сервере ровно в том сценарии, ради которого откат и
|
||||||
// существует. Это тот же класс, что и с диагностикой ниже.
|
// существует. Это тот же класс, что и с диагностикой ниже.
|
||||||
|
const failurePhase: InstallPhase =
|
||||||
|
failureKind === "smoke_readiness_timeout"
|
||||||
|
? "smoke_failed"
|
||||||
|
: failureKind === "firewall_guard_fired"
|
||||||
|
? "firewall_guard_fired"
|
||||||
|
: "failed";
|
||||||
|
|
||||||
await persistFailureState(() =>
|
await persistFailureState(() =>
|
||||||
advanceInstallState(
|
advanceInstallState(context, ownership, failurePhase, `${failureKind}: ${message}`)
|
||||||
context,
|
|
||||||
ownership,
|
|
||||||
failureKind === "smoke_readiness_timeout" ? "smoke_failed" : "failed",
|
|
||||||
`${failureKind}: ${message}`
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Диагностика — best effort, откат — обязателен.
|
// Диагностика — best effort, откат — обязателен.
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ import { preflight } from "../steps/preflight";
|
|||||||
import { generateConfig } from "../steps/config";
|
import { generateConfig } from "../steps/config";
|
||||||
import { deploySystemd } from "../steps/systemd";
|
import { deploySystemd } from "../steps/systemd";
|
||||||
import {
|
import {
|
||||||
|
FirewallGuardFiredError,
|
||||||
applyFirewall,
|
applyFirewall,
|
||||||
cleanupFirewallRollback,
|
cleanupFirewallRollback,
|
||||||
disarmFirewallRollback,
|
disarmFirewallRollback,
|
||||||
|
operationKeyFor,
|
||||||
rollbackFirewallNow
|
rollbackFirewallNow
|
||||||
} from "../steps/firewall";
|
} from "../steps/firewall";
|
||||||
import {
|
import {
|
||||||
@@ -47,6 +49,7 @@ type ReconfigurePhase =
|
|||||||
| "smoke_ok"
|
| "smoke_ok"
|
||||||
| "reconfigure_failed"
|
| "reconfigure_failed"
|
||||||
| "firewall_connectivity_failure"
|
| "firewall_connectivity_failure"
|
||||||
|
| "firewall_guard_fired"
|
||||||
| "smoke_failed"
|
| "smoke_failed"
|
||||||
| "installed";
|
| "installed";
|
||||||
|
|
||||||
@@ -81,8 +84,20 @@ function newReconfigureOwnership(): ReconfigureOwnership {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Порядок веток — от самой поздней стадии к самой ранней. */
|
/**
|
||||||
export function classifyReconfigureFailure(ownership: ReconfigureOwnership): ReconfigurePhase {
|
* Порядок веток — от самой поздней стадии к самой ранней.
|
||||||
|
*
|
||||||
|
* Сработавший автоматический откат firewall классифицируется по ТИПУ ошибки, а
|
||||||
|
* не по её тексту: по владению он неотличим от обычного отказа smoke, но чинить
|
||||||
|
* надо другое — сервер уже вернулся на прежний firewall.
|
||||||
|
*/
|
||||||
|
export function classifyReconfigureFailure(
|
||||||
|
ownership: ReconfigureOwnership,
|
||||||
|
error?: unknown
|
||||||
|
): ReconfigurePhase {
|
||||||
|
if (error instanceof FirewallGuardFiredError) {
|
||||||
|
return "firewall_guard_fired";
|
||||||
|
}
|
||||||
if (ownership.servicesRestarted) {
|
if (ownership.servicesRestarted) {
|
||||||
return "smoke_failed";
|
return "smoke_failed";
|
||||||
}
|
}
|
||||||
@@ -93,11 +108,13 @@ export function classifyReconfigureFailure(ownership: ReconfigureOwnership): Rec
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ключ операции. Санитизируется так же, как в steps/firewall.ts: он служит
|
* Ключ операции. Санитизация живёт в steps/firewall.ts одной функцией: ключ
|
||||||
* ИМЕНЕМ КАТАЛОГА резервной копии, а не только полем в маркере.
|
* служит именем каталога резервной копии, именем каталога в /run, именем
|
||||||
|
* systemd-юнита и полем `op_id` в маркере установки. Пока копий формулы было
|
||||||
|
* две, они успели разойтись — install писал в маркер сырой ISO-timestamp.
|
||||||
*/
|
*/
|
||||||
function operationKey(context: ReconfigureContext): string {
|
function operationKey(context: ReconfigureContext): string {
|
||||||
return context.installDate.replace(/[^a-zA-Z0-9_.-]/g, "-");
|
return operationKeyFor(context.installDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ownedPaths(context: ReconfigureContext): string[] {
|
function ownedPaths(context: ReconfigureContext): string[] {
|
||||||
@@ -218,16 +235,23 @@ async function readBackupManifest(opId: string): Promise<BackupManifest> {
|
|||||||
* отчитывался успешным, что бы ни произошло.
|
* отчитывался успешным, что бы ни произошло.
|
||||||
*
|
*
|
||||||
* Непрерывность обеспечивает стадийный раннер, а не подавление ошибок.
|
* Непрерывность обеспечивает стадийный раннер, а не подавление ошибок.
|
||||||
|
*
|
||||||
|
* Независимость доведена до ОТДЕЛЬНОГО ФАЙЛА. Раньше стадия соответствовала
|
||||||
|
* группе, и группа «restore systemd units» восстанавливала два юнита одним
|
||||||
|
* телом: отказ `cp` для hy2xs-admin.service отменял восстановление
|
||||||
|
* hysteria-server.service. Внешне это выглядело честно — стадия честно попадала
|
||||||
|
* в список отказавших, — но принцип «восстановить максимум» на уровне файлов не
|
||||||
|
* выполнялся. Группы остались как порядок восстановления и как префикс имени.
|
||||||
*/
|
*/
|
||||||
function restoreStages(manifest: BackupManifest, opId: string): RollbackStage[] {
|
function restoreStages(manifest: BackupManifest, opId: string): RollbackStage[] {
|
||||||
const byPath = new Map(manifest.entries.map((entry) => [entry.path, entry]));
|
const byPath = new Map(manifest.entries.map((entry) => [entry.path, entry]));
|
||||||
const stages: RollbackStage[] = [];
|
const stages: RollbackStage[] = [];
|
||||||
|
|
||||||
for (const group of BACKUP_GROUPS) {
|
for (const group of BACKUP_GROUPS) {
|
||||||
stages.push({
|
for (const path of group.paths) {
|
||||||
name: group.stage,
|
stages.push({
|
||||||
run: async () => {
|
name: `${group.stage}: ${path}`,
|
||||||
for (const path of group.paths) {
|
run: async () => {
|
||||||
const entry = byPath.get(path);
|
const entry = byPath.get(path);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
throw new Error(`манифест резервной копии не описывает ${path}`);
|
throw new Error(`манифест резервной копии не описывает ${path}`);
|
||||||
@@ -235,12 +259,12 @@ function restoreStages(manifest: BackupManifest, opId: string): RollbackStage[]
|
|||||||
if (!entry.present) {
|
if (!entry.present) {
|
||||||
// Файла не существовало до операции: восстановление — удаление.
|
// Файла не существовало до операции: восстановление — удаление.
|
||||||
await runMutatingVisible`rm -f ${path}`;
|
await runMutatingVisible`rm -f ${path}`;
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
await runMutatingVisible`cp -a ${backupFilePath(opId, entry.stored as string)} ${path}`;
|
await runMutatingVisible`cp -a ${backupFilePath(opId, entry.stored as string)} ${path}`;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const entrypoint = byPath.get("/etc/nftables.conf");
|
const entrypoint = byPath.get("/etc/nftables.conf");
|
||||||
@@ -463,7 +487,9 @@ export async function reconfigure(options: ReconfigureOptions): Promise<void> {
|
|||||||
// стояла обычным await, её отказ уносил управление наружу мимо обоих
|
// стояла обычным await, её отказ уносил управление наружу мимо обоих
|
||||||
// восстановлений — и снятия firewall, и возврата конфигов из
|
// восстановлений — и снятия firewall, и возврата конфигов из
|
||||||
// /etc/hy2xs/backups.
|
// /etc/hy2xs/backups.
|
||||||
await persistFailureState(() => markPhase(context, classifyReconfigureFailure(ownership), message));
|
await persistFailureState(() =>
|
||||||
|
markPhase(context, classifyReconfigureFailure(ownership, error), message)
|
||||||
|
);
|
||||||
|
|
||||||
// Диагностика — best effort, откат — обязателен.
|
// Диагностика — best effort, откат — обязателен.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
* runReadOnly / runReadOnlySecret
|
* runReadOnly / runReadOnlySecret
|
||||||
* наблюдение за системой. Guard не трогает — они разрешены в любой фазе.
|
* наблюдение за системой. Guard не трогает — они разрешены в любой фазе.
|
||||||
*
|
*
|
||||||
* runMutating / runMutatingVisible / runMutatingHidden / runMutatingRaw
|
* runMutating / runMutatingVisible / runMutatingHidden / runMutatingRaw /
|
||||||
|
* runMutatingStatus
|
||||||
* всё, что может изменить хост. Каждый спрашивает разрешения у guard'а.
|
* всё, что может изменить хост. Каждый спрашивает разрешения у guard'а.
|
||||||
*
|
*
|
||||||
* Выбор набора — сознательное решение на месте вызова, а не умолчание.
|
* Выбор набора — сознательное решение на месте вызова, а не умолчание.
|
||||||
@@ -115,6 +116,50 @@ export async function runMutatingRaw(command: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MutationResult = {
|
||||||
|
exitCode: number;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Мутация, которая ВОЗВРАЩАЕТ код возврата вместо броска.
|
||||||
|
*
|
||||||
|
* Существует ровно ради одного класса команд: тех, у которых ненулевой код —
|
||||||
|
* законный исход, а не отказ. Канонический пример — остановка транзиентного
|
||||||
|
* юнита: `systemctl stop hy2xs-fw-rollback-<op>.timer` возвращает 5 («unit not
|
||||||
|
* loaded»), если таймер уже отработал и был убран systemd, и это ровно то же
|
||||||
|
* наблюдаемое состояние, что и успешная остановка.
|
||||||
|
*
|
||||||
|
* Раньше такие места писались как `systemctl stop ... || true`. Разница
|
||||||
|
* принципиальная: `|| true` стирает ВСЕ коды возврата, включая настоящий отказ,
|
||||||
|
* и вызывающий не может отличить «юнита уже нет» от «остановить не удалось».
|
||||||
|
* Именно из-за этого снятие rollback guard рапортовало об успехе, ничего не
|
||||||
|
* сняв.
|
||||||
|
*
|
||||||
|
* Контракт раннера: он ничего не решает сам. Решение — «этот код возврата
|
||||||
|
* допустим» или «состояние надо доказать отдельно» — принимает вызывающий,
|
||||||
|
* на месте, где известен смысл команды.
|
||||||
|
*/
|
||||||
|
export async function runMutatingStatus(
|
||||||
|
command: TemplateStringsArray,
|
||||||
|
...args: unknown[]
|
||||||
|
): Promise<MutationResult> {
|
||||||
|
const rendered = renderCommand(command, args);
|
||||||
|
assertMutationAllowed(`runMutatingStatus(${rendered})`);
|
||||||
|
info(`running: ${rendered}`);
|
||||||
|
const process = Bun.spawn(["sh", "-c", rendered], {
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe"
|
||||||
|
});
|
||||||
|
const [stdout, stderr, exitCode] = await Promise.all([
|
||||||
|
new Response(process.stdout).text(),
|
||||||
|
new Response(process.stderr).text(),
|
||||||
|
process.exited
|
||||||
|
]);
|
||||||
|
return { exitCode, stdout, stderr };
|
||||||
|
}
|
||||||
|
|
||||||
/** Мутация без эха команды: аргументы могут содержать секреты. */
|
/** Мутация без эха команды: аргументы могут содержать секреты. */
|
||||||
export async function runMutatingHidden(command: TemplateStringsArray, ...args: unknown[]): Promise<void> {
|
export async function runMutatingHidden(command: TemplateStringsArray, ...args: unknown[]): Promise<void> {
|
||||||
const rendered = renderCommand(command, args);
|
const rendered = renderCommand(command, args);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { RuntimeContext } from "../types/context";
|
import type { RuntimeContext } from "../types/context";
|
||||||
import { fileExists, readText, renderTemplate, writeText } from "../lib/fs";
|
import { fileExists, readText, renderTemplate, writeText } from "../lib/fs";
|
||||||
import { fail, info } from "../lib/log";
|
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";
|
import { runRollbackStages, type RollbackStage } from "../lib/rollback";
|
||||||
|
|
||||||
type NftEntrypointKind =
|
type NftEntrypointKind =
|
||||||
@@ -14,6 +14,63 @@ type NftEntrypointKind =
|
|||||||
|
|
||||||
export type FirewallEntrypointKind = 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 {
|
function rollbackRoot(opId: string): string {
|
||||||
return `/run/hy2xs/rollback/${opId}`;
|
return `/run/hy2xs/rollback/${opId}`;
|
||||||
}
|
}
|
||||||
@@ -22,14 +79,44 @@ function rollbackUnit(opId: string): string {
|
|||||||
return `hy2xs-fw-rollback-${opId}`;
|
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 {
|
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 {
|
function rollbackMarker(opId: string): string {
|
||||||
return `${rollbackRoot(opId)}/prepared`;
|
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 {
|
function rollbackBackup(path: string, opId: string): string {
|
||||||
return `${rollbackRoot(opId)}/${path}`;
|
return `${rollbackRoot(opId)}/${path}`;
|
||||||
}
|
}
|
||||||
@@ -49,10 +136,50 @@ async function cleanupFirewallBackupFiles(opId: string): Promise<void> {
|
|||||||
* именно то, что нужно» — разные утверждения, и rollback опирается на второе.
|
* именно то, что нужно» — разные утверждения, и rollback опирается на второе.
|
||||||
*/
|
*/
|
||||||
const FIREWALL_BACKUP_TARGETS = [
|
const FIREWALL_BACKUP_TARGETS = [
|
||||||
{ path: "/etc/nftables.conf", backup: "nftables.conf.bak", marker: "nftables.conf.existed" },
|
{ path: NFTABLES_ENTRYPOINT_PATH, backup: "nftables.conf.bak", marker: "nftables.conf.existed" },
|
||||||
{ path: "/etc/nftables.d/hy2xs.nft", backup: "hy2xs.nft.bak", marker: "hy2xs.nft.existed" }
|
{ path: HY2XS_NFT_PATH, backup: "hy2xs.nft.bak", marker: "hy2xs.nft.existed" }
|
||||||
] as const;
|
] 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 — и доказывает, что снял.
|
* Снимает резервные копии ДО первой мутации firewall — и доказывает, что снял.
|
||||||
*
|
*
|
||||||
@@ -67,10 +194,21 @@ const FIREWALL_BACKUP_TARGETS = [
|
|||||||
*
|
*
|
||||||
* Маркер `prepared` теперь ставится ПОСЛЕ проверенных копий, а не до них: он
|
* Маркер `prepared` теперь ставится ПОСЛЕ проверенных копий, а не до них: он
|
||||||
* означает «данные для отката существуют», и раньше это было неправдой.
|
* означает «данные для отката существуют», и раньше это было неправдой.
|
||||||
|
*
|
||||||
|
* Вместе с файлами сохраняется состояние юнита nftables.service. Восстановление
|
||||||
|
* одних только файлов оставляло на хосте системную мутацию: `applyFirewall`
|
||||||
|
* выполняет `systemctl enable --now nftables`, и после отката неудачной ПЕРВОЙ
|
||||||
|
* установки сервис оставался включённым в автозапуск, хотя до установки был
|
||||||
|
* выключен.
|
||||||
*/
|
*/
|
||||||
async function backupFirewallState(opId: string): Promise<void> {
|
async function backupFirewallState(opId: string): Promise<void> {
|
||||||
|
assertSafeOperationKey(opId);
|
||||||
await ensureRollbackRoot(opId);
|
await ensureRollbackRoot(opId);
|
||||||
|
|
||||||
|
// Маркер срабатывания принадлежит ЭТОЙ операции. Оставшийся от предыдущей он
|
||||||
|
// запретил бы фиксацию успеха на ровном месте.
|
||||||
|
await runMutatingVisible`rm -f ${autoRollbackFiredMarker(opId)}`;
|
||||||
|
|
||||||
for (const target of FIREWALL_BACKUP_TARGETS) {
|
for (const target of FIREWALL_BACKUP_TARGETS) {
|
||||||
const backupPath = rollbackBackup(target.backup, opId);
|
const backupPath = rollbackBackup(target.backup, opId);
|
||||||
const markerPath = rollbackBackup(target.marker, opId);
|
const markerPath = rollbackBackup(target.marker, opId);
|
||||||
@@ -93,9 +231,27 @@ async function backupFirewallState(opId: string): Promise<void> {
|
|||||||
await runMutatingVisible`printf 1 > ${markerPath}`;
|
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)}`;
|
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 {
|
function stripNftComments(content: string): string {
|
||||||
return content
|
return content
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
@@ -135,10 +291,166 @@ function classifyNftEntrypoint(content: string): NftEntrypointKind {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function detectFirewallEntrypointKind(): Promise<FirewallEntrypointKind> {
|
export async function detectFirewallEntrypointKind(): Promise<FirewallEntrypointKind> {
|
||||||
if (!(await fileExists("/etc/nftables.conf"))) {
|
if (!(await fileExists(NFTABLES_ENTRYPOINT_PATH))) {
|
||||||
return "missing";
|
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), 0o700);
|
||||||
|
|
||||||
|
const unit = rollbackUnit(opId);
|
||||||
|
await runMutatingVisible`systemd-run --unit ${unit} --on-active=${FIREWALL_ROLLBACK_DEADLINE} /bin/sh ${scriptPath}`;
|
||||||
|
info(
|
||||||
|
`firewall rollback guard armed: ${unit} fires in ${FIREWALL_ROLLBACK_DEADLINE} unless the operation disarms it`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function applyFirewall(context: RuntimeContext): Promise<void> {
|
export async function applyFirewall(context: RuntimeContext): Promise<void> {
|
||||||
@@ -153,19 +465,10 @@ export async function applyFirewall(context: RuntimeContext): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const acmeChallengePort = context.config.acmeType === "tls" ? 443 : 80;
|
const rendered = await renderHy2xsNft(context);
|
||||||
const acmeRule = context.config.tlsMode === "acme"
|
|
||||||
? `meta nfproto ipv4 tcp dport ${acmeChallengePort} accept`
|
|
||||||
: "# acme challenge port disabled";
|
|
||||||
|
|
||||||
const rendered = renderTemplate(await readText(`${context.options.packageDir}/templates/nftables/hy2xs.nft.tpl`), {
|
const existing = await fileExists(NFTABLES_ENTRYPOINT_PATH)
|
||||||
SSH_PORT: context.config.sshPort,
|
? await readText(NFTABLES_ENTRYPOINT_PATH)
|
||||||
HYSTERIA_PORT: context.config.hysteriaPort,
|
|
||||||
ACME_RULE: acmeRule
|
|
||||||
});
|
|
||||||
|
|
||||||
const existing = await fileExists("/etc/nftables.conf")
|
|
||||||
? await readText("/etc/nftables.conf")
|
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const entrypointKind = classifyNftEntrypoint(existing);
|
const entrypointKind = classifyNftEntrypoint(existing);
|
||||||
@@ -183,46 +486,83 @@ export async function applyFirewall(context: RuntimeContext): Promise<void> {
|
|||||||
// Резервные копии снимаются и ПРОВЕРЯЮТСЯ до первой записи в /etc.
|
// Резервные копии снимаются и ПРОВЕРЯЮТСЯ до первой записи в /etc.
|
||||||
await backupFirewallState(opId);
|
await backupFirewallState(opId);
|
||||||
|
|
||||||
await writeText("/etc/nftables.d/hy2xs.nft.candidate", rendered, 0o600);
|
await writeText(HY2XS_NFT_CANDIDATE, rendered, 0o600);
|
||||||
await runMutatingVisible`nft -c -f /etc/nftables.d/hy2xs.nft.candidate`;
|
await runMutatingVisible`nft -c -f ${HY2XS_NFT_CANDIDATE}`;
|
||||||
|
|
||||||
const nftablesConfCandidate = `#!/usr/sbin/nft -f
|
await writeText(NFTABLES_ENTRYPOINT_CANDIDATE, renderNftablesEntrypoint(HY2XS_NFT_CANDIDATE), 0o644);
|
||||||
# HY2XS-MANAGED: root nftables entrypoint
|
await runMutatingVisible`nft -c -f ${NFTABLES_ENTRYPOINT_CANDIDATE}`;
|
||||||
# Generated by hy2xs-orchestrator. Do not edit manually; edit /etc/hy2xs/hy2xs.env and run reconfigure.
|
|
||||||
|
|
||||||
flush ruleset
|
await runMutatingVisible`mv ${HY2XS_NFT_CANDIDATE} ${HY2XS_NFT_PATH}`;
|
||||||
|
|
||||||
include "/etc/nftables.d/hy2xs.nft.candidate"
|
await writeText(NFTABLES_ENTRYPOINT_PATH, renderNftablesEntrypoint(HY2XS_NFT_PATH), 0o644);
|
||||||
`;
|
await runMutatingVisible`nft -c -f ${NFTABLES_ENTRYPOINT_PATH}`;
|
||||||
await writeText("/etc/nftables.conf.candidate", nftablesConfCandidate, 0o644);
|
|
||||||
await runMutatingVisible`nft -c -f /etc/nftables.conf.candidate`;
|
|
||||||
|
|
||||||
await runMutatingVisible`mv /etc/nftables.d/hy2xs.nft.candidate /etc/nftables.d/hy2xs.nft`;
|
|
||||||
|
|
||||||
const nftablesConf = `#!/usr/sbin/nft -f
|
|
||||||
# HY2XS-MANAGED: root nftables entrypoint
|
|
||||||
# Generated by hy2xs-orchestrator. Do not edit manually; edit /etc/hy2xs/hy2xs.env and run reconfigure.
|
|
||||||
|
|
||||||
flush ruleset
|
|
||||||
|
|
||||||
include "/etc/nftables.d/hy2xs.nft"
|
|
||||||
`;
|
|
||||||
await writeText("/etc/nftables.conf", nftablesConf, 0o644);
|
|
||||||
await runMutatingVisible`nft -c -f /etc/nftables.conf`;
|
|
||||||
|
|
||||||
if (context.config.firewallStagedApply) {
|
if (context.config.firewallStagedApply) {
|
||||||
const unit = rollbackUnit(opId);
|
await armRollbackGuard(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 runMutatingVisible`nft -f /etc/nftables.conf`;
|
await runMutatingVisible`nft -f ${NFTABLES_ENTRYPOINT_PATH}`;
|
||||||
await runMutatingVisible`systemctl enable --now nftables`;
|
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 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");
|
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 {
|
function firewallRollbackIsInactive(context: RuntimeContext): boolean {
|
||||||
return (
|
return (
|
||||||
context.options.skipFirewall ||
|
context.options.skipFirewall ||
|
||||||
@@ -231,13 +571,104 @@ function firewallRollbackIsInactive(context: RuntimeContext): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopRollbackTimer(context: RuntimeContext, opId: string): Promise<void> {
|
async function assertGuardHasNotFired(opId: string, when: string): Promise<void> {
|
||||||
if (!context.config.firewallStagedApply) {
|
if (!(await fileExists(autoRollbackFiredMarker(opId)))) {
|
||||||
return;
|
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 unit = rollbackUnit(opId);
|
||||||
await runMutatingVisible`systemctl stop ${unit}.timer ${unit}.service || true`;
|
const allowedStates = options.assertNotFired
|
||||||
await runMutatingVisible`systemctl reset-failed ${unit}.timer ${unit}.service || true`;
|
? 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 и cleanup стоит долговечная фиксация успеха, и до неё
|
||||||
* ручное восстановление остаётся возможным.
|
* ручное восстановление остаётся возможным. Сам disarm при этом стал
|
||||||
|
* доказательством, а не сообщением: см. `stopRollbackGuard`.
|
||||||
*/
|
*/
|
||||||
export async function disarmFirewallRollback(context: RuntimeContext): Promise<void> {
|
export async function disarmFirewallRollback(context: RuntimeContext): Promise<void> {
|
||||||
if (firewallRollbackIsInactive(context)) {
|
if (firewallRollbackIsInactive(context)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await stopRollbackTimer(context, operationKey(context));
|
await stopRollbackGuard(context, operationKey(context), { assertNotFired: true });
|
||||||
info("firewall rollback timer disarmed; backups are kept until the installation is durably committed");
|
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.
|
* Немедленное восстановление firewall.
|
||||||
*
|
*
|
||||||
* Два правила, которых здесь раньше не было.
|
* Три правила, которых здесь раньше не было.
|
||||||
*
|
*
|
||||||
* 1. Ошибки восстановления НЕ скрываются. Было:
|
* 1. Ошибки восстановления НЕ скрываются. Было:
|
||||||
*
|
*
|
||||||
@@ -300,9 +737,15 @@ export async function cleanupFirewallRollback(context: RuntimeContext): Promise<
|
|||||||
* ошибка восстановления скрыта, а данные, по которым оператор мог бы
|
* ошибка восстановления скрыта, а данные, по которым оператор мог бы
|
||||||
* поднять firewall вручную, уничтожены.
|
* поднять firewall вручную, уничтожены.
|
||||||
*
|
*
|
||||||
* Стадии независимы: файл nftables.conf и файл hy2xs.nft восстанавливаются
|
* 3. Остановка guard'а — такая же стадия, как остальные. Раньше она выполнялась
|
||||||
* порознь, применение правил выполняется в любом случае — частично
|
* отдельным вызовом с `|| true` внутри, поэтому откат мог начать
|
||||||
* восстановленный firewall лучше полностью отсутствующего.
|
* восстановление, не остановив таймер, и не сообщить об этом.
|
||||||
|
*
|
||||||
|
* Стадии независимы и идут в порядке, в котором ошибка одной не портит
|
||||||
|
* результат другой. Порядок важен для nftables.service: на Debian у него
|
||||||
|
* `ExecStop=/usr/sbin/nft flush ruleset`, поэтому восстановление состояния
|
||||||
|
* сервиса обязано идти ДО применения ruleset — иначе остановка сервиса стёрла бы
|
||||||
|
* только что восстановленные правила.
|
||||||
*/
|
*/
|
||||||
export async function rollbackFirewallNow(context: RuntimeContext): Promise<void> {
|
export async function rollbackFirewallNow(context: RuntimeContext): Promise<void> {
|
||||||
const opId = operationKey(context);
|
const opId = operationKey(context);
|
||||||
@@ -315,11 +758,17 @@ export async function rollbackFirewallNow(context: RuntimeContext): Promise<void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await stopRollbackTimer(context, opId);
|
|
||||||
|
|
||||||
const entrypointExisted = await fileExists(rollbackBackup("nftables.conf.existed", opId));
|
const entrypointExisted = await fileExists(rollbackBackup("nftables.conf.existed", opId));
|
||||||
|
const serviceState = await readNftablesServiceState(opId);
|
||||||
const stages: RollbackStage[] = [];
|
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) {
|
for (const target of FIREWALL_BACKUP_TARGETS) {
|
||||||
const backupPath = rollbackBackup(target.backup, opId);
|
const backupPath = rollbackBackup(target.backup, opId);
|
||||||
const markerPath = rollbackBackup(target.marker, opId);
|
const markerPath = rollbackBackup(target.marker, opId);
|
||||||
@@ -338,18 +787,79 @@ export async function rollbackFirewallNow(context: RuntimeContext): Promise<void
|
|||||||
}
|
}
|
||||||
|
|
||||||
stages.push({
|
stages.push({
|
||||||
name: "apply restored ruleset",
|
name: "restore nftables.service unit file state",
|
||||||
run: async () => {
|
run: async () => {
|
||||||
if (entrypointExisted) {
|
if (!serviceState) {
|
||||||
await runMutatingVisible`nft -f /etc/nftables.conf`;
|
info("nftables.service state was not captured for this operation; unit file state is left as is");
|
||||||
return;
|
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);
|
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) {
|
if (failures.length > 0) {
|
||||||
info(
|
info(
|
||||||
`firewall rollback did not complete; manual recovery data preserved at ${rollbackRoot(opId)}`
|
`firewall rollback did not complete; manual recovery data preserved at ${rollbackRoot(opId)}`
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { readText } from "../lib/fs";
|
|||||||
import { runReadOnly, runReadOnlySecret, runMutatingVisible } from "../lib/process";
|
import { runReadOnly, runReadOnlySecret, runMutatingVisible } from "../lib/process";
|
||||||
import { HYSTERIA_MACHINE_AUTH_PATH, hysteriaMachineAuthUrl } from "../config/profile";
|
import { HYSTERIA_MACHINE_AUTH_PATH, hysteriaMachineAuthUrl } from "../config/profile";
|
||||||
import { assertHysteriaConfigMatchesProfile } from "./configAssertions";
|
import { assertHysteriaConfigMatchesProfile } from "./configAssertions";
|
||||||
|
import { assertEffectiveFirewallIsOurs } from "./firewall";
|
||||||
|
|
||||||
function parseLocalAddress(line: string): string {
|
function parseLocalAddress(line: string): string {
|
||||||
const cols = line.trim().split(/\s+/);
|
const cols = line.trim().split(/\s+/);
|
||||||
@@ -225,6 +226,16 @@ export async function smoke(context: RuntimeContext): Promise<void> {
|
|||||||
// синтаксиса, а не изменение ruleset.
|
// синтаксиса, а не изменение ruleset.
|
||||||
await runReadOnly`nft -c -f /etc/nftables.conf`;
|
await runReadOnly`nft -c -f /etc/nftables.conf`;
|
||||||
|
|
||||||
|
// Разбор файла ничего не говорит о том, ЧЕЙ это файл.
|
||||||
|
//
|
||||||
|
// Пока единственной проверкой firewall был `nft -c`, сработавший за время
|
||||||
|
// smoke автоматический откат оставался незамеченным: он возвращал прежний —
|
||||||
|
// синтаксически валидный — ruleset, проверка проходила зелёной, и операция
|
||||||
|
// объявляла успешной установку, работающую на firewall, который она же
|
||||||
|
// только что заменила.
|
||||||
|
info("verifying that the effective firewall is the one generated for this configuration");
|
||||||
|
await assertEffectiveFirewallIsOurs(context);
|
||||||
|
|
||||||
// Семантическая проверка установленного конфига: разбираем YAML и сверяем
|
// Семантическая проверка установленного конфига: разбираем YAML и сверяем
|
||||||
// с production-профилем, а не ищем подстроки.
|
// с production-профилем, а не ищем подстроки.
|
||||||
info("verifying effective Hysteria config against HY2XS production profile");
|
info("verifying effective Hysteria config against HY2XS production profile");
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ describe("снятие таймера и удаление копий — раз
|
|||||||
const start = firewallSource.indexOf("export async function disarmFirewallRollback");
|
const start = firewallSource.indexOf("export async function disarmFirewallRollback");
|
||||||
const body = firewallSource.slice(start, firewallSource.indexOf("export async function cleanupFirewallRollback"));
|
const body = firewallSource.slice(start, firewallSource.indexOf("export async function cleanupFirewallRollback"));
|
||||||
expect(body).not.toContain("cleanupFirewallBackupFiles");
|
expect(body).not.toContain("cleanupFirewallBackupFiles");
|
||||||
expect(body).toContain("stopRollbackTimer");
|
expect(body).toContain("stopRollbackGuard");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("cleanup удаляет копии и ничего больше", () => {
|
test("cleanup удаляет копии и ничего больше", () => {
|
||||||
|
|||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { classifyFailure } from "../src/commands/install";
|
||||||
|
import {
|
||||||
|
FirewallGuardFiredError,
|
||||||
|
buildAutoRollbackScript,
|
||||||
|
operationKeyFor,
|
||||||
|
parseNftablesServiceState,
|
||||||
|
renderNftablesEntrypoint,
|
||||||
|
renderNftablesServiceState
|
||||||
|
} from "../src/steps/firewall";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Транзакционность staged firewall guard.
|
||||||
|
*
|
||||||
|
* Дефект, который здесь закрепляется, — второй по счёту commit ordering, и он
|
||||||
|
* тоньше первого. Первый был про то, что данные для отката уничтожались до
|
||||||
|
* фиксации успеха. Этот — про то, что сама фиксация опиралась на недоказанное
|
||||||
|
* утверждение.
|
||||||
|
*
|
||||||
|
* systemctl stop <unit>.timer <unit>.service || true
|
||||||
|
* -> "firewall rollback timer disarmed"
|
||||||
|
* -> phase=installed
|
||||||
|
*
|
||||||
|
* Между «мы думаем, что guard снят» и «guard действительно снят» не было ни
|
||||||
|
* одной проверки. Отказ остановки стирался через `|| true`, и взведённый таймер
|
||||||
|
* мог вернуть прежний firewall уже ПОСЛЕ того, как установка объявлена
|
||||||
|
* успешной и долговечно записана.
|
||||||
|
*
|
||||||
|
* Соседний дефект того же корня: guard мог сработать ВО ВРЕМЯ успешного smoke.
|
||||||
|
* Окно 45 секунд заведомо короче худшего случая smoke (retry-бюджеты дают
|
||||||
|
* заметно больше), а единственной проверкой firewall в smoke был `nft -c` —
|
||||||
|
* разбор текущего файла, каким бы он ни был. Откатившийся прежний ruleset
|
||||||
|
* проходил её зелёным.
|
||||||
|
*
|
||||||
|
* Оба закрываются одним фактом: маркером `auto-rollback-fired`, который
|
||||||
|
* rollback-скрипт создаёт первым действием. Дальше инвариант становится
|
||||||
|
* детерминированным:
|
||||||
|
*
|
||||||
|
* маркер отсутствует И юниты inactive => можно фиксировать успех
|
||||||
|
*/
|
||||||
|
|
||||||
|
function source(relativeToSrc: string): string {
|
||||||
|
return readFileSync(new URL(`../src/${relativeToSrc}`, import.meta.url), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function occurrences(haystack: string, needle: string): number {
|
||||||
|
return haystack.split(needle).length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firewallSource = source("steps/firewall.ts");
|
||||||
|
const smokeSource = source("steps/smoke.ts");
|
||||||
|
|
||||||
|
const OP_ID = "2026-08-30T12-34-56.789Z";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ищет shell, которому можно доверить разбор скрипта.
|
||||||
|
*
|
||||||
|
* Кандидат принимается только после ДВУСТОРОННЕЙ проверки: он обязан принять
|
||||||
|
* заведомо корректный скрипт и отвергнуть заведомо сломанный. Односторонней
|
||||||
|
* проверки мало — `C:\Windows\system32\bash.exe` без установленного WSL
|
||||||
|
* «проваливает» любой файл одинаково, и тест на таком парсере либо всегда
|
||||||
|
* зелёный, либо всегда красный, но в обоих случаях ничего не проверяет.
|
||||||
|
*/
|
||||||
|
function findShellParser(sandbox: string): string | null {
|
||||||
|
const good = join(sandbox, "good.sh");
|
||||||
|
const bad = join(sandbox, "bad.sh");
|
||||||
|
writeFileSync(good, "true\n");
|
||||||
|
writeFileSync(bad, "if then fi else\n");
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
Bun.which("dash"),
|
||||||
|
Bun.which("sh"),
|
||||||
|
Bun.which("bash"),
|
||||||
|
"C:\\Program Files\\Git\\usr\\bin\\bash.exe",
|
||||||
|
"E:\\Git\\usr\\bin\\bash.exe"
|
||||||
|
].filter((candidate): candidate is string => Boolean(candidate) && existsSync(candidate as string));
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const accepts = Bun.spawnSync([candidate, "-n", good], { stdout: "pipe", stderr: "pipe" });
|
||||||
|
const rejects = Bun.spawnSync([candidate, "-n", bad], { stdout: "pipe", stderr: "pipe" });
|
||||||
|
if (accepts.exitCode === 0 && rejects.exitCode !== 0) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("скрипт автоматического отката firewall", () => {
|
||||||
|
const script = buildAutoRollbackScript(OP_ID);
|
||||||
|
|
||||||
|
test("маркер срабатывания создаётся ПЕРВЫМ действием", () => {
|
||||||
|
const marker = script.indexOf(`: >"$root/auto-rollback-fired"`);
|
||||||
|
const prepared = script.indexOf(`if [ ! -f "$root/prepared" ]`);
|
||||||
|
const firstRestore = script.indexOf(`restore_file "$root/nftables.conf.existed"`);
|
||||||
|
|
||||||
|
expect(marker).toBeGreaterThan(-1);
|
||||||
|
expect(prepared).toBeGreaterThan(marker);
|
||||||
|
expect(firstRestore).toBeGreaterThan(prepared);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Без этого «guard сработал» недоказуемо: транзиентные юниты systemd после
|
||||||
|
// выполнения исчезают, и `systemctl stop` для них неотличим от успешного
|
||||||
|
// снятия взведённого таймера.
|
||||||
|
test("маркер создаётся даже когда восстанавливать нечего", () => {
|
||||||
|
const marker = script.indexOf(`: >"$root/auto-rollback-fired"`);
|
||||||
|
const earlyExit = script.indexOf("nothing to restore");
|
||||||
|
|
||||||
|
expect(marker).toBeLessThan(earlyExit);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ошибки не маскируются", () => {
|
||||||
|
expect(script).not.toContain("|| true");
|
||||||
|
expect(script).not.toContain("2>/dev/null");
|
||||||
|
expect(script).not.toContain(">/dev/null 2>&1");
|
||||||
|
});
|
||||||
|
|
||||||
|
// set -e прервал бы восстановление на первой отказавшей части, а задача
|
||||||
|
// guard'а — восстановить ВСЁ, что получится.
|
||||||
|
test("непрерывность обеспечивается rc, а не set -e", () => {
|
||||||
|
expect(script).not.toMatch(/^set -e/m);
|
||||||
|
expect(script).toContain("rc=0");
|
||||||
|
expect(script).toContain('exit "$rc"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("каждый сообщённый отказ поднимает код возврата", () => {
|
||||||
|
expect(occurrences(script, "rc=1")).toBe(occurrences(script, "auto-rollback: failed"));
|
||||||
|
expect(occurrences(script, "rc=1")).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* На Debian у nftables.service `ExecStop=/usr/sbin/nft flush ruleset`.
|
||||||
|
* Остановка сервиса внутри guard'а стёрла бы только что восстановленные
|
||||||
|
* правила — то есть сделала бы ровно противоположное задаче.
|
||||||
|
*/
|
||||||
|
test("состояние nftables.service guard не трогает", () => {
|
||||||
|
expect(script).not.toContain("systemctl");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("пути привязаны к каталогу этой операции", () => {
|
||||||
|
expect(script).toContain(`root='/run/hy2xs/rollback/${OP_ID}'`);
|
||||||
|
expect(buildAutoRollbackScript("2026-09-01T00-00-00.000Z")).not.toContain(OP_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ключ операции служит именем каталога, именем systemd-юнита и подставляется
|
||||||
|
// в текст скрипта. Прежний однострочник держался на том, что в нём не бывает
|
||||||
|
// пробелов; теперь это проверяемое утверждение, а не допущение.
|
||||||
|
test("небезопасный ключ операции отвергается", () => {
|
||||||
|
expect(() => buildAutoRollbackScript("op id")).toThrow(/unsafe operation key/);
|
||||||
|
expect(() => buildAutoRollbackScript("op'; rm -rf /")).toThrow(/unsafe operation key/);
|
||||||
|
expect(() => buildAutoRollbackScript("")).toThrow(/unsafe operation key/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("разбирается настоящим shell-парсером", () => {
|
||||||
|
const sandbox = mkdtempSync(join(tmpdir(), "hy2xs-guard-"));
|
||||||
|
try {
|
||||||
|
const parser = findShellParser(sandbox);
|
||||||
|
if (!parser) {
|
||||||
|
// Сборка идёт на Linux, где парсер есть всегда; на машине разработчика
|
||||||
|
// его может не быть, и это не повод объявлять скрипт корректным.
|
||||||
|
console.warn("shell parser is unavailable: skipping the syntax check of the rollback script");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scriptPath = join(sandbox, "auto-rollback.sh");
|
||||||
|
writeFileSync(scriptPath, script);
|
||||||
|
const parsed = Bun.spawnSync([parser, "-n", scriptPath], { stdout: "pipe", stderr: "pipe" });
|
||||||
|
|
||||||
|
expect(parsed.stderr.toString().trim()).toBe("");
|
||||||
|
expect(parsed.exitCode).toBe(0);
|
||||||
|
} finally {
|
||||||
|
rmSync(sandbox, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ключ операции один на все её артефакты", () => {
|
||||||
|
// Раньше формула была записана дважды, и копии разошлись: install писал в
|
||||||
|
// маркер сырой ISO-timestamp с двоеточиями, а каталог отката назывался
|
||||||
|
// санитизированным ключом. Runbook предлагает оператору открыть
|
||||||
|
// /run/hy2xs/rollback/<op_id> — по значению из маркера такого пути не было.
|
||||||
|
test("двоеточия ISO-времени заменяются", () => {
|
||||||
|
expect(operationKeyFor("2026-08-30T12:34:56.789Z")).toBe("2026-08-30T12-34-56.789Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("install и reconfigure берут ключ из одного места", () => {
|
||||||
|
expect(source("commands/install.ts")).toContain("opId: operationKeyFor(context.installDate)");
|
||||||
|
expect(source("commands/reconfigure.ts")).toContain("return operationKeyFor(context.installDate)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("состояние nftables.service переживает откат", () => {
|
||||||
|
test("запись и разбор согласованы", () => {
|
||||||
|
const state = { unitFileState: "disabled", activeState: "inactive" };
|
||||||
|
expect(parseNftablesServiceState(renderNftablesServiceState(state))).toEqual(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("непонятый файл не притворяется состоянием", () => {
|
||||||
|
expect(parseNftablesServiceState("")).toBeNull();
|
||||||
|
expect(parseNftablesServiceState("мусор без разделителя\n")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("частичное состояние разбирается, а не отбрасывается", () => {
|
||||||
|
expect(parseNftablesServiceState("unit_file_state=enabled\n")).toEqual({
|
||||||
|
unitFileState: "enabled",
|
||||||
|
activeState: ""
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("состояние снимается до первой мутации firewall", () => {
|
||||||
|
const backup = firewallSource.indexOf("async function backupFirewallState");
|
||||||
|
const capture = firewallSource.indexOf("nftablesServiceStatePath(opId)", backup);
|
||||||
|
const prepared = firewallSource.indexOf("touch ${rollbackMarker(opId)}", backup);
|
||||||
|
|
||||||
|
expect(capture).toBeGreaterThan(backup);
|
||||||
|
expect(prepared).toBeGreaterThan(capture);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Порядок стадий отката. `systemctl stop nftables` выполняет
|
||||||
|
* `nft flush ruleset`, поэтому восстановление состояния сервиса обязано идти
|
||||||
|
* ДО применения ruleset — иначе остановка стёрла бы восстановленные правила.
|
||||||
|
*/
|
||||||
|
test("состояние сервиса восстанавливается до применения ruleset", () => {
|
||||||
|
const body = firewallSource.slice(firewallSource.indexOf("export async function rollbackFirewallNow"));
|
||||||
|
const unitFile = body.indexOf('name: "restore nftables.service unit file state"');
|
||||||
|
const inactive = body.indexOf('name: "restore nftables.service inactive state"');
|
||||||
|
const ruleset = body.indexOf('name: "apply restored ruleset"');
|
||||||
|
|
||||||
|
expect(unitFile).toBeGreaterThan(-1);
|
||||||
|
expect(inactive).toBeGreaterThan(unitFile);
|
||||||
|
expect(ruleset).toBeGreaterThan(inactive);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("disarm доказывает снятие guard'а, а не сообщает о нём", () => {
|
||||||
|
const body = firewallSource.slice(
|
||||||
|
firewallSource.indexOf("async function stopRollbackGuard"),
|
||||||
|
firewallSource.indexOf("export async function disarmFirewallRollback")
|
||||||
|
);
|
||||||
|
|
||||||
|
test("остановка guard'а больше не глушит свой код возврата", () => {
|
||||||
|
expect(body).not.toContain("|| true");
|
||||||
|
expect(body).toContain("runMutatingStatus`systemctl stop");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("маркер срабатывания проверяется до и после остановки", () => {
|
||||||
|
const before = body.indexOf("before stopping the rollback guard");
|
||||||
|
const stop = body.indexOf("runMutatingStatus`systemctl stop");
|
||||||
|
const after = body.indexOf("after stopping the rollback guard");
|
||||||
|
|
||||||
|
expect(before).toBeGreaterThan(-1);
|
||||||
|
expect(stop).toBeGreaterThan(before);
|
||||||
|
expect(after).toBeGreaterThan(stop);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("снятие подтверждается наблюдаемым состоянием юнитов", () => {
|
||||||
|
expect(body).toContain('readUnitProperty(target, "ActiveState")');
|
||||||
|
expect(body).toContain("`${unit}.timer`");
|
||||||
|
expect(body).toContain("`${unit}.service`");
|
||||||
|
});
|
||||||
|
|
||||||
|
// На пути фиксации успеха допустимо ровно одно состояние. `failed` там
|
||||||
|
// означает сработавший и упавший guard, то есть запрет на фиксацию.
|
||||||
|
test("на пути фиксации допустим только inactive", () => {
|
||||||
|
expect(firewallSource).toContain('const GUARD_STOPPED_STATES_FOR_COMMIT = ["inactive"] as const');
|
||||||
|
expect(firewallSource).toContain(
|
||||||
|
'const GUARD_STOPPED_STATES_FOR_RECOVERY = ["inactive", "failed"] as const'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("остановка guard'а стала стадией отката с отчётом", () => {
|
||||||
|
const rollback = firewallSource.slice(firewallSource.indexOf("export async function rollbackFirewallNow"));
|
||||||
|
expect(rollback).toContain('name: "stop firewall rollback guard"');
|
||||||
|
expect(rollback).toContain("assertNotFired: false");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("сработавший guard запрещает фиксацию успеха", () => {
|
||||||
|
function ownership(overrides: Record<string, boolean> = {}) {
|
||||||
|
return {
|
||||||
|
stateTouched: true,
|
||||||
|
bootstrapTouched: true,
|
||||||
|
depsTouched: true,
|
||||||
|
filesystemTouched: true,
|
||||||
|
uiTouched: true,
|
||||||
|
hysteriaTouched: true,
|
||||||
|
configTouched: true,
|
||||||
|
unitsTouched: true,
|
||||||
|
firewallTouched: true,
|
||||||
|
postInstallTouched: true,
|
||||||
|
bootstrapSecretTouched: true,
|
||||||
|
servicesStarted: true,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("install классифицирует его отдельной причиной отказа", () => {
|
||||||
|
expect(classifyFailure(ownership(), "smoke_ok", new FirewallGuardFiredError("guard"))).toBe(
|
||||||
|
"firewall_guard_fired"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Классификация по ТИПУ, а не по тексту: ошибка с теми же словами, но другим
|
||||||
|
// типом обязана классифицироваться по владению, как и прежде.
|
||||||
|
test("похожий текст чужой ошибки на классификацию не влияет", () => {
|
||||||
|
expect(
|
||||||
|
classifyFailure(ownership(), "smoke_ok", new Error("automatic firewall rollback has already fired"))
|
||||||
|
).toBe("service_start_failure");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("маркер отказа получает собственную фазу", () => {
|
||||||
|
const installSource = source("commands/install.ts");
|
||||||
|
expect(installSource).toContain('? "firewall_guard_fired"');
|
||||||
|
expect(source("commands/reconfigure.ts")).toContain('return "firewall_guard_fired"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("эффективный firewall проверяется семантически", () => {
|
||||||
|
test("smoke сверяет firewall, а не только разбирает файл", () => {
|
||||||
|
const syntax = smokeSource.indexOf("nft -c -f /etc/nftables.conf");
|
||||||
|
const semantic = smokeSource.indexOf("assertEffectiveFirewallIsOurs(context)");
|
||||||
|
|
||||||
|
expect(syntax).toBeGreaterThan(-1);
|
||||||
|
expect(semantic).toBeGreaterThan(syntax);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("сверяется отрендеренный фрагмент, entrypoint и загруженная таблица", () => {
|
||||||
|
const body = firewallSource.slice(
|
||||||
|
firewallSource.indexOf("export async function assertEffectiveFirewallIsOurs"),
|
||||||
|
firewallSource.indexOf("function firewallRollbackIsInactive")
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(body).toContain("await renderHy2xsNft(context)");
|
||||||
|
expect(body).toContain("effective !== expected");
|
||||||
|
expect(body).toContain('entrypointKind !== "hy2xs-managed"');
|
||||||
|
expect(body).toContain("nft list table inet hy2xs");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Проверка обязана оставаться наблюдением: она выполняется и в doctor,
|
||||||
|
// который целиком идёт под read-only guard.
|
||||||
|
test("проверка выполняется read-only раннером", () => {
|
||||||
|
const body = firewallSource.slice(
|
||||||
|
firewallSource.indexOf("export async function assertEffectiveFirewallIsOurs"),
|
||||||
|
firewallSource.indexOf("function firewallRollbackIsInactive")
|
||||||
|
);
|
||||||
|
expect(body).not.toMatch(/runMutating[A-Za-z]*`/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("рендер фрагмента объявлен один раз и переиспользуется", () => {
|
||||||
|
expect(occurrences(firewallSource, "templates/nftables/hy2xs.nft.tpl")).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("candidate-файлы не переживают операцию", () => {
|
||||||
|
test("успешный apply убирает оба", () => {
|
||||||
|
expect(firewallSource).toContain(
|
||||||
|
"runMutatingVisible`rm -f ${HY2XS_NFT_CANDIDATE} ${NFTABLES_ENTRYPOINT_CANDIDATE}`"
|
||||||
|
);
|
||||||
|
const apply = firewallSource.slice(
|
||||||
|
firewallSource.indexOf("export async function applyFirewall"),
|
||||||
|
firewallSource.indexOf("export async function assertEffectiveFirewallIsOurs")
|
||||||
|
);
|
||||||
|
expect(apply).toContain("await cleanupFirewallCandidates()");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("откат убирает их best-effort и не срывается на этом", () => {
|
||||||
|
const rollback = firewallSource.slice(firewallSource.indexOf("export async function rollbackFirewallNow"));
|
||||||
|
const cleanup = rollback.indexOf("await cleanupFirewallCandidates()");
|
||||||
|
const guarded = rollback.indexOf("catch (candidateError)");
|
||||||
|
|
||||||
|
expect(cleanup).toBeGreaterThan(-1);
|
||||||
|
expect(guarded).toBeGreaterThan(cleanup);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("entrypoint nftables", () => {
|
||||||
|
test("объявляет владение HY2XS и подключает переданный фрагмент", () => {
|
||||||
|
const rendered = renderNftablesEntrypoint("/etc/nftables.d/hy2xs.nft");
|
||||||
|
expect(rendered).toContain("HY2XS-MANAGED");
|
||||||
|
expect(rendered).toContain("flush ruleset");
|
||||||
|
expect(rendered).toContain('include "/etc/nftables.d/hy2xs.nft"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("candidate и production entrypoint строятся одной функцией", () => {
|
||||||
|
expect(occurrences(firewallSource, "HY2XS-MANAGED: root nftables entrypoint")).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { preflight } from "../src/steps/preflight";
|
import { preflight } from "../src/steps/preflight";
|
||||||
import { classifyReconfigureFailure } from "../src/commands/reconfigure";
|
import { classifyReconfigureFailure } from "../src/commands/reconfigure";
|
||||||
|
import { FirewallGuardFiredError } from "../src/steps/firewall";
|
||||||
import { baselineConfig, testContext } from "./fixtures";
|
import { baselineConfig, testContext } from "./fixtures";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,7 +135,26 @@ describe("классификация отказа reconfigure/repair", () => {
|
|||||||
// классификация по тексту ошибки убрана, должен быть разрешён.
|
// классификация по тексту ошибки убрана, должен быть разрешён.
|
||||||
test("текст ошибки на классификацию не влияет", () => {
|
test("текст ошибки на классификацию не влияет", () => {
|
||||||
expect(reconfigureSource).not.toContain(".test(message)");
|
expect(reconfigureSource).not.toContain(".test(message)");
|
||||||
expect(reconfigureSource).toContain("classifyReconfigureFailure(ownership)");
|
expect(reconfigureSource).toContain("classifyReconfigureFailure(ownership, error)");
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сработавший guard классифицируется по ТИПУ ошибки.
|
||||||
|
*
|
||||||
|
* Разница с прежним разбором сообщения принципиальна, и проверяется она
|
||||||
|
* именно так: ошибка, в тексте которой есть все нужные слова, но у которой
|
||||||
|
* другой тип, обязана классифицироваться по владению, как и раньше.
|
||||||
|
*/
|
||||||
|
test("сработавший guard опознаётся по типу ошибки, а не по её тексту", () => {
|
||||||
|
const late = ownership({ firewallTouched: true, servicesRestarted: true });
|
||||||
|
|
||||||
|
expect(classifyReconfigureFailure(late, new FirewallGuardFiredError("guard"))).toBe(
|
||||||
|
"firewall_guard_fired"
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
classifyReconfigureFailure(late, new Error("automatic firewall rollback has already fired"))
|
||||||
|
).toBe("smoke_failed");
|
||||||
|
expect(classifyReconfigureFailure(late)).toBe("smoke_failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("отказ диагностики не отменяет откат firewall и конфигов", () => {
|
test("отказ диагностики не отменяет откат firewall и конфигов", () => {
|
||||||
|
|||||||
@@ -198,7 +198,13 @@ describe("reconfigure: откат обязателен после операци
|
|||||||
const reconfigureSource = source("commands/reconfigure.ts");
|
const reconfigureSource = source("commands/reconfigure.ts");
|
||||||
|
|
||||||
test("запись состояния отказа обёрнута в persistFailureState", () => {
|
test("запись состояния отказа обёрнута в persistFailureState", () => {
|
||||||
expect(reconfigureSource).toContain("await persistFailureState(() => markPhase(context");
|
// Проверяется вложенность вызовов, а не форматирование: markPhase обязан
|
||||||
|
// выполняться ВНУТРИ persistFailureState, в какую бы строку его ни перенёс
|
||||||
|
// редактор.
|
||||||
|
const wrapped = reconfigureSource.match(
|
||||||
|
/await persistFailureState\(\(\) =>\s*markPhase\(context,\s*classifyReconfigureFailure\(ownership, error\), message\)\s*\)/
|
||||||
|
);
|
||||||
|
expect(wrapped).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("в обработчике ошибки нет незащищённого markPhase", () => {
|
test("в обработчике ошибки нет незащищённого markPhase", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user