diff --git a/src/functions/backend/setup/apply-wireguard-host-live.ts b/src/functions/backend/setup/apply-wireguard-host-live.ts new file mode 100644 index 0000000..c52d7fb --- /dev/null +++ b/src/functions/backend/setup/apply-wireguard-host-live.ts @@ -0,0 +1,92 @@ +import type { APIResponseObject } from "@moduletrace/bunext/types"; +import { execFileSync } from "node:child_process"; +import grabHostDirnames from "./grab-host-dir-names"; +import manageWireguardHost from "./manage-wireguard-host"; + +type Params = { + host_id: number; +}; + +function errorOutput({ error }: { error: any }) { + const stderr = error?.stderr ? String(error.stderr).trim() : ""; + const stdout = error?.stdout ? String(error.stdout).trim() : ""; + + return stderr || stdout || error?.message || "unknown error"; +} + +function isInterfaceUp({ interface_name }: { interface_name: string }) { + try { + execFileSync("ip", ["link", "show", "dev", interface_name], { + stdio: "pipe", + }); + return true; + } catch { + return false; + } +} + +/** + * Applies an already-written host config and PostUp script to the running + * tunnel without restarting it: peers are synced with `wg syncconf` and the + * per-host iptables chains are reloaded atomically by re-running PostUp. + * Connected clients stay connected. Falls back to bringing the tunnel up if + * it isn't running. + * + * Only for changes that leave the interface itself alone (peers and client + * rules). Address or listen-port changes need a full restart. + */ +export default function applyWireguardHostLive({ + host_id, +}: Params): APIResponseObject { + const { HOST_CONFIG_FILE, INTERFACE_NAME, POST_UP_PATH } = + grabHostDirnames({ host: { id: host_id } }); + + const IS_ROOT = + typeof process.getuid === "function" && process.getuid() === 0; + + if (!IS_ROOT) { + return { + success: false, + msg: "wg-ui must run as root to manage wireguard tunnels", + }; + } + + if (!isInterfaceUp({ interface_name: INTERFACE_NAME })) { + return manageWireguardHost({ host_id, action: "up" }); + } + + try { + const stripped = execFileSync("wg-quick", ["strip", HOST_CONFIG_FILE], { + encoding: "utf-8", + stdio: "pipe", + }); + + execFileSync("wg", ["syncconf", INTERFACE_NAME, "/dev/stdin"], { + input: stripped, + stdio: "pipe", + }); + } catch (error: any) { + return { + success: false, + msg: `Could not sync peers on ${INTERFACE_NAME}: ${errorOutput({ error })}`, + }; + } + + try { + execFileSync(POST_UP_PATH, [], { + encoding: "utf-8", + stdio: "pipe", + timeout: 30000, + }); + } catch (error: any) { + return { + success: false, + msg: `Peers synced on ${INTERFACE_NAME}, but the firewall rules could not be applied: ${errorOutput({ error })}`, + }; + } + + return { + success: true, + msg: `Applied changes to ${INTERFACE_NAME} without a restart`, + }; +} diff --git a/src/functions/backend/setup/build-host-iptables-scripts.test.ts b/src/functions/backend/setup/build-host-iptables-scripts.test.ts index c546a2f..342e430 100644 --- a/src/functions/backend/setup/build-host-iptables-scripts.test.ts +++ b/src/functions/backend/setup/build-host-iptables-scripts.test.ts @@ -14,9 +14,9 @@ describe("buildHostIptablesScripts", () => { }); expect(res.success).toBe(true); - expect(res.post_up).toContain("iptables -N WGUI0FWD"); - expect(res.post_up).toContain("iptables -A WGUI0FWD -j DROP"); - expect(res.post_up).toContain("iptables -A WGUI0IN -j DROP"); + expect(res.post_up).toContain(":WGUI0FWD - [0:0]"); + expect(res.post_up).toContain("-A WGUI0FWD -j DROP"); + expect(res.post_up).toContain("-A WGUI0IN -j DROP"); expect(res.post_up).not.toContain("WGUI0FWD -s"); expect(res.post_down).toContain("iptables -X WGUI0FWD"); expect(res.post_down).toContain( @@ -38,10 +38,10 @@ describe("buildHostIptablesScripts", () => { expect(res.success).toBe(true); expect(res.post_up).toContain( - "iptables -A WGUI0FWD -s 10.0.0.2/32 -j ACCEPT", + "-A WGUI0FWD -s 10.0.0.2/32 -j ACCEPT", ); expect(res.post_up).toContain( - "iptables -A WGUI0IN -s 10.0.0.2/32 -j ACCEPT", + "-A WGUI0IN -s 10.0.0.2/32 -j ACCEPT", ); }); @@ -66,10 +66,10 @@ describe("buildHostIptablesScripts", () => { expect(res.success).toBe(true); expect(res.post_up).toContain( - "iptables -A WGUI0FWD -s 10.0.0.3/32 -d 192.168.1.10/32 -p tcp -m multiport --dports 80,443 -j ACCEPT", + "-A WGUI0FWD -s 10.0.0.3/32 -d 192.168.1.10/32 -p tcp -m multiport --dports 80,443 -j ACCEPT", ); expect(res.post_up).toContain( - "iptables -A WGUI0IN -s 10.0.0.3/32 -d 192.168.1.10/32 -p tcp -m multiport --dports 80,443 -j ACCEPT", + "-A WGUI0IN -s 10.0.0.3/32 -d 192.168.1.10/32 -p tcp -m multiport --dports 80,443 -j ACCEPT", ); }); @@ -95,13 +95,13 @@ describe("buildHostIptablesScripts", () => { expect(res.success).toBe(true); expect(res.post_up).toContain( - "iptables -A WGUI0FWD -s 10.5.0.2/32 -d 65.108.81.79/32 -p tcp --dport 443 -j ACCEPT", + "-A WGUI0FWD -s 10.5.0.2/32 -d 65.108.81.79/32 -p tcp --dport 443 -j ACCEPT", ); expect(res.post_up).toContain( - "iptables -A WGUI0IN -s 10.5.0.2/32 -d 65.108.81.79/32 -p tcp --dport 443 -j ACCEPT", + "-A WGUI0IN -s 10.5.0.2/32 -d 65.108.81.79/32 -p tcp --dport 443 -j ACCEPT", ); expect(res.post_up).not.toContain( - "iptables -A WGUI0IN -s 10.5.0.2/32 -d 10.5.0.0/24 -p tcp --dport 443 -j ACCEPT", + "-A WGUI0IN -s 10.5.0.2/32 -d 10.5.0.0/24 -p tcp --dport 443 -j ACCEPT", ); }); @@ -125,16 +125,16 @@ describe("buildHostIptablesScripts", () => { expect(res.success).toBe(true); expect(res.post_up).toContain( - "iptables -A WGUI0FWD -s 10.0.0.4/32 -p tcp --dport 53 -j ACCEPT", + "-A WGUI0FWD -s 10.0.0.4/32 -p tcp --dport 53 -j ACCEPT", ); expect(res.post_up).toContain( - "iptables -A WGUI0FWD -s 10.0.0.4/32 -p udp --dport 53 -j ACCEPT", + "-A WGUI0FWD -s 10.0.0.4/32 -p udp --dport 53 -j ACCEPT", ); expect(res.post_up).toContain( - "iptables -A WGUI0IN -s 10.0.0.4/32 -p tcp --dport 53 -j ACCEPT", + "-A WGUI0IN -s 10.0.0.4/32 -p tcp --dport 53 -j ACCEPT", ); expect(res.post_up).toContain( - "iptables -A WGUI0IN -s 10.0.0.4/32 -p udp --dport 53 -j ACCEPT", + "-A WGUI0IN -s 10.0.0.4/32 -p udp --dport 53 -j ACCEPT", ); }); @@ -229,10 +229,10 @@ describe("buildHostIptablesScripts", () => { expect(res.success).toBe(true); expect(res.post_up).toContain( - "iptables -A WGUI0FWD -s 10.0.0.7/32 -j ACCEPT", + "-A WGUI0FWD -s 10.0.0.7/32 -j ACCEPT", ); expect(res.post_up).toContain( - "iptables -A WGUI0IN -s 10.0.0.7/32 -d 10.6.0.0/24 -j ACCEPT", + "-A WGUI0IN -s 10.0.0.7/32 -d 10.6.0.0/24 -j ACCEPT", ); }); @@ -256,10 +256,10 @@ describe("buildHostIptablesScripts", () => { expect(res.success).toBe(true); expect(res.post_up).toContain( - "iptables -A WGUI0FWD -s 10.0.0.8/32 -p tcp --dport 53 -j ACCEPT", + "-A WGUI0FWD -s 10.0.0.8/32 -p tcp --dport 53 -j ACCEPT", ); expect(res.post_up).toContain( - "iptables -A WGUI0IN -s 10.0.0.8/32 -p tcp --dport 53 -j ACCEPT", + "-A WGUI0IN -s 10.0.0.8/32 -p tcp --dport 53 -j ACCEPT", ); }); @@ -278,10 +278,10 @@ describe("buildHostIptablesScripts", () => { expect(res.success).toBe(true); expect(res.post_up).toContain( - "iptables -A WGUI0FWD -s 10.0.0.2/32 -j ACCEPT", + "-A WGUI0FWD -s 10.0.0.2/32 -j ACCEPT", ); expect(res.post_up).toContain( - "iptables -A WGUI0IN -s 10.0.0.2/32 -d 10.0.0.0/24 -j ACCEPT", + "-A WGUI0IN -s 10.0.0.2/32 -d 10.0.0.0/24 -j ACCEPT", ); }); @@ -307,10 +307,10 @@ describe("buildHostIptablesScripts", () => { expect(res.success).toBe(true); const post_up = res.post_up ?? ""; - const sibling_drop = "iptables -A WGUI0FWD -s 10.0.0.2/32 -d 10.1.0.0/24 -j DROP"; - const sibling_drop_2 = "iptables -A WGUI0FWD -s 10.0.0.2/32 -d 10.2.0.0/24 -j DROP"; - const sibling_input_drop = "iptables -A WGUI0IN -s 10.0.0.2/32 -d 10.1.0.0/24 -j DROP"; - const accept = "iptables -A WGUI0FWD -s 10.0.0.2/32 -j ACCEPT"; + const sibling_drop = "-A WGUI0FWD -s 10.0.0.2/32 -d 10.1.0.0/24 -j DROP"; + const sibling_drop_2 = "-A WGUI0FWD -s 10.0.0.2/32 -d 10.2.0.0/24 -j DROP"; + const sibling_input_drop = "-A WGUI0IN -s 10.0.0.2/32 -d 10.1.0.0/24 -j DROP"; + const accept = "-A WGUI0FWD -s 10.0.0.2/32 -j ACCEPT"; expect(post_up).toContain(sibling_drop); expect(post_up).toContain(sibling_drop_2); @@ -337,7 +337,7 @@ describe("buildHostIptablesScripts", () => { expect(res.success).toBe(true); expect(res.post_up).not.toContain("-d 10.1.0.0/24 -j DROP"); expect(res.post_up).toContain( - "iptables -A WGUI0FWD -s 10.0.0.2/32 -j ACCEPT", + "-A WGUI0FWD -s 10.0.0.2/32 -j ACCEPT", ); }); @@ -356,10 +356,10 @@ describe("buildHostIptablesScripts", () => { expect(res.success).toBe(true); expect(res.post_up).toContain( - "iptables -A WGUI0FWD -s 10.7.0.3/32 -j ACCEPT", + "-A WGUI0FWD -s 10.7.0.3/32 -j ACCEPT", ); expect(res.post_up).toContain( - "iptables -A WGUI0IN -s 10.7.0.3/32 -j ACCEPT", + "-A WGUI0IN -s 10.7.0.3/32 -j ACCEPT", ); expect(res.post_up).not.toContain("-d 10.7.0.0/24"); }); @@ -382,8 +382,8 @@ describe("buildHostIptablesScripts", () => { const post_up = res.post_up ?? ""; const sibling_drop = - "iptables -A WGUI0IN -s 10.7.0.4/32 -d 10.3.0.0/24 -j DROP"; - const input_accept = "iptables -A WGUI0IN -s 10.7.0.4/32 -j ACCEPT"; + "-A WGUI0IN -s 10.7.0.4/32 -d 10.3.0.0/24 -j DROP"; + const input_accept = "-A WGUI0IN -s 10.7.0.4/32 -j ACCEPT"; expect(post_up).toContain(sibling_drop); expect(post_up).toContain(input_accept); @@ -414,7 +414,7 @@ describe("buildHostIptablesScripts", () => { expect(res.success).toBe(true); expect(res.post_up).toContain( - "iptables -A WGUI0FWD -s 10.7.0.5/32 -j ACCEPT", + "-A WGUI0FWD -s 10.7.0.5/32 -j ACCEPT", ); expect(res.post_up).not.toContain("192.0.2.1"); }); @@ -444,9 +444,9 @@ describe("buildHostIptablesScripts", () => { const post_up = res.post_up ?? ""; const sibling_drop = - "iptables -A WGUI0IN -s 10.0.0.6/32 -d 10.1.0.0/24 -j DROP"; + "-A WGUI0IN -s 10.0.0.6/32 -d 10.1.0.0/24 -j DROP"; const accept = - "iptables -A WGUI0IN -s 10.0.0.6/32 -d 10.1.0.25/32 -p tcp --dport 80 -j ACCEPT"; + "-A WGUI0IN -s 10.0.0.6/32 -d 10.1.0.25/32 -p tcp --dport 80 -j ACCEPT"; expect(post_up).toContain(sibling_drop); expect(post_up).toContain(accept); @@ -464,7 +464,7 @@ describe("buildHostIptablesScripts", () => { expect(res.success).toBe(true); expect(res.post_up).toContain( - "iptables -A WGUI0IN -i wgui0 -p tcp --dport 8443 -j ACCEPT", + "-A WGUI0IN -i wgui0 -p tcp --dport 8443 -j ACCEPT", ); }); @@ -489,4 +489,27 @@ describe("buildHostIptablesScripts", () => { expect(res.success).toBe(false); expect(res.msg).toBe("Invalid host id"); }); + test("loads the host chains atomically with iptables-restore", () => { + const res = buildHostIptablesScripts({ + ...base, + clients: [ + { id: 1, wg_ip_address: "10.0.0.2", allow_all_ips: true }, + ], + }); + + const post_up = res.post_up ?? ""; + const start = post_up.indexOf( + "iptables-restore --noflush <<'WGUI_RULES' || exit 1", + ); + const end = post_up.indexOf("\nWGUI_RULES\n"); + const block = post_up.slice(start, end); + + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + expect(block).toContain("*filter\n:WGUI0FWD - [0:0]\n:WGUI0IN - [0:0]"); + expect(block).toContain("-A WGUI0FWD -s 10.0.0.2/32 -j ACCEPT"); + expect(block.trimEnd().endsWith("-A WGUI0IN -j DROP\nCOMMIT")).toBe(true); + expect(post_up).not.toContain("iptables -F WGUI0FWD"); + expect(post_up.indexOf("iptables -I FORWARD 1 -i wgui0 -j WGUI0FWD")).toBeGreaterThan(end); + }); }); diff --git a/src/functions/backend/setup/build-host-iptables-scripts.ts b/src/functions/backend/setup/build-host-iptables-scripts.ts index c331114..e6c7316 100644 --- a/src/functions/backend/setup/build-host-iptables-scripts.ts +++ b/src/functions/backend/setup/build-host-iptables-scripts.ts @@ -119,7 +119,7 @@ function acceptLines({ lines.push( joinArgs([ - `iptables -A ${forward_chain}`, + `-A ${forward_chain}`, `-s ${source}`, direction, proto ? `-p ${proto}` : undefined, @@ -130,7 +130,7 @@ function acceptLines({ lines.push( joinArgs([ - `iptables -A ${input_chain}`, + `-A ${input_chain}`, `-s ${source}`, input_destination ? `-d ${input_destination}` : undefined, proto ? `-p ${proto}` : undefined, @@ -198,7 +198,7 @@ export default function buildHostIptablesScripts({ } accept_lines.push( - `iptables -A ${input} -i ${interface_name} -p tcp --dport ${port} -j ACCEPT`, + `-A ${input} -i ${interface_name} -p tcp --dport ${port} -j ACCEPT`, ); } @@ -231,15 +231,15 @@ export default function buildHostIptablesScripts({ for (let s = 0; s < sibling_host_subnets.length; s++) { const sibling_host_subnet = sibling_host_subnets[s]; accept_lines.push( - `iptables -A ${forward} -s ${source} -d ${sibling_host_subnet} -j DROP`, - `iptables -A ${input} -s ${source} -d ${sibling_host_subnet} -j DROP`, + `-A ${forward} -s ${source} -d ${sibling_host_subnet} -j DROP`, + `-A ${input} -s ${source} -d ${sibling_host_subnet} -j DROP`, ); } if (client.allow_all_ips) { accept_lines.push( - `iptables -A ${forward} -s ${source} -j ACCEPT`, - `iptables -A ${input} -s ${source} -j ACCEPT`, + `-A ${forward} -s ${source} -j ACCEPT`, + `-A ${input} -s ${source} -j ACCEPT`, ); continue; @@ -264,12 +264,12 @@ export default function buildHostIptablesScripts({ if (rule.rule_type == "all") { accept_lines.push( joinArgs([ - `iptables -A ${forward}`, + `-A ${forward}`, `-s ${source}`, `-j ACCEPT`, ]), joinArgs([ - `iptables -A ${input}`, + `-A ${input}`, `-s ${source}`, scoped_subnet ? `-d ${scoped_subnet}` : undefined, `-j ACCEPT`, @@ -314,21 +314,22 @@ export default function buildHostIptablesScripts({ `iptables -D FORWARD -i ${interface_name} -j ACCEPT 2>/dev/null || true`, `iptables -D FORWARD -o ${interface_name} -j ACCEPT 2>/dev/null || true`, ``, - `iptables -N ${forward} 2>/dev/null || true`, - `iptables -F ${forward}`, - `iptables -N ${input} 2>/dev/null || true`, - `iptables -F ${input}`, - ``, - `iptables -A ${forward} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT`, - `iptables -A ${input} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT`, - ``, - `iptables -A ${input} -i ${interface_name} -p udp --dport 53 -j ACCEPT`, - `iptables -A ${input} -i ${interface_name} -p tcp --dport 53 -j ACCEPT`, - ``, + // Declaring a chain under --noflush creates or flushes it, and the + // whole table commits at once — so re-running this on a live tunnel + // swaps the rules without a window where the chains are empty. + `iptables-restore --noflush <<'WGUI_RULES' || exit 1`, + `*filter`, + `:${forward} - [0:0]`, + `:${input} - [0:0]`, + `-A ${forward} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT`, + `-A ${input} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT`, + `-A ${input} -i ${interface_name} -p udp --dport 53 -j ACCEPT`, + `-A ${input} -i ${interface_name} -p tcp --dport 53 -j ACCEPT`, ...accept_lines, - ``, - `iptables -A ${forward} -j DROP`, - `iptables -A ${input} -j DROP`, + `-A ${forward} -j DROP`, + `-A ${input} -j DROP`, + `COMMIT`, + `WGUI_RULES`, ``, `iptables -C FORWARD -i ${interface_name} -j ${forward} 2>/dev/null || iptables -I FORWARD 1 -i ${interface_name} -j ${forward}`, `iptables -C FORWARD -o ${interface_name} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || iptables -I FORWARD 1 -o ${interface_name} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT`, diff --git a/src/functions/backend/setup/setup-wireguard-client.ts b/src/functions/backend/setup/setup-wireguard-client.ts index 5b86efd..a6204b6 100644 --- a/src/functions/backend/setup/setup-wireguard-client.ts +++ b/src/functions/backend/setup/setup-wireguard-client.ts @@ -184,6 +184,7 @@ export default async function setupWireguardClient({ host: resolved_host, user, is_update_after_client_setup: true, + apply_live: true, }); if (!host_setup_res.success) { diff --git a/src/functions/backend/setup/setup-wireguard-host.ts b/src/functions/backend/setup/setup-wireguard-host.ts index 3da303f..02aab35 100644 --- a/src/functions/backend/setup/setup-wireguard-host.ts +++ b/src/functions/backend/setup/setup-wireguard-host.ts @@ -12,6 +12,7 @@ import BunSQLite from "@moduletrace/bun-sqlite"; import type { TableType, User } from "@/src/types"; import checkPrivateIPAvailability from "./check-private-ip-availability"; import manageWireguardHost from "./manage-wireguard-host"; +import applyWireguardHostLive from "./apply-wireguard-host-live"; import grabHostDirnames from "./grab-host-dir-names"; import grabClientDirnames from "./grab-client-dir-names"; import buildHostIptablesScripts from "./build-host-iptables-scripts"; @@ -27,6 +28,13 @@ type Params = { wg_subnet_ip?: string; user: User; is_update_after_client_setup?: boolean; + /** + * Apply the new config to the running tunnel (`wg syncconf` + atomic + * iptables reload) instead of restarting it, and skip the firewall + * manager rules. Only for peer/rule changes — the interface address and + * listen port must be unchanged. + */ + apply_live?: boolean; }; export default async function setupWireguardHost({ @@ -34,6 +42,7 @@ export default async function setupWireguardHost({ wg_subnet_ip, user, is_update_after_client_setup, + apply_live, }: Params): Promise { const { HOST_CLIENTS_DIR, @@ -296,6 +305,10 @@ export default async function setupWireguardHost({ ); } + if (apply_live) { + return applyWireguardHostLive({ host_id: Number(HOST_ID) }); + } + const manage_res = manageWireguardHost({ host_id: HOST_ID, action: "restart", diff --git a/src/functions/backend/setup/update-wireguard-host-public-ip.ts b/src/functions/backend/setup/update-wireguard-host-public-ip.ts index 7fcb85e..724e912 100644 --- a/src/functions/backend/setup/update-wireguard-host-public-ip.ts +++ b/src/functions/backend/setup/update-wireguard-host-public-ip.ts @@ -125,6 +125,7 @@ export default async function updateWireguardHostPublicIP({ host, user, is_update_after_client_setup: true, + apply_live: true, }); if (!host_setup_res.success) { diff --git a/src/pages/api/admin/crud/(tables)/clients/del.ts b/src/pages/api/admin/crud/(tables)/clients/del.ts index a87c4fd..5d3a80a 100644 --- a/src/pages/api/admin/crud/(tables)/clients/del.ts +++ b/src/pages/api/admin/crud/(tables)/clients/del.ts @@ -116,6 +116,7 @@ export default async function ( host, user, is_update_after_client_setup: true, + apply_live: true, }); if (!host_setup_res.success) {