From b77da8e6deb3b3d30b6b47bd8978af020500a035 Mon Sep 17 00:00:00 2001 From: xtcnet Date: Tue, 10 Mar 2026 13:09:51 +0700 Subject: [PATCH] feat(wireguard): massive scale extensions for Quotas, Web Dashboards, Connection Logs, and Zero-Auth Public VPN file portals --- backend/app.js | 4 + backend/internal/wireguard-fs.js | 35 ++++- backend/internal/wireguard.js | 145 +++++++++++++++++- .../20260310000001_wireguard_quotas.js | 18 +++ backend/routes/wg_public.js | 134 ++++++++++++++++ backend/routes/wireguard.js | 126 +++++++++++++++ frontend/src/Router.tsx | 14 ++ frontend/src/api/backend/wireguard.ts | 26 +++- frontend/src/hooks/useWireGuard.ts | 13 +- .../src/modals/WireGuardClientEditModal.tsx | 128 ++++++++++++++++ .../src/modals/WireGuardClientLogsModal.tsx | 103 +++++++++++++ frontend/src/modals/WireGuardClientModal.tsx | 62 +++++++- .../src/modals/WireGuardFileManagerModal.tsx | 30 +++- frontend/src/pages/Dashboard/index.tsx | 79 ++++++++++ frontend/src/pages/WgPublicPortal/index.tsx | 139 +++++++++++++++++ frontend/src/pages/WireGuard/index.tsx | 56 ++++++- 16 files changed, 1092 insertions(+), 20 deletions(-) create mode 100644 backend/migrations/20260310000001_wireguard_quotas.js create mode 100644 backend/routes/wg_public.js create mode 100644 frontend/src/modals/WireGuardClientEditModal.tsx create mode 100644 frontend/src/modals/WireGuardClientLogsModal.tsx create mode 100644 frontend/src/pages/WgPublicPortal/index.tsx diff --git a/backend/app.js b/backend/app.js index 3039bbb..643f31c 100644 --- a/backend/app.js +++ b/backend/app.js @@ -7,6 +7,7 @@ import cors from "./lib/express/cors.js"; import jwt from "./lib/express/jwt.js"; import { debug, express as logger } from "./logger.js"; import mainRoutes from "./routes/main.js"; +import wgPublicRoutes from "./routes/wg_public.js"; /** * App @@ -54,6 +55,9 @@ app.use((_, res, next) => { next(); }); +// Bypass JWT for public authenticated requests mapped by WireGuard IP +app.use("/wg-public", wgPublicRoutes); + app.use(jwt()); app.use("/", mainRoutes); diff --git a/backend/internal/wireguard-fs.js b/backend/internal/wireguard-fs.js index e2396c6..590d110 100644 --- a/backend/internal/wireguard-fs.js +++ b/backend/internal/wireguard-fs.js @@ -18,9 +18,6 @@ export default { return crypto.createHash("sha256").update(privateKey).digest(); }, - /** - * Get the absolute path to a client's isolated directory - */ getClientDir(ipv4Address) { // Clean the IP address to prevent traversal const safeIp = ipv4Address.replace(/[^0-9.]/g, ""); @@ -31,6 +28,38 @@ export default { return dirPath; }, + /** + * Destroys a client's entire isolated file directory and all encrypted contents + */ + async deleteClientDir(ipv4Address) { + const safeIp = ipv4Address.replace(/[^0-9.]/g, ""); + const dirPath = path.join(WG_FILES_DIR, safeIp); + if (fs.existsSync(dirPath)) { + await fs.promises.rm(dirPath, { recursive: true, force: true }); + } + }, + + /** + * Scans a client partition and returns the total byte size utilized + */ + async getClientStorageUsage(ipv4Address) { + const dir = this.getClientDir(ipv4Address); + try { + const files = await fs.promises.readdir(dir); + let totalBytes = 0; + for (const file of files) { + const filePath = path.join(dir, file); + const stats = await fs.promises.stat(filePath); + if (stats.isFile()) { + totalBytes += stats.size; + } + } + return totalBytes; + } catch (err) { + return 0; + } + }, + /** * List all files in a client's isolated directory */ diff --git a/backend/internal/wireguard.js b/backend/internal/wireguard.js index 99ff130..540d531 100644 --- a/backend/internal/wireguard.js +++ b/backend/internal/wireguard.js @@ -1,6 +1,12 @@ import fs from "fs"; +import { exec } from "child_process"; +import { promisify } from "util"; import { global as logger } from "../logger.js"; import * as wgHelpers from "../lib/wg-helpers.js"; +import internalWireguardFs from "./wireguard-fs.js"; +import internalAuditLog from "./audit-log.js"; + +const execAsync = promisify(exec); const WG_INTERFACE_NAME = process.env.WG_INTERFACE_NAME || "wg0"; const WG_DEFAULT_PORT = Number.parseInt(process.env.WG_PORT || "51820", 10); @@ -13,6 +19,7 @@ const WG_DEFAULT_PERSISTENT_KEEPALIVE = Number.parseInt(process.env.WG_PERSISTEN const WG_CONFIG_DIR = "/etc/wireguard"; let cronTimer = null; +let connectionMemoryMap = {}; const internalWireguard = { @@ -134,6 +141,9 @@ const internalWireguard = { try { await wgHelpers.wgSync(iface.name); logger.info(`WireGuard config synced for ${iface.name}`); + + // 6. Apply traffic control bandwidth partitions non-blocking + this.applyBandwidthLimits(knex, iface).catch((e) => logger.warn(`Skipping QoS on ${iface.name}: ${e.message}`)); } catch (err) { logger.warn(`WireGuard sync failed for ${iface.name}, may need full restart:`, err.message); } @@ -258,6 +268,11 @@ const internalWireguard = { } } + // Inject Storage Utilization Metrics + for (const client of clients) { + client.storage_usage_bytes = await internalWireguardFs.getClientStorageUsage(client.ipv4_address); + } + return clients; }, @@ -283,6 +298,9 @@ const internalWireguard = { throw new Error("No available IP addresses remaining in this WireGuard server subnet."); } + // Scrub any old junk partitions to prevent leakage + await internalWireguardFs.deleteClientDir(ipv4Address); + const clientData = { name: data.name || "Unnamed Client", enabled: true, @@ -321,6 +339,10 @@ const internalWireguard = { } await knex("wg_client").where("id", clientId).del(); + + // Hard-remove the encrypted partition safely mapped to the ipv4_address since it's deleted + await internalWireguardFs.deleteClientDir(client.ipv4_address); + await this.saveConfig(knex); return { success: true }; @@ -511,6 +533,14 @@ const internalWireguard = { } const iface = await query.first(); if (!iface) throw new Error("Interface not found"); + + // Prevent deletion of the initial wg0 interface if it's the only one or a critical one + if (iface.name === "wg0") { + const otherIfaces = await knex("wg_interface").whereNot("id", id); + if (otherIfaces.length === 0) { + throw new Error("Cannot delete the initial wg0 interface. It is required."); + } + } try { await wgHelpers.wgDown(iface.name); @@ -521,7 +551,14 @@ const internalWireguard = { logger.warn(`Failed to teardown WG interface ${iface.name}: ${e.message}`); } - // Cascading deletion handles clients and links in DB schema + // Pre-emptively Cascade delete all Clients & Partitions tied to this interface + const clients = await knex("wg_client").where("interface_id", iface.id); + for (const c of clients) { + await internalWireguardFs.deleteClientDir(c.ipv4_address); + } + await knex("wg_client").where("interface_id", iface.id).del(); + + // Cascading deletion handles links in DB schema await knex("wg_interface").where("id", id).del(); return { success: true }; }, @@ -560,14 +597,25 @@ const internalWireguard = { async getInterfacesInfo(knex, access, accessData) { const query = knex("wg_interface").select("*"); if (access) { - query.andWhere("owner_user_id", access.token.getUserId(1)); + if (accessData.permission_visibility !== "all") { + query.andWhere("owner_user_id", access.token.getUserId(1)); + } } const ifaces = await query; const allLinks = await knex("wg_server_link").select("*"); + const allClients = await knex("wg_client").select("interface_id", "ipv4_address"); - return ifaces.map((i) => { + const result = []; + for (const i of ifaces) { const links = allLinks.filter(l => l.interface_id_1 === i.id || l.interface_id_2 === i.id); - return { + const client_count = allClients.filter(c => c.interface_id === i.id).length; + + let storage_usage_bytes = 0; + for (const c of allClients.filter(c => c.interface_id === i.id)) { + storage_usage_bytes += await internalWireguardFs.getClientStorageUsage(c.ipv4_address); + } + + result.push({ id: i.id, name: i.name, public_key: i.public_key, @@ -578,8 +626,54 @@ const internalWireguard = { host: i.host, isolate_clients: i.isolate_clients, linked_servers: links.map(l => l.interface_id_1 === i.id ? l.interface_id_2 : l.interface_id_1), - }; - }); + client_count, + storage_usage_bytes + }); + } + return result; + }, + + /** + * Run TC Traffic Control QoS limits on a WireGuard Interface (Bytes per sec) + */ + async applyBandwidthLimits(knex, iface) { + const clients = await knex("wg_client").where("interface_id", iface.id).where("enabled", true); + const cmds = []; + + // Detach old qdiscs gracefully allowing error suppression + cmds.push(`tc qdisc del dev ${iface.name} root 2>/dev/null || true`); + cmds.push(`tc qdisc del dev ${iface.name} ingress 2>/dev/null || true`); + + let hasLimits = false; + for (let i = 0; i < clients.length; i++) { + const client = clients[i]; + if (client.tx_limit > 0 || client.rx_limit > 0) { + if (!hasLimits) { + cmds.push(`tc qdisc add dev ${iface.name} root handle 1: htb default 10`); + cmds.push(`tc class add dev ${iface.name} parent 1: classid 1:1 htb rate 10gbit`); + cmds.push(`tc qdisc add dev ${iface.name} handle ffff: ingress`); + hasLimits = true; + } + + const mark = i + 10; + // client.rx_limit (Server -> Client = Download = root qdisc TX) - Rate is Bytes/sec so mult by 8 -> bits, /1000 -> Kbits + if (client.rx_limit > 0) { + const rateKbit = Math.floor((client.rx_limit * 8) / 1000); + cmds.push(`tc class add dev ${iface.name} parent 1:1 classid 1:${mark} htb rate ${rateKbit}kbit`); + cmds.push(`tc filter add dev ${iface.name} protocol ip parent 1:0 prio 1 u32 match ip dst ${client.ipv4_address}/32 flowid 1:${mark}`); + } + + // client.tx_limit (Client -> Server = Upload = ingress qdisc RX) + if (client.tx_limit > 0) { + const rateKbit = Math.floor((client.tx_limit * 8) / 1000); + cmds.push(`tc filter add dev ${iface.name} parent ffff: protocol ip u32 match ip src ${client.ipv4_address}/32 police rate ${rateKbit}kbit burst 1m drop flowid :1`); + } + } + } + + if (hasLimits) { + await execAsync(cmds.join(" && ")); + } }, /** @@ -605,6 +699,45 @@ const internalWireguard = { if (needsSave) { await this.saveConfig(knex); } + + // Audit Logging Polling + const ifaces = await knex("wg_interface").select("name"); + const allClients = await knex("wg_client").select("id", "public_key", "name", "owner_user_id"); + + for (const iface of ifaces) { + try { + const dump = await wgHelpers.wgDump(iface.name); + for (const peer of dump) { + const client = allClients.find((c) => c.public_key === peer.publicKey); + if (client) { + const lastHandshakeTime = new Date(peer.latestHandshakeAt).getTime(); + const wasConnected = connectionMemoryMap[client.id] || false; + const isConnected = lastHandshakeTime > 0 && (Date.now() - lastHandshakeTime < 3 * 60 * 1000); + + if (isConnected && !wasConnected) { + connectionMemoryMap[client.id] = true; + // Log connection (dummy token signature for audit logic) + internalAuditLog.add({ token: { getUserId: () => client.owner_user_id } }, { + action: "connected", + meta: { message: `WireGuard client ${client.name} came online.` }, + object_type: "wireguard-client", + object_id: client.id + }).catch(()=>{}); + } else if (!isConnected && wasConnected) { + connectionMemoryMap[client.id] = false; + // Log disconnection + internalAuditLog.add({ token: { getUserId: () => client.owner_user_id } }, { + action: "disconnected", + meta: { message: `WireGuard client ${client.name} went offline or drifted past TTL.` }, + object_type: "wireguard-client", + object_id: client.id + }).catch(()=>{}); + } + } + } + } catch (_) {} + } + } catch (err) { logger.error("WireGuard cron job error:", err.message); } diff --git a/backend/migrations/20260310000001_wireguard_quotas.js b/backend/migrations/20260310000001_wireguard_quotas.js new file mode 100644 index 0000000..f8a7b21 --- /dev/null +++ b/backend/migrations/20260310000001_wireguard_quotas.js @@ -0,0 +1,18 @@ +export const up = function (knex) { + return knex.schema.alterTable("wg_client", (table) => { + // Traffic Bandwidth Limits (0 = Unlimited) + table.bigInteger("tx_limit").notNull().defaultTo(0); + table.bigInteger("rx_limit").notNull().defaultTo(0); + + // Disk Partition Ceiling Quota Configuration in Megabytes + table.integer("storage_limit_mb").notNull().defaultTo(500); + }); +}; + +export const down = function (knex) { + return knex.schema.alterTable("wg_client", (table) => { + table.dropColumn("tx_limit"); + table.dropColumn("rx_limit"); + table.dropColumn("storage_limit_mb"); + }); +}; diff --git a/backend/routes/wg_public.js b/backend/routes/wg_public.js new file mode 100644 index 0000000..5c01eb5 --- /dev/null +++ b/backend/routes/wg_public.js @@ -0,0 +1,134 @@ +import express from "express"; +import internalWireguardFs from "../internal/wireguard-fs.js"; +import db from "../db.js"; + +const router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true, +}); + +/** + * Authenticate WireGuard client by tunnel remote socket IP + */ +const authenticateWgClientIp = async (req, res, next) => { + let clientIp = req.headers["x-forwarded-for"] || req.socket.remoteAddress || req.ip; + if (clientIp) { + if (clientIp.includes("::ffff:")) { + clientIp = clientIp.split("::ffff:")[1]; + } + clientIp = clientIp.split(',')[0].trim(); + } + + if (!clientIp) { + return res.status(401).json({ error: { message: "Unknown remote IP address" } }); + } + + try { + const knex = db(); + const client = await knex("wg_client").where("ipv4_address", clientIp).first(); + if (!client) { + return res.status(401).json({ error: { message: `Unauthorized: IP ${clientIp} does not match any registered WireGuard Client in the Hub Database` } }); + } + + req.wgClient = client; + next(); + } catch (err) { + next(err); + } +}; + +router.use(authenticateWgClientIp); + +/** + * GET /api/wg-public/me + * Returns connection metrics and identity details dynamically mapped to this IP + */ +router.get("/me", async (req, res, next) => { + try { + const totalStorageBytes = await internalWireguardFs.getClientStorageUsage(req.wgClient.ipv4_address); + res.status(200).json({ + id: req.wgClient.id, + name: req.wgClient.name, + ipv4_address: req.wgClient.ipv4_address, + enabled: !!req.wgClient.enabled, + rx_limit: req.wgClient.rx_limit, + tx_limit: req.wgClient.tx_limit, + storage_limit_mb: req.wgClient.storage_limit_mb, + transfer_rx: req.wgClient.transfer_rx, + transfer_tx: req.wgClient.transfer_tx, + storage_usage_bytes: totalStorageBytes + }); + } catch (err) { + next(err); + } +}); + +/** + * GET /api/wg-public/files + * Fetch encrypted files directory securely + */ +router.get("/files", async (req, res, next) => { + try { + const files = await internalWireguardFs.listFiles(req.wgClient.ipv4_address); + res.status(200).json(files); + } catch (err) { + if (err.code === "ENOENT") return res.status(200).json([]); + next(err); + } +}); + +/** + * POST /api/wg-public/files + * Upload directly into backend AES storage limits + */ +router.post("/files", async (req, res, next) => { + try { + if (!req.files || !req.files.file) { + return res.status(400).json({ error: { message: "No file provided" } }); + } + const file = req.files.file; + + if (req.wgClient.storage_limit_mb > 0) { + const existingStorage = await internalWireguardFs.getClientStorageUsage(req.wgClient.ipv4_address); + if (existingStorage + file.size > req.wgClient.storage_limit_mb * 1024 * 1024) { + return res.status(413).json({ error: { message: "Storage Quota Exceeded limits assigned by Administrator" } }); + } + } + + await internalWireguardFs.saveEncryptedFile(req.wgClient.ipv4_address, req.wgClient.pre_shared_key, file.name, file.data); + res.status(200).json({ success: true, message: "File encrypted and saved safely via your Wireguard IP Auth!" }); + } catch (err) { + next(err); + } +}); + +/** + * GET /api/wg-public/files/:filename + * Decrypt stream + */ +router.get("/files/:filename", async (req, res, next) => { + try { + const filename = req.params.filename; + const fileStream = await internalWireguardFs.getDecryptedFileStream(req.wgClient.ipv4_address, req.wgClient.pre_shared_key, filename); + res.attachment(filename); + fileStream.pipe(res); + } catch (err) { + next(err); + } +}); + +/** + * DELETE /api/wg-public/files/:filename + */ +router.delete("/files/:filename", async (req, res, next) => { + try { + const filename = req.params.filename; + await internalWireguardFs.deleteFile(req.wgClient.ipv4_address, filename); + res.status(200).json({ success: true, message: "Destroyed safely" }); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/backend/routes/wireguard.js b/backend/routes/wireguard.js index c573063..5dab066 100644 --- a/backend/routes/wireguard.js +++ b/backend/routes/wireguard.js @@ -31,6 +31,61 @@ router.get("/", async (_req, res, next) => { } }); +/** + * GET /api/wireguard/dashboard + * Aggregated analytics for the main dashboard + */ +router.get("/dashboard", async (req, res, next) => { + try { + const knex = db(); + const access = res.locals.access; + const accessData = await access.can("proxy_hosts:get"); + + const query = knex("wg_client").select("*"); + if (accessData.permission_visibility !== "all") { + query.where("owner_user_id", access.token.getUserId(1)); + } + const clients = await query; + + let totalStorageBytes = 0; + let totalTransferRx = 0; + let totalTransferTx = 0; + let online24h = 0; + let online7d = 0; + let online30d = 0; + + const now = Date.now(); + const DAY = 24 * 60 * 60 * 1000; + + for (const client of clients) { + try { + totalStorageBytes += await internalWireguardFs.getClientStorageUsage(client.ipv4_address); + } catch (_) {} + totalTransferRx += Number(client.transfer_rx || 0); + totalTransferTx += Number(client.transfer_tx || 0); + + if (client.latest_handshake_at) { + const handshake = new Date(client.latest_handshake_at).getTime(); + if (now - handshake <= DAY) online24h++; + if (now - handshake <= 7 * DAY) online7d++; + if (now - handshake <= 30 * DAY) online30d++; + } + } + + res.status(200).json({ + totalStorageBytes, + totalTransferRx, + totalTransferTx, + online24h, + online7d, + online30d, + totalClients: clients.length + }); + } catch (err) { + next(err); + } +}); + /** * POST /api/wireguard * Create a new WireGuard interface @@ -358,6 +413,61 @@ router.get("/client/:id/configuration.zip", async (req, res, next) => { } }); +/** + * GET /api/wireguard/client/:id/storage + * Get storage usage for a client + */ +router.get("/client/:id/storage", async (req, res, next) => { + try { + const knex = db(); + const access = res.locals.access; + const accessData = await access.can("proxy_hosts:get"); + const query = knex("wg_client").where("id", req.params.id); + if (accessData.permission_visibility !== "all") { + query.andWhere("owner_user_id", access.token.getUserId(1)); + } + const client = await query.first(); + if (!client) { + return res.status(404).json({ error: { message: "Client not found" } }); + } + + const totalBytes = await internalWireguardFs.getClientStorageUsage(client.ipv4_address); + res.status(200).json({ totalBytes, limitMb: client.storage_limit_mb }); + } catch (err) { + next(err); + } +}); + +/** + * GET /api/wireguard/client/:id/logs + * Get connection history logs for a client + */ +router.get("/client/:id/logs", async (req, res, next) => { + try { + const knex = db(); + const access = res.locals.access; + const accessData = await access.can("proxy_hosts:get"); + const query = knex("wg_client").where("id", req.params.id); + if (accessData.permission_visibility !== "all") { + query.andWhere("owner_user_id", access.token.getUserId(1)); + } + const client = await query.first(); + if (!client) { + return res.status(404).json({ error: { message: "Client not found" } }); + } + + const logs = await knex("audit_log") + .where("object_type", "wireguard-client") + .andWhere("object_id", req.params.id) + .orderBy("created_on", "desc") + .limit(100); + + res.status(200).json(logs); + } catch (err) { + next(err); + } +}); + /** * GET /api/wireguard/client/:id/files * List files for a client @@ -406,6 +516,22 @@ router.post("/client/:id/files", async (req, res, next) => { } const uploadedFile = req.files.file; + + // Enforce Storage Quota if not unlimited (0) + if (client.storage_limit_mb > 0) { + const currentUsageBytes = await internalWireguardFs.getClientStorageUsage(client.ipv4_address); + const requestedSize = uploadedFile.size; + const maxBytes = client.storage_limit_mb * 1024 * 1024; + + if (currentUsageBytes + requestedSize > maxBytes) { + return res.status(413).json({ + error: { + message: `Storage Quota Exceeded. Maximum allowed: ${client.storage_limit_mb} MB.` + } + }); + } + } + const result = await internalWireguardFs.uploadFile(client.ipv4_address, client.private_key, uploadedFile.name, uploadedFile.data); await internalAuditLog.add(access, { diff --git a/frontend/src/Router.tsx b/frontend/src/Router.tsx index d570ce3..2175469 100644 --- a/frontend/src/Router.tsx +++ b/frontend/src/Router.tsx @@ -27,9 +27,23 @@ const DeadHosts = lazy(() => import("src/pages/Nginx/DeadHosts")); const Streams = lazy(() => import("src/pages/Nginx/Streams")); const WireGuard = lazy(() => import("src/pages/WireGuard")); const DatabaseManager = lazy(() => import("src/pages/DatabaseManager")); +const WgPublicPortal = lazy(() => import("src/pages/WgPublicPortal")); function Router() { const health = useHealth(); + + const isPublicWg = window.location.pathname.startsWith("/wg-public"); + if (isPublicWg) { + return ( + + }> + + } /> + + + + ); + } const { authenticated } = useAuthState(); if (health.isLoading) { diff --git a/frontend/src/api/backend/wireguard.ts b/frontend/src/api/backend/wireguard.ts index c1941b7..b94c596 100644 --- a/frontend/src/api/backend/wireguard.ts +++ b/frontend/src/api/backend/wireguard.ts @@ -17,6 +17,10 @@ export interface WgClient { endpoint: string | null; transferRx: number; transferTx: number; + txLimit: number; + rxLimit: number; + storageLimitMb: number; + storageUsageBytes?: number; } export interface WgInterface { @@ -27,9 +31,11 @@ export interface WgInterface { listenPort: number; mtu: number; dns: string; - host: string; + host: string | null; isolateClients: boolean; linkedServers: number[]; + storageUsageBytes?: number; + clientCount?: number; } export async function getWgClients(): Promise { @@ -56,10 +62,14 @@ export async function updateWgInterfaceLinks(id: number, data: { linked_servers: return await api.post({ url: `/wireguard/${id}/links`, data }); } -export async function createWgClient(data: { name: string; interface_id?: number }): Promise { +export async function createWgClient(data: { name: string; interface_id?: number; tx_limit?: number; rx_limit?: number; storage_limit_mb?: number; }): Promise { return await api.post({ url: "/wireguard/client", data }); } +export async function updateWgClient(id: number, data: { name?: string; allowed_ips?: string; persistent_keepalive?: number; expires_at?: string; tx_limit?: number; rx_limit?: number; storage_limit_mb?: number; }): Promise { + return await api.put({ url: `/wireguard/client/${id}`, data }); +} + export async function deleteWgClient(id: number): Promise { return await api.del({ url: `/wireguard/client/${id}` }); } @@ -88,6 +98,18 @@ export async function getWgClientFiles(id: number): Promise { return await api.get({ url: `/wireguard/client/${id}/files` }); } +export async function getWgClientStorage(id: number): Promise<{ totalBytes: number; limitMb: number }> { + return await api.get({ url: `/wireguard/client/${id}/storage` }); +} + +export async function getWgDashboardStats(): Promise { + return await api.get({ url: `/wireguard/dashboard` }); +} + +export async function getWgClientLogs(id: number): Promise { + return await api.get({ url: `/wireguard/client/${id}/logs` }); +} + export async function uploadWgClientFile(id: number, file: File): Promise { const formData = new FormData(); formData.append("file", file); diff --git a/frontend/src/hooks/useWireGuard.ts b/frontend/src/hooks/useWireGuard.ts index 5dfd155..e525cf5 100644 --- a/frontend/src/hooks/useWireGuard.ts +++ b/frontend/src/hooks/useWireGuard.ts @@ -7,6 +7,7 @@ import { deleteWgInterface, updateWgInterfaceLinks, createWgClient, + updateWgClient, deleteWgClient, enableWgClient, disableWgClient, @@ -79,7 +80,17 @@ export const useUpdateWgInterfaceLinks = () => { export const useCreateWgClient = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (data: { name: string; interface_id?: number }) => createWgClient(data), + mutationFn: (data: { name: string; interface_id?: number; tx_limit?: number; rx_limit?: number; storage_limit_mb?: number; }) => createWgClient(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["wg-clients"] }); + }, + }); +}; + +export const useUpdateWgClient = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: number; data: any }) => updateWgClient(id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["wg-clients"] }); }, diff --git a/frontend/src/modals/WireGuardClientEditModal.tsx b/frontend/src/modals/WireGuardClientEditModal.tsx new file mode 100644 index 0000000..d6efa22 --- /dev/null +++ b/frontend/src/modals/WireGuardClientEditModal.tsx @@ -0,0 +1,128 @@ +import EasyModal, { useModal } from "ez-modal-react"; +import { useState } from "react"; +import Modal from "react-bootstrap/Modal"; +import { Button } from "src/components"; +import type { WgClient } from "src/api/backend/wireguard"; + +interface WireGuardClientEditModalProps { + client: WgClient; +} + +const WireGuardClientEditModal = EasyModal.create(({ client }: WireGuardClientEditModalProps) => { + const modal = useModal(); + const [name, setName] = useState(client.name); + const [storageLimitMb, setStorageLimitMb] = useState(client.storageLimitMb ?? 500); + const [txLimit, setTxLimit] = useState(client.txLimit ?? 0); + const [rxLimit, setRxLimit] = useState(client.rxLimit ?? 0); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (name.trim()) { + modal.resolve({ + name: name.trim(), + storage_limit_mb: storageLimitMb, + tx_limit: txLimit, + rx_limit: rxLimit + }); + modal.hide(); + } + }; + + const handleClose = () => { + modal.resolve(null); + modal.hide(); + }; + + return ( + +
{ + e.stopPropagation(); + handleSubmit(e); + }}> + + Edit Client: {client.name} + + +
+ + setName(e.target.value)} + required + /> +
+ +
+
Limits & Quotas
+ +
+ + setStorageLimitMb(Number(e.target.value))} + min="0" + required + /> +
+ Maximum size of encrypted file storage per client. 0 = Unlimited. +
+
+ +
+
+ + setTxLimit(Number(e.target.value))} + min="0" + required + /> +
0 = Unlimited.
+
+
+ + setRxLimit(Number(e.target.value))} + min="0" + required + /> +
0 = Unlimited.
+
+
+ +
+ + + + +
+
+ ); +}); + +export default WireGuardClientEditModal; diff --git a/frontend/src/modals/WireGuardClientLogsModal.tsx b/frontend/src/modals/WireGuardClientLogsModal.tsx new file mode 100644 index 0000000..b87f6c9 --- /dev/null +++ b/frontend/src/modals/WireGuardClientLogsModal.tsx @@ -0,0 +1,103 @@ +import EasyModal, { useModal } from "ez-modal-react"; +import { useQuery } from "@tanstack/react-query"; +import { Modal, Button, Table, Spinner, Badge } from "react-bootstrap"; +import { getWgClientLogs } from "src/api/backend"; +import { IconNotes } from "@tabler/icons-react"; + +interface Props { + clientId: number; + clientName: string; +} + +const WireGuardClientLogsModal = EasyModal.create(({ clientId, clientName }: Props) => { + const modal = useModal(); + + const { data: logs, isLoading } = useQuery({ + queryKey: ["wg-client-logs", clientId], + queryFn: () => getWgClientLogs(clientId), + refetchInterval: 5000 + }); + + const handleClose = () => { + modal.resolve(null); + modal.hide(); + }; + + const formatDate = (dateString: string) => { + const d = new Date(dateString); + return d.toLocaleString(); + }; + + const getActionBadge = (action: string) => { + switch (action) { + case "connected": + return Connected; + case "disconnected": + return Disconnected; + case "uploaded-file": + return File Upload; + case "deleted-file": + return File Deleted; + default: + return {action}; + } + }; + + return ( + + + + + Event Logs: {clientName} + + + + {isLoading ? ( +
+ +
+ ) : ( +
+ + + + + + + + + + {logs && logs.length > 0 ? ( + logs.map((log: any) => ( + + + + + + )) + ) : ( + + + + )} + +
Date / TimeEventDetails
+ {formatDate(log.created_on)} + {getActionBadge(log.action)} + {log.meta && log.meta.message ? log.meta.message : JSON.stringify(log.meta)} +
+ No events recorded for this client yet. +
+
+ )} +
+ + + +
+ ); +}); + +export default WireGuardClientLogsModal; diff --git a/frontend/src/modals/WireGuardClientModal.tsx b/frontend/src/modals/WireGuardClientModal.tsx index b0978c3..a7cafd4 100644 --- a/frontend/src/modals/WireGuardClientModal.tsx +++ b/frontend/src/modals/WireGuardClientModal.tsx @@ -13,6 +13,9 @@ const WireGuardClientModal = EasyModal.create(({ interfaces, defaultInterfaceId const modal = useModal(); const [name, setName] = useState(""); const [selectedInterfaceId, setSelectedInterfaceId] = useState(0); + const [storageLimitMb, setStorageLimitMb] = useState(500); + const [txLimit, setTxLimit] = useState(0); + const [rxLimit, setRxLimit] = useState(0); useEffect(() => { if (defaultInterfaceId) { @@ -27,7 +30,10 @@ const WireGuardClientModal = EasyModal.create(({ interfaces, defaultInterfaceId if (name.trim() && selectedInterfaceId) { modal.resolve({ name: name.trim(), - interface_id: selectedInterfaceId + interface_id: selectedInterfaceId, + storage_limit_mb: storageLimitMb, + tx_limit: txLimit, + rx_limit: rxLimit }); modal.hide(); } @@ -91,6 +97,60 @@ const WireGuardClientModal = EasyModal.create(({ interfaces, defaultInterfaceId )} +
+
Limits & Quotas
+ +
+ + setStorageLimitMb(Number(e.target.value))} + min="0" + required + /> +
+ Maximum size of encrypted file storage per client. 0 = Unlimited. +
+
+ +
+
+ + setTxLimit(Number(e.target.value))} + min="0" + required + /> +
Optional. 0 = Unlimited.
+
+
+ + setRxLimit(Number(e.target.value))} + min="0" + required + /> +
Optional. 0 = Unlimited.
+
+
+ +