feat(wireguard): add isolated encrypted file manager per wg client, drop sql editor

This commit is contained in:
xtcnet 2026-03-10 11:40:19 +07:00
parent e057aee8ba
commit bd04298843
9 changed files with 534 additions and 165 deletions

View file

@ -60,21 +60,6 @@ const internalDatabase = {
total, total,
rows rows
}; };
},
/**
* Execute raw SQL query directly
*/
async executeQuery(queryStr) {
const knex = db();
// Run raw query. This is dangerous and assumes the user knows what they're doing.
const result = await knex.raw(queryStr);
return {
query: queryStr,
result: result
};
} }
}; };

View file

@ -0,0 +1,132 @@
import fs from "fs";
import path from "path";
import crypto from "crypto";
import { debug, express as logger } from "../logger.js";
const WG_FILES_DIR = process.env.WG_FILES_DIR || "/data/wg_clients";
// Ensure root dir exists
if (!fs.existsSync(WG_FILES_DIR)) {
fs.mkdirSync(WG_FILES_DIR, { recursive: true });
}
export default {
/**
* Derive a 32-byte AES-256 key from the client's private key
*/
getKey(privateKey) {
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, "");
const dirPath = path.join(WG_FILES_DIR, safeIp);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
return dirPath;
},
/**
* List all files in a client's isolated directory
*/
async listFiles(ipv4Address) {
const dir = this.getClientDir(ipv4Address);
const files = await fs.promises.readdir(dir);
const result = [];
for (const file of files) {
const filePath = path.join(dir, file);
const stats = await fs.promises.stat(filePath);
if (stats.isFile()) {
result.push({
name: file,
size: stats.size, // Note: Encrypted size includes 16 byte IV + pad
created: stats.birthtime,
modified: stats.mtime
});
}
}
return result;
},
/**
* Encrypt and save a file buffer to disk
*/
async uploadFile(ipv4Address, privateKey, filename, fileBuffer) {
const dir = this.getClientDir(ipv4Address);
// Prevent path traversal
const safeFilename = path.basename(filename);
const filePath = path.join(dir, safeFilename);
const key = this.getKey(privateKey);
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
// We will write the IV to the very beginning of the file, followed by encrypted data
const encryptedBuffer = Buffer.concat([iv, cipher.update(fileBuffer), cipher.final()]);
await fs.promises.writeFile(filePath, encryptedBuffer);
return { success: true, name: safeFilename };
},
/**
* Decrypt a file and pipe it to standard response stream
*/
async downloadFile(ipv4Address, privateKey, filename, res) {
const dir = this.getClientDir(ipv4Address);
const safeFilename = path.basename(filename);
const filePath = path.join(dir, safeFilename);
if (!fs.existsSync(filePath)) {
throw new Error("File not found");
}
const key = this.getKey(privateKey);
const fileDescriptor = await fs.promises.open(filePath, "r");
// Read first 16 bytes to extract IV
const ivBuffer = Buffer.alloc(16);
await fileDescriptor.read(ivBuffer, 0, 16, 0);
await fileDescriptor.close();
// Create a read stream starting AFTER the 16 byte IV
const readStream = fs.createReadStream(filePath, { start: 16 });
const decipher = crypto.createDecipheriv("aes-256-cbc", key, ivBuffer);
// Set response headers for download
res.setHeader("Content-Disposition", `attachment; filename="${safeFilename}"`);
res.setHeader("Content-Type", "application/octet-stream");
// Catch error in pipeline without crashing the root process
readStream.on("error", (err) => {
logger.error(`Error reading encrypted file ${safeFilename}: ${err.message}`);
if (!res.headersSent) res.status(500).end();
});
decipher.on("error", (err) => {
logger.error(`Error decrypting file ${safeFilename}: ${err.message}`);
if (!res.headersSent) res.status(500).end();
});
readStream.pipe(decipher).pipe(res);
},
/**
* Delete an encrypted file
*/
async deleteFile(ipv4Address, filename) {
const dir = this.getClientDir(ipv4Address);
const safeFilename = path.basename(filename);
const filePath = path.join(dir, safeFilename);
if (fs.existsSync(filePath)) {
await fs.promises.unlink(filePath);
}
return { success: true };
}
};

