diff --git a/src/functions/backend/setup/setup-wireguard-client.ts b/src/functions/backend/setup/setup-wireguard-client.ts index d5fce2d..f805b01 100644 --- a/src/functions/backend/setup/setup-wireguard-client.ts +++ b/src/functions/backend/setup/setup-wireguard-client.ts @@ -15,7 +15,7 @@ import type { TableType } from "@/src/types"; const { WGUI_LIB_CLIENTS_CONFIGS_DIR, - WIREGUARD_HOST_CONFIG_DIR, + WGUI_LIB_HOSTS_CONFIGS_DIR, WIREGUARD_PRIVATE_KEY_FILE_NAME, WIREGUARD_PUBLIC_KEY_FILE_NAME, WGUI_LIB_KEYS_DIR, @@ -133,7 +133,7 @@ export default async function setupWireguardClient({ host?.public_key || execSync( `cat ${path.join( - WIREGUARD_HOST_CONFIG_DIR, + WGUI_LIB_HOSTS_CONFIGS_DIR, WIREGUARD_PUBLIC_KEY_FILE_NAME, )}`, { encoding: "utf-8" }, diff --git a/src/functions/backend/setup/setup-wireguard-host.ts b/src/functions/backend/setup/setup-wireguard-host.ts index dfb5cd1..fc8cf9b 100644 --- a/src/functions/backend/setup/setup-wireguard-host.ts +++ b/src/functions/backend/setup/setup-wireguard-host.ts @@ -5,6 +5,7 @@ import type { } from "@/db/types/db"; import { AppData } from "@/src/data/app-data"; import grabDirNames from "@/src/utils/grab-dir-names"; +import deriveWireguardInterfaceName from "@/src/utils/derive-wireguard-interface-name"; import { execSync } from "node:child_process"; import path from "node:path"; import grabHostNetworkInterface from "./grab-host-network-interface"; @@ -15,7 +16,8 @@ import checkPrivateIPAvailability from "./check-private-ip-availability"; const { WGUI_LIB_IP_TABLES_DIR, - WIREGUARD_HOST_CONFIG_DIR, + WGUI_LIB_HOSTS_CONFIGS_DIR, + WGUI_WG_QUICK_SYSTEMD_SCRIPT, WIREGUARD_PRIVATE_KEY_FILE_NAME, WIREGUARD_PUBLIC_KEY_FILE_NAME, } = grabDirNames(); @@ -31,6 +33,12 @@ export default async function setupWireguardHost({ }: Params): Promise { const host_id = host?.id || AppData["WireguardHostID"]; + const INTERFACE_NAME = deriveWireguardInterfaceName({ host_id }); + const HOST_CONFIG_PATH = path.join( + WGUI_LIB_HOSTS_CONFIGS_DIR, + `${INTERFACE_NAME}.conf`, + ); + const variables_res = await BunSQLite.select< BUN_SQLITE_WGUI_VARIABLES, TableType @@ -83,7 +91,8 @@ export default async function setupWireguardHost({ let pre_sh = ``; pre_sh += `set -e\n`; - pre_sh += `cd ${WIREGUARD_HOST_CONFIG_DIR}\n`; + pre_sh += `mkdir -p ${WGUI_LIB_HOSTS_CONFIGS_DIR}\n`; + pre_sh += `cd ${WGUI_LIB_HOSTS_CONFIGS_DIR}\n`; pre_sh += `if [ ! -f ${WIREGUARD_PRIVATE_KEY_FILE_NAME} ]; then\n`; pre_sh += ` wg genkey | tee ${WIREGUARD_PRIVATE_KEY_FILE_NAME} | wg pubkey > ${WIREGUARD_PUBLIC_KEY_FILE_NAME}\n`; pre_sh += `fi\n`; @@ -103,12 +112,12 @@ export default async function setupWireguardHost({ const HOST_PUBLIC_KEY = host?.id ? host.public_key : execSync( - `cat ${path.join(WIREGUARD_HOST_CONFIG_DIR, WIREGUARD_PUBLIC_KEY_FILE_NAME)}`, + `cat ${path.join(WGUI_LIB_HOSTS_CONFIGS_DIR, WIREGUARD_PUBLIC_KEY_FILE_NAME)}`, ); const HOST_PRIVATE_KEY = host?.private_key ? host.private_key : execSync( - `cat ${path.join(WIREGUARD_HOST_CONFIG_DIR, WIREGUARD_PRIVATE_KEY_FILE_NAME)}`, + `cat ${path.join(WGUI_LIB_HOSTS_CONFIGS_DIR, WIREGUARD_PRIVATE_KEY_FILE_NAME)}`, ); let sh = ``; @@ -124,17 +133,17 @@ export default async function setupWireguardHost({ `${host_id}-down.sh`, ); - sh += `cd ${WIREGUARD_HOST_CONFIG_DIR}\n`; + sh += `cd ${WGUI_LIB_HOSTS_CONFIGS_DIR}\n`; sh += `cat > ${POST_UP_PATH} << EOF\n`; sh += `#!/bin/bash\n\n`; sh += `# Allow WireGuard traffic to/from the server itself\n`; - sh += `iptables -I INPUT 1 -i wg${host_id} -j ACCEPT\n`; - sh += `iptables -I OUTPUT 1 -o wg${host_id} -j ACCEPT\n`; + sh += `iptables -I INPUT 1 -i ${INTERFACE_NAME} -j ACCEPT\n`; + sh += `iptables -I OUTPUT 1 -o ${INTERFACE_NAME} -j ACCEPT\n`; sh += `\n`; sh += `# Allow WireGuard traffic to be forwarded (insert above Docker rules)\n`; - sh += `iptables -I FORWARD 1 -i wg${host_id} -j ACCEPT\n`; - sh += `iptables -I FORWARD 1 -o wg${host_id} -j ACCEPT\n`; + sh += `iptables -I FORWARD 1 -i ${INTERFACE_NAME} -j ACCEPT\n`; + sh += `iptables -I FORWARD 1 -o ${INTERFACE_NAME} -j ACCEPT\n`; sh += `\n`; sh += `iptables -t nat -A POSTROUTING -o ${TARGET_INTERFACE} -j MASQUERADE\n`; sh += `EOF\n`; @@ -144,19 +153,19 @@ export default async function setupWireguardHost({ sh += `cat > ${POST_DOWN_PATH} << EOF\n`; sh += `#!/bin/bash\n\n`; sh += `# Remove WireGuard INPUT/OUTPUT rules\n`; - sh += `iptables -D INPUT -i wg${host_id} -j ACCEPT\n`; - sh += `iptables -D OUTPUT -o wg${host_id} -j ACCEPT\n`; + sh += `iptables -D INPUT -i ${INTERFACE_NAME} -j ACCEPT\n`; + sh += `iptables -D OUTPUT -o ${INTERFACE_NAME} -j ACCEPT\n`; sh += `\n`; sh += `# Remove FORWARD rules\n`; - sh += `iptables -D FORWARD -i wg${host_id} -j ACCEPT\n`; - sh += `iptables -D FORWARD -o wg${host_id} -j ACCEPT\n`; + sh += `iptables -D FORWARD -i ${INTERFACE_NAME} -j ACCEPT\n`; + sh += `iptables -D FORWARD -o ${INTERFACE_NAME} -j ACCEPT\n`; sh += `\n`; sh += `iptables -t nat -D POSTROUTING -o ${TARGET_INTERFACE} -j MASQUERADE\n`; sh += `EOF\n`; sh += `\n`; - sh += `cat > wg${host_id}.conf << EOF\n`; + sh += `cat > ${INTERFACE_NAME}.conf << EOF\n`; sh += `[Interface]\n`; sh += `Address = ${HOST_WG_IP}/24\n`; sh += `ListenPort = 51820\n`; @@ -183,10 +192,28 @@ export default async function setupWireguardHost({ const exec = execSync(sh, { encoding: "utf-8" }); - return { - success: true, - msg: exec, - }; + const IS_ROOT = + typeof process.getuid === "function" && process.getuid() === 0; + const SUDO_PREFIX = IS_ROOT ? "" : "sudo -n "; + const MANAGE_WG_QUICK_CMD = `${SUDO_PREFIX}${WGUI_WG_QUICK_SYSTEMD_SCRIPT} ${INTERFACE_NAME} ${HOST_CONFIG_PATH}`; + + let exec_systemd = ``; + + try { + exec_systemd = execSync(MANAGE_WG_QUICK_CMD, { + encoding: "utf-8", + }); + + return { + success: true, + msg: [exec.trim(), exec_systemd.trim()].join("\n\n"), + }; + } catch (error: any) { + return { + success: false, + msg: `Host config written to ${HOST_CONFIG_PATH}, but could not manage the tunnel via systemd (wg-quick@${INTERFACE_NAME}.service): ${error.message}`, + }; + } } catch (error: any) { return { success: false, diff --git a/src/pages/admin/hosts/(functions)/derive-host-config.ts b/src/pages/admin/hosts/(functions)/derive-host-config.ts index a138675..3bfbf0c 100644 --- a/src/pages/admin/hosts/(functions)/derive-host-config.ts +++ b/src/pages/admin/hosts/(functions)/derive-host-config.ts @@ -1,4 +1,5 @@ import { AppData } from "@/src/data/app-data"; +import deriveWireguardInterfaceName from "@/src/utils/derive-wireguard-interface-name"; import type { BUN_SQLITE_WGUI_CLIENTS, BUN_SQLITE_WGUI_HOSTS, @@ -7,6 +8,7 @@ import type { const LISTEN_PORT = 51820; const IP_TABLES_DIR = `/var/lib/wgui/iptables`; +const HOSTS_CONFIG_DIR = `/var/lib/wgui/hosts`; type Params = { host?: BUN_SQLITE_WGUI_HOSTS; @@ -25,10 +27,14 @@ export default function deriveHostConfig({ host?.wg_ip_address || variables?.find((v) => v.key == "main_host_wg_ip_address")?.value; + const interface_name = deriveWireguardInterfaceName({ host_id }); + return { host_id, - interface_name: `wg${host_id}`, - config_path: `/etc/wireguard/wg${host_id}.conf`, + interface_name, + config_path: `${HOSTS_CONFIG_DIR}/${interface_name}.conf`, + systemd_unit_name: `wg-quick@${interface_name}.service`, + address: wg_ip_address ? `${wg_ip_address}/24` : undefined, listen_port: LISTEN_PORT, post_up: `${IP_TABLES_DIR}/${host_id}-up.sh`, diff --git a/src/pages/admin/hosts/(sections)/interface-config-section.tsx b/src/pages/admin/hosts/(sections)/interface-config-section.tsx index 02c7843..2eb97d3 100644 --- a/src/pages/admin/hosts/(sections)/interface-config-section.tsx +++ b/src/pages/admin/hosts/(sections)/interface-config-section.tsx @@ -62,6 +62,9 @@ export default function InterfaceConfigSection({

{config.config_path}

+

+ {config.systemd_unit_name} +

$*"; } fail() { echo "error: $*" >&2; exit 1; } WGUI_LIB_DIR="/var/lib/wgui" +WG_QUICK_HELPER="$WGUI_LIB_DIR/scripts/wg-quick-systemd.sh" INSTALL_DIR="${INSTALL_DIR:-}" SERVICE_USER="${SERVICE_USER:-}" SERVICE_GROUP="${SERVICE_GROUP:-}" @@ -125,8 +126,9 @@ install_bun() { } setup_service_user() { - mkdir -p "$WGUI_LIB_DIR/iptables" "$WGUI_LIB_DIR/keys" "$WGUI_LIB_DIR/clients" + mkdir -p "$WGUI_LIB_DIR/iptables" "$WGUI_LIB_DIR/keys" "$WGUI_LIB_DIR/clients" "$WGUI_LIB_DIR/hosts" "$WGUI_LIB_DIR/scripts" chown -R "$SERVICE_USER:$SERVICE_GROUP" "$WGUI_LIB_DIR" + chmod 770 "$WGUI_LIB_DIR/hosts" } clone_or_update() { @@ -187,11 +189,30 @@ setup_wireguard() { fi } -grant_runtime_access() { - log "granting $SERVICE_USER access to /etc/wireguard ..." - mkdir -p /etc/wireguard - chown "root:$SERVICE_GROUP" /etc/wireguard - chmod 770 /etc/wireguard +install_wg_quick_helper() { + local helper_src="$INSTALL_DIR/src/scripts/wg-quick-systemd.sh" + if [ ! -f "$helper_src" ]; then + fail "missing $helper_src — cannot install the wg-quick systemd helper" + fi + log "installing wg-quick systemd helper to $WG_QUICK_HELPER ..." + install -m 755 -o root -g root "$helper_src" "$WG_QUICK_HELPER" +} + +install_wg_quick_sudoers() { + if [ "$SERVICE_USER" = "root" ]; then + return 0 + fi + if ! command -v visudo >/dev/null 2>&1; then + log "visudo not found — skipping sudoers entry for $SERVICE_USER (tunnel management will require root)" + return 0 + fi + local sudoers_file="/etc/sudoers.d/wgui-wg-quick" + log "granting $SERVICE_USER passwordless access to $WG_QUICK_HELPER ..." + cat > "$sudoers_file" </dev/null 2>&1 || fail "invalid sudoers file $sudoers_file" } install_systemd_unit() { @@ -274,7 +295,8 @@ fi install_dependencies ensure_env_file setup_wireguard -grant_runtime_access +install_wg_quick_helper +install_wg_quick_sudoers if [ "$DEV_MODE" = false ]; then case "$INIT_SYSTEM" in @@ -295,7 +317,8 @@ fi PORT="$(grab_port)" log "wg-ui install complete." log "webapp: $INSTALL_DIR" -log "runtime: $WGUI_LIB_DIR (keys, iptables, client configs)" +log "runtime: $WGUI_LIB_DIR (host configs, keys, iptables, client configs)" +log "tunnels: systemd units wg-quick@wgui.service (configs in $WGUI_LIB_DIR/hosts — /etc/wireguard is never touched)" if [ "$DEV_MODE" = true ]; then log "process: development mode — no system service installed" else diff --git a/src/scripts/setup-wireguard.sh b/src/scripts/setup-wireguard.sh index 55ae970..745c0a6 100755 --- a/src/scripts/setup-wireguard.sh +++ b/src/scripts/setup-wireguard.sh @@ -77,8 +77,12 @@ for tool in "${REQUIRED_TOOLS[@]}"; do fi done -mkdir -p /etc/wireguard -chmod 700 /etc/wireguard +# Configs are project-scoped under /var/lib/wgui (hosts + helper scripts), +# never /etc/wireguard, so stock wg-quick setups on this machine are untouched. +mkdir -p /var/lib/wgui/hosts /var/lib/wgui/scripts +chown root:root /var/lib/wgui/hosts /var/lib/wgui/scripts +chmod 700 /var/lib/wgui/hosts +chmod 755 /var/lib/wgui/scripts echo "wireguard setup complete." wg --version \ No newline at end of file diff --git a/src/scripts/wg-quick-systemd.sh b/src/scripts/wg-quick-systemd.sh new file mode 100755 index 0000000..76464cc --- /dev/null +++ b/src/scripts/wg-quick-systemd.sh @@ -0,0 +1,58 @@ +#!/bin/bash + +# Manages the wg-quick systemd unit for a single wireguard-ui host tunnel. +# +# This project deliberately avoids /etc/wireguard entirely so it can never +# collide with an existing WireGuard setup. Host configs live under +# /var/lib/wgui/hosts and are wired into the stock wg-quick@ template unit +# via a per-instance drop-in that points ExecStart/ExecStop at the project +# config path. Interfaces follow the project convention wgui +# (e.g. wgui0), never stock names like wg0. +# +# Usage: wg-quick-systemd.sh +# interface e.g. wgui0 — must match wgui[0-9]* (project convention) +# config-path absolute path to the host config file +# +# Designed to run as root (directly or via a NOPASSWD sudoers entry). + +set -euo pipefail + +INTERFACE="${1:-}" +CONFIG_PATH="${2:-}" + +if [ -z "$INTERFACE" ] || [ -z "$CONFIG_PATH" ]; then + echo "usage: $0 " >&2 + exit 1 +fi + +case "$INTERFACE" in + wgui[0-9]*) + ;; + *) + echo "error: refusing to manage non-project interface '$INTERFACE' (expected wgui[0-9]*)" >&2 + exit 1 + ;; +esac + +WG_QUICK_BIN="$(command -v wg-quick)" +if [ -z "$WG_QUICK_BIN" ]; then + echo "error: wg-quick not found — run setup-wireguard.sh first" >&2 + exit 1 +fi + +DROP_IN_DIR="/etc/systemd/system/wg-quick@${INTERFACE}.service.d" +mkdir -p "$DROP_IN_DIR" + +cat > "$DROP_IN_DIR/override.conf" < { + test("derives interface name from host id", () => { + expect(deriveWireguardInterfaceName({ host_id: 0 })).toBe("wgui0"); + expect(deriveWireguardInterfaceName({ host_id: 1 })).toBe("wgui1"); + expect(deriveWireguardInterfaceName({ host_id: 7 })).toBe("wgui7"); + }); + + test("uses a project-scoped prefix, never stock wgN", () => { + expect(deriveWireguardInterfaceName({ host_id: 0 })).not.toBe("wg0"); + expect(deriveWireguardInterfaceName({ host_id: 1 })).not.toBe("wg1"); + }); + + test("prefix matches wg-quick interface name requirements (<= 15 chars)", () => { + expect(deriveWireguardInterfaceName({ host_id: 0 }).length).toBeLessThanOrEqual( + 15, + ); + }); +}); \ No newline at end of file diff --git a/src/utils/derive-wireguard-interface-name.ts b/src/utils/derive-wireguard-interface-name.ts new file mode 100644 index 0000000..d2056df --- /dev/null +++ b/src/utils/derive-wireguard-interface-name.ts @@ -0,0 +1,9 @@ +const WIREGUARD_INTERFACE_NAME_PREFIX = `wgui`; + +type Params = { + host_id: number; +}; + +export default function deriveWireguardInterfaceName({ host_id }: Params) { + return `${WIREGUARD_INTERFACE_NAME_PREFIX}${host_id}`; +} \ No newline at end of file diff --git a/src/utils/grab-dir-names.ts b/src/utils/grab-dir-names.ts index d07110e..d9cb230 100644 --- a/src/utils/grab-dir-names.ts +++ b/src/utils/grab-dir-names.ts @@ -33,8 +33,13 @@ export default function grabDirNames(params?: Params) { const WGUI_LIB_IP_TABLES_DIR = path.join(WGUI_LIB_DIR, `iptables`); const WGUI_LIB_KEYS_DIR = path.join(WGUI_LIB_DIR, `keys`); const WGUI_LIB_CLIENTS_CONFIGS_DIR = path.join(WGUI_LIB_DIR, `clients`); + const WGUI_LIB_HOSTS_CONFIGS_DIR = path.join(WGUI_LIB_DIR, `hosts`); + const WGUI_LIB_SCRIPTS_DIR = path.join(WGUI_LIB_DIR, `scripts`); + const WGUI_WG_QUICK_SYSTEMD_SCRIPT = path.join( + WGUI_LIB_SCRIPTS_DIR, + `wg-quick-systemd.sh`, + ); - const WIREGUARD_HOST_CONFIG_DIR = `/etc/wireguard`; const WIREGUARD_PRIVATE_KEY_FILE_NAME = `private.key`; const WIREGUARD_PUBLIC_KEY_FILE_NAME = `public.key`; const WIREGUARD_CLIENT_CONFIG_FILE_NAME = `wg.conf`; @@ -52,8 +57,10 @@ export default function grabDirNames(params?: Params) { WGUI_LIB_IP_TABLES_DIR, WGUI_LIB_KEYS_DIR, WGUI_LIB_CLIENTS_CONFIGS_DIR, + WGUI_LIB_HOSTS_CONFIGS_DIR, + WGUI_LIB_SCRIPTS_DIR, + WGUI_WG_QUICK_SYSTEMD_SCRIPT, - WIREGUARD_HOST_CONFIG_DIR, WIREGUARD_PRIVATE_KEY_FILE_NAME, WIREGUARD_PUBLIC_KEY_FILE_NAME, WIREGUARD_CLIENT_CONFIG_FILE_NAME,