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:
@@ -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), 0o600);
|
||||
info(
|
||||
`nftables.service state before the operation: unit_file_state=${serviceState.unitFileState || "(empty)"}, ` +
|
||||
`active_state=${serviceState.activeState || "(empty)"}`
|
||||
);
|
||||
|
||||
await runMutatingVisible`touch ${rollbackMarker(opId)}`;
|
||||
}
|
||||
|
||||
async function readNftablesServiceState(opId: string): Promise<NftablesServiceState | null> {
|
||||
const path = nftablesServiceStatePath(opId);
|
||||
if (!(await fileExists(path))) {
|
||||
return null;
|
||||
}
|
||||
return parseNftablesServiceState(await readText(path));
|
||||
}
|
||||
|
||||
function stripNftComments(content: string): string {
|
||||
return content
|
||||
.split(/\r?\n/)
|
||||
@@ -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), 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> {
|
||||
@@ -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, 0o600);
|
||||
await runMutatingVisible`nft -c -f /etc/nftables.d/hy2xs.nft.candidate`;
|
||||
await writeText(HY2XS_NFT_CANDIDATE, rendered, 0o600);
|
||||
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), 0o644);
|
||||
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, 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`;
|
||||
await writeText(NFTABLES_ENTRYPOINT_PATH, renderNftablesEntrypoint(HY2XS_NFT_PATH), 0o644);
|
||||
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)}`
|
||||
|
||||
Reference in New Issue
Block a user