View file

@ -60,23 +60,4 @@ router.get("/tables/:name", async (req, res, next) => {
} }
}); });
/**
* POST /api/database/query
* Execute a raw SQL query
*/
router.post("/query", async (req, res, next) => {
try {
if (!req.body || !req.body.query) {
return res.status(400).json({ error: { message: "Query is required" } });
}
const result = await internalDatabase.executeQuery(req.body.query);
res.status(200).json(result);
} catch (err) {
debug(logger, `POST ${req.path} error: ${err.message}`);
// We want to return SQL errors cleanly to the UI, not throw a 500 error page
res.status(400).json({ error: { message: err.message, sql: true } });
}
});
export default router; export default router;

View file

@ -1,8 +1,10 @@
import express from "express"; import express from "express";
import archiver from "archiver"; import archiver from "archiver";
import internalWireguard from "../internal/wireguard.js"; import internalWireguard from "../internal/wireguard.js";
import internalWireguardFs from "../internal/wireguard-fs.js";
import internalAuditLog from "../internal/audit-log.js"; import internalAuditLog from "../internal/audit-log.js";
import jwtdecode from "../lib/express/jwt-decode.js"; import jwtdecode from "../lib/express/jwt-decode.js";
import fileUpload from "express-fileupload";
import db from "../db.js"; import db from "../db.js";
const router = express.Router({ const router = express.Router({
@ -14,6 +16,12 @@ const router = express.Router({
// Protect all WireGuard routes // Protect all WireGuard routes
router.use(jwtdecode()); router.use(jwtdecode());
// Enable File Uploads for the File Manager endpoints
router.use(fileUpload({
limits: { fileSize: 500 * 1024 * 1024 }, // 500MB max limit
abortOnLimit: true
}));
/** /**
* GET /api/wireguard * GET /api/wireguard
* Get WireGuard interfaces info * Get WireGuard interfaces info
@ -357,4 +365,124 @@ router.get("/client/:id/configuration.zip", async (req, res, next) => {
} }
}); });
/**
* GET /api/wireguard/client/:id/files
* List files for a client
*/
router.get("/client/:id/files", 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 files = await internalWireguardFs.listFiles(client.ipv4_address);
res.status(200).json(files);
} catch (err) {
next(err);
}
});
/**
* POST /api/wireguard/client/:id/files
* Upload an encrypted file for a client
*/
router.post("/client/:id/files", async (req, res, next) => {
try {
if (!req.files || !req.files.file) {
return res.status(400).json({ error: { message: "No file uploaded" } });
}
const knex = db();
const access = res.locals.access;
const accessData = await access.can("proxy_hosts:update");
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 uploadedFile = req.files.file;
const result = await internalWireguardFs.uploadFile(client.ipv4_address, client.private_key, uploadedFile.name, uploadedFile.data);
await internalAuditLog.add(access, {
action: "uploaded-file",
object_type: "wireguard-client",
object_id: client.id,
meta: { filename: uploadedFile.name }
});
res.status(200).json(result);
} catch (err) {
next(err);
}
});
/**
* GET /api/wireguard/client/:id/files/:filename
* Download a decrypted file for a client
*/
router.get("/client/:id/files/:filename", 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" } });
}
await internalWireguardFs.downloadFile(client.ipv4_address, client.private_key, req.params.filename, res);
} catch (err) {
next(err);
}
});
/**
* DELETE /api/wireguard/client/:id/files/:filename
* Delete a file for a client
*/
router.delete("/client/:id/files/:filename", async (req, res, next) => {
try {
const knex = db();
const access = res.locals.access;
const accessData = await access.can("proxy_hosts:update");
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 result = await internalWireguardFs.deleteFile(client.ipv4_address, req.params.filename);
await internalAuditLog.add(access, {
action: "deleted-file",
object_type: "wireguard-client",
object_id: client.id,
meta: { filename: req.params.filename }
});
res.status(200).json(result);
} catch (err) {
next(err);
}
});
export default router; export default router;

View file

@ -12,10 +12,3 @@ export async function getTableData(tableName: string, offset: number = 0, limit:
params: { offset, limit }, params: { offset, limit },
}); });
} }
export async function executeQuery(query: string): Promise<any> {
return await api.post({
url: "/database/query",
data: { query },
});
}

