Add firewall rules logic for all hosts

This commit is contained in:
2026-09-27 14:25:27 +01:00
parent bbf29a5c1a
commit dbae73a746
13 changed files with 1115 additions and 15 deletions
+30
View File
@@ -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
@@ -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>
);
}
+2
View File
@@ -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>
</>
);
+43
View File
@@ -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(),
};
};
+40 -14
View File
@@ -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."