From f05384d8fe51453b242aefcf32f657347bd0ded0 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Sun, 20 Sep 2026 18:15:51 +0100 Subject: [PATCH] Add admin setup page --- README.md | 2 + src/data/site-data.ts | 1 + .../backend/setup/grab-system-setup-status.ts | 266 ++++++++++++++++++ .../backend/setup/run-setup-script.ts | 51 ++++ .../(partials)/admin-aside-links-dict.tsx | 21 +- .../setup/(hooks)/use-system-setup-status.ts | 48 ++++ .../setup/(partials)/setup-action-card.tsx | 114 ++++++++ .../(partials)/setup-check-status-item.tsx | 64 +++++ .../admin/setup/(partials)/setup-console.tsx | 28 ++ .../setup/(partials)/setup-stat-cell.tsx | 39 +++ .../(partials)/setup-update-wg-ui-action.tsx | 101 +++++++ .../(sections)/setup-actions-section.tsx | 91 ++++++ .../(sections)/system-status-section.tsx | 194 +++++++++++++ src/pages/admin/setup/index.server.ts | 37 +++ src/pages/admin/setup/index.tsx | 18 +- src/pages/api/admin/restart-wg-ui-service.ts | 91 ++++++ src/pages/api/admin/setup-wireguard-tools.ts | 46 +++ src/pages/api/admin/system-setup-status.ts | 43 +++ src/pages/api/admin/update-wg-ui.ts | 61 ++++ src/scripts/install-wg-ui.sh | 2 +- src/types/index.ts | 4 + 21 files changed, 1318 insertions(+), 4 deletions(-) create mode 100644 src/functions/backend/setup/grab-system-setup-status.ts create mode 100644 src/functions/backend/setup/run-setup-script.ts create mode 100644 src/pages/admin/setup/(hooks)/use-system-setup-status.ts create mode 100644 src/pages/admin/setup/(partials)/setup-action-card.tsx create mode 100644 src/pages/admin/setup/(partials)/setup-check-status-item.tsx create mode 100644 src/pages/admin/setup/(partials)/setup-console.tsx create mode 100644 src/pages/admin/setup/(partials)/setup-stat-cell.tsx create mode 100644 src/pages/admin/setup/(partials)/setup-update-wg-ui-action.tsx create mode 100644 src/pages/admin/setup/(sections)/setup-actions-section.tsx create mode 100644 src/pages/admin/setup/(sections)/system-status-section.tsx create mode 100644 src/pages/admin/setup/index.server.ts create mode 100644 src/pages/api/admin/restart-wg-ui-service.ts create mode 100644 src/pages/api/admin/setup-wireguard-tools.ts create mode 100644 src/pages/api/admin/system-setup-status.ts create mode 100644 src/pages/api/admin/update-wg-ui.ts diff --git a/README.md b/README.md index 287a54d..4061103 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ # Wireguard UI An admin dashboard for Wireguard. + +Installation script located at `https://git.tben.me/Moduletrace/wireguard-ui/raw/branch/main/src/scripts/install-wg-ui.sh` diff --git a/src/data/site-data.ts b/src/data/site-data.ts index 09bd8ad..168d347 100644 --- a/src/data/site-data.ts +++ b/src/data/site-data.ts @@ -3,5 +3,6 @@ export const SiteData = { SiteDescription: `Wireguard UI is the ultimate dashboard for managing Wireguard.`, SiteSlug: `wgui`, SiteURL: `https://git.tben.me/Moduletrace/wireguard-ui`, + RepoURL: `https://git.tben.me/Moduletrace/wireguard-ui.git`, ServerPort: 10752, } as const; diff --git a/src/functions/backend/setup/grab-system-setup-status.ts b/src/functions/backend/setup/grab-system-setup-status.ts new file mode 100644 index 0000000..7e5967f --- /dev/null +++ b/src/functions/backend/setup/grab-system-setup-status.ts @@ -0,0 +1,266 @@ +import grabDirNames from "@/src/utils/grab-dir-names"; +import fs from "node:fs"; +import path from "node:path"; +import { execSync } from "node:child_process"; + +export type SystemSetupToolStatus = { + name: string; + command: string; + version?: string | null; + installed: boolean; +}; + +export type SystemSetupLibDirStatus = { + label: string; + path: string; + exists: boolean; +}; + +export type SystemSetupStatus = { + is_root: boolean; + is_dev: boolean; + environment: string; + distro: string; + distro_pretty_name: string; + init_system: "systemd" | "openrc" | "unknown"; + install_dir: string; + app_installed: boolean; + app_version: string | null; + repo_url: string | null; + repo_branch: string | null; + service_name: string; + service_active: boolean | null; + wg_module_loaded: boolean; + wg_quick_helper_installed: boolean; + tools: SystemSetupToolStatus[]; + lib_dirs: SystemSetupLibDirStatus[]; +}; + +type ShellResult = { + success: boolean; + output: string; +}; + +const { + ROOT_DIR, + WGUI_LIB_DIR, + WGUI_LIB_KEYS_DIR, + WGUI_LIB_HOSTS_CONFIGS_DIR, + WGUI_LIB_SCRIPTS_DIR, + WGUI_WG_QUICK_MANAGE_SCRIPT, +} = grabDirNames(); + +const TOOL_CHECKS: { + name: string; + command: string; + version_command?: string; +}[] = [ + { + name: "wireguard", + command: "wg", + version_command: "wg --version", + }, + { + name: "wg-quick", + command: "wg-quick", + }, + { + name: "ip (iproute2)", + command: "ip", + version_command: "ip -V", + }, + { + name: "curl", + command: "curl", + version_command: "curl --version", + }, + { + name: "git", + command: "git", + version_command: "git --version", + }, + { + name: "bun", + command: "bun", + version_command: "bun --version", + }, +]; + +const tryShell = ({ + command, + timeout_ms = 10000, +}: { + command: string; + timeout_ms?: number; +}): ShellResult => { + try { + const result = execSync(command, { + encoding: "utf-8", + stdio: "pipe", + timeout: timeout_ms, + }); + + 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 runCommand = (command: string, timeout_ms = 10000): ShellResult => + tryShell({ command, timeout_ms }); + +const grabDistro = (): { id: string; pretty_name: string } => { + try { + const os_release = fs.readFileSync("/etc/os-release", "utf-8"); + + const id_match = os_release.match(/^ID=(.+)$/m); + const pretty_match = os_release.match(/^PRETTY_NAME=(.+)$/m); + + const strip = (value: string) => + value.replace(/^["']|["']$/g, "").trim(); + + return { + id: id_match?.[1] ? strip(id_match[1]) : "unknown", + pretty_name: pretty_match?.[1] ? strip(pretty_match[1]) : "", + }; + } catch { + return { + id: "unknown", + pretty_name: "", + }; + } +}; + +const grabInitSystem = (): "systemd" | "openrc" | "unknown" => { + if (runCommand(`command -v systemctl`, 5000).success) { + return "systemd"; + } + + if ( + runCommand(`command -v rc-service`, 5000).success || + fs.existsSync("/etc/alpine-release") + ) { + return "openrc"; + } + + return "unknown"; +}; + +const grabGitValue = ({ + install_dir, + command, + env_value, +}: { + install_dir: string; + command: string; + env_value?: string; +}): string | null => { + if (env_value) { + return env_value; + } + + const res = runCommand(`git -C "${install_dir}" ${command}`, 10000); + + return res.success && res.output ? res.output : null; +}; + +export default function grabSystemSetupStatus(): SystemSetupStatus { + const IS_DEV = (process.env.NODE_ENV || "development") == "development"; + const IS_ROOT = + typeof process.getuid === "function" && process.getuid() === 0; + + const INSTALL_DIR = IS_DEV ? ROOT_DIR : path.join(WGUI_LIB_DIR, "webapp"); + + const APP_INSTALLED = fs.existsSync(path.join(INSTALL_DIR, ".git")); + + const tools: SystemSetupToolStatus[] = TOOL_CHECKS.map((tool) => { + const which_res = runCommand(`command -v ${tool.command}`, 5000); + const installed = which_res.success; + + let version: string | null = null; + + if (installed && tool.version_command) { + const version_res = runCommand(tool.version_command, 5000); + + if (version_res.success) { + version = version_res.output.split("\n")[0] || null; + } + } + + return { + name: tool.name, + command: tool.command, + installed, + version, + }; + }); + + const { id: distro, pretty_name: distro_pretty_name } = grabDistro(); + const init_system = grabInitSystem(); + const service_name = process.env.SERVICE_NAME || "wgui"; + + let service_active: boolean | null = null; + + if (!IS_DEV && init_system == "systemd") { + const service_res = runCommand( + `systemctl is-active ${service_name} 2>/dev/null`, + 10000, + ); + + service_active = service_res.success && service_res.output == "active"; + } + + return { + is_root: IS_ROOT, + is_dev: IS_DEV, + environment: IS_DEV ? "development" : "production", + distro, + distro_pretty_name, + init_system, + install_dir: INSTALL_DIR, + app_installed: APP_INSTALLED, + app_version: APP_INSTALLED + ? grabGitValue({ + install_dir: INSTALL_DIR, + command: `rev-parse --short HEAD 2>/dev/null`, + }) + : null, + repo_url: grabGitValue({ + install_dir: INSTALL_DIR, + command: `config --get remote.origin.url 2>/dev/null`, + env_value: process.env.REPO_URL, + }), + repo_branch: + grabGitValue({ + install_dir: INSTALL_DIR, + command: `branch --show-current 2>/dev/null`, + env_value: process.env.BRANCH, + }) || "main", + service_name, + service_active, + wg_module_loaded: fs.existsSync("/sys/module/wireguard"), + wg_quick_helper_installed: fs.existsSync(WGUI_WG_QUICK_MANAGE_SCRIPT), + tools, + lib_dirs: [ + { + label: "Host configs", + path: WGUI_LIB_HOSTS_CONFIGS_DIR, + exists: fs.existsSync(WGUI_LIB_HOSTS_CONFIGS_DIR), + }, + { + label: "Scripts", + path: WGUI_LIB_SCRIPTS_DIR, + exists: fs.existsSync(WGUI_LIB_SCRIPTS_DIR), + }, + ], + }; +} diff --git a/src/functions/backend/setup/run-setup-script.ts b/src/functions/backend/setup/run-setup-script.ts new file mode 100644 index 0000000..4f08067 --- /dev/null +++ b/src/functions/backend/setup/run-setup-script.ts @@ -0,0 +1,51 @@ +import grabDirNames from "@/src/utils/grab-dir-names"; +import { execSync } from "node:child_process"; +import path from "node:path"; + +type Params = { + script: string; + timeout_ms?: number; + env?: Record; +}; + +export type RunSetupScriptResult = { + success: boolean; + output: string; +}; + +export default function runSetupScript({ + script, + timeout_ms = 600000, + env, +}: Params): RunSetupScriptResult { + const { ROOT_DIR } = grabDirNames(); + const script_path = path.join(ROOT_DIR, "src", "scripts", script); + + try { + const result = execSync(`bash "${script_path}" 2>&1`, { + encoding: "utf-8", + stdio: "pipe", + timeout: timeout_ms, + env: { + ...process.env, + ...env, + }, + }); + + 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(), + }; + } +} \ No newline at end of file diff --git a/src/layouts/admin/(partials)/admin-aside-links-dict.tsx b/src/layouts/admin/(partials)/admin-aside-links-dict.tsx index 46b8631..e5513b5 100644 --- a/src/layouts/admin/(partials)/admin-aside-links-dict.tsx +++ b/src/layouts/admin/(partials)/admin-aside-links-dict.tsx @@ -3,7 +3,7 @@ import LinkList from "@/src/components/twui/elements/LinkList"; import Stack from "@/src/components/twui/layout/Stack"; import { AppContext } from "@/src/pages/__root"; import { useContext } from "react"; -import { LayoutDashboard, LogOut, Server, Settings, Users } from "lucide-react"; +import { LayoutDashboard, LogOut, Server, Settings, Users, Wrench } from "lucide-react"; const sectionLabel = (label: string) => (
@@ -27,6 +27,9 @@ export default function AdminAsideLinks() { return null; } + const is_super_admin = + user_types?.find((t) => t.user_type === "super_admin") !== undefined; + const links: TWUILink[] = [ { component: sectionLabel("Overview") }, { @@ -54,6 +57,22 @@ export default function AdminAsideLinks() { url: "/admin/clients", icon: , }, + { component: sectionLabel("System") }, + ...(is_super_admin + ? [ + { + title: "Setup", + url: "/admin/setup", + icon: ( + + ), + }, + ] + : []), { component: sectionLabel("Account") }, { title: "Settings", diff --git a/src/pages/admin/setup/(hooks)/use-system-setup-status.ts b/src/pages/admin/setup/(hooks)/use-system-setup-status.ts new file mode 100644 index 0000000..8f44478 --- /dev/null +++ b/src/pages/admin/setup/(hooks)/use-system-setup-status.ts @@ -0,0 +1,48 @@ +import fetchApi from "@/src/components/twui/utils/fetch/fetchApi"; +import type grabSystemSetupStatus from "@/src/functions/backend/setup/grab-system-setup-status"; +import type { ApiReqParams } from "@/src/types"; +import { useState } from "react"; + +export type SystemSetupStatus = ReturnType; + +type SetupStatusResponse = { + success: boolean; + singleRes?: SystemSetupStatus | null; + msg?: string; +}; + +type Props = { + setup_status?: SystemSetupStatus | null; +}; + +export default function useSystemSetupStatus({ setup_status }: Props) { + const [current, setCurrent] = useState( + setup_status || null, + ); + const [loading, setLoading] = useState(false); + + const refresh = () => + fetchApi(`/api/admin/system-setup-status`, { + method: "GET", + }).then((res) => { + if (res.success && res.singleRes) { + setCurrent(res.singleRes); + } + + return res; + }); + + const refreshWithLoading = () => { + setLoading(true); + + refresh().finally(() => { + setLoading(false); + }); + }; + + return { + setup_status: current, + refresh: refreshWithLoading, + loading, + }; +} \ No newline at end of file diff --git a/src/pages/admin/setup/(partials)/setup-action-card.tsx b/src/pages/admin/setup/(partials)/setup-action-card.tsx new file mode 100644 index 0000000..6484bdb --- /dev/null +++ b/src/pages/admin/setup/(partials)/setup-action-card.tsx @@ -0,0 +1,114 @@ +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 useStatus from "@/src/components/twui/hooks/useStatus"; +import { TriangleAlert } from "lucide-react"; +import { useState, type ComponentProps, type ReactNode } from "react"; +import SetupConsole from "./setup-console"; + +type Props = { + title: string; + description?: string | ReactNode; + icon?: ReactNode; + button_title: string; + buttonProps?: Omit, "title">; + on_run: () => + | Promise<{ success: boolean; msg?: string } | void> + | { success: boolean; msg?: string } + | void; + children?: ReactNode; +}; + +export default function SetupActionCard({ + title, + description, + icon, + button_title, + buttonProps, + on_run, + children, +}: Props) { + const { loading, setLoading, status, setStatus } = useStatus(); + const [output, setOutput] = useState(null); + const [is_error, setIsError] = useState(false); + + const run = () => { + setLoading(true); + setStatus(undefined); + setOutput(null); + + Promise.resolve() + .then(() => on_run()) + .then((res) => { + if (res) { + setOutput(res.msg || null); + setIsError(!res.success); + } + }) + .catch((error: any) => { + setStatus({ + error: true, + msg: `Request failed — the wg-ui server may have restarted. ${ + error?.message || "" + }`.trim(), + }); + }) + .finally(() => { + setLoading(false); + }); + }; + + return ( + + + {icon ? ( + + {icon} + + ) : null} + +

+ {title} +

+ {description ? ( + typeof description == "string" ? ( +

+ {description} +

+ ) : ( + description + ) + ) : null} +
+
+ + {children} + + + + {status?.error && status.msg ? ( + + + {status.msg} + + ) : null} + + +
+ ); +} \ No newline at end of file diff --git a/src/pages/admin/setup/(partials)/setup-check-status-item.tsx b/src/pages/admin/setup/(partials)/setup-check-status-item.tsx new file mode 100644 index 0000000..bba5b2a --- /dev/null +++ b/src/pages/admin/setup/(partials)/setup-check-status-item.tsx @@ -0,0 +1,64 @@ +import Card from "@/src/components/twui/elements/Card"; +import Tag from "@/src/components/twui/elements/Tag"; +import Span from "@/src/components/twui/layout/Span"; +import Stack from "@/src/components/twui/layout/Stack"; +import { twMerge } from "tailwind-merge"; + +type Props = { + title: string; + subtitle?: string; + ok: boolean; + ok_label?: string; + fail_label?: string; +}; + +export default function SetupCheckStatusItem({ + title, + subtitle, + ok, + ok_label = "Installed", + fail_label = "Missing", +}: Props) { + return ( + + + ); +} diff --git a/src/pages/admin/setup/(partials)/setup-console.tsx b/src/pages/admin/setup/(partials)/setup-console.tsx new file mode 100644 index 0000000..8950e05 --- /dev/null +++ b/src/pages/admin/setup/(partials)/setup-console.tsx @@ -0,0 +1,28 @@ +import Span from "@/src/components/twui/layout/Span"; +import { twMerge } from "tailwind-merge"; + +type Props = { + output?: string | null; + error?: boolean; +}; + +export default function SetupConsole({ output, error }: Props) { + if (!output) { + return null; + } + + return ( +
+
+                {output}
+            
+
+ ); +} \ No newline at end of file diff --git a/src/pages/admin/setup/(partials)/setup-stat-cell.tsx b/src/pages/admin/setup/(partials)/setup-stat-cell.tsx new file mode 100644 index 0000000..788014e --- /dev/null +++ b/src/pages/admin/setup/(partials)/setup-stat-cell.tsx @@ -0,0 +1,39 @@ +import Span from "@/src/components/twui/layout/Span"; +import Stack from "@/src/components/twui/layout/Stack"; +import { twMerge } from "tailwind-merge"; + +export type SetupStatCellTone = "success" | "error" | "warning" | "muted"; + +const toneClassName: Record = { + success: "text-success", + error: "text-error", + warning: "text-warning", + muted: "text-foreground-light/40 dark:text-foreground-dark/40", +}; + +type Props = { + label: string; + value: string; + tone?: SetupStatCellTone; +}; + +export default function SetupStatCell({ label, value, tone }: Props) { + return ( + + + {label} + + + {value} + + + ); +} diff --git a/src/pages/admin/setup/(partials)/setup-update-wg-ui-action.tsx b/src/pages/admin/setup/(partials)/setup-update-wg-ui-action.tsx new file mode 100644 index 0000000..77aae29 --- /dev/null +++ b/src/pages/admin/setup/(partials)/setup-update-wg-ui-action.tsx @@ -0,0 +1,101 @@ +import fetchApi from "@/src/components/twui/utils/fetch/fetchApi"; +import Input from "@/src/components/twui/form/Input"; +import Stack from "@/src/components/twui/layout/Stack"; +import Span from "@/src/components/twui/layout/Span"; +import type useSystemSetupStatus from "../(hooks)/use-system-setup-status"; +import SetupActionCard from "./setup-action-card"; +import type { ApiReqParams } from "@/src/types"; +import type { APIResponseObject } from "@moduletrace/bunext/types"; +import { CloudDownload } from "lucide-react"; +import { useState } from "react"; +import { SiteData } from "@/src/data/site-data"; + +type Props = { + setup: ReturnType; +}; + +export default function SetupUpdateWgUiAction({ setup }: Props) { + const setup_status = setup.setup_status; + + const [repoUrl, setRepoUrl] = useState( + setup_status?.repo_url || "", + ); + const [branch, setBranch] = useState( + setup_status?.repo_branch || "main", + ); + + const needs_repo_url = !setup_status?.is_dev && !setup_status?.repo_url; + + return ( + + wg-ui isn't installed yet — provide the git repository + URL so the installer can clone it, then run the update + again to pull newer versions. + + ) : setup_status?.is_dev ? ( + + Development mode — uses this local checkout. Reinstalls + dependencies and syncs the wg-quick helper script. The + dev server is not restarted. + + ) : ( + + Pulls the latest code, installs dependencies, syncs + helper scripts and restarts the{" "} + {setup_status?.service_name || "wgui"} service. The + server will briefly go offline. + + ) + } + icon={} + button_title={ + setup_status?.app_installed ? "Update wg-ui" : "Install wg-ui" + } + on_run={async () => { + const res = await fetchApi( + `/api/admin/update-wg-ui`, + { + method: "POST", + body: { + repo_url: repoUrl, + branch, + }, + }, + ); + + setup.refresh(); + + return res; + }} + > + {needs_repo_url || !setup_status?.is_dev ? ( + + {needs_repo_url ? ( + + label="Repository URL" + placeholder={SiteData["RepoURL"]} + value={repoUrl} + onChange={(e) => { + setRepoUrl(e.target.value); + }} + /> + ) : null} + {!setup_status?.is_dev ? ( + + label="Branch" + placeholder="main" + value={branch} + onChange={(e) => { + setBranch(e.target.value); + }} + /> + ) : null} + + ) : null} + + ); +} diff --git a/src/pages/admin/setup/(sections)/setup-actions-section.tsx b/src/pages/admin/setup/(sections)/setup-actions-section.tsx new file mode 100644 index 0000000..b0ea773 --- /dev/null +++ b/src/pages/admin/setup/(sections)/setup-actions-section.tsx @@ -0,0 +1,91 @@ +import fetchApi from "@/src/components/twui/utils/fetch/fetchApi"; +import Row from "@/src/components/twui/layout/Row"; +import Span from "@/src/components/twui/layout/Span"; +import type useSystemSetupStatus from "../(hooks)/use-system-setup-status"; +import SetupActionCard from "../(partials)/setup-action-card"; +import SetupUpdateWgUiAction from "../(partials)/setup-update-wg-ui-action"; +import type { ApiReqParams } from "@/src/types"; +import type { APIResponseObject } from "@moduletrace/bunext/types"; +import { RotateCcw, TriangleAlert, Wrench } from "lucide-react"; + +type Props = { + setup: ReturnType; +}; + +const WG_TOOL_COMMANDS = [`wg`, `wg-quick`, `ip`, `curl`]; + +export default function SetupActionsSection({ setup }: Props) { + const setup_status = setup.setup_status; + + if (!setup_status) { + return null; + } + + const have_wg_tools = setup_status.tools + .filter((tool) => WG_TOOL_COMMANDS.includes(tool.command)) + .every((tool) => tool.installed); + + const show_restart = + !setup_status.is_dev && setup_status.init_system !== "unknown"; + + return ( + <> + {!setup_status.is_root ? ( + + + + The server is not running as root — installs and + tunnel management may fail. Run wg-ui as root for full + functionality. + + + ) : null} + + } + button_title={ + have_wg_tools + ? "Update WireGuard tools" + : "Install WireGuard tools" + } + on_run={async () => { + const res = await fetchApi( + `/api/admin/setup-wireguard-tools`, + { + method: "POST", + }, + ); + + setup.refresh(); + + return res; + }} + /> + + + + {show_restart ? ( + } + button_title="Restart service" + on_run={async () => { + const res = await fetchApi< + ApiReqParams, + APIResponseObject + >(`/api/admin/restart-wg-ui-service`, { + method: "POST", + }); + + setup.refresh(); + + return res; + }} + /> + ) : null} + + ); +} \ No newline at end of file diff --git a/src/pages/admin/setup/(sections)/system-status-section.tsx b/src/pages/admin/setup/(sections)/system-status-section.tsx new file mode 100644 index 0000000..d8a79f2 --- /dev/null +++ b/src/pages/admin/setup/(sections)/system-status-section.tsx @@ -0,0 +1,194 @@ +import AdminCard from "@/src/components/general/admin-card"; +import Button from "@/src/components/twui/layout/Button"; +import Divider from "@/src/components/twui/layout/Divider"; +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 EmptyContent from "@/src/components/twui/elements/EmptyContent"; +import type useSystemSetupStatus from "../(hooks)/use-system-setup-status"; +import SetupCheckStatusItem from "../(partials)/setup-check-status-item"; +import SetupStatCell, { + type SetupStatCellTone, +} from "../(partials)/setup-stat-cell"; +import { RefreshCw } from "lucide-react"; + +type Props = { + setup: ReturnType; +}; + +export default function SystemStatusSection({ setup }: Props) { + const setup_status = setup.setup_status; + + if (!setup_status) { + return ; + } + + const service_label = + setup_status.service_active === null + ? "Not managed" + : setup_status.service_active + ? "Active" + : "Inactive"; + + const service_tone: SetupStatCellTone = + setup_status.service_active === null + ? "muted" + : setup_status.service_active + ? "success" + : "error"; + + const overview_items: { + label: string; + value: string; + tone?: SetupStatCellTone; + }[] = [ + { + label: "Environment", + value: setup_status.environment, + tone: setup_status.is_dev ? "warning" : "success", + }, + { + label: "Running as root", + value: setup_status.is_root ? "Yes" : "No", + tone: setup_status.is_root ? "success" : "error", + }, + { + label: "Distro", + value: setup_status.distro_pretty_name || setup_status.distro, + }, + { + label: "Init system", + value: setup_status.init_system, + }, + { + label: "Install directory", + value: setup_status.install_dir, + }, + { + label: "App version", + value: setup_status.app_version || "—", + }, + { + label: "Repository", + value: setup_status.repo_url || "—", + }, + { + label: "Service", + value: setup_status.service_name, + }, + { + label: "Service status", + value: service_label, + tone: service_tone, + }, + ]; + + const runtime_items: { + key: string; + title: string; + subtitle: string; + ok: boolean; + ok_label: string; + }[] = [ + { + key: "kernel-module", + title: "WireGuard kernel module", + subtitle: "/sys/module/wireguard", + ok: setup_status.wg_module_loaded, + ok_label: "Loaded", + }, + { + key: "wg-quick-helper", + title: "wg-quick manage helper", + subtitle: `/var/lib/wgui/scripts/wg-quick-manage.sh`, + ok: setup_status.wg_quick_helper_installed, + ok_label: "Installed", + }, + ...setup_status.lib_dirs.map((dir) => ({ + key: dir.path, + title: `Runtime directory · ${dir.label}`, + subtitle: dir.path, + ok: dir.exists, + ok_label: "Exists", + })), + ]; + + return ( + + + +

+ System status +

+

+ The web server's environment and required tools +

+
+ +
+ + + +
+ {overview_items.map((item) => ( + + ))} +
+ + + + + WireGuard tools + +
+ {setup_status.tools.map((tool) => ( + + ))} +
+ + + Runtime + +
+ {runtime_items.map((item) => ( + + ))} +
+
+ ); +} diff --git a/src/pages/admin/setup/index.server.ts b/src/pages/admin/setup/index.server.ts new file mode 100644 index 0000000..5a66895 --- /dev/null +++ b/src/pages/admin/setup/index.server.ts @@ -0,0 +1,37 @@ +import checkUserAccess from "@/src/functions/backend/auth/check-user-access"; +import userAuth from "@/src/functions/backend/auth/user-auth"; +import grabSystemSetupStatus from "@/src/functions/backend/setup/grab-system-setup-status"; +import type { PagePropsType } from "@/src/types"; +import type { BunextPageServerFn } from "@moduletrace/bunext/types"; + +const server: BunextPageServerFn = async ({ req }) => { + const { user, user_types } = await userAuth({ req }); + + if (!user?.logged_in_status || !user.id) { + return { + redirect: { + destination: "/auth/login", + permanent: false, + }, + }; + } + + const is_super_admin = checkUserAccess({ user_types }); + + if (!is_super_admin.success) { + return { + redirect: { + destination: "/admin", + permanent: false, + }, + }; + } + + return { + props: { + setup_status: grabSystemSetupStatus(), + }, + }; +}; + +export default server; \ No newline at end of file diff --git a/src/pages/admin/setup/index.tsx b/src/pages/admin/setup/index.tsx index a639e40..81959d2 100644 --- a/src/pages/admin/setup/index.tsx +++ b/src/pages/admin/setup/index.tsx @@ -3,8 +3,19 @@ import AdminHero from "@/src/components/general/admin-hero"; import { SiteData } from "@/src/data/site-data"; import Divider from "@/src/components/twui/layout/Divider"; 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 { useContext } from "react"; +import { AppContext } from "@/src/pages/__root"; export default function AdminWireguardSetupPage() { + const { pageProps } = useContext(AppContext); + + const setup = useSystemSetupStatus({ + setup_status: pageProps?.setup_status, + }); + return ( <> - + + + + ); } @@ -20,4 +34,4 @@ export default function AdminWireguardSetupPage() { export const meta: BunextPageModuleMeta = { title: `Admin Wireguard Setup | ${SiteData["SiteName"]}`, description: `Setup wireguard and wg-ui. Check if dependencies are installed. Update wg-ui and related packages. Etc.`, -}; +}; \ No newline at end of file diff --git a/src/pages/api/admin/restart-wg-ui-service.ts b/src/pages/api/admin/restart-wg-ui-service.ts new file mode 100644 index 0000000..08f41b5 --- /dev/null +++ b/src/pages/api/admin/restart-wg-ui-service.ts @@ -0,0 +1,91 @@ +import checkUserAccess from "@/src/functions/backend/auth/check-user-access"; +import userAuth from "@/src/functions/backend/auth/user-auth"; +import grabSystemSetupStatus from "@/src/functions/backend/setup/grab-system-setup-status"; +import { execSync } from "node:child_process"; +import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types"; + +const restart_service = ({ + command, +}: { + command: string; +}): APIResponseObject => { + try { + const output = execSync(command, { + encoding: "utf-8", + stdio: "pipe", + timeout: 30000, + }); + + return { + success: true, + msg: String(output).trim() || `Restarted service successfully`, + }; + } catch (error: any) { + const stdout = error?.stdout ? String(error.stdout).trim() : ""; + const stderr = error?.stderr ? String(error.stderr).trim() : ""; + + return { + success: false, + msg: `${error.message}\n${[stdout, stderr] + .filter(Boolean) + .join("\n") + .trim()}`.trim(), + }; + } +}; + +export const handler: BunextAPIRouteHandler = async ({ + req, +}) => { + if (req.method !== "POST") { + 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`, + }; + } + + const setup_status = grabSystemSetupStatus(); + + if (setup_status.is_dev) { + return { + success: false, + msg: `Development mode — the wg-ui server is run manually. Restart it yourself (e.g. Ctrl+C then run the dev command again).`, + }; + } + + switch (setup_status.init_system) { + case "systemd": + return restart_service({ + command: `systemctl restart ${setup_status.service_name}`, + }); + + case "openrc": + return restart_service({ + command: `rc-service ${setup_status.service_name} restart`, + }); + + default: + return { + success: false, + msg: `No supported init system detected — restart the wg-ui process manually.`, + }; + } +}; \ No newline at end of file diff --git a/src/pages/api/admin/setup-wireguard-tools.ts b/src/pages/api/admin/setup-wireguard-tools.ts new file mode 100644 index 0000000..802234f --- /dev/null +++ b/src/pages/api/admin/setup-wireguard-tools.ts @@ -0,0 +1,46 @@ +import checkUserAccess from "@/src/functions/backend/auth/check-user-access"; +import userAuth from "@/src/functions/backend/auth/user-auth"; +import runSetupScript from "@/src/functions/backend/setup/run-setup-script"; +import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types"; + +const SETUP_WIREGUARD_SCRIPT = `setup-wireguard.sh`; +const SETUP_WIREGUARD_TIMEOUT_MS = 15 * 60 * 1000; + +export const handler: BunextAPIRouteHandler = async ({ + req, +}) => { + if (req.method !== "POST") { + 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`, + }; + } + + const { success, output } = runSetupScript({ + script: SETUP_WIREGUARD_SCRIPT, + timeout_ms: SETUP_WIREGUARD_TIMEOUT_MS, + }); + + return { + success, + msg: output, + }; +}; \ No newline at end of file diff --git a/src/pages/api/admin/system-setup-status.ts b/src/pages/api/admin/system-setup-status.ts new file mode 100644 index 0000000..bd0a79e --- /dev/null +++ b/src/pages/api/admin/system-setup-status.ts @@ -0,0 +1,43 @@ +import checkUserAccess from "@/src/functions/backend/auth/check-user-access"; +import userAuth from "@/src/functions/backend/auth/user-auth"; +import grabSystemSetupStatus, { + type SystemSetupStatus, +} from "@/src/functions/backend/setup/grab-system-setup-status"; +import type { + APIResponseObject, + BunextAPIRouteHandler, +} from "@moduletrace/bunext/types"; + +export const handler: BunextAPIRouteHandler< + APIResponseObject +> = 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: grabSystemSetupStatus(), + }; +}; \ No newline at end of file diff --git a/src/pages/api/admin/update-wg-ui.ts b/src/pages/api/admin/update-wg-ui.ts new file mode 100644 index 0000000..dadc6f3 --- /dev/null +++ b/src/pages/api/admin/update-wg-ui.ts @@ -0,0 +1,61 @@ +import checkUserAccess from "@/src/functions/backend/auth/check-user-access"; +import userAuth from "@/src/functions/backend/auth/user-auth"; +import grabSystemSetupStatus from "@/src/functions/backend/setup/grab-system-setup-status"; +import runSetupScript from "@/src/functions/backend/setup/run-setup-script"; +import type { ApiReqParams } from "@/src/types"; +import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types"; + +const INSTALL_WG_UI_SCRIPT = `install-wg-ui.sh`; +const INSTALL_WG_UI_TIMEOUT_MS = 20 * 60 * 1000; + +export const handler: BunextAPIRouteHandler = async ( + params, +) => { + const req = params.req; + const body = params.body as ApiReqParams; + + if (req.method !== "POST") { + 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`, + }; + } + + const setup_status = grabSystemSetupStatus(); + + const { success, output } = runSetupScript({ + script: INSTALL_WG_UI_SCRIPT, + timeout_ms: INSTALL_WG_UI_TIMEOUT_MS, + env: { + NODE_ENV: setup_status.is_dev ? "development" : "production", + REPO_URL: + body.repo_url?.trim() || setup_status.repo_url || "", + BRANCH: + body.branch?.trim() || setup_status.repo_branch || "main", + SERVICE_NAME: setup_status.service_name, + }, + }); + + return { + success, + msg: output, + }; +}; \ No newline at end of file diff --git a/src/scripts/install-wg-ui.sh b/src/scripts/install-wg-ui.sh index 6d987f9..fd9a37e 100755 --- a/src/scripts/install-wg-ui.sh +++ b/src/scripts/install-wg-ui.sh @@ -48,7 +48,7 @@ if [ "$DEV_MODE" = true ]; then log "NODE_ENV=development — using local repo at $INSTALL_DIR, skipping clone and system service installation (daemon is assumed to already be running)" elif [ -z "$REPO_URL" ]; then fail "REPO_URL is not set — pass the git URL of the wg-ui repo, e.g. - REPO_URL=https://git.example.com/org/wireguard-ui.git $0" + REPO_URL=https://git.tben.me/Moduletrace/wireguard-ui.git $0" else INSTALL_DIR="${INSTALL_DIR:-$WGUI_LIB_DIR/webapp}" fi diff --git a/src/types/index.ts b/src/types/index.ts index df8eec3..a23f87f 100755 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -24,6 +24,7 @@ import type { import type { ImageInputToBase64FunctionReturn } from "../components/twui/utils/form/imageInputToBase64"; import type { ServerQueryParam } from "@moduletrace/bun-sqlite/dist/types"; import type grabHostDirnames from "../functions/backend/setup/grab-host-dir-names"; +import type grabSystemSetupStatus from "../functions/backend/setup/grab-system-setup-status"; export type User = { id: number; @@ -71,6 +72,7 @@ export type PagePropsType = { main_host?: BUN_SQLITE_WGUI_HOSTS | null; next_available_client_ip?: string | null; is_local_ip?: boolean | null; + setup_status?: ReturnType | null; }; export type AppContextObject = { @@ -267,6 +269,8 @@ export type ApiReqParams< ip_address?: string; wg_ip_address?: string | null; is_main_host?: boolean | null; + repo_url?: string; + branch?: string; }; export type UserAuthReturn = {