feat(wireguard): massive scale extensions for Quotas, Web Dashboards, Connection Logs, and Zero-Auth Public VPN file portals
This commit is contained in:
parent
66dc95bc6b
commit
b77da8e6de
16 changed files with 1092 additions and 20 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -512,6 +534,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);
|
||||
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}`);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
18
backend/migrations/20260310000001_wireguard_quotas.js
Normal file
18
backend/migrations/20260310000001_wireguard_quotas.js
Normal 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
134
backend/routes/wg_public.js
Normal 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;
|
||||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<LoadingPage />}>
|
||||
<Routes>
|
||||
<Route path="/wg-public/*" element={<WgPublicPortal />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
const { authenticated } = useAuthState();
|
||||
|
||||
if (health.isLoading) {
|
||||
|
|
|
|||
|
|
@ -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<WgClient[]> {
|
||||
|
|
@ -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<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 });
|
||||
}
|
||||
|
||||
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> {
|
||||
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` });
|
||||
}
|
||||
|
||||
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> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
|
|
|||
|
|
@ -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"] });
|
||||
},
|
||||
|
|
|
|||
128
frontend/src/modals/WireGuardClientEditModal.tsx
Normal file
128
frontend/src/modals/WireGuardClientEditModal.tsx
Normal 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;
|
||||
103
frontend/src/modals/WireGuardClientLogsModal.tsx
Normal file
103
frontend/src/modals/WireGuardClientLogsModal.tsx
Normal 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;
|
||||
|
|
@ -13,6 +13,9 @@ const WireGuardClientModal = EasyModal.create(({ interfaces, defaultInterfaceId
|
|||
const modal = useModal<any>();
|
||||
const [name, setName] = useState("");
|
||||
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(() => {
|
||||
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
|
|||
</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.Footer>
|
||||
<Button data-bs-dismiss="modal" onClick={handleClose}>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { IconFolder, IconUpload, IconTrash, IconDownload } from "@tabler/icons-react";
|
||||
import { useState, useRef } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Modal, Button, Table, Spinner, Badge } from "react-bootstrap";
|
||||
import { getWgClientFiles, uploadWgClientFile, deleteWgClientFile, downloadWgClientFile } from "src/api/backend";
|
||||
import { Modal, Button, Table, Spinner, Badge, ProgressBar } from "react-bootstrap";
|
||||
import { getWgClientFiles, getWgClientStorage, uploadWgClientFile, deleteWgClientFile, downloadWgClientFile } from "src/api/backend";
|
||||
import { showError, showSuccess } from "src/notifications";
|
||||
import { Loading } from "src/components";
|
||||
|
||||
|
|
@ -31,11 +31,17 @@ export default function WireGuardFileManagerModal({ resolve, clientId, clientNam
|
|||
queryFn: () => getWgClientFiles(clientId)
|
||||
});
|
||||
|
||||
const { data: storageData, refetch: refetchStorage } = useQuery({
|
||||
queryKey: ["wg-client-storage", clientId],
|
||||
queryFn: () => getWgClientStorage(clientId)
|
||||
});
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (file: File) => uploadWgClientFile(clientId, file),
|
||||
onSuccess: () => {
|
||||
showSuccess("File uploaded and encrypted successfully!");
|
||||
queryClient.invalidateQueries({ queryKey: ["wg-client-files", clientId] });
|
||||
refetchStorage();
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
},
|
||||
onError: (err: any) => {
|
||||
|
|
@ -48,6 +54,7 @@ export default function WireGuardFileManagerModal({ resolve, clientId, clientNam
|
|||
onSuccess: () => {
|
||||
showSuccess("File deleted successfully!");
|
||||
queryClient.invalidateQueries({ queryKey: ["wg-client-files", clientId] });
|
||||
refetchStorage();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
showError(err.message || "Failed to delete file");
|
||||
|
|
@ -87,7 +94,24 @@ export default function WireGuardFileManagerModal({ resolve, clientId, clientNam
|
|||
<div className="mb-4">
|
||||
<h5 className="mb-1">Client: <strong>{clientName}</strong></h5>
|
||||
<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">
|
||||
<span className="me-1">✓</span> AES-256-CBC End-to-End Encryption Active
|
||||
</Badge>
|
||||
|
|
|
|||
|
|
@ -5,8 +5,11 @@ import {
|
|||
IconDisc,
|
||||
IconNetwork,
|
||||
IconServer,
|
||||
IconFolder
|
||||
} from "@tabler/icons-react";
|
||||
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 { useHostReport } from "src/hooks";
|
||||
import { T } from "src/locale";
|
||||
|
|
@ -18,10 +21,24 @@ import {
|
|||
VIEW,
|
||||
} 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 { data: hostReport } = useHostReport();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: wgStats } = useQuery({
|
||||
queryKey: ["wg-dashboard-stats"],
|
||||
queryFn: getWgDashboardStats,
|
||||
refetchInterval: 10000
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>
|
||||
|
|
@ -190,6 +207,68 @@ const Dashboard = () => {
|
|||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
139
frontend/src/pages/WgPublicPortal/index.tsx
Normal file
139
frontend/src/pages/WgPublicPortal/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
IconLink,
|
||||
IconZip,
|
||||
IconFolder,
|
||||
IconNotes,
|
||||
} from "@tabler/icons-react";
|
||||
import EasyModal from "ez-modal-react";
|
||||
import { useState } from "react";
|
||||
|
|
@ -25,6 +26,7 @@ import {
|
|||
useUpdateWgInterfaceLinks,
|
||||
useCreateWgClient,
|
||||
useDeleteWgClient,
|
||||
useUpdateWgClient,
|
||||
useToggleWgClient,
|
||||
} from "src/hooks/useWireGuard";
|
||||
import WireGuardClientModal from "src/modals/WireGuardClientModal";
|
||||
|
|
@ -32,6 +34,8 @@ import WireGuardServerModal from "src/modals/WireGuardServerModal";
|
|||
import WireGuardLinkedServersModal from "src/modals/WireGuardLinkedServersModal";
|
||||
import WireGuardQRModal from "src/modals/WireGuardQRModal";
|
||||
import WireGuardFileManagerModal from "src/modals/WireGuardFileManagerModal";
|
||||
import WireGuardClientEditModal from "src/modals/WireGuardClientEditModal";
|
||||
import WireGuardClientLogsModal from "src/modals/WireGuardClientLogsModal";
|
||||
|
||||
function formatBytes(bytes: number | null): string {
|
||||
if (bytes === null || bytes === 0) return "0 B";
|
||||
|
|
@ -60,6 +64,7 @@ function WireGuard() {
|
|||
const updateLinks = useUpdateWgInterfaceLinks();
|
||||
|
||||
const createClient = useCreateWgClient();
|
||||
const updateClient = useUpdateWgClient();
|
||||
const deleteClient = useDeleteWgClient();
|
||||
const toggleClient = useToggleWgClient();
|
||||
|
||||
|
|
@ -134,7 +139,14 @@ function WireGuard() {
|
|||
}
|
||||
const result = (await EasyModal.show(WireGuardClientModal, { interfaces: interfaces || [] })) as any;
|
||||
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 (
|
||||
<div className="container-xl">
|
||||
{/* Page Header */}
|
||||
|
|
@ -228,6 +247,7 @@ function WireGuard() {
|
|||
<th>Port</th>
|
||||
<th>Endpoint Host</th>
|
||||
<th>Isolation</th>
|
||||
<th>Capacity</th>
|
||||
<th>Links</th>
|
||||
<th className="text-end">Actions</th>
|
||||
</tr>
|
||||
|
|
@ -248,6 +268,12 @@ function WireGuard() {
|
|||
<span className="badge bg-secondary text-secondary-fg">Disabled</span>
|
||||
)}
|
||||
</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>
|
||||
<div className="d-flex align-items-center">
|
||||
<span className="badge bg-azure me-2">{iface.linkedServers?.length || 0}</span>
|
||||
|
|
@ -362,7 +388,7 @@ function WireGuard() {
|
|||
<th>Server</th>
|
||||
<th>IP Address</th>
|
||||
<th>Last Handshake</th>
|
||||
<th>Transfer ↓ / ↑</th>
|
||||
<th>Traffic & Storage</th>
|
||||
<th className="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -401,12 +427,24 @@ function WireGuard() {
|
|||
<td>{timeAgo(client.latestHandshakeAt)}</td>
|
||||
<td>
|
||||
<div className="d-flex flex-column text-muted small">
|
||||
<span>↓ {formatBytes(client.transferRx)}</span>
|
||||
<span>↑ {formatBytes(client.transferTx)}</span>
|
||||
<span>↓ {formatBytes(client.transferRx)} / ↑ {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>
|
||||
</td>
|
||||
<td className="text-end">
|
||||
<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
|
||||
type="button"
|
||||
className="btn btn-outline-primary"
|
||||
|
|
@ -427,6 +465,16 @@ function WireGuard() {
|
|||
>
|
||||
<IconFolder size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-dark"
|
||||
title="View Event Logs"
|
||||
onClick={() =>
|
||||
handleLogs(client)
|
||||
}
|
||||
>
|
||||
<IconNotes size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-primary"
|
||||
|
|
|
|||
Loading…
Reference in a new issue