Files
Termix/src/backend/ssh/managers/firewall.ts
T

130 lines
4.0 KiB
TypeScript
Raw Normal View History

+2
2026-06-16 15:59:53 -05:00
import type { Express } from "express";
import { collectFirewallMetrics } from "../widgets/firewall-collector.js";
import { execElevated } from "./exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import {
isValidPort,
isValidIpProtocol,
isValidFirewallTarget,
type IpProtocol,
type FirewallTarget,
} from "./validation.js";
import { detectPlatform } from "./platform.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface FirewallRuleSpec {
protocol: IpProtocol;
port: number;
target: FirewallTarget;
}
/**
* Build an iptables add/delete for an INPUT rule. We only ever touch INPUT for a
* specific dport with an explicit target, and never the chain policy, so an
* existing ESTABLISHED/SSH rule is left intact.
*/
export function buildIptablesRuleCommand(
op: "add" | "delete",
spec: FirewallRuleSpec,
): string {
const flag = op === "add" ? "-A" : "-D";
return `iptables ${flag} INPUT -p ${spec.protocol} --dport ${spec.port} -j ${spec.target}`;
}
export function buildNftRuleCommand(
op: "add" | "delete",
spec: FirewallRuleSpec,
): string {
// nftables uses the inet filter table's input chain by convention.
const verb = op === "add" ? "add" : "delete";
const action =
spec.target.toLowerCase() === "reject"
? "reject"
: spec.target.toLowerCase();
return `nft ${verb} rule inet filter input ${spec.protocol} dport ${spec.port} ${action}`;
}
export function registerFirewallRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
app.get(
"/host-metrics/managers/firewall/:id",
validateHostId,
managerHandler(runOnHost, "read", "firewall_read", async (client) => {
return await collectFirewallMetrics(client);
}),
);
app.post(
"/host-metrics/managers/firewall/:id/rule",
validateHostId,
managerHandler(
runOnHost,
"execute",
"firewall_rule",
async (client, host, req) => {
const { op, protocol, port, target } = req.body as {
op?: "add" | "delete";
protocol?: string;
port?: number;
target?: string;
};
if (op !== "add" && op !== "delete") {
throw new ManagerInputError("Invalid op");
}
if (!isValidIpProtocol(protocol))
throw new ManagerInputError("Invalid protocol");
if (!isValidPort(port)) throw new ManagerInputError("Invalid port");
if (!isValidFirewallTarget(target))
throw new ManagerInputError("Invalid target");
const spec: FirewallRuleSpec = {
protocol,
port: Number(port),
target,
};
const fw = await collectFirewallMetrics(client);
const cmd =
fw.type === "nftables"
? buildNftRuleCommand(op, spec)
: buildIptablesRuleCommand(op, spec);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
});
return {
success: result.code === 0,
output: result.stdout || result.stderr,
backend: fw.type,
};
},
),
);
app.post(
"/host-metrics/managers/firewall/:id/persist",
validateHostId,
managerHandler(
runOnHost,
"execute",
"firewall_persist",
async (client, host) => {
const platform = await detectPlatform(client);
// Best-effort persistence across common tools.
const cmd =
"(command -v netfilter-persistent >/dev/null 2>&1 && netfilter-persistent save) || " +
"(command -v service >/dev/null 2>&1 && service iptables save) || " +
"(command -v nft >/dev/null 2>&1 && nft list ruleset > /etc/nftables.conf) || true";
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
});
return {
success: result.code === 0,
output: result.stdout || result.stderr,
pkg: platform.pkg,
};
},
),
);
}