Add firewall rules logic for all hosts
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import validateIpv4Cidr from "@/src/utils/validate-ipv4-cidr";
|
||||
import detectFirewallManager, {
|
||||
type FirewallManager,
|
||||
} from "./detect-firewall-manager";
|
||||
|
||||
export type AllowSubnetFirewallRulesResult = {
|
||||
success: boolean;
|
||||
manager: FirewallManager | "none";
|
||||
msg: string;
|
||||
added_rules: string[];
|
||||
skipped_rules: string[];
|
||||
};
|
||||
|
||||
type Params = {
|
||||
subnet: string;
|
||||
listen_port: number;
|
||||
};
|
||||
|
||||
type ShellResult = {
|
||||
success: boolean;
|
||||
output: string;
|
||||
};
|
||||
|
||||
const tryShell = ({ command }: { command: string }): ShellResult => {
|
||||
try {
|
||||
const result = execSync(command, {
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: String(result).trim(),
|
||||
};
|
||||
} catch (error: any) {
|
||||
const stdout = error?.stdout ? String(error.stdout).trim() : "";
|
||||
const stderr = error?.stderr ? String(error.stderr).trim() : "";
|
||||
|
||||
return {
|
||||
success: false,
|
||||
output: [stdout, stderr].filter(Boolean).join("\n").trim(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const isValidPort = ({ port }: { port: number }) =>
|
||||
Number.isInteger(port) && port >= 1 && port <= 65535;
|
||||
|
||||
export default async function allowSubnetFirewallRules({
|
||||
subnet,
|
||||
listen_port,
|
||||
}: Params): Promise<AllowSubnetFirewallRulesResult> {
|
||||
const subnet_trimmed = subnet?.trim();
|
||||
|
||||
if (!validateIpv4Cidr({ value: subnet_trimmed })) {
|
||||
return {
|
||||
success: false,
|
||||
manager: "none",
|
||||
msg: `Invalid subnet "${subnet_trimmed}"`,
|
||||
added_rules: [],
|
||||
skipped_rules: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (!isValidPort({ port: listen_port })) {
|
||||
return {
|
||||
success: false,
|
||||
manager: "none",
|
||||
msg: `Invalid listen port ${listen_port}`,
|
||||
added_rules: [],
|
||||
skipped_rules: [],
|
||||
};
|
||||
}
|
||||
|
||||
const manager = detectFirewallManager();
|
||||
|
||||
const added_rules: string[] = [];
|
||||
const skipped_rules: string[] = [];
|
||||
|
||||
if (manager == "firewalld") {
|
||||
const subnet_rule = `rule family="ipv4" source address="${subnet_trimmed}" accept`;
|
||||
const subnet_add = tryShell({
|
||||
command: `firewall-cmd --permanent --add-rich-rule='${subnet_rule}'`,
|
||||
});
|
||||
|
||||
if (subnet_add.success) {
|
||||
added_rules.push(`allow all traffic from ${subnet_trimmed}`);
|
||||
} else {
|
||||
skipped_rules.push(
|
||||
`firewalld subnet rule not applied: ${subnet_add.output}`,
|
||||
);
|
||||
}
|
||||
|
||||
const port_rule = `${listen_port}/udp`;
|
||||
const port_add = tryShell({
|
||||
command: `firewall-cmd --permanent --add-port=${port_rule}`,
|
||||
});
|
||||
|
||||
if (port_add.success) {
|
||||
added_rules.push(`allow ${port_rule} from anywhere (wireguard)`);
|
||||
} else {
|
||||
skipped_rules.push(
|
||||
`firewalld port rule not applied: ${port_add.output}`,
|
||||
);
|
||||
}
|
||||
|
||||
const reload = tryShell({ command: `firewall-cmd --reload` });
|
||||
|
||||
if (!reload.success) {
|
||||
skipped_rules.push(`firewalld reload failed: ${reload.output}`);
|
||||
}
|
||||
} else if (manager == "ufw") {
|
||||
const ufw_status = tryShell({ command: `ufw status` });
|
||||
|
||||
if (
|
||||
!ufw_status.success ||
|
||||
!ufw_status.output.includes(`Status: active`)
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
manager,
|
||||
msg: `ufw is not active — cannot add firewall rules`,
|
||||
added_rules,
|
||||
skipped_rules,
|
||||
};
|
||||
}
|
||||
|
||||
if (ufw_status.output.includes(subnet_trimmed)) {
|
||||
skipped_rules.push(`subnet ${subnet_trimmed} already allowed`);
|
||||
} else {
|
||||
const subnet_add = tryShell({
|
||||
command: `ufw allow from ${subnet_trimmed}`,
|
||||
});
|
||||
|
||||
if (subnet_add.success) {
|
||||
added_rules.push(`allow all traffic from ${subnet_trimmed}`);
|
||||
} else {
|
||||
skipped_rules.push(
|
||||
`ufw subnet rule not applied: ${subnet_add.output}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const port_rule = `${listen_port}/udp`;
|
||||
|
||||
if (ufw_status.output.includes(port_rule)) {
|
||||
skipped_rules.push(`port ${port_rule} already allowed`);
|
||||
} else {
|
||||
const port_add = tryShell({ command: `ufw allow ${port_rule}` });
|
||||
|
||||
if (port_add.success) {
|
||||
added_rules.push(
|
||||
`allow ${port_rule} from anywhere (wireguard)`,
|
||||
);
|
||||
} else {
|
||||
skipped_rules.push(
|
||||
`ufw port rule not applied: ${port_add.output}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (manager == "none") {
|
||||
return {
|
||||
success: true,
|
||||
manager,
|
||||
msg: `no active firewall manager detected (firewalld/ufw) — no rules added; if you use iptables/nftables directly, allow ${subnet_trimmed} and udp port ${listen_port} manually`,
|
||||
added_rules,
|
||||
skipped_rules,
|
||||
};
|
||||
}
|
||||
|
||||
const summary = [
|
||||
added_rules.length
|
||||
? `firewall(${manager}): added — ${added_rules.join("; ")}`
|
||||
: `firewall(${manager}): no new rules added`,
|
||||
skipped_rules.length ? `skipped — ${skipped_rules.join("; ")}` : ``,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(` `);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
manager,
|
||||
msg: summary,
|
||||
added_rules,
|
||||
skipped_rules,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
export type FirewallManager = "firewalld" | "ufw" | "none";
|
||||
|
||||
export default function detectFirewallManager(): FirewallManager {
|
||||
try {
|
||||
execSync(`command -v firewall-cmd`, {
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
execSync(`systemctl is-active --quiet firewalld`, {
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
return "firewalld";
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const ufw_status = execSync(`ufw status`, {
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
if (String(ufw_status).includes(`Status: active`)) {
|
||||
return "ufw";
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return "none";
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import type { TableType } from "@/src/types";
|
||||
import validateIpv4Cidr from "@/src/utils/validate-ipv4-cidr";
|
||||
import detectFirewallManager, {
|
||||
type FirewallManager,
|
||||
} from "./detect-firewall-manager";
|
||||
|
||||
export type FirewallRuleKind = "required" | "temporary" | "orphaned" | "other";
|
||||
|
||||
export type FirewallRuleInfo = {
|
||||
id: string;
|
||||
kind: FirewallRuleKind;
|
||||
description: string;
|
||||
delete_command?: string;
|
||||
};
|
||||
|
||||
export type FirewallStatus = {
|
||||
manager: FirewallManager;
|
||||
rules: FirewallRuleInfo[];
|
||||
managed_subnets: string[];
|
||||
managed_ports: string[];
|
||||
dashboard_port: number;
|
||||
hosts_found: boolean;
|
||||
};
|
||||
|
||||
type ShellResult = {
|
||||
success: boolean;
|
||||
output: string;
|
||||
};
|
||||
|
||||
const tryShell = ({ command }: { command: string }): ShellResult => {
|
||||
try {
|
||||
const result = execSync(command, {
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: String(result).trim(),
|
||||
};
|
||||
} catch (error: any) {
|
||||
const stdout = error?.stdout ? String(error.stdout).trim() : "";
|
||||
const stderr = error?.stderr ? String(error.stderr).trim() : "";
|
||||
|
||||
return {
|
||||
success: false,
|
||||
output: [stdout, stderr].filter(Boolean).join("\n").trim(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const isCidr = ({ value }: { value: string }) => validateIpv4Cidr({ value });
|
||||
|
||||
const stripV6Suffix = ({ value }: { value: string }) =>
|
||||
value.replace(/\s*\(v6\)$/i, "").trim();
|
||||
|
||||
const normalizeSpec = ({ value }: { value: string }) =>
|
||||
stripV6Suffix({ value: value.toLowerCase() });
|
||||
|
||||
type ManagedSpecs = {
|
||||
managed_subnets: string[];
|
||||
managed_ports: string[];
|
||||
dashboard_port: number;
|
||||
};
|
||||
|
||||
type ClassifiedRule = {
|
||||
id: string;
|
||||
kind: FirewallRuleKind;
|
||||
description: string;
|
||||
delete_command?: string;
|
||||
};
|
||||
|
||||
const classifyRule = ({
|
||||
spec,
|
||||
manager,
|
||||
managed,
|
||||
}: {
|
||||
spec: string;
|
||||
manager: "ufw" | "firewalld";
|
||||
managed: ManagedSpecs;
|
||||
}): ClassifiedRule | null => {
|
||||
const normalized = normalizeSpec({ value: spec });
|
||||
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (normalized == `${managed.dashboard_port}/tcp`) {
|
||||
const delete_command =
|
||||
manager == "ufw"
|
||||
? `ufw delete allow ${normalized}`
|
||||
: `firewall-cmd --permanent --remove-port=${normalized} && firewall-cmd --reload`;
|
||||
|
||||
return {
|
||||
id: `${manager}-${normalized}`,
|
||||
kind: "temporary",
|
||||
description: `TCP ${managed.dashboard_port} open to the world — the wg-ui dashboard bootstrap rule (remove once hosts are reachable via the tunnel)`,
|
||||
delete_command,
|
||||
};
|
||||
}
|
||||
|
||||
if (managed.managed_ports.includes(normalized)) {
|
||||
return {
|
||||
id: `${manager}-${normalized}`,
|
||||
kind: "required",
|
||||
description: `${normalized} — wireguard listen port (matches a host)`,
|
||||
};
|
||||
}
|
||||
|
||||
if (isCidr({ value: normalized })) {
|
||||
if (managed.managed_subnets.includes(normalized)) {
|
||||
return {
|
||||
id: `${manager}-${normalized}`,
|
||||
kind: "required",
|
||||
description: `All traffic from ${normalized} — host subnet`,
|
||||
};
|
||||
}
|
||||
|
||||
const delete_command =
|
||||
manager == "ufw"
|
||||
? `ufw delete allow from ${normalized}`
|
||||
: `firewall-cmd --permanent --remove-rich-rule='rule family="ipv4" source address="${normalized}" accept' && firewall-cmd --reload`;
|
||||
|
||||
return {
|
||||
id: `${manager}-${normalized}`,
|
||||
kind: "orphaned",
|
||||
description: `All traffic from ${normalized} — matches no host subnet (stale, safe to remove)`,
|
||||
delete_command,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: `${manager}-${normalized}`,
|
||||
kind: "other",
|
||||
description: `${normalized} — unmanaged rule`,
|
||||
};
|
||||
};
|
||||
|
||||
const grabUfwRules = ({
|
||||
managed,
|
||||
}: {
|
||||
managed: ManagedSpecs;
|
||||
}): ClassifiedRule[] => {
|
||||
const status = tryShell({ command: `ufw status numbered` });
|
||||
|
||||
if (!status.success) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rules: ClassifiedRule[] = [];
|
||||
|
||||
for (const line of status.output.split("\n")) {
|
||||
const stripped = line
|
||||
.replace(/^\[\s*\d+\]\s*/, "")
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
|
||||
const status_index = stripped.indexOf("ALLOW");
|
||||
|
||||
if (status_index < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const spec = stripped.slice(0, status_index).join(" ");
|
||||
const rule = classifyRule({ spec, manager: "ufw", managed });
|
||||
|
||||
if (rule) {
|
||||
rules.push(rule);
|
||||
}
|
||||
}
|
||||
|
||||
return rules;
|
||||
};
|
||||
|
||||
const grabFirewalldRules = ({
|
||||
managed,
|
||||
}: {
|
||||
managed: ManagedSpecs;
|
||||
}): ClassifiedRule[] => {
|
||||
const rules: ClassifiedRule[] = [];
|
||||
const port_res = tryShell({ command: `firewall-cmd --permanent --list-ports` });
|
||||
|
||||
if (port_res.success) {
|
||||
for (const spec of port_res.output.split(/\s+/).filter(Boolean)) {
|
||||
const rule = classifyRule({ spec, manager: "firewalld", managed });
|
||||
|
||||
if (rule) {
|
||||
rules.push(rule);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rich_res = tryShell({
|
||||
command: `firewall-cmd --permanent --list-rich-rules`,
|
||||
});
|
||||
|
||||
if (rich_res.success) {
|
||||
for (const line of rich_res.output.split("\n")) {
|
||||
const source_match = line.match(/source\s+address="([^"]+)"/);
|
||||
|
||||
if (!source_match?.[1]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rule = classifyRule({
|
||||
spec: source_match[1],
|
||||
manager: "firewalld",
|
||||
managed,
|
||||
});
|
||||
|
||||
if (rule) {
|
||||
rules.push(rule);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rules;
|
||||
};
|
||||
|
||||
export default async function grabFirewallStatus(): Promise<FirewallStatus> {
|
||||
const manager = detectFirewallManager();
|
||||
|
||||
const hosts_res = await BunSQLite.select<BUN_SQLITE_WGUI_HOSTS, TableType>({
|
||||
table: "hosts",
|
||||
});
|
||||
|
||||
const hosts = hosts_res.payload || [];
|
||||
const managed_subnets: string[] = [];
|
||||
const managed_ports: string[] = [];
|
||||
|
||||
for (const host of hosts) {
|
||||
if (host?.wg_ip_address) {
|
||||
managed_subnets.push(
|
||||
`${host.wg_ip_address.split(".").slice(0, 3).join(".")}.0/24`,
|
||||
);
|
||||
}
|
||||
|
||||
const listen_port =
|
||||
host?.listen_port || AppData["DefaultWGListenPort"];
|
||||
|
||||
const port_spec = `${listen_port}/udp`;
|
||||
|
||||
if (!managed_ports.includes(port_spec)) {
|
||||
managed_ports.push(port_spec);
|
||||
}
|
||||
}
|
||||
|
||||
const managed: ManagedSpecs = {
|
||||
managed_subnets,
|
||||
managed_ports,
|
||||
dashboard_port: SiteData["ServerPort"],
|
||||
};
|
||||
|
||||
let rules: ClassifiedRule[] = [];
|
||||
|
||||
if (manager == "ufw") {
|
||||
rules = grabUfwRules({ managed });
|
||||
} else if (manager == "firewalld") {
|
||||
rules = grabFirewalldRules({ managed });
|
||||
}
|
||||
|
||||
return {
|
||||
manager,
|
||||
rules,
|
||||
managed_subnets,
|
||||
managed_ports,
|
||||
dashboard_port: SiteData["ServerPort"],
|
||||
hosts_found: hosts.length > 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import validateIpv4Cidr from "@/src/utils/validate-ipv4-cidr";
|
||||
import detectFirewallManager, {
|
||||
type FirewallManager,
|
||||
} from "./detect-firewall-manager";
|
||||
|
||||
export type RemoveSubnetFirewallRulesResult = {
|
||||
success: boolean;
|
||||
manager: FirewallManager | "none";
|
||||
msg: string;
|
||||
removed_rules: string[];
|
||||
skipped_rules: string[];
|
||||
};
|
||||
|
||||
type Params = {
|
||||
subnet: string;
|
||||
listen_port: number;
|
||||
};
|
||||
|
||||
type ShellResult = {
|
||||
success: boolean;
|
||||
output: string;
|
||||
};
|
||||
|
||||
const tryShell = ({ command }: { command: string }): ShellResult => {
|
||||
try {
|
||||
const result = execSync(command, {
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: String(result).trim(),
|
||||
};
|
||||
} catch (error: any) {
|
||||
const stdout = error?.stdout ? String(error.stdout).trim() : "";
|
||||
const stderr = error?.stderr ? String(error.stderr).trim() : "";
|
||||
|
||||
return {
|
||||
success: false,
|
||||
output: [stdout, stderr].filter(Boolean).join("\n").trim(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const isValidPort = ({ port }: { port: number }) =>
|
||||
Number.isInteger(port) && port >= 1 && port <= 65535;
|
||||
|
||||
export default async function removeSubnetFirewallRules({
|
||||
subnet,
|
||||
listen_port,
|
||||
}: Params): Promise<RemoveSubnetFirewallRulesResult> {
|
||||
const subnet_trimmed = subnet?.trim();
|
||||
|
||||
if (!validateIpv4Cidr({ value: subnet_trimmed })) {
|
||||
return {
|
||||
success: false,
|
||||
manager: "none",
|
||||
msg: `Invalid subnet "${subnet_trimmed}"`,
|
||||
removed_rules: [],
|
||||
skipped_rules: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (!isValidPort({ port: listen_port })) {
|
||||
return {
|
||||
success: false,
|
||||
manager: "none",
|
||||
msg: `Invalid listen port ${listen_port}`,
|
||||
removed_rules: [],
|
||||
skipped_rules: [],
|
||||
};
|
||||
}
|
||||
|
||||
const manager = detectFirewallManager();
|
||||
const removed_rules: string[] = [];
|
||||
const skipped_rules: string[] = [];
|
||||
|
||||
if (manager == "firewalld") {
|
||||
const subnet_rule = `rule family="ipv4" source address="${subnet_trimmed}" accept`;
|
||||
const subnet_remove = tryShell({
|
||||
command: `firewall-cmd --permanent --remove-rich-rule='${subnet_rule}'`,
|
||||
});
|
||||
|
||||
if (subnet_remove.success) {
|
||||
removed_rules.push(`removed subnet allow for ${subnet_trimmed}`);
|
||||
} else {
|
||||
skipped_rules.push(
|
||||
`firewalld subnet rule not removed: ${subnet_remove.output}`,
|
||||
);
|
||||
}
|
||||
|
||||
const port_rule = `${listen_port}/udp`;
|
||||
const port_remove = tryShell({
|
||||
command: `firewall-cmd --permanent --remove-port=${port_rule}`,
|
||||
});
|
||||
|
||||
if (port_remove.success) {
|
||||
removed_rules.push(`removed ${port_rule} allow`);
|
||||
} else {
|
||||
skipped_rules.push(`firewalld port rule not removed: ${port_remove.output}`);
|
||||
}
|
||||
|
||||
const reload = tryShell({ command: `firewall-cmd --reload` });
|
||||
|
||||
if (!reload.success) {
|
||||
skipped_rules.push(`firewalld reload failed: ${reload.output}`);
|
||||
}
|
||||
} else if (manager == "ufw") {
|
||||
const ufw_status = tryShell({ command: `ufw status` });
|
||||
const ufw_rules = ufw_status.success ? ufw_status.output : "";
|
||||
|
||||
if (ufw_rules.includes(subnet_trimmed)) {
|
||||
const subnet_remove = tryShell({
|
||||
command: `ufw delete allow from ${subnet_trimmed}`,
|
||||
});
|
||||
|
||||
if (subnet_remove.success) {
|
||||
removed_rules.push(`removed subnet allow for ${subnet_trimmed}`);
|
||||
} else {
|
||||
skipped_rules.push(
|
||||
`ufw subnet rule not removed: ${subnet_remove.output}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
skipped_rules.push(`subnet ${subnet_trimmed} not present — nothing to remove`);
|
||||
}
|
||||
|
||||
const port_rule = `${listen_port}/udp`;
|
||||
|
||||
if (ufw_rules.includes(port_rule)) {
|
||||
const port_remove = tryShell({
|
||||
command: `ufw delete allow ${port_rule}`,
|
||||
});
|
||||
|
||||
if (port_remove.success) {
|
||||
removed_rules.push(`removed ${port_rule} allow`);
|
||||
} else {
|
||||
skipped_rules.push(`ufw port rule not removed: ${port_remove.output}`);
|
||||
}
|
||||
} else {
|
||||
skipped_rules.push(`port ${port_rule} not present — nothing to remove`);
|
||||
}
|
||||
}
|
||||
|
||||
if (manager == "none") {
|
||||
return {
|
||||
success: true,
|
||||
manager,
|
||||
msg: `no active firewall manager detected (firewalld/ufw) — no rules removed; if you use iptables/nftables directly, remove ${subnet_trimmed} and udp port ${listen_port} rules manually`,
|
||||
removed_rules,
|
||||
skipped_rules,
|
||||
};
|
||||
}
|
||||
|
||||
const summary = [
|
||||
removed_rules.length
|
||||
? `firewall(${manager}): removed — ${removed_rules.join("; ")}`
|
||||
: `firewall(${manager}): no rules removed`,
|
||||
skipped_rules.length ? `skipped — ${skipped_rules.join("; ")}` : ``,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(` `);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
manager,
|
||||
msg: summary,
|
||||
removed_rules,
|
||||
skipped_rules,
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import type { TableType, User } from "@/src/types";
|
||||
import manageWireguardHost from "./manage-wireguard-host";
|
||||
import grabHostDirnames from "./grab-host-dir-names";
|
||||
import grabClientDirnames from "./grab-client-dir-names";
|
||||
import removeSubnetFirewallRules from "../firewall/remove-subnet-firewall-rules";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
type Params = {
|
||||
@@ -74,6 +76,41 @@ export default async function deleteWireguardHost({
|
||||
});
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const host_res = await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_HOSTS,
|
||||
TableType
|
||||
>({
|
||||
table: "hosts",
|
||||
targetId: host_id,
|
||||
});
|
||||
|
||||
const host = host_res.singleRes;
|
||||
|
||||
if (host?.wg_ip_address) {
|
||||
const host_subnet = `${host.wg_ip_address
|
||||
.split(".")
|
||||
.slice(0, 3)
|
||||
.join(".")}.0/24`;
|
||||
|
||||
const firewall_res = await removeSubnetFirewallRules({
|
||||
subnet: host_subnet,
|
||||
listen_port:
|
||||
host.listen_port || AppData["DefaultWGListenPort"],
|
||||
});
|
||||
|
||||
if (!firewall_res.success) {
|
||||
console.log(
|
||||
`Warning removing firewall rules for host ${host_id} => ${firewall_res.msg}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.log(
|
||||
`Warning removing firewall rules for host ${host_id} => ${error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(`rm -rf ${HOST_CONFIG_DIR}`, { encoding: "utf-8" });
|
||||
} catch {}
|
||||
|
||||
@@ -14,6 +14,7 @@ import manageWireguardHost from "./manage-wireguard-host";
|
||||
import grabHostDirnames from "./grab-host-dir-names";
|
||||
import grabClientDirnames from "./grab-client-dir-names";
|
||||
import buildHostIptablesScripts from "./build-host-iptables-scripts";
|
||||
import allowSubnetFirewallRules from "../firewall/allow-subnet-firewall-rules";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
|
||||
const { WIREGUARD_PRIVATE_KEY_FILE_NAME, WIREGUARD_PUBLIC_KEY_FILE_NAME } =
|
||||
@@ -267,9 +268,20 @@ export default async function setupWireguardHost({
|
||||
};
|
||||
}
|
||||
|
||||
const firewall_res = await allowSubnetFirewallRules({
|
||||
subnet: HOST_SUBNET,
|
||||
listen_port: host?.listen_port || AppData["DefaultWGListenPort"],
|
||||
});
|
||||
|
||||
if (!firewall_res.success) {
|
||||
console.log(
|
||||
`Warning setting up firewall rules for host ${HOST_ID} => ${firewall_res.msg}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
msg: [exec.trim(), manage_res.msg].join("\n\n"),
|
||||
msg: [exec.trim(), manage_res.msg, firewall_res.msg].join("\n\n"),
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.log(
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import fetchApi from "@/src/components/twui/utils/fetch/fetchApi";
|
||||
import type { FirewallStatus } from "@/src/functions/backend/firewall/grab-firewall-status";
|
||||
import type { ApiReqParams } from "@/src/types";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type FirewallStatusResponse = {
|
||||
success: boolean;
|
||||
singleRes?: FirewallStatus | null;
|
||||
msg?: string;
|
||||
};
|
||||
|
||||
export default function useFirewallStatus() {
|
||||
const [firewall_status, setFirewallStatus] =
|
||||
useState<FirewallStatus | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = () =>
|
||||
fetchApi<ApiReqParams, FirewallStatusResponse>(
|
||||
`/api/admin/firewall-status`,
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
).then((res) => {
|
||||
if (res.success && res.singleRes) {
|
||||
setFirewallStatus(res.singleRes);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(res.msg || `Could not fetch firewall status`);
|
||||
}
|
||||
|
||||
return res;
|
||||
});
|
||||
|
||||
const refreshWithLoading = () => {
|
||||
setLoading(true);
|
||||
|
||||
refresh()
|
||||
.catch((request_error: any) => {
|
||||
setError(
|
||||
request_error?.message ||
|
||||
`Could not fetch firewall status`,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refreshWithLoading();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
firewall_status,
|
||||
refresh: refreshWithLoading,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import CodeBlock from "@/src/components/twui/elements/CodeBlock";
|
||||
import Tag from "@/src/components/twui/elements/Tag";
|
||||
import type {
|
||||
FirewallRuleKind,
|
||||
FirewallRuleInfo,
|
||||
} from "@/src/functions/backend/firewall/grab-firewall-status";
|
||||
import Row from "@/src/components/twui/layout/Row";
|
||||
import Span from "@/src/components/twui/layout/Span";
|
||||
import Stack from "@/src/components/twui/layout/Stack";
|
||||
import type { TWUIButtonColorType } from "@/src/components/twui/layout/Button";
|
||||
import {
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
ShieldX,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
const KIND_META: Record<
|
||||
FirewallRuleKind,
|
||||
{
|
||||
label: string;
|
||||
color: TWUIButtonColorType;
|
||||
icon: LucideIcon;
|
||||
text_class: string;
|
||||
}
|
||||
> = {
|
||||
required: {
|
||||
label: `Active`,
|
||||
color: `success`,
|
||||
icon: ShieldCheck,
|
||||
text_class: `text-success`,
|
||||
},
|
||||
temporary: {
|
||||
label: `Temporary`,
|
||||
color: `warning`,
|
||||
icon: ShieldAlert,
|
||||
text_class: `text-warning`,
|
||||
},
|
||||
orphaned: {
|
||||
label: `Orphaned`,
|
||||
color: `error`,
|
||||
icon: ShieldX,
|
||||
text_class: `text-error`,
|
||||
},
|
||||
other: {
|
||||
label: `Unmanaged`,
|
||||
color: `gray`,
|
||||
icon: ShieldCheck,
|
||||
text_class: `text-foreground-light/45 dark:text-foreground-dark/45`,
|
||||
},
|
||||
};
|
||||
|
||||
type Props = {
|
||||
rule: FirewallRuleInfo;
|
||||
};
|
||||
|
||||
export default function FirewallRuleRow({ rule }: Props) {
|
||||
const meta = KIND_META[rule.kind];
|
||||
const Icon = meta.icon;
|
||||
|
||||
return (
|
||||
<Stack className="w-full items-stretch gap-2.5 rounded-lg bg-background-light dark:bg-[#0e0e10] border border-slate-200 dark:border-white/10 p-3">
|
||||
<Row className="w-full items-start justify-between gap-3 flex-wrap">
|
||||
<Row className="gap-2.5 items-start min-w-0">
|
||||
<Icon
|
||||
size={15}
|
||||
className={`mt-0.5 shrink-0 ${meta.text_class}`}
|
||||
/>
|
||||
<Span className="text-[12.5px] leading-5 min-w-0">
|
||||
{rule.description}
|
||||
</Span>
|
||||
</Row>
|
||||
<Tag
|
||||
variant="outlined"
|
||||
color={meta.color}
|
||||
className="shrink-0"
|
||||
>
|
||||
{meta.label}
|
||||
</Tag>
|
||||
</Row>
|
||||
|
||||
{rule.delete_command ? (
|
||||
<CodeBlock
|
||||
singleBlock
|
||||
language="shell"
|
||||
wrapperProps={{
|
||||
className: `max-w-none! xl:max-w-none!`,
|
||||
}}
|
||||
>
|
||||
{`# remove rule\n${rule.delete_command}`}
|
||||
</CodeBlock>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import AdminCard from "@/src/components/general/admin-card";
|
||||
import Button from "@/src/components/twui/layout/Button";
|
||||
import H3 from "@/src/components/twui/layout/H3";
|
||||
import P from "@/src/components/twui/layout/P";
|
||||
import Row from "@/src/components/twui/layout/Row";
|
||||
import Span from "@/src/components/twui/layout/Span";
|
||||
import Stack from "@/src/components/twui/layout/Stack";
|
||||
import Tag from "@/src/components/twui/elements/Tag";
|
||||
import { RefreshCw, Shield, TriangleAlert } from "lucide-react";
|
||||
import useFirewallStatus from "../(hooks)/use-firewall-status";
|
||||
import FirewallRuleRow from "../(partials)/firewall-rule-row";
|
||||
|
||||
const MANAGER_LABEL: Record<string, string> = {
|
||||
firewalld: `firewalld (active)`,
|
||||
ufw: `ufw (active)`,
|
||||
none: `none detected`,
|
||||
};
|
||||
|
||||
export default function FirewallSection() {
|
||||
const { firewall_status, refresh, loading, error } = useFirewallStatus();
|
||||
|
||||
if (!firewall_status) {
|
||||
return (
|
||||
<AdminCard className="w-full p-5 flex flex-col gap-4">
|
||||
<Row className="gap-3 items-start">
|
||||
<Span className="p-2.5 rounded-lg bg-primary/10 dark:bg-primary-dark/10 text-primary dark:text-primary-dark shrink-0">
|
||||
<Shield size={19} />
|
||||
</Span>
|
||||
<Stack className="gap-1 min-w-0">
|
||||
<H3 className="text-[14px] font-semibold mb-0!">
|
||||
Firewall
|
||||
</H3>
|
||||
<P
|
||||
noMargin
|
||||
className="text-[12.5px] text-foreground-light/45 dark:text-foreground-dark/45"
|
||||
>
|
||||
{error ||
|
||||
`Checking firewall status on this host ...`}
|
||||
</P>
|
||||
</Stack>
|
||||
</Row>
|
||||
</AdminCard>
|
||||
);
|
||||
}
|
||||
|
||||
const { manager, rules, hosts_found } = firewall_status;
|
||||
|
||||
return (
|
||||
<AdminCard className="w-full p-5 flex flex-col gap-4">
|
||||
<Row className="gap-3 items-start">
|
||||
<Span className="p-2.5 rounded-lg bg-primary/10 dark:bg-primary-dark/10 text-primary dark:text-primary-dark shrink-0">
|
||||
<Shield size={19} />
|
||||
</Span>
|
||||
<Stack className="gap-1 min-w-0 flex-1">
|
||||
<Row className="gap-2 items-center flex-wrap">
|
||||
<H3 className="text-[14px] font-semibold mb-0!">
|
||||
Firewall
|
||||
</H3>
|
||||
<Tag
|
||||
variant="outlined"
|
||||
color={manager == "none" ? "warning" : "success"}
|
||||
className="shrink-0"
|
||||
>
|
||||
{MANAGER_LABEL[manager] || manager}
|
||||
</Tag>
|
||||
</Row>
|
||||
<P
|
||||
noMargin
|
||||
className="text-[12.5px] text-foreground-light/45 dark:text-foreground-dark/45"
|
||||
>
|
||||
Host setups automatically allow their wireguard subnet
|
||||
and listen port here. Rules that are no longer needed
|
||||
(bootstrap or stale) are listed with removal commands.
|
||||
</P>
|
||||
</Stack>
|
||||
<Button
|
||||
title="Refresh"
|
||||
variant="outlined"
|
||||
loading={loading}
|
||||
beforeIcon={<RefreshCw size={14} />}
|
||||
onClick={refresh}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Row>
|
||||
|
||||
{manager == "none" ? (
|
||||
<Row className="gap-2 items-center bg-warning/5 rounded-lg px-3 py-2.5 text-warning">
|
||||
<TriangleAlert size={15} className="shrink-0" />
|
||||
<Span className="text-[12.5px]">
|
||||
No active high-level firewall manager detected
|
||||
(firewalld/ufw). If you use iptables/nftables directly,
|
||||
allow each host subnet and its wireguard listen port
|
||||
manually.
|
||||
</Span>
|
||||
</Row>
|
||||
) : null}
|
||||
|
||||
{rules.length ? (
|
||||
<Stack className="w-full gap-2 items-stretch">
|
||||
{rules.map((rule, index) => (
|
||||
<FirewallRuleRow
|
||||
key={rule.id || `${manager}-${index}`}
|
||||
rule={rule}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack className="w-full items-start gap-1 rounded-lg bg-background-light dark:bg-[#0e0e10] border border-slate-200 dark:border-white/10 p-3">
|
||||
<Span className="text-[12.5px] text-foreground-light/60 dark:text-foreground-dark/60">
|
||||
{hosts_found
|
||||
? `No wg-ui related firewall rules found.`
|
||||
: `No hosts created yet — creating a host automatically allows its subnet (e.g. 10.5.0.0/24) and its wireguard listen port (e.g. 51821/udp) here.`}
|
||||
</Span>
|
||||
</Stack>
|
||||
)}
|
||||
</AdminCard>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import Stack from "@/src/components/twui/layout/Stack";
|
||||
import useSystemSetupStatus from "./(hooks)/use-system-setup-status";
|
||||
import SystemStatusSection from "./(sections)/system-status-section";
|
||||
import SetupActionsSection from "./(sections)/setup-actions-section";
|
||||
import FirewallSection from "./(sections)/firewall-section";
|
||||
import { useContext } from "react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
|
||||
@@ -26,6 +27,7 @@ export default function AdminWireguardSetupPage() {
|
||||
<Stack className="w-full px-6 pb-8 gap-5 items-stretch">
|
||||
<SystemStatusSection setup={setup} />
|
||||
<SetupActionsSection setup={setup} />
|
||||
<FirewallSection />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabFirewallStatus, {
|
||||
type FirewallStatus,
|
||||
} from "@/src/functions/backend/firewall/grab-firewall-status";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<FirewallStatus>
|
||||
> = async ({ req }) => {
|
||||
if (req.method !== "GET") {
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const { user, user_types } = await userAuth({ req });
|
||||
|
||||
if (!user?.logged_in_status) {
|
||||
return {
|
||||
success: false,
|
||||
msg: `Unauthorized`,
|
||||
logoutUser: true,
|
||||
};
|
||||
}
|
||||
|
||||
const is_super_admin = checkUserAccess({ user_types });
|
||||
|
||||
if (!is_super_admin.success) {
|
||||
return {
|
||||
success: false,
|
||||
msg: `Unauthorized`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
singleRes: await grabFirewallStatus(),
|
||||
};
|
||||
};
|
||||
@@ -245,25 +245,51 @@ grab_port() {
|
||||
grep -oE 'ServerPort: [0-9]+' "$INSTALL_DIR/src/data/site-data.ts" 2>/dev/null | grep -oE '[0-9]+' | head -1 || echo "10752"
|
||||
}
|
||||
|
||||
configure_firewall() {
|
||||
print_firewall_instructions() {
|
||||
local port="$1"
|
||||
log "configuring firewall to allow inbound traffic on port $port/tcp ..."
|
||||
|
||||
local manager="none"
|
||||
|
||||
# Check for firewalld (RHEL, CentOS, Fedora, Rocky, Alma)
|
||||
if command -v firewall-cmd >/dev/null 2>&1 && systemctl is-active --quiet firewalld 2>/dev/null; then
|
||||
log "firewalld is active. opening port $port/tcp ..."
|
||||
firewall-cmd --permanent --add-port="${port}/tcp" >/dev/null 2>&1 || fail "failed to add port to firewalld"
|
||||
firewall-cmd --reload >/dev/null 2>&1 || fail "failed to reload firewalld"
|
||||
|
||||
manager="firewalld"
|
||||
# Check for ufw (Ubuntu, Debian, Mint)
|
||||
elif command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then
|
||||
log "ufw is active. opening port $port/tcp ..."
|
||||
ufw allow "${port}/tcp" >/dev/null 2>&1 || log "warning: failed to add port to ufw"
|
||||
|
||||
# Fallback for Alpine or manual iptables
|
||||
else
|
||||
log "no active high-level firewall manager detected (firewalld/ufw). if you use iptables/nftables directly, ensure port $port/tcp is allowed."
|
||||
manager="ufw"
|
||||
fi
|
||||
|
||||
log ""
|
||||
log "firewall: intentionally left untouched — the admin dashboard port $port/tcp was NOT opened to the internet (auto-opening it would be a security risk)."
|
||||
log ""
|
||||
|
||||
if [ "$manager" = "firewalld" ]; then
|
||||
log "to finish setup you must temporarily allow port $port/tcp so you can reach the dashboard once:"
|
||||
log " firewall-cmd --permanent --add-port=${port}/tcp && firewall-cmd --reload"
|
||||
log ""
|
||||
log "afterwards, delete the temporary rule with:"
|
||||
log " firewall-cmd --permanent --remove-port=${port}/tcp && firewall-cmd --reload"
|
||||
elif [ "$manager" = "ufw" ]; then
|
||||
log "to finish setup you must temporarily allow port $port/tcp so you can reach the dashboard once:"
|
||||
log " ufw allow ${port}/tcp"
|
||||
log ""
|
||||
log "afterwards, delete the temporary rule with:"
|
||||
log " ufw delete allow ${port}/tcp"
|
||||
else
|
||||
log "no active high-level firewall manager detected (firewalld/ufw). if you use iptables/nftables directly, ensure port $port/tcp is reachable temporarily while you finish setup."
|
||||
fi
|
||||
|
||||
local manager_text="your host firewall (iptables/nftables)"
|
||||
local auto_allow_text="allow each host subnet and its wireguard listen port manually"
|
||||
if [ "$manager" = "firewalld" ] || [ "$manager" = "ufw" ]; then
|
||||
manager_text="$manager"
|
||||
auto_allow_text="each host setup automatically allows its wireguard subnet plus the host's listen port in $manager"
|
||||
fi
|
||||
|
||||
log ""
|
||||
log "recommended flow:"
|
||||
log " 1. add the temporary allow above (keep it only for as long as needed)"
|
||||
log " 2. log in and create your hosts — $auto_allow_text"
|
||||
log " 3. once everything is reachable via the tunnel, remove the temporary rule"
|
||||
log " and review all firewall rules under /admin/setup -> Firewall"
|
||||
}
|
||||
|
||||
install_deps
|
||||
@@ -296,7 +322,7 @@ fi
|
||||
PORT="$(grab_port)"
|
||||
|
||||
if [ "$DEV_MODE" = false ]; then
|
||||
configure_firewall "$PORT"
|
||||
print_firewall_instructions "$PORT"
|
||||
fi
|
||||
|
||||
log "wg-ui install complete."
|
||||
|
||||
Reference in New Issue
Block a user