diff --git a/README.md b/README.md index 15a2078..0d3271c 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,36 @@ The install script will: 8. Install the `wg-quick` helper script 9. Set up a systemd or OpenRC service (`wgui`) +The installer **never modifies your firewall** — it will print the commands to +temporarily allow the dashboard port so you can reach the first login, and then +point you to **Setup → Firewall** for reviewing and removing rules. + +### Firewall + +Firewall rules are managed per host, automatically: + +1. **Bootstrap (manual, temporary)** — allow the dashboard port so you can + reach the web UI once: + ```bash + ufw allow 10752/tcp # ufw (Debian/Ubuntu) + firewall-cmd --permanent --add-port=10752/tcp && firewall-cmd --reload # firewalld (RHEL/Fedora) + ``` +2. **Host creation** — every host setup automatically allows its entire + wireguard subnet (e.g. `10.5.0.1` → `10.5.0.0/24` from anywhere) and the + host's wireguard listen port (e.g. `51821/udp`) in ufw or firewalld. + Detailed per-client traffic rules are enforced by the existing iptables + chain scripts, not the host firewall. +3. **Host deletion** — the host's subnet and listen-port rules are removed + automatically. +4. **Cleanup** — once everything is reachable via the tunnel, remove the + temporary bootstrap rule and review all rules under **Setup → Firewall**: + ```bash + ufw delete allow 10752/tcp + firewall-cmd --permanent --remove-port=10752/tcp && firewall-cmd --reload + ``` + Rules that no longer match any host (`temporary`/`orphaned`) are flagged + with copyable removal commands on the setup page. + ### Manual (Development) ```bash diff --git a/src/functions/backend/firewall/allow-subnet-firewall-rules.ts b/src/functions/backend/firewall/allow-subnet-firewall-rules.ts new file mode 100644 index 0000000..b225c10 --- /dev/null +++ b/src/functions/backend/firewall/allow-subnet-firewall-rules.ts @@ -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 { + 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, + }; +} diff --git a/src/functions/backend/firewall/detect-firewall-manager.ts b/src/functions/backend/firewall/detect-firewall-manager.ts new file mode 100644 index 0000000..47ee34b --- /dev/null +++ b/src/functions/backend/firewall/detect-firewall-manager.ts @@ -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"; +} \ No newline at end of file diff --git a/src/functions/backend/firewall/grab-firewall-status.ts b/src/functions/backend/firewall/grab-firewall-status.ts new file mode 100644 index 0000000..d86aa41 --- /dev/null +++ b/src/functions/backend/firewall/grab-firewall-status.ts @@ -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 { + const manager = detectFirewallManager(); + + const hosts_res = await BunSQLite.select({ + 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, + }; +} \ No newline at end of file diff --git a/src/functions/backend/firewall/remove-subnet-firewall-rules.ts b/src/functions/backend/firewall/remove-subnet-firewall-rules.ts new file mode 100644 index 0000000..017bf95 --- /dev/null +++ b/src/functions/backend/firewall/remove-subnet-firewall-rules.ts @@ -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 { + 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, + }; +} \ No newline at end of file diff --git a/src/functions/backend/setup/delete-wireguard-host.ts b/src/functions/backend/setup/delete-wireguard-host.ts index 0a57957..365d1ed 100644 --- a/src/functions/backend/setup/delete-wireguard-host.ts +++ b/src/functions/backend/setup/delete-wireguard-host.ts @@ -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 {} diff --git a/src/functions/backend/setup/setup-wireguard-host.ts b/src/functions/backend/setup/setup-wireguard-host.ts index fe5f0d1..58c0a37 100644 --- a/src/functions/backend/setup/setup-wireguard-host.ts +++ b/src/functions/backend/setup/setup-wireguard-host.ts @@ -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( diff --git a/src/pages/admin/setup/(hooks)/use-firewall-status.ts b/src/pages/admin/setup/(hooks)/use-firewall-status.ts new file mode 100644 index 0000000..eea9380 --- /dev/null +++ b/src/pages/admin/setup/(hooks)/use-firewall-status.ts @@ -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(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const refresh = () => + fetchApi( + `/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, + }; +} \ No newline at end of file diff --git a/src/pages/admin/setup/(partials)/firewall-rule-row.tsx b/src/pages/admin/setup/(partials)/firewall-rule-row.tsx new file mode 100644 index 0000000..40fd861 --- /dev/null +++ b/src/pages/admin/setup/(partials)/firewall-rule-row.tsx @@ -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 ( + + + + + + {rule.description} + + + + {meta.label} + + + + {rule.delete_command ? ( + + {`# remove rule\n${rule.delete_command}`} + + ) : null} + + ); +} \ No newline at end of file diff --git a/src/pages/admin/setup/(sections)/firewall-section.tsx b/src/pages/admin/setup/(sections)/firewall-section.tsx new file mode 100644 index 0000000..af75d09 --- /dev/null +++ b/src/pages/admin/setup/(sections)/firewall-section.tsx @@ -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 = { + firewalld: `firewalld (active)`, + ufw: `ufw (active)`, + none: `none detected`, +}; + +export default function FirewallSection() { + const { firewall_status, refresh, loading, error } = useFirewallStatus(); + + if (!firewall_status) { + return ( + + + + + + +

+ Firewall +

+

+ {error || + `Checking firewall status on this host ...`} +

+
+
+
+ ); + } + + const { manager, rules, hosts_found } = firewall_status; + + return ( + + + + + + + +

+ Firewall +

+ + {MANAGER_LABEL[manager] || manager} + +
+

+ 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. +

+
+ +
+ + {manager == "none" ? ( + + + + 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. + + + ) : null} + + {rules.length ? ( + + {rules.map((rule, index) => ( + + ))} + + ) : ( + + + {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.`} + + + )} +
+ ); +} \ No newline at end of file diff --git a/src/pages/admin/setup/index.tsx b/src/pages/admin/setup/index.tsx index 81959d2..a48760c 100644 --- a/src/pages/admin/setup/index.tsx +++ b/src/pages/admin/setup/index.tsx @@ -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() { + ); diff --git a/src/pages/api/admin/firewall-status.ts b/src/pages/api/admin/firewall-status.ts new file mode 100644 index 0000000..aa73ff6 --- /dev/null +++ b/src/pages/api/admin/firewall-status.ts @@ -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 +> = 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(), + }; +}; \ No newline at end of file diff --git a/src/scripts/install-wg-ui.sh b/src/scripts/install-wg-ui.sh index fbe31e9..95e6a5b 100755 --- a/src/scripts/install-wg-ui.sh +++ b/src/scripts/install-wg-ui.sh @@ -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."