From ad94385bcbefe3e79cab1fbd3734625765e87bc3 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Sun, 13 Sep 2026 11:43:26 +0100 Subject: [PATCH] Improve wg-ui host setup: IP availability checks and current-user install script - setup-main-host-button: live availability status, grab-next-subnet helper, error/success feedback - setup-wireguard-host: harden generated shell scripts with set -e - check-private-ip-address-availability: add user access check, drop manual body validation - setup-main-host: pass host object with wg_ip_address - install-wg-ui.sh: run service as the invoking user (root or sudo) instead of a dedicated wgui user, and skip systemd/OpenRC daemon setup when NODE_ENV=development --- .../general/setup-main-host-button.tsx | 306 +++++++++++++----- .../backend/setup/setup-wireguard-host.ts | 3 + .../check-private-ip-address-availability.ts | 15 +- src/pages/api/admin/setup-main-host.ts | 4 +- src/scripts/install-wg-ui.sh | 79 +++-- 5 files changed, 295 insertions(+), 112 deletions(-) diff --git a/src/components/general/setup-main-host-button.tsx b/src/components/general/setup-main-host-button.tsx index 6b15c97..54b227a 100755 --- a/src/components/general/setup-main-host-button.tsx +++ b/src/components/general/setup-main-host-button.tsx @@ -1,77 +1,229 @@ -import { useEffect, useState, type ComponentProps } from "react"; -import Button from "../twui/layout/Button"; -import useStatus from "../twui/hooks/useStatus"; -import fetchApi from "../twui/utils/fetch/fetchApi"; -import type { ApiReqParams } from "@/src/types"; -import Row from "../twui/layout/Row"; -import Input from "../twui/form/Input"; -import Stack from "../twui/layout/Stack"; -import { Network } from "lucide-react"; -import Span from "../twui/layout/Span"; -import { AppData } from "@/src/data/app-data"; - -type Props = { - button_props?: Omit, "title">; -}; - -export default function SetupMainHostButton({ button_props }: Props) { - const { loading, setLoading, ready, setReady } = useStatus(); - - const [wgIP, setWgIP] = useState(AppData["DefaultPrivateIP"]); - - useEffect(() => { - fetchApi( - `/api/admin/check-private-ip-address-availability`, - { - method: "POST", - body: { ip_address: wgIP }, - }, - ).then((res) => { - console.log(`res`, res); - }); - }, [wgIP]); - - return ( - - { - setWgIP(v); - }} - prefix={} - suffix={ - - Selected Wireguard IP Address - - } - autoFocus - /> - - - - ); -} +import { useEffect, useRef, useState, type ComponentProps } from "react"; +import Button from "../twui/layout/Button"; +import useStatus from "../twui/hooks/useStatus"; +import fetchApi from "../twui/utils/fetch/fetchApi"; +import type { ApiReqParams } from "@/src/types"; +import Row from "../twui/layout/Row"; +import Input from "../twui/form/Input"; +import Stack from "../twui/layout/Stack"; +import { + CircleCheck, + Loader2, + Network, + TriangleAlert, + Wand2, +} from "lucide-react"; +import Span from "../twui/layout/Span"; +import Tag from "../twui/elements/Tag"; +import { AppData } from "@/src/data/app-data"; + +type Props = { + button_props?: Omit, "title">; +}; + +type AvailabilityType = { + success: boolean; +}; + +type GrabSubnetType = { + success: boolean; + msg?: string; +}; + +const subnet_ip_pattern = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.1$/; + +type IPStatusType = "checking" | "available" | "not_available" | "invalid"; + +export default function SetupMainHostButton({ button_props }: Props) { + const { loading, setLoading, status, setStatus } = useStatus(); + + const [wgIP, setWgIP] = useState(AppData["DefaultPrivateIP"]); + const [availability, setAvailability] = useState(); + const [grabbing, setGrabbing] = useState(false); + + const input_ref = useRef(null); + + useEffect(() => { + setAvailability(undefined); + + if (!subnet_ip_pattern.test(wgIP)) return; + + let cancelled = false; + + fetchApi( + `/api/admin/check-private-ip-address-availability`, + { + method: "POST", + body: { ip_address: wgIP }, + }, + ).then((res) => { + if (cancelled) return; + setAvailability(res); + }); + + return () => { + cancelled = true; + }; + }, [wgIP]); + + const ip_status: IPStatusType = subnet_ip_pattern.test(wgIP) + ? availability + ? availability.success + ? "available" + : "not_available" + : "checking" + : "invalid"; + + function grabNextSubnet() { + setGrabbing(true); + + fetchApi( + `/api/admin/grab-next-available-private-ip`, + { method: "POST" }, + ) + .then((res) => { + if (res?.success && res.msg) { + setWgIP(res.msg); + if (input_ref.current) { + input_ref.current.value = res.msg; + } + } + }) + .finally(() => { + setGrabbing(false); + }); + } + + return ( + + + { + setWgIP(v); + }} + prefix={} + suffix={ + + Selected Wireguard IP Address + + } + componentRef={input_ref} + autoFocus + /> + + {ip_status === "invalid" ? ( + + + + Invalid IP + + + ) : ip_status === "not_available" ? ( + + + + Not available + + + ) : ip_status === "available" ? ( + + + + Available + + + ) : ( + + + + Checking availability… + + + )} + + + {status?.error && status.msg ? ( + + + + {status.msg} + + + ) : null} + {status?.success && status.msg ? ( + + + + {status.msg} + + + ) : null} + + + + + + + + ); +} diff --git a/src/functions/backend/setup/setup-wireguard-host.ts b/src/functions/backend/setup/setup-wireguard-host.ts index 769705f..dfb5cd1 100644 --- a/src/functions/backend/setup/setup-wireguard-host.ts +++ b/src/functions/backend/setup/setup-wireguard-host.ts @@ -82,6 +82,7 @@ export default async function setupWireguardHost({ let pre_sh = ``; + pre_sh += `set -e\n`; pre_sh += `cd ${WIREGUARD_HOST_CONFIG_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`; @@ -112,6 +113,8 @@ export default async function setupWireguardHost({ let sh = ``; + sh += `set -e\n`; + const POST_UP_PATH = path.join( WGUI_LIB_IP_TABLES_DIR, `${host_id}-up.sh`, diff --git a/src/pages/api/admin/check-private-ip-address-availability.ts b/src/pages/api/admin/check-private-ip-address-availability.ts index 04dadc9..f4c301e 100644 --- a/src/pages/api/admin/check-private-ip-address-availability.ts +++ b/src/pages/api/admin/check-private-ip-address-availability.ts @@ -1,3 +1,4 @@ +import checkUserAccess from "@/src/functions/backend/auth/check-user-access"; import userAuth from "@/src/functions/backend/auth/user-auth"; import checkPrivateIPAvailability from "@/src/functions/backend/setup/check-private-ip-availability"; import type { ApiReqParams } from "@/src/types"; @@ -11,7 +12,7 @@ export const handler: BunextAPIRouteHandler = async ( params, ) => { const req = params.req; - const body = params.body as ApiReqParams | undefined; + const body = params.body as ApiReqParams; if (req.method !== "POST") { return { @@ -30,17 +31,13 @@ export const handler: BunextAPIRouteHandler = async ( } try { - const ip_address = body?.ip_address; - - if (!ip_address) { - throw new Error(`No IP address passed!`); - } - - return await checkPrivateIPAvailability({ ip_address }); + return await checkPrivateIPAvailability({ + ip_address: body?.ip_address || "", + }); } catch (error: any) { return { success: false, msg: error.message, }; } -}; +}; \ No newline at end of file diff --git a/src/pages/api/admin/setup-main-host.ts b/src/pages/api/admin/setup-main-host.ts index 18bef5f..e32adc7 100644 --- a/src/pages/api/admin/setup-main-host.ts +++ b/src/pages/api/admin/setup-main-host.ts @@ -41,7 +41,9 @@ export const handler: BunextAPIRouteHandler = async ( } return await setupWireguardHost({ - wg_subnet_ip: body.ip_address, + host: { + wg_ip_address: body.ip_address, + }, }); } catch (error: any) { return { diff --git a/src/scripts/install-wg-ui.sh b/src/scripts/install-wg-ui.sh index 8c1e709..4b0b6c2 100755 --- a/src/scripts/install-wg-ui.sh +++ b/src/scripts/install-wg-ui.sh @@ -10,20 +10,46 @@ set -euo pipefail WGUI_LIB_DIR="/var/lib/wgui" INSTALL_DIR="${INSTALL_DIR:-$WGUI_LIB_DIR/webapp}" -SERVICE_USER="${SERVICE_USER:-wgui}" +SERVICE_USER="${SERVICE_USER:-}" +SERVICE_GROUP="${SERVICE_GROUP:-}" SERVICE_NAME="${SERVICE_NAME:-wgui}" REPO_URL="${REPO_URL:-}" BRANCH="${BRANCH:-main}" BUN_INSTALL_DIR="${BUN_INSTALL_DIR:-/opt/bun}" BUN_BIN="/usr/local/bin/bun" UPDATE=false +DEV_MODE=false +if [ "${NODE_ENV:-}" = "development" ]; then + DEV_MODE=true + log "NODE_ENV=development — skipping system service installation (daemon is assumed to already be running)" +fi log() { echo "==> $*"; } fail() { echo "error: $*" >&2; exit 1; } -if [ "$(id -u)" -ne 0 ]; then - fail "this script must be run as root" -fi +require_root() { + if [ "$(id -u)" -ne 0 ]; then + if ! command -v sudo >/dev/null 2>&1; then + fail "this script must be run as root, and sudo is not installed" + fi + log "not running as root — re-executing with sudo ..." + sudo -v || fail "current user does not have sudo privileges — run this script as root or grant the current user sudo access" + exec sudo -E "$0" "$@" + fi +} + +resolve_service_user() { + if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ] && id -u "$SUDO_USER" >/dev/null 2>&1; then + echo "$SUDO_USER" + else + echo "root" + fi +} + +require_root +SERVICE_USER="${SERVICE_USER:-$(resolve_service_user)}" +SERVICE_GROUP="${SERVICE_GROUP:-$(id -gn "$SERVICE_USER")}" +log "running the wg-ui service as $SERVICE_USER (group: $SERVICE_GROUP)" if [ -z "$REPO_URL" ]; then fail "REPO_URL is not set — pass the git URL of the wg-ui repo, e.g. @@ -90,11 +116,8 @@ install_bun() { } setup_service_user() { - if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then - useradd --system --home-dir "$WGUI_LIB_DIR" --shell /usr/sbin/nologin "$SERVICE_USER" - fi mkdir -p "$WGUI_LIB_DIR/iptables" "$WGUI_LIB_DIR/keys" "$WGUI_LIB_DIR/clients" - chown -R "$SERVICE_USER:$SERVICE_USER" "$WGUI_LIB_DIR" + chown -R "$SERVICE_USER:$SERVICE_GROUP" "$WGUI_LIB_DIR" } clone_or_update() { @@ -115,7 +138,7 @@ clone_or_update() { log "cloning wg-ui ($REPO_URL, branch $BRANCH) ..." git clone --depth 1 --branch "$BRANCH" "$REPO_URL" "$INSTALL_DIR" fi - chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR" + chown -R "$SERVICE_USER:$SERVICE_GROUP" "$INSTALL_DIR" } run_as_service_user() { @@ -137,12 +160,12 @@ ensure_env_file() { if [ ! -f "$INSTALL_DIR/.env" ]; then log "generating $INSTALL_DIR/.env with fresh encryption secrets ..." { - echo "NODE_ENV=production" + echo "NODE_ENV=${NODE_ENV:-production}" echo "ENCRYPTION_KEY=$(openssl rand -base64 32 | tr -d '\n')" echo "ENCRYPTION_SALT=$(openssl rand -base64 32 | tr -d '\n')" echo "DATA_DIR=$INSTALL_DIR/.data" } > "$INSTALL_DIR/.env" - chown "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR/.env" + chown "$SERVICE_USER:$SERVICE_GROUP" "$INSTALL_DIR/.env" chmod 600 "$INSTALL_DIR/.env" fi } @@ -158,7 +181,7 @@ setup_wireguard() { grant_runtime_access() { log "granting $SERVICE_USER access to /etc/wireguard ..." mkdir -p /etc/wireguard - chown "root:$SERVICE_USER" /etc/wireguard + chown "root:$SERVICE_GROUP" /etc/wireguard chmod 770 /etc/wireguard } @@ -174,7 +197,7 @@ Wants=network-online.target [Service] Type=simple User=$SERVICE_USER -Group=$SERVICE_USER +Group=$SERVICE_GROUP WorkingDirectory=$INSTALL_DIR Environment=NODE_ENV=production ExecStart=$BUN_BIN src/server.ts @@ -242,23 +265,29 @@ ensure_env_file setup_wireguard grant_runtime_access -case "$INIT_SYSTEM" in - systemd) - install_systemd_unit - ;; - openrc) - install_openrc_unit - ;; - *) - log "no supported init system found — start manually with: +if [ "$DEV_MODE" = false ]; then + case "$INIT_SYSTEM" in + systemd) + install_systemd_unit + ;; + openrc) + install_openrc_unit + ;; + *) + log "no supported init system found — start manually with: su -s /bin/bash $SERVICE_USER -c 'cd $INSTALL_DIR && NODE_ENV=production $BUN_BIN src/server.ts' (add the line above to your boot scripts)" - ;; -esac + ;; + esac +fi PORT="$(grab_port)" log "wg-ui install complete." log "webapp: $INSTALL_DIR" log "runtime: $WGUI_LIB_DIR (keys, iptables, client configs)" -log "process: managed by $INIT_SYSTEM as $SERVICE_NAME" +if [ "$DEV_MODE" = true ]; then + log "process: development mode — no system service installed" +else + log "process: managed by $INIT_SYSTEM as $SERVICE_NAME" +fi log "open http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo localhost):$PORT in your browser" \ No newline at end of file