feat(wireguard): massive scale extensions for Quotas, Web Dashboards, Connection Logs, and Zero-Auth Public VPN file portals

This commit is contained in:
xtcnet 2026-03-10 13:09:51 +07:00
parent 66dc95bc6b
commit b77da8e6de
16 changed files with 1092 additions and 20 deletions

View file

@ -7,6 +7,7 @@ import cors from "./lib/express/cors.js";
import jwt from "./lib/express/jwt.js"; import jwt from "./lib/express/jwt.js";
import { debug, express as logger } from "./logger.js"; import { debug, express as logger } from "./logger.js";
import mainRoutes from "./routes/main.js"; import mainRoutes from "./routes/main.js";
import wgPublicRoutes from "./routes/wg_public.js";
/** /**
* App * App
@ -54,6 +55,9 @@ app.use((_, res, next) => {
next(); next();
}); });
// Bypass JWT for public authenticated requests mapped by WireGuard IP
app.use("/wg-public", wgPublicRoutes);
app.use(jwt()); app.use(jwt());
app.use("/", mainRoutes); app.use("/", mainRoutes);

View file

@ -18,9 +18,6 @@ export default {
return crypto.createHash("sha256").update(privateKey).digest(); return crypto.createHash("sha256").update(privateKey).digest();
}, },
/**
* Get the absolute path to a client's isolated directory
*/
getClientDir(ipv4Address) { getClientDir(ipv4Address) {
// Clean the IP address to prevent traversal // Clean the IP address to prevent traversal
const safeIp = ipv4Address.replace(/[^0-9.]/g, ""); const safeIp = ipv4Address.replace(/[^0-9.]/g, "");
@ -31,6 +28,38 @@ export default {
return dirPath; 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 * List all files in a client's isolated directory
*/ */

View file

@ -1,6 +1,12 @@
import fs from "fs"; import fs from "fs";
import { exec } from "child_process";
import { promisify } from "util";
import { global as logger } from "../logger.js"; import { global as logger } from "../logger.js";
import * as wgHelpers from "../lib/wg-helpers.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_INTERFACE_NAME = process.env.WG_INTERFACE_NAME || "wg0";
const WG_DEFAULT_PORT = Number.parseInt(process.env.WG_PORT || "51820", 10); 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"; const WG_CONFIG_DIR = "/etc/wireguard";
let cronTimer = null; let cronTimer = null;
let connectionMemoryMap = {};
const internalWireguard = { const internalWireguard = {
@ -134,6 +141,9 @@ const internalWireguard = {
try { try {
await wgHelpers.wgSync(iface.name); await wgHelpers.wgSync(iface.name);
logger.info(`WireGuard config synced for ${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) { } catch (err) {
logger.warn(`WireGuard sync failed for ${iface.name}, may need full restart:`, err.message); 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; return clients;
}, },
@ -283,6 +298,9 @@ const internalWireguard = {
throw new Error("No available IP addresses remaining in this WireGuard server subnet."); 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 = { const clientData = {
name: data.name || "Unnamed Client", name: data.name || "Unnamed Client",
enabled: true, enabled: true,
@ -321,6 +339,10 @@ const internalWireguard = {
} }
await knex("wg_client").where("id", clientId).del(); 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); await this.saveConfig(knex);
return { success: true }; return { success: true };
@ -512,6 +534,14 @@ const internalWireguard = {
const iface = await query.first(); const iface = await query.first();
if (!iface) throw new Error("Interface not found"); 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 { try {
await wgHelpers.wgDown(iface.name); await wgHelpers.wgDown(iface.name);
if (fs.existsSync(`${WG_CONFIG_DIR}/${iface.name}.conf`)) { if (fs.existsSync(`${WG_CONFIG_DIR}/${iface.name}.conf`)) {
@ -521,7 +551,14 @@ const internalWireguard = {
logger.warn(`Failed to teardown WG interface ${iface.name}: ${e.message}`); 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(); await knex("wg_interface").where("id", id).del();
return { success: true }; return { success: true };
}, },
@ -560,14 +597,25 @@ const internalWireguard = {
async getInterfacesInfo(knex, access, accessData) { async getInterfacesInfo(knex, access, accessData) {
const query = knex("wg_interface").select("*"); const query = knex("wg_interface").select("*");
if (access) { if (access) {
if (accessData.permission_visibility !== "all") {
query.andWhere("owner_user_id", access.token.getUserId(1)); query.andWhere("owner_user_id", access.token.getUserId(1));
} }
}
const ifaces = await query; const ifaces = await query;
const allLinks = await knex("wg_server_link").select("*"); 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); 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, id: i.id,
name: i.name, name: i.name,
public_key: i.public_key, public_key: i.public_key,
@ -578,8 +626,54 @@ const internalWireguard = {
host: i.host, host: i.host,
isolate_clients: i.isolate_clients, isolate_clients: i.isolate_clients,
linked_servers: links.map(l => l.interface_id_1 === i.id ? l.interface_id_2 : l.interface_id_1), 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) { if (needsSave) {
await this.saveConfig(knex); 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) { } catch (err) {
logger.error("WireGuard cron job error:", err.message); logger.error("WireGuard cron job error:", err.message);
} }

View file

@ -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");
});
};

134
backend/routes/wg_public.js Normal file
View file

@ -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;

View file

@ -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 * POST /api/wireguard
* Create a new WireGuard interface * 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 * GET /api/wireguard/client/:id/files
* List files for a client * List files for a client
@ -406,6 +516,22 @@ router.post("/client/:id/files", async (req, res, next) => {
} }
const uploadedFile = req.files.file; 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); const result = await internalWireguardFs.uploadFile(client.ipv4_address, client.private_key, uploadedFile.name, uploadedFile.data);
await internalAuditLog.add(access, { await internalAuditLog.add(access, {

View file

@ -27,9 +27,23 @@ const DeadHosts = lazy(() => import("src/pages/Nginx/DeadHosts"));
const Streams = lazy(() => import("src/pages/Nginx/Streams")); const Streams = lazy(() => import("src/pages/Nginx/Streams"));
const WireGuard = lazy(() => import("src/pages/WireGuard")); const WireGuard = lazy(() => import("src/pages/WireGuard"));
const DatabaseManager = lazy(() => import("src/pages/DatabaseManager")); const DatabaseManager = lazy(() => import("src/pages/DatabaseManager"));
const WgPublicPortal = lazy(() => import("src/pages/WgPublicPortal"));
function Router() { function Router() {
const health = useHealth(); const health = useHealth();
const isPublicWg = window.location.pathname.startsWith("/wg-public");
if (isPublicWg) {
return (
<BrowserRouter>
<Suspense fallback={<LoadingPage />}>
<Routes>
<Route path="/wg-public/*" element={<WgPublicPortal />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
const { authenticated } = useAuthState(); const { authenticated } = useAuthState();
if (health.isLoading) { if (health.isLoading) {

View file

@ -17,6 +17,10 @@ export interface WgClient {
endpoint: string | null; endpoint: string | null;
transferRx: number; transferRx: number;
transferTx: number; transferTx: number;
txLimit: number;
rxLimit: number;
storageLimitMb: number;
storageUsageBytes?: number;
} }
export interface WgInterface { export interface WgInterface {
@ -27,9 +31,11 @@ export interface WgInterface {
listenPort: number; listenPort: number;
mtu: number; mtu: number;
dns: string; dns: string;
host: string; host: string | null;
isolateClients: boolean; isolateClients: boolean;
linkedServers: number[]; linkedServers: number[];
storageUsageBytes?: number;
clientCount?: number;
} }
export async function getWgClients(): Promise<WgClient[]> { export async function getWgClients(): Promise<WgClient[]> {
@ -56,10 +62,14 @@ export async function updateWgInterfaceLinks(id: number, data: { linked_servers:
return await api.post({ url: `/wireguard/${id}/links`, data }); return await api.post({ url: `/wireguard/${id}/links`, data });
} }
export async function createWgClient(data: { name: string; interface_id?: number }): Promise<WgClient> { export async function createWgClient(data: { name: string; interface_id?: number; tx_limit?: number; rx_limit?: number; storage_limit_mb?: number; }): Promise<WgClient> {
return await api.post({ url: "/wireguard/client", data }); 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<WgClient> {
return await api.put({ url: `/wireguard/client/${id}`, data });
}
export async function deleteWgClient(id: number): Promise<boolean> { export async function deleteWgClient(id: number): Promise<boolean> {
return await api.del({ url: `/wireguard/client/${id}` }); return await api.del({ url: `/wireguard/client/${id}` });
} }
@ -88,6 +98,18 @@ export async function getWgClientFiles(id: number): Promise<any[]> {
return await api.get({ url: `/wireguard/client/${id}/files` }); 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<any> {
return await api.get({ url: `/wireguard/dashboard` });
}
export async function getWgClientLogs(id: number): Promise<any[]> {
return await api.get({ url: `/wireguard/client/${id}/logs` });
}
export async function uploadWgClientFile(id: number, file: File): Promise<any> { export async function uploadWgClientFile(id: number, file: File): Promise<any> {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);

View file

@ -7,6 +7,7 @@ import {
deleteWgInterface, deleteWgInterface,
updateWgInterfaceLinks, updateWgInterfaceLinks,
createWgClient, createWgClient,
updateWgClient,
deleteWgClient, deleteWgClient,
enableWgClient, enableWgClient,
disableWgClient, disableWgClient,
@ -79,7 +80,17 @@ export const useUpdateWgInterfaceLinks = () => {
export const useCreateWgClient = () => { export const useCreateWgClient = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ 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: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["wg-clients"] }); queryClient.invalidateQueries({ queryKey: ["wg-clients"] });
}, },

View file

@ -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<any>();
const [name, setName] = useState(client.name);
const [storageLimitMb, setStorageLimitMb] = useState<number>(client.storageLimitMb ?? 500);
const [txLimit, setTxLimit] = useState<number>(client.txLimit ?? 0);
const [rxLimit, setRxLimit] = useState<number>(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 (
<Modal show={modal.visible} onHide={handleClose} backdrop="static">
<form onSubmit={(e) => {
e.stopPropagation();
handleSubmit(e);
}}>
<Modal.Header closeButton>
<Modal.Title>Edit Client: {client.name}</Modal.Title>
</Modal.Header>
<Modal.Body>
<div className="mb-3">
<label htmlFor="wg-edit-name" className="form-label required">
Client Name
</label>
<input
type="text"
className="form-control"
id="wg-edit-name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<hr />
<h5 className="mb-3">Limits & Quotas</h5>
<div className="mb-3">
<label htmlFor="wg-edit-storage" className="form-label required">
Storage Partition Limit (MB)
</label>
<input
type="number"
className="form-control"
id="wg-edit-storage"
value={storageLimitMb}
onChange={(e) => setStorageLimitMb(Number(e.target.value))}
min="0"
required
/>
<div className="form-text">
Maximum size of encrypted file storage per client. 0 = Unlimited.
</div>
</div>
<div className="row">
<div className="col-md-6 mb-3">
<label htmlFor="wg-edit-tx" className="form-label">
Upload Bandwidth Limit (Bytes)
</label>
<input
type="number"
className="form-control"
id="wg-edit-tx"
value={txLimit}
onChange={(e) => setTxLimit(Number(e.target.value))}
min="0"
required
/>
<div className="form-text">0 = Unlimited.</div>
</div>
<div className="col-md-6 mb-3">
<label htmlFor="wg-edit-rx" className="form-label">
Download Bandwidth Limit (Bytes)
</label>
<input
type="number"
className="form-control"
id="wg-edit-rx"
value={rxLimit}
onChange={(e) => setRxLimit(Number(e.target.value))}
min="0"
required
/>
<div className="form-text">0 = Unlimited.</div>
</div>
</div>
</Modal.Body>
<Modal.Footer>
<Button data-bs-dismiss="modal" onClick={handleClose}>
Cancel
</Button>
<Button type="submit" className="ms-auto btn-primary" disabled={!name.trim()}>
Save Modifications
</Button>
</Modal.Footer>
</form>
</Modal>
);
});
export default WireGuardClientEditModal;

View file

@ -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<any>();
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 <Badge bg="success">Connected</Badge>;
case "disconnected":
return <Badge bg="warning" text="dark">Disconnected</Badge>;
case "uploaded-file":
return <Badge bg="info">File Upload</Badge>;
case "deleted-file":
return <Badge bg="danger">File Deleted</Badge>;
default:
return <Badge bg="secondary">{action}</Badge>;
}
};
return (
<Modal show={modal.visible} onHide={handleClose} size="lg">
<Modal.Header closeButton>
<Modal.Title>
<IconNotes className="me-2" size={20} />
Event Logs: {clientName}
</Modal.Title>
</Modal.Header>
<Modal.Body className="p-0">
{isLoading ? (
<div className="text-center py-5">
<Spinner animation="border" variant="primary" />
</div>
) : (
<div className="table-responsive" style={{ maxHeight: "500px" }}>
<Table hover className="card-table table-vcenter table-nowrap mb-0">
<thead className="sticky-top bg-white">
<tr>
<th>Date / Time</th>
<th>Event</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{logs && logs.length > 0 ? (
logs.map((log: any) => (
<tr key={log.id}>
<td className="text-muted small">
{formatDate(log.created_on)}
</td>
<td>{getActionBadge(log.action)}</td>
<td className="small text-muted text-wrap">
{log.meta && log.meta.message ? log.meta.message : JSON.stringify(log.meta)}
</td>
</tr>
))
) : (
<tr>
<td colSpan={3} className="text-center py-4 text-muted">
No events recorded for this client yet.
</td>
</tr>
)}
</tbody>
</Table>
</div>
)}
</Modal.Body>
<Modal.Footer>
<Button data-bs-dismiss="modal" onClick={handleClose}>
Close
</Button>
</Modal.Footer>
</Modal>
);
});
export default WireGuardClientLogsModal;

View file

@ -13,6 +13,9 @@ const WireGuardClientModal = EasyModal.create(({ interfaces, defaultInterfaceId
const modal = useModal<any>(); const modal = useModal<any>();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [selectedInterfaceId, setSelectedInterfaceId] = useState<number>(0); const [selectedInterfaceId, setSelectedInterfaceId] = useState<number>(0);
const [storageLimitMb, setStorageLimitMb] = useState<number>(500);
const [txLimit, setTxLimit] = useState<number>(0);
const [rxLimit, setRxLimit] = useState<number>(0);
useEffect(() => { useEffect(() => {
if (defaultInterfaceId) { if (defaultInterfaceId) {
@ -27,7 +30,10 @@ const WireGuardClientModal = EasyModal.create(({ interfaces, defaultInterfaceId
if (name.trim() && selectedInterfaceId) { if (name.trim() && selectedInterfaceId) {
modal.resolve({ modal.resolve({
name: name.trim(), name: name.trim(),
interface_id: selectedInterfaceId interface_id: selectedInterfaceId,
storage_limit_mb: storageLimitMb,
tx_limit: txLimit,
rx_limit: rxLimit
}); });
modal.hide(); modal.hide();
} }
@ -91,6 +97,60 @@ const WireGuardClientModal = EasyModal.create(({ interfaces, defaultInterfaceId
</div> </div>
)} )}
<hr />
<h5 className="mb-3">Limits & Quotas</h5>
<div className="mb-3">
<label htmlFor="wg-client-storage" className="form-label required">
Storage Partition Limit (MB)
</label>
<input
type="number"
className="form-control"
id="wg-client-storage"
value={storageLimitMb}
onChange={(e) => setStorageLimitMb(Number(e.target.value))}
min="0"
required
/>
<div className="form-text">
Maximum size of encrypted file storage per client. 0 = Unlimited.
</div>
</div>
<div className="row">
<div className="col-md-6 mb-3">
<label htmlFor="wg-client-tx" className="form-label">
Upload Bandwidth Limit (Bytes)
</label>
<input
type="number"
className="form-control"
id="wg-client-tx"
value={txLimit}
onChange={(e) => setTxLimit(Number(e.target.value))}
min="0"
required
/>
<div className="form-text">Optional. 0 = Unlimited.</div>
</div>
<div className="col-md-6 mb-3">
<label htmlFor="wg-client-rx" className="form-label">
Download Bandwidth Limit (Bytes)
</label>
<input
type="number"
className="form-control"
id="wg-client-rx"
value={rxLimit}
onChange={(e) => setRxLimit(Number(e.target.value))}
min="0"
required
/>
<div className="form-text">Optional. 0 = Unlimited.</div>
</div>
</div>
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>
<Button data-bs-dismiss="modal" onClick={handleClose}> <Button data-bs-dismiss="modal" onClick={handleClose}>

View file

@ -1,8 +1,8 @@
import { IconFolder, IconUpload, IconTrash, IconDownload } from "@tabler/icons-react"; import { IconFolder, IconUpload, IconTrash, IconDownload } from "@tabler/icons-react";
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Modal, Button, Table, Spinner, Badge } from "react-bootstrap"; import { Modal, Button, Table, Spinner, Badge, ProgressBar } from "react-bootstrap";
import { getWgClientFiles, uploadWgClientFile, deleteWgClientFile, downloadWgClientFile } from "src/api/backend"; import { getWgClientFiles, getWgClientStorage, uploadWgClientFile, deleteWgClientFile, downloadWgClientFile } from "src/api/backend";
import { showError, showSuccess } from "src/notifications"; import { showError, showSuccess } from "src/notifications";
import { Loading } from "src/components"; import { Loading } from "src/components";
@ -31,11 +31,17 @@ export default function WireGuardFileManagerModal({ resolve, clientId, clientNam
queryFn: () => getWgClientFiles(clientId) queryFn: () => getWgClientFiles(clientId)
}); });
const { data: storageData, refetch: refetchStorage } = useQuery({
queryKey: ["wg-client-storage", clientId],
queryFn: () => getWgClientStorage(clientId)
});
const uploadMutation = useMutation({ const uploadMutation = useMutation({
mutationFn: (file: File) => uploadWgClientFile(clientId, file), mutationFn: (file: File) => uploadWgClientFile(clientId, file),
onSuccess: () => { onSuccess: () => {
showSuccess("File uploaded and encrypted successfully!"); showSuccess("File uploaded and encrypted successfully!");
queryClient.invalidateQueries({ queryKey: ["wg-client-files", clientId] }); queryClient.invalidateQueries({ queryKey: ["wg-client-files", clientId] });
refetchStorage();
if (fileInputRef.current) fileInputRef.current.value = ""; if (fileInputRef.current) fileInputRef.current.value = "";
}, },
onError: (err: any) => { onError: (err: any) => {
@ -48,6 +54,7 @@ export default function WireGuardFileManagerModal({ resolve, clientId, clientNam
onSuccess: () => { onSuccess: () => {
showSuccess("File deleted successfully!"); showSuccess("File deleted successfully!");
queryClient.invalidateQueries({ queryKey: ["wg-client-files", clientId] }); queryClient.invalidateQueries({ queryKey: ["wg-client-files", clientId] });
refetchStorage();
}, },
onError: (err: any) => { onError: (err: any) => {
showError(err.message || "Failed to delete file"); showError(err.message || "Failed to delete file");
@ -87,7 +94,24 @@ export default function WireGuardFileManagerModal({ resolve, clientId, clientNam
<div className="mb-4"> <div className="mb-4">
<h5 className="mb-1">Client: <strong>{clientName}</strong></h5> <h5 className="mb-1">Client: <strong>{clientName}</strong></h5>
<p className="text-muted mb-0">Storage Partition: <code>/data/wg_clients/{ipv4Address}/</code></p> <p className="text-muted mb-0">Storage Partition: <code>/data/wg_clients/{ipv4Address}/</code></p>
<div className="mt-2">
{storageData && (
<div className="mt-3">
<div className="d-flex justify-content-between align-items-end mb-1">
<span className="small fw-bold">Partition Capacity</span>
<span className="small text-muted">
{formatBytes(storageData.totalBytes)} / {storageData.limitMb === 0 ? "Unlimited" : formatBytes(storageData.limitMb * 1024 * 1024)}
</span>
</div>
<ProgressBar
now={storageData.limitMb === 0 ? 0 : (storageData.totalBytes / (storageData.limitMb * 1024 * 1024)) * 100}
variant={storageData.limitMb > 0 && (storageData.totalBytes / (storageData.limitMb * 1024 * 1024)) > 0.9 ? "danger" : "primary"}
style={{ height: "8px" }}
/>
</div>
)}
<div className="mt-3">
<Badge bg="success" className="d-inline-flex align-items-center"> <Badge bg="success" className="d-inline-flex align-items-center">
<span className="me-1"></span> AES-256-CBC End-to-End Encryption Active <span className="me-1"></span> AES-256-CBC End-to-End Encryption Active
</Badge> </Badge>

View file

@ -5,8 +5,11 @@ import {
IconDisc, IconDisc,
IconNetwork, IconNetwork,
IconServer, IconServer,
IconFolder
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { getWgDashboardStats } from "src/api/backend/wireguard";
import { HasPermission } from "src/components"; import { HasPermission } from "src/components";
import { useHostReport } from "src/hooks"; import { useHostReport } from "src/hooks";
import { T } from "src/locale"; import { T } from "src/locale";
@ -18,10 +21,24 @@ import {
VIEW, VIEW,
} from "src/modules/Permissions"; } from "src/modules/Permissions";
function formatBytes(bytes: number | null): string {
if (bytes === null || bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${Number.parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
const Dashboard = () => { const Dashboard = () => {
const { data: hostReport } = useHostReport(); const { data: hostReport } = useHostReport();
const navigate = useNavigate(); const navigate = useNavigate();
const { data: wgStats } = useQuery({
queryKey: ["wg-dashboard-stats"],
queryFn: getWgDashboardStats,
refetchInterval: 10000
});
return ( return (
<div> <div>
<h2> <h2>
@ -190,6 +207,68 @@ const Dashboard = () => {
</div> </div>
</div> </div>
</div> </div>
{/* ====== WireGuard Extended Analytics ====== */}
<div className="mt-4 mb-4">
<h3 className="mb-3"><IconNetwork className="me-2 text-muted" size={24} />WireGuard Global Analytics</h3>
<div className="row row-cards">
<div className="col-sm-6 col-lg-3">
<div className="card">
<div className="card-body">
<div className="d-flex align-items-center">
<div className="subheader">Total Storage Utilized</div>
</div>
<div className="h1 mb-3">{formatBytes(wgStats?.totalStorageBytes || 0)}</div>
<div className="d-flex mb-2">
<div className="text-muted small"><IconFolder size={14} className="me-1"/> Encrypted Partition Capacity</div>
</div>
</div>
</div>
</div>
<div className="col-sm-6 col-lg-3">
<div className="card">
<div className="card-body">
<div className="d-flex align-items-center">
<div className="subheader">Global Traffic Transfer</div>
</div>
<div className="h1 mb-3 text-blue">{formatBytes((wgStats?.totalTransferRx || 0) + (wgStats?.totalTransferTx || 0))}</div>
<div className="d-flex mb-2">
<div className="text-muted small">
{formatBytes(wgStats?.totalTransferRx || 0)} | {formatBytes(wgStats?.totalTransferTx || 0)}
</div>
</div>
</div>
</div>
</div>
<div className="col-sm-6 col-lg-3">
<div className="card">
<div className="card-body">
<div className="d-flex align-items-center">
<div className="subheader">Active Connectivity</div>
</div>
<div className="h1 mb-3 text-green">{wgStats?.online24h || 0} Clients</div>
<div className="d-flex mb-2">
<div className="text-muted small">Online past 24 hours</div>
</div>
</div>
</div>
</div>
<div className="col-sm-6 col-lg-3">
<div className="card">
<div className="card-body">
<div className="d-flex align-items-center">
<div className="subheader">Extended Retention</div>
</div>
<div className="h1 mb-3">{wgStats?.online7d || 0} <span className="text-muted fs-4">/ {wgStats?.online30d || 0}</span></div>
<div className="d-flex mb-2">
<div className="text-muted small">7 Days / 30 Days Trend</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
); );
}; };

View file

@ -0,0 +1,139 @@
import { useState, useEffect } from "react";
import { IconShieldLock, IconNetwork, IconApi, IconFolders } from "@tabler/icons-react";
function formatBytes(bytes: number | null): string {
if (!bytes) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${Number.parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
export default function WgPublicPortal() {
const [client, setClient] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
fetch("/api/wg-public/me")
.then(res => res.json().then(data => ({ status: res.status, data })))
.then(({ status, data }) => {
if (status === 200) {
setClient(data);
} else {
setError(data.error?.message || "Unauthorized context");
}
})
.catch((e) => setError("Network Error: " + e.message))
.finally(() => setLoading(false));
}, []);
if (loading) {
return (
<div className="page page-center bg-dark text-white text-center">
<h2>Verifying WireGuard Tunnel...</h2>
</div>
);
}
if (error || !client) {
return (
<div className="page page-center bg-dark text-white">
<div className="container-tight py-4 text-center">
<IconShieldLock size={64} className="text-danger mb-4" />
<h1 className="text-danger">Access Denied</h1>
<p className="text-muted">
This portal is restricted to devices actively connected through the WireGuard VPN.<br/>
{error}
</p>
</div>
</div>
);
}
return (
<div className="page bg-dark text-white" style={{ minHeight: "100vh" }}>
<div className="container-xl py-4">
<div className="row justify-content-center">
<div className="col-12 col-md-10">
<div className="card bg-dark text-light border-secondary mb-4">
<div className="card-header border-secondary">
<h3 className="card-title text-success d-flex align-items-center">
<IconNetwork className="me-2" />
Secure Intranet Connection Active
</h3>
</div>
<div className="card-body">
<div className="row text-center mb-4">
<div className="col-md-3">
<div className="text-muted small">Assigned IP</div>
<h2 className="text-info">{client.ipv4_address}</h2>
</div>
<div className="col-md-3">
<div className="text-muted small">Client Name</div>
<h2 className="text-light">{client.name}</h2>
</div>
<div className="col-md-3">
<div className="text-muted small">Storage Quota</div>
<h2 className="text-warning">
{formatBytes(client.storage_usage_bytes)} / {client.storage_limit_mb ? formatBytes(client.storage_limit_mb * 1024 * 1024) : "Unlimited"}
</h2>
</div>
<div className="col-md-3">
<div className="text-muted small">Traffic Throttle (RX/TX)</div>
<h2 className="text-success">
{client.rx_limit ? formatBytes(client.rx_limit) + "/s" : "Unlimited"} / {client.tx_limit ? formatBytes(client.tx_limit) + "/s" : "Unlimited"}
</h2>
</div>
</div>
</div>
</div>
{/* API Capabilities */}
<div className="card bg-dark text-light border-secondary">
<div className="card-header border-secondary">
<h3 className="card-title d-flex align-items-center">
<IconApi className="me-2" />
REST API Documentation
</h3>
</div>
<div className="card-body">
<p className="text-muted mb-4">
You can access your isolated AES-256 encrypted storage partition directly through these headless programmatic endpoints securely.
</p>
<div className="mb-4">
<h4 className="text-info"><IconFolders size={18} className="me-2"/>List Files</h4>
<code className="d-block p-3 bg-black rounded border border-secondary text-success">
GET http://{window.location.host}/api/wg-public/files
</code>
</div>
<div className="mb-4">
<h4 className="text-info"><IconFolders size={18} className="me-2"/>Upload File</h4>
<code className="d-block p-3 bg-black rounded border border-secondary text-warning">
curl -F "file=@/path/to/local/file.txt" http://{window.location.host}/api/wg-public/files
</code>
</div>
<div className="mb-4">
<h4 className="text-info"><IconFolders size={18} className="me-2"/>Download File</h4>
<code className="d-block p-3 bg-black rounded border border-secondary text-primary">
curl -O http://{window.location.host}/api/wg-public/files/filename.txt
</code>
</div>
<div>
<h4 className="text-info"><IconFolders size={18} className="me-2"/>Delete File</h4>
<code className="d-block p-3 bg-black rounded border border-secondary text-danger">
curl -X DELETE http://{window.location.host}/api/wg-public/files/filename.txt
</code>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -11,6 +11,7 @@ import {
IconLink, IconLink,
IconZip, IconZip,
IconFolder, IconFolder,
IconNotes,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import EasyModal from "ez-modal-react"; import EasyModal from "ez-modal-react";
import { useState } from "react"; import { useState } from "react";
@ -25,6 +26,7 @@ import {
useUpdateWgInterfaceLinks, useUpdateWgInterfaceLinks,
useCreateWgClient, useCreateWgClient,
useDeleteWgClient, useDeleteWgClient,
useUpdateWgClient,
useToggleWgClient, useToggleWgClient,
} from "src/hooks/useWireGuard"; } from "src/hooks/useWireGuard";
import WireGuardClientModal from "src/modals/WireGuardClientModal"; import WireGuardClientModal from "src/modals/WireGuardClientModal";
@ -32,6 +34,8 @@ import WireGuardServerModal from "src/modals/WireGuardServerModal";
import WireGuardLinkedServersModal from "src/modals/WireGuardLinkedServersModal"; import WireGuardLinkedServersModal from "src/modals/WireGuardLinkedServersModal";
import WireGuardQRModal from "src/modals/WireGuardQRModal"; import WireGuardQRModal from "src/modals/WireGuardQRModal";
import WireGuardFileManagerModal from "src/modals/WireGuardFileManagerModal"; import WireGuardFileManagerModal from "src/modals/WireGuardFileManagerModal";
import WireGuardClientEditModal from "src/modals/WireGuardClientEditModal";
import WireGuardClientLogsModal from "src/modals/WireGuardClientLogsModal";
function formatBytes(bytes: number | null): string { function formatBytes(bytes: number | null): string {
if (bytes === null || bytes === 0) return "0 B"; if (bytes === null || bytes === 0) return "0 B";
@ -60,6 +64,7 @@ function WireGuard() {
const updateLinks = useUpdateWgInterfaceLinks(); const updateLinks = useUpdateWgInterfaceLinks();
const createClient = useCreateWgClient(); const createClient = useCreateWgClient();
const updateClient = useUpdateWgClient();
const deleteClient = useDeleteWgClient(); const deleteClient = useDeleteWgClient();
const toggleClient = useToggleWgClient(); const toggleClient = useToggleWgClient();
@ -134,7 +139,14 @@ function WireGuard() {
} }
const result = (await EasyModal.show(WireGuardClientModal, { interfaces: interfaces || [] })) as any; const result = (await EasyModal.show(WireGuardClientModal, { interfaces: interfaces || [] })) as any;
if (result && result.name && result.interface_id) { if (result && result.name && result.interface_id) {
createClient.mutate({ name: result.name, interface_id: result.interface_id }); createClient.mutate(result);
}
};
const handleEditClient = async (client: any) => {
const result = (await EasyModal.show(WireGuardClientEditModal, { client })) as any;
if (result) {
updateClient.mutate({ id: client.id, data: result });
} }
}; };
@ -170,6 +182,13 @@ function WireGuard() {
}); });
}; };
const handleLogs = (client: any) => {
EasyModal.show(WireGuardClientLogsModal, {
clientId: client.id,
clientName: client.name
});
};
return ( return (
<div className="container-xl"> <div className="container-xl">
{/* Page Header */} {/* Page Header */}
@ -228,6 +247,7 @@ function WireGuard() {
<th>Port</th> <th>Port</th>
<th>Endpoint Host</th> <th>Endpoint Host</th>
<th>Isolation</th> <th>Isolation</th>
<th>Capacity</th>
<th>Links</th> <th>Links</th>
<th className="text-end">Actions</th> <th className="text-end">Actions</th>
</tr> </tr>
@ -248,6 +268,12 @@ function WireGuard() {
<span className="badge bg-secondary text-secondary-fg">Disabled</span> <span className="badge bg-secondary text-secondary-fg">Disabled</span>
)} )}
</td> </td>
<td>
<div className="d-flex flex-column small">
<span className="text-muted"><IconServer size={14} className="me-1"/> {iface.clientCount || 0} Clients</span>
<span className="text-muted"><IconFolder size={14} className="me-1"/> {formatBytes(iface.storageUsageBytes ?? 0)}</span>
</div>
</td>
<td> <td>
<div className="d-flex align-items-center"> <div className="d-flex align-items-center">
<span className="badge bg-azure me-2">{iface.linkedServers?.length || 0}</span> <span className="badge bg-azure me-2">{iface.linkedServers?.length || 0}</span>
@ -362,7 +388,7 @@ function WireGuard() {
<th>Server</th> <th>Server</th>
<th>IP Address</th> <th>IP Address</th>
<th>Last Handshake</th> <th>Last Handshake</th>
<th>Transfer / </th> <th>Traffic & Storage</th>
<th className="text-end">Actions</th> <th className="text-end">Actions</th>
</tr> </tr>
</thead> </thead>
@ -401,12 +427,24 @@ function WireGuard() {
<td>{timeAgo(client.latestHandshakeAt)}</td> <td>{timeAgo(client.latestHandshakeAt)}</td>
<td> <td>
<div className="d-flex flex-column text-muted small"> <div className="d-flex flex-column text-muted small">
<span> {formatBytes(client.transferRx)}</span> <span> {formatBytes(client.transferRx)} / {formatBytes(client.transferTx)}</span>
<span> {formatBytes(client.transferTx)}</span> <span className={client.storageLimitMb > 0 && ((client.storageUsageBytes||0) / (client.storageLimitMb * 1024 * 1024)) > 0.9 ? "text-danger" : ""}>
<IconFolder size={14} className="me-1"/> {formatBytes(client.storageUsageBytes || 0)} / {client.storageLimitMb === 0 ? "∞" : formatBytes(client.storageLimitMb * 1024 * 1024)}
</span>
</div> </div>
</td> </td>
<td className="text-end"> <td className="text-end">
<div className="btn-group btn-group-sm"> <div className="btn-group btn-group-sm">
<button
type="button"
className="btn btn-outline-secondary"
title="Edit Client"
onClick={() =>
handleEditClient(client)
}
>
<IconEdit size={16} />
</button>
<button <button
type="button" type="button"
className="btn btn-outline-primary" className="btn btn-outline-primary"
@ -427,6 +465,16 @@ function WireGuard() {
> >
<IconFolder size={16} /> <IconFolder size={16} />
</button> </button>
<button
type="button"
className="btn btn-outline-dark"
title="View Event Logs"
onClick={() =>
handleLogs(client)
}
>
<IconNotes size={16} />
</button>
<button <button
type="button" type="button"
className="btn btn-outline-primary" className="btn btn-outline-primary"