Apply client changes to the running tunnel without a restart

Saving a client or rule restarted the whole host tunnel (wg-quick down/up)
and re-ran the firewall manager setup, which was slow and briefly dropped
every client on the host.

Client and rule changes now sync peers with `wg syncconf` and re-run the
PostUp script, skipping the firewalld/ufw step. Host creation and edits
still do a full restart.

PostUp now loads the per-host chains with `iptables-restore --noflush`, so
re-running it on a live tunnel swaps the rules atomically instead of
leaving the chains empty between flush and re-add.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-11-03 08:30:41 +01:00
co-authored by Claude Opus 5.5
parent 77ced6cc52
commit 0d8daab3d0
7 changed files with 188 additions and 56 deletions
@@ -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`,
};
}
@@ -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);
});
});
@@ -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`,
@@ -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) {
@@ -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<APIResponseObject> {
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",
@@ -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) {
@@ -116,6 +116,7 @@ export default async function (
host,
user,
is_update_after_client_setup: true,
apply_live: true,
});
if (!host_setup_res.success) {