import type { RuntimeContext } from "../types/context"; import { fileExists, readText, renderTemplate, writeText } from "../lib/fs"; import { fail, info } from "../lib/log"; import { runVisible } from "../lib/process"; type NftEntrypointKind = | "missing" | "hy2xs-managed" | "empty" | "debian-empty-template" | "include-compatible" | "foreign"; export type FirewallEntrypointKind = NftEntrypointKind; function rollbackRoot(opId: string): string { return `/run/hy2xs/rollback/${opId}`; } function rollbackUnit(opId: string): string { return `hy2xs-fw-rollback-${opId}`; } function operationKey(context: RuntimeContext): string { return context.installDate.replace(/[^a-zA-Z0-9_.-]/g, "-"); } function rollbackMarker(opId: string): string { return `${rollbackRoot(opId)}/prepared`; } function rollbackBackup(path: string, opId: string): string { return `${rollbackRoot(opId)}/${path}`; } async function ensureRollbackRoot(opId: string): Promise { await runVisible`mkdir -p ${rollbackRoot(opId)}`; } async function cleanupFirewallBackupFiles(opId: string): Promise { await runVisible`rm -rf ${rollbackRoot(opId)}`; } function stripNftComments(content: string): string { return content .split(/\r?\n/) .map((line) => line.replace(/#.*/, "").trim()) .filter(Boolean) .join("\n"); } function classifyNftEntrypoint(content: string): NftEntrypointKind { if (!content.trim()) { return "missing"; } if (content.includes("HY2XS-MANAGED")) { return "hy2xs-managed"; } const withoutComments = stripNftComments(content); const effective = withoutComments .replace(/^#!\/usr\/sbin\/nft\s+-f\s*/m, "") .trim(); if (!effective) { return "empty"; } const normalized = effective.replace(/\s+/g, " ").trim(); if (normalized === "flush ruleset") { return "debian-empty-template"; } if (/include\s+"\/etc\/nftables\.d\/hy2xs\.nft"/.test(effective)) { return "include-compatible"; } return "foreign"; } export async function detectFirewallEntrypointKind(): Promise { if (!(await fileExists("/etc/nftables.conf"))) { return "missing"; } return classifyNftEntrypoint(await readText("/etc/nftables.conf")); } export async function applyFirewall(context: RuntimeContext): Promise { const opId = operationKey(context); if (context.options.skipFirewall || context.config.firewallMode === "off") { info("firewall skipped by flag"); return; } if (context.config.firewallMode === "external") { info("firewall mode is external: nftables is not modified"); return; } const acmeChallengePort = context.config.acmeType === "tls" ? 443 : 80; const acmeRule = context.config.tlsMode === "acme" ? `tcp dport ${acmeChallengePort} accept` : "# acme challenge port disabled"; 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 entrypointKind = classifyNftEntrypoint(existing); const managedAllowed = new Set([ "missing", "hy2xs-managed", "empty", "debian-empty-template", "include-compatible" ]); if (context.config.firewallMode === "managed" && !managedAllowed.has(entrypointKind)) { fail("foreign nftables.conf detected; use HY2XS_FIREWALL_MODE=takeover|external|off"); } await ensureRollbackRoot(opId); await runVisible`touch ${rollbackMarker(opId)}`; await runVisible`cp -a /etc/nftables.conf ${rollbackBackup("nftables.conf.bak", opId)} 2>/dev/null || true`; await runVisible`cp -a /etc/nftables.d/hy2xs.nft ${rollbackBackup("hy2xs.nft.bak", opId)} 2>/dev/null || true`; await runVisible`test -f /etc/nftables.conf && echo 1 > ${rollbackBackup("nftables.conf.existed", opId)} || rm -f ${rollbackBackup("nftables.conf.existed", opId)}`; await runVisible`test -f /etc/nftables.d/hy2xs.nft && echo 1 > ${rollbackBackup("hy2xs.nft.existed", opId)} || rm -f ${rollbackBackup("hy2xs.nft.existed", opId)}`; await writeText("/etc/nftables.d/hy2xs.nft.candidate", rendered, 0o600); await runVisible`nft -c -f /etc/nftables.d/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. flush ruleset include "/etc/nftables.d/hy2xs.nft.candidate" `; await writeText("/etc/nftables.conf.candidate", nftablesConfCandidate, 0o644); await runVisible`nft -c -f /etc/nftables.conf.candidate`; await runVisible`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 runVisible`nft -c -f /etc/nftables.conf`; if (context.config.firewallStagedApply) { const unit = rollbackUnit(opId); await runVisible`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 runVisible`nft -f /etc/nftables.conf`; await runVisible`systemctl enable --now nftables`; await runVisible`ss -H -ltn | grep -q ':${context.config.sshPort} ' || (echo 'ssh port check failed' >&2; exit 1)`; info("firewall applied with rollback guard; guard will be cancelled only after successful smoke checks"); } export async function cancelFirewallRollback(context: RuntimeContext): Promise { const opId = operationKey(context); if (context.options.skipFirewall || context.config.firewallMode === "off" || context.config.firewallMode === "external") { return; } if (context.config.firewallStagedApply) { const unit = rollbackUnit(opId); await runVisible`systemctl stop ${unit}.timer ${unit}.service || true`; await runVisible`systemctl reset-failed ${unit}.timer ${unit}.service || true`; } await cleanupFirewallBackupFiles(opId); } export async function rollbackFirewallNow(context: RuntimeContext): Promise { const opId = operationKey(context); if (context.options.skipFirewall || context.config.firewallMode === "off" || context.config.firewallMode === "external") { return; } if (!(await fileExists(rollbackMarker(opId)))) { info("firewall rollback skipped: no HY2XS rollback markers found"); return; } if (context.config.firewallStagedApply) { const unit = rollbackUnit(opId); await runVisible`systemctl stop ${unit}.timer ${unit}.service || true`; await runVisible`systemctl reset-failed ${unit}.timer ${unit}.service || true`; } await runVisible`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`; await runVisible`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`; await runVisible`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`; await cleanupFirewallBackupFiles(opId); }