211 lines
8.0 KiB
TypeScript
211 lines
8.0 KiB
TypeScript
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<void> {
|
|
await runVisible`mkdir -p ${rollbackRoot(opId)}`;
|
|
}
|
|
|
|
async function cleanupFirewallBackupFiles(opId: string): Promise<void> {
|
|
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<FirewallEntrypointKind> {
|
|
if (!(await fileExists("/etc/nftables.conf"))) {
|
|
return "missing";
|
|
}
|
|
return classifyNftEntrypoint(await readText("/etc/nftables.conf"));
|
|
}
|
|
|
|
export async function applyFirewall(context: RuntimeContext): Promise<void> {
|
|
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<NftEntrypointKind>([
|
|
"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<void> {
|
|
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<void> {
|
|
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);
|
|
}
|