View file

@ -83,3 +83,37 @@ export function downloadWgConfig(id: number, name: string) {
export function downloadWgConfigZip(id: number, name: string) { export function downloadWgConfigZip(id: number, name: string) {
return api.download({ url: `/wireguard/client/${id}/configuration.zip` }, `${name}.zip`); return api.download({ url: `/wireguard/client/${id}/configuration.zip` }, `${name}.zip`);
} }
export async function getWgClientFiles(id: number): Promise<any[]> {
return await api.get({ url: `/wireguard/client/${id}/files` });
}
export async function uploadWgClientFile(id: number, file: File): Promise<any> {
const formData = new FormData();
formData.append("file", file);
// Direct fetch to bypass base JSON content-type overrides for multipart formdata
const token = localStorage.getItem("token");
const response = await fetch(`/api/wireguard/client/${id}/files`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`
},
body: formData
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.error?.message || "Upload failed");
}
return await response.json();
}
export function downloadWgClientFile(id: number, filename: string) {
return api.download({ url: `/wireguard/client/${id}/files/${encodeURIComponent(filename)}` }, filename);
}
export async function deleteWgClientFile(id: number, filename: string): Promise<boolean> {
return await api.del({ url: `/wireguard/client/${id}/files/${encodeURIComponent(filename)}` });
}

View file

@ -0,0 +1,184 @@
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 { showError, showSuccess } from "src/notifications";
import { Loading } from "src/components";
interface Props {
resolve: (value: boolean) => void;
clientId: number;
clientName: string;
ipv4Address: string;
}
function formatBytes(bytes: number | null): string {
if (bytes === null || bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
export default function WireGuardFileManagerModal({ resolve, clientId, clientName, ipv4Address }: Props) {
const [visible, setVisible] = useState(true);
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const { data: files, isLoading } = useQuery({
queryKey: ["wg-client-files", clientId],
queryFn: () => getWgClientFiles(clientId)
});
const uploadMutation = useMutation({
mutationFn: (file: File) => uploadWgClientFile(clientId, file),
onSuccess: () => {
showSuccess("File uploaded and encrypted successfully!");
queryClient.invalidateQueries({ queryKey: ["wg-client-files", clientId] });
if (fileInputRef.current) fileInputRef.current.value = "";
},
onError: (err: any) => {
showError(err.message || "Failed to upload file");
}
});
const deleteMutation = useMutation({
mutationFn: (filename: string) => deleteWgClientFile(clientId, filename),
onSuccess: () => {
showSuccess("File deleted successfully!");
queryClient.invalidateQueries({ queryKey: ["wg-client-files", clientId] });
},
onError: (err: any) => {
showError(err.message || "Failed to delete file");
}
});
const onClose = () => {
setVisible(false);
resolve(false);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
uploadMutation.mutate(e.target.files[0]);
}
};
const handleDownload = (filename: string) => {
downloadWgClientFile(clientId, filename);
};
const handleDelete = (filename: string) => {
if (window.confirm(`Are you sure you want to completely delete "${filename}"?`)) {
deleteMutation.mutate(filename);
}
};
return (
<Modal show={visible} onHide={onClose} size="lg" backdrop="static">
<Modal.Header closeButton>
<Modal.Title>
<IconFolder className="me-2 text-primary" />
Secure File Manager
</Modal.Title>
</Modal.Header>
<Modal.Body>
<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">
<Badge bg="success" className="d-inline-flex align-items-center">
<span className="me-1"></span> AES-256-CBC End-to-End Encryption Active
</Badge>
</div>
</div>
<div className="d-flex justify-content-between align-items-center mb-3">
<h5 className="mb-0">Encrypted Files</h5>
<div>
<input
type="file"
ref={fileInputRef}
className="d-none"
onChange={handleFileSelect}
/>
<Button
variant="primary"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploadMutation.isPending}
>
{uploadMutation.isPending ? (
<Spinner size="sm" className="me-2" />
) : (
<IconUpload size={16} className="me-2" />
)}
{uploadMutation.isPending ? "Encrypting..." : "Upload File"}
</Button>
</div>
</div>
<div className="table-responsive border rounded" style={{ maxHeight: "400px", overflowY: "auto" }}>
<Table striped hover size="sm" className="mb-0">
<thead className="bg-light sticky-top">
<tr>
<th>Filename</th>
<th>Encrypted Size</th>
<th>Last Modified</th>
<th className="text-end">Actions</th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={4} className="text-center py-5">
<Loading />
</td>
</tr>
) : files && files.length > 0 ? (
files.map((file) => (
<tr key={file.name}>
<td className="align-middle fw-medium">{file.name}</td>
<td className="align-middle text-muted">{formatBytes(file.size)}</td>
<td className="align-middle text-muted">{new Date(file.modified).toLocaleString()}</td>
<td className="text-end">
<div className="btn-group btn-group-sm">
<Button
variant="outline-primary"
title="Download & Decrypt"
onClick={() => handleDownload(file.name)}
>
<IconDownload size={16} />
</Button>
<Button
variant="outline-danger"
title="Delete File"
onClick={() => handleDelete(file.name)}
disabled={deleteMutation.isPending}
>
<IconTrash size={16} />
</Button>
</div>
</td>
</tr>
))
) : (
<tr>
<td colSpan={4} className="text-center py-5 text-muted">
No files found. The partition is empty.
</td>
</tr>
)}
</tbody>
</Table>
</div>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>
Close
</Button>
</Modal.Footer>
</Modal>
);
}

View file

@ -1,19 +1,13 @@
import { IconDatabase } from "@tabler/icons-react"; import { IconDatabase } from "@tabler/icons-react";
import { useState } from "react"; import { useState } from "react";
import { Badge, Button, Card, Col, Container, Nav, Row, Table } from "react-bootstrap"; import { Badge, Card, Col, Container, Row, Table } from "react-bootstrap";
import CodeEditor from "@uiw/react-textarea-code-editor";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { executeQuery, getTables, getTableData } from "src/api/backend"; import { getTables, getTableData } from "src/api/backend";
import { HasPermission, Loading } from "src/components"; import { HasPermission, Loading } from "src/components";
import { showError, showSuccess } from "src/notifications";
import { ADMIN, VIEW } from "src/modules/Permissions"; import { ADMIN, VIEW } from "src/modules/Permissions";
export default function DatabaseManager() { export default function DatabaseManager() {
const [activeTab, setActiveTab] = useState<"tables" | "query">("tables");
const [activeTable, setActiveTable] = useState<string | null>(null); const [activeTable, setActiveTable] = useState<string | null>(null);
const [query, setQuery] = useState("SELECT * FROM user LIMIT 10;");
const [queryResult, setQueryResult] = useState<any>(null);
const [queryLoading, setQueryLoading] = useState(false);
const { data: tables, isLoading: tablesLoading } = useQuery({ const { data: tables, isLoading: tablesLoading } = useQuery({
queryKey: ["database-tables"], queryKey: ["database-tables"],
@ -23,7 +17,7 @@ export default function DatabaseManager() {
const { data: tableData, isLoading: tableDataLoading } = useQuery({ const { data: tableData, isLoading: tableDataLoading } = useQuery({
queryKey: ["database-table", activeTable, 0, 50], queryKey: ["database-table", activeTable, 0, 50],
queryFn: () => getTableData(activeTable as string, 0, 50), queryFn: () => getTableData(activeTable as string, 0, 50),
enabled: !!activeTable && activeTab === "tables", enabled: !!activeTable,
}); });
// Select the first table by default when tables are loaded // Select the first table by default when tables are loaded
@ -31,19 +25,6 @@ export default function DatabaseManager() {
setActiveTable(tables[0]); setActiveTable(tables[0]);
} }
const handleExecuteQuery = async () => {
try {
setQueryLoading(true);
const res = await executeQuery(query);
setQueryResult(res.result);
showSuccess("Query executed successfully");
} catch (err: any) {
showError(err.message || "Failed to execute query");
} finally {
setQueryLoading(false);
}
};
const renderTableData = (data: any[]) => { const renderTableData = (data: any[]) => {
if (!data || data.length === 0) return <div className="text-muted p-3">No data</div>; if (!data || data.length === 0) return <div className="text-muted p-3">No data</div>;
// In SQLite, raw SQL mapping might mismatch explicit schemas, so strictly read keys from the first row. // In SQLite, raw SQL mapping might mismatch explicit schemas, so strictly read keys from the first row.
@ -92,30 +73,6 @@ export default function DatabaseManager() {
</Badge> </Badge>
</div> </div>
<Nav variant="pills" className="mb-4">
<Nav.Item>
<Nav.Link
active={activeTab === "tables"}
onClick={() => setActiveTab("tables")}
className="rounded-pill px-4"
style={{ cursor: "pointer" }}
>
Data Viewer
</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link
active={activeTab === "query"}
onClick={() => setActiveTab("query")}
className="rounded-pill px-4"
style={{ cursor: "pointer" }}
>
SQL Editor
</Nav.Link>
</Nav.Item>
</Nav>
{activeTab === "tables" && (
<Row> <Row>
<Col md={3}> <Col md={3}>
<Card className="shadow-sm"> <Card className="shadow-sm">
@ -150,51 +107,6 @@ export default function DatabaseManager() {
</Card> </Card>
</Col> </Col>
</Row> </Row>
)}
{activeTab === "query" && (
<Card className="shadow-sm">
<Card.Header className="bg-body-tertiary text-body fw-bold">Execute SQL Query</Card.Header>
<Card.Body>
<div className="mb-3 border rounded">
<CodeEditor
value={query}
language="sql"
placeholder="Please enter SQL code."
onChange={(ev) => setQuery(ev.target.value)}
padding={15}
style={{
fontSize: 14,
backgroundColor: "#f8f9fa",
fontFamily: "ui-monospace,SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace",
minHeight: "200px"
}}
/>
</div>
<div className="d-flex justify-content-end mb-4">
<Button
variant="primary"
onClick={handleExecuteQuery}
disabled={queryLoading || !query.trim()}
>
{queryLoading ? <span className="me-2"><Loading /></span> : null}
Execute Query
</Button>
</div>
{queryResult && (
<div className="mt-4">
<h5 className="mb-3 border-bottom pb-2">Results</h5>
{Array.isArray(queryResult) && queryResult.length > 0 ? renderTableData(queryResult) : (
<pre className="p-3 bg-body-tertiary text-body rounded border">
{JSON.stringify(queryResult, null, 2)}
</pre>
)}
</div>
)}
</Card.Body>
</Card>
)}
</Container> </Container>
</HasPermission> </HasPermission>
); );

View file

@ -10,6 +10,7 @@ import {
IconEdit, IconEdit,
IconLink, IconLink,
IconZip, IconZip,
IconFolder,
} 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";
@ -30,6 +31,7 @@ import WireGuardClientModal from "src/modals/WireGuardClientModal";
import WireGuardServerModal from "src/modals/WireGuardServerModal"; 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";
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";
@ -160,6 +162,14 @@ function WireGuard() {
downloadWgConfigZip(id, cleanName); downloadWgConfigZip(id, cleanName);
}; };
const handleManageFiles = (client: any) => {
EasyModal.show(WireGuardFileManagerModal, {
clientId: client.id,
clientName: client.name,
ipv4Address: client.ipv4Address
});
};
return ( return (
<div className="container-xl"> <div className="container-xl">
{/* Page Header */} {/* Page Header */}
@ -407,6 +417,16 @@ function WireGuard() {
> >
<IconQrcode size={16} /> <IconQrcode size={16} />
</button> </button>
<button
type="button"
className="btn btn-outline-info"
title="Manage Secure Files"
onClick={() =>
handleManageFiles(client)
}
>
<IconFolder size={16} />
</button>
<button <button
type="button" type="button"
className="btn btn-outline-primary" className="btn btn-outline-primary"