Add admin setup page

This commit is contained in:
2026-09-20 18:15:51 +01:00
parent 27676c5a3f
commit f05384d8fe
21 changed files with 1318 additions and 4 deletions
+2
View File
@@ -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`
+1
View File
@@ -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;
@@ -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),
},
],
};
}
@@ -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<string, string | undefined>;
};
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(),
};
}
}
@@ -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) => (
<div className="px-3 pt-6 pb-1.5 text-[11.5px] font-semibold uppercase tracking-[0.08em] text-foreground-light/40 dark:text-foreground-dark/40">
@@ -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: <Users size={16} strokeWidth={2} className={iconClassName} />,
},
{ component: sectionLabel("System") },
...(is_super_admin
? [
{
title: "Setup",
url: "/admin/setup",
icon: (
<Wrench
size={16}
strokeWidth={2}
className={iconClassName}
/>
),
},
]
: []),
{ component: sectionLabel("Account") },
{
title: "Settings",
@@ -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<typeof grabSystemSetupStatus>;
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<SystemSetupStatus | null>(
setup_status || null,
);
const [loading, setLoading] = useState(false);
const refresh = () =>
fetchApi<ApiReqParams, SetupStatusResponse>(`/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,
};
}
@@ -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<ComponentProps<typeof Button>, "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<string | null>(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 (
<AdminCard className="w-full p-5 flex flex-col gap-4">
<Row className="gap-3 items-start">
{icon ? (
<Span className="p-2.5 rounded-lg bg-primary/10 dark:bg-primary-dark/10 text-primary dark:text-primary-dark shrink-0">
{icon}
</Span>
) : null}
<Stack className="gap-1 min-w-0">
<H3 className="text-[14px] font-semibold mb-0!">
{title}
</H3>
{description ? (
typeof description == "string" ? (
<P
noMargin
className="text-[12.5px] text-foreground-light/45 dark:text-foreground-dark/45"
>
{description}
</P>
) : (
description
)
) : null}
</Stack>
</Row>
{children}
<Button
title={button_title}
className="w-full"
loading={loading}
{...buttonProps}
onClick={run}
>
{button_title}
</Button>
{status?.error && status.msg ? (
<Row className="gap-2 items-center bg-error/5 rounded-lg px-3 py-2 text-error">
<TriangleAlert size={15} className="shrink-0" />
<Span className="text-[12.5px]">{status.msg}</Span>
</Row>
) : null}
<SetupConsole output={output} error={is_error} />
</AdminCard>
);
}
@@ -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 (
<Card
noHover
className="w-full p-2.5 flex flex-row items-center gap-2.5"
title={subtitle}
>
<Span
aria-hidden="true"
className={twMerge(
"relative flex w-2.5 h-2.5 shrink-0 rounded-full",
ok ? "bg-success" : "bg-error",
)}
/>
<Stack className="gap-1 min-w-0 flex-1">
<Span
className={twMerge(
"text-[13px] font-semibold leading-none text-foreground-light",
"dark:text-foreground-dark truncate w-full line-clamp-1",
)}
>
{title}
</Span>
{subtitle ? (
<Span
className={twMerge(
"font-mono text-[11.5px] text-foreground-light/45 dark:text-foreground-dark/45 truncate",
"line-clamp-1 w-full",
)}
>
{subtitle}
</Span>
) : null}
</Stack>
<Tag
variant="outlined"
color={ok ? "success" : "error"}
className="shrink-0"
>
{ok ? ok_label : fail_label}
</Tag>
</Card>
);
}
@@ -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 (
<div
aria-live="polite"
className={twMerge(
"w-full max-h-64 overflow-y-auto overflow-x-hidden rounded-lg bg-slate-950 p-3",
"border border-solid",
error ? "border-error/40" : "border-slate-700/40",
)}
>
<pre className="text-[11.5px] leading-5 font-mono whitespace-pre-wrap break-words text-slate-300">
{output}
</pre>
</div>
);
}
@@ -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<SetupStatCellTone, string> = {
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 (
<Stack className="gap-1 min-w-0">
<Span className="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-foreground-light/40 dark:text-foreground-dark/40">
{label}
</Span>
<Span
title={value}
className={twMerge(
"tabular text-[15px] font-semibold truncate line-clamp-1 w-full",
tone
? toneClassName[tone]
: "text-foreground-light/85 dark:text-foreground-dark/85",
)}
>
{value}
</Span>
</Stack>
);
}
@@ -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<typeof useSystemSetupStatus>;
};
export default function SetupUpdateWgUiAction({ setup }: Props) {
const setup_status = setup.setup_status;
const [repoUrl, setRepoUrl] = useState<string>(
setup_status?.repo_url || "",
);
const [branch, setBranch] = useState<string>(
setup_status?.repo_branch || "main",
);
const needs_repo_url = !setup_status?.is_dev && !setup_status?.repo_url;
return (
<SetupActionCard
title="Update wg-ui"
description={
needs_repo_url ? (
<Span className="text-[12.5px] text-foreground-light/45 dark:text-foreground-dark/45">
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.
</Span>
) : setup_status?.is_dev ? (
<Span className="text-[12.5px] text-foreground-light/45 dark:text-foreground-dark/45">
Development mode — uses this local checkout. Reinstalls
dependencies and syncs the wg-quick helper script. The
dev server is not restarted.
</Span>
) : (
<Span className="text-[12.5px] text-foreground-light/45 dark:text-foreground-dark/45">
Pulls the latest code, installs dependencies, syncs
helper scripts and restarts the{" "}
{setup_status?.service_name || "wgui"} service. The
server will briefly go offline.
</Span>
)
}
icon={<CloudDownload size={19} />}
button_title={
setup_status?.app_installed ? "Update wg-ui" : "Install wg-ui"
}
on_run={async () => {
const res = await fetchApi<ApiReqParams, APIResponseObject>(
`/api/admin/update-wg-ui`,
{
method: "POST",
body: {
repo_url: repoUrl,
branch,
},
},
);
setup.refresh();
return res;
}}
>
{needs_repo_url || !setup_status?.is_dev ? (
<Stack className="gap-2.5">
{needs_repo_url ? (
<Input<"repo_url">
label="Repository URL"
placeholder={SiteData["RepoURL"]}
value={repoUrl}
onChange={(e) => {
setRepoUrl(e.target.value);
}}
/>
) : null}
{!setup_status?.is_dev ? (
<Input<"branch">
label="Branch"
placeholder="main"
value={branch}
onChange={(e) => {
setBranch(e.target.value);
}}
/>
) : null}
</Stack>
) : null}
</SetupActionCard>
);
}
@@ -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<typeof useSystemSetupStatus>;
};
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 ? (
<Row className="gap-2 items-center bg-warning/5 rounded-lg px-3 py-2.5 text-warning">
<TriangleAlert size={16} className="shrink-0" />
<Span className="text-[12.5px]">
The server is not running as root — installs and
tunnel management may fail. Run wg-ui as root for full
functionality.
</Span>
</Row>
) : null}
<SetupActionCard
title="WireGuard tools"
description="Installs or updates the WireGuard tools (wg, wg-quick, iproute2) using your distro's package manager. Safe to re-run — the installer is idempotent."
icon={<Wrench size={19} />}
button_title={
have_wg_tools
? "Update WireGuard tools"
: "Install WireGuard tools"
}
on_run={async () => {
const res = await fetchApi<ApiReqParams, APIResponseObject>(
`/api/admin/setup-wireguard-tools`,
{
method: "POST",
},
);
setup.refresh();
return res;
}}
/>
<SetupUpdateWgUiAction setup={setup} />
{show_restart ? (
<SetupActionCard
title="Restart service"
description={`Restarts the ${setup_status.service_name} system service. Use this after manual changes — the server goes briefly offline.`}
icon={<RotateCcw size={19} />}
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}
</>
);
}
@@ -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<typeof useSystemSetupStatus>;
};
export default function SystemStatusSection({ setup }: Props) {
const setup_status = setup.setup_status;
if (!setup_status) {
return <EmptyContent title="No system status available" />;
}
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 (
<AdminCard className="w-full p-5 flex flex-col gap-4">
<Row className="justify-between items-start gap-3">
<Stack className="gap-1">
<H3 className="text-[14px] font-semibold mb-0!">
System status
</H3>
<P
noMargin
className="text-[12.5px] text-foreground-light/45 dark:text-foreground-dark/45"
>
The web server's environment and required tools
</P>
</Stack>
<Button
title="Refresh system status"
size="small"
variant="ghost"
color="gray"
beforeIcon={<RefreshCw size={15} />}
loading={setup.loading}
onClick={setup.refresh}
>
Refresh
</Button>
</Row>
<Divider className="w-full" />
<div className="grid grid-cols-2 lg:grid-cols-4 gap-x-4 gap-y-5 min-w-0">
{overview_items.map((item) => (
<SetupStatCell
key={item.label}
label={item.label}
value={item.value}
tone={item.tone}
/>
))}
</div>
<Divider className="w-full" />
<Span className="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-foreground-light/40 dark:text-foreground-dark/40">
WireGuard tools
</Span>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{setup_status.tools.map((tool) => (
<SetupCheckStatusItem
key={tool.command}
title={tool.name}
subtitle={
tool.version
? `${tool.command} · ${tool.version}`
: tool.command
}
ok={tool.installed}
/>
))}
</div>
<Span className="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-foreground-light/40 dark:text-foreground-dark/40">
Runtime
</Span>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{runtime_items.map((item) => (
<SetupCheckStatusItem
key={item.key}
title={item.title}
subtitle={item.subtitle}
ok={item.ok}
ok_label={item.ok_label}
/>
))}
</div>
</AdminCard>
);
}
+37
View File
@@ -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<PagePropsType> = 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;
+15 -1
View File
@@ -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 (
<>
<AdminHero
@@ -12,7 +23,10 @@ export default function AdminWireguardSetupPage() {
description="Setup wireguard and wg-ui. Check if dependencies are installed. Update wg-ui and related packages. Etc."
/>
<Divider className="mb-6" />
<Stack className="w-full px-6 pb-8 gap-5 items-stretch"></Stack>
<Stack className="w-full px-6 pb-8 gap-5 items-stretch">
<SystemStatusSection setup={setup} />
<SetupActionsSection setup={setup} />
</Stack>
</>
);
}
@@ -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<APIResponseObject> = 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.`,
};
}
};
@@ -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<APIResponseObject> = 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,
};
};
@@ -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<SystemSetupStatus>
> = 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(),
};
};
+61
View File
@@ -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<APIResponseObject> = 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,
};
};
+1 -1
View File
@@ -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
+4
View File
@@ -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<typeof grabSystemSetupStatus> | 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 = {