diff --git a/backend/internal/database.js b/backend/internal/database.js index 2a58cc5..125c206 100644 --- a/backend/internal/database.js +++ b/backend/internal/database.js @@ -60,21 +60,6 @@ const internalDatabase = { total, 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 - }; } }; diff --git a/backend/internal/wireguard-fs.js b/backend/internal/wireguard-fs.js new file mode 100644 index 0000000..abfa101 --- /dev/null +++ b/backend/internal/wireguard-fs.js @@ -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 }; + } +}; diff --git a/backend/routes/api/database.js b/backend/routes/api/database.js index f5b4f22..efe03de 100644 --- a/backend/routes/api/database.js +++ b/backend/routes/api/database.js @@ -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; diff --git a/backend/routes/wireguard.js b/backend/routes/wireguard.js index d7eea38..1b45806 100644 --- a/backend/routes/wireguard.js +++ b/backend/routes/wireguard.js @@ -1,8 +1,10 @@ import express from "express"; import archiver from "archiver"; import internalWireguard from "../internal/wireguard.js"; +import internalWireguardFs from "../internal/wireguard-fs.js"; import internalAuditLog from "../internal/audit-log.js"; import jwtdecode from "../lib/express/jwt-decode.js"; +import fileUpload from "express-fileupload"; import db from "../db.js"; const router = express.Router({ @@ -14,6 +16,12 @@ const router = express.Router({ // Protect all WireGuard routes 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 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; diff --git a/frontend/src/api/backend/database.ts b/frontend/src/api/backend/database.ts index ca63a1d..6d3e899 100644 --- a/frontend/src/api/backend/database.ts +++ b/frontend/src/api/backend/database.ts @@ -12,10 +12,3 @@ export async function getTableData(tableName: string, offset: number = 0, limit: params: { offset, limit }, }); } - -export async function executeQuery(query: string): Promise { - return await api.post({ - url: "/database/query", - data: { query }, - }); -} diff --git a/frontend/src/api/backend/wireguard.ts b/frontend/src/api/backend/wireguard.ts index a3b9d4f..ebfa9a5 100644 --- a/frontend/src/api/backend/wireguard.ts +++ b/frontend/src/api/backend/wireguard.ts @@ -83,3 +83,37 @@ export function downloadWgConfig(id: number, name: string) { export function downloadWgConfigZip(id: number, name: string) { return api.download({ url: `/wireguard/client/${id}/configuration.zip` }, `${name}.zip`); } + +export async function getWgClientFiles(id: number): Promise { + return await api.get({ url: `/wireguard/client/${id}/files` }); +} + +export async function uploadWgClientFile(id: number, file: File): Promise { + 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 { + return await api.del({ url: `/wireguard/client/${id}/files/${encodeURIComponent(filename)}` }); +} diff --git a/frontend/src/modals/WireGuardFileManagerModal.tsx b/frontend/src/modals/WireGuardFileManagerModal.tsx new file mode 100644 index 0000000..c4f8ff4 --- /dev/null +++ b/frontend/src/modals/WireGuardFileManagerModal.tsx @@ -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(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) => { + 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 ( + + + + + Secure File Manager + + + +
+
Client: {clientName}
+

Storage Partition: /data/wg_clients/{ipv4Address}/

+
+ + AES-256-CBC End-to-End Encryption Active + +
+
+ +
+
Encrypted Files
+
+ + +
+
+ +
+ + + + + + + + + + + {isLoading ? ( + + + + ) : files && files.length > 0 ? ( + files.map((file) => ( + + + + + + + )) + ) : ( + + + + )} + +
FilenameEncrypted SizeLast ModifiedActions
+ +
{file.name}{formatBytes(file.size)}{new Date(file.modified).toLocaleString()} +
+ + +
+
+ No files found. The partition is empty. +
+
+
+ + + +
+ ); +} diff --git a/frontend/src/pages/DatabaseManager/index.tsx b/frontend/src/pages/DatabaseManager/index.tsx index 2364a3a..d39d107 100644 --- a/frontend/src/pages/DatabaseManager/index.tsx +++ b/frontend/src/pages/DatabaseManager/index.tsx @@ -1,19 +1,13 @@ import { IconDatabase } from "@tabler/icons-react"; import { useState } from "react"; -import { Badge, Button, Card, Col, Container, Nav, Row, Table } from "react-bootstrap"; -import CodeEditor from "@uiw/react-textarea-code-editor"; +import { Badge, Card, Col, Container, Row, Table } from "react-bootstrap"; 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 { showError, showSuccess } from "src/notifications"; import { ADMIN, VIEW } from "src/modules/Permissions"; export default function DatabaseManager() { - const [activeTab, setActiveTab] = useState<"tables" | "query">("tables"); const [activeTable, setActiveTable] = useState(null); - const [query, setQuery] = useState("SELECT * FROM user LIMIT 10;"); - const [queryResult, setQueryResult] = useState(null); - const [queryLoading, setQueryLoading] = useState(false); const { data: tables, isLoading: tablesLoading } = useQuery({ queryKey: ["database-tables"], @@ -23,7 +17,7 @@ export default function DatabaseManager() { const { data: tableData, isLoading: tableDataLoading } = useQuery({ queryKey: ["database-table", activeTable, 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 @@ -31,19 +25,6 @@ export default function DatabaseManager() { 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[]) => { if (!data || data.length === 0) return
No data
; // In SQLite, raw SQL mapping might mismatch explicit schemas, so strictly read keys from the first row. @@ -92,109 +73,40 @@ export default function DatabaseManager() { - - - {activeTab === "tables" && ( - - - - Tables -
- {tablesLoading &&
} - {tables?.map((table: string) => ( - - ))} -
-
- - - - -
{activeTable || "Select a table"}
- {tableData && {tableData.total} rows} -
- - {tableDataLoading ? ( -
- ) : ( - tableData && renderTableData(tableData.rows) - )} -
-
- -
- )} - - {activeTab === "query" && ( - - Execute SQL Query - -
- 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" - }} - /> + + + + Tables +
+ {tablesLoading &&
} + {tables?.map((table: string) => ( + + ))}
-
- -
- - {queryResult && ( -
-
Results
- {Array.isArray(queryResult) && queryResult.length > 0 ? renderTableData(queryResult) : ( -
-											{JSON.stringify(queryResult, null, 2)}
-										
- )} -
- )} - -
- )} + + + + + +
{activeTable || "Select a table"}
+ {tableData && {tableData.total} rows} +
+ + {tableDataLoading ? ( +
+ ) : ( + tableData && renderTableData(tableData.rows) + )} +
+
+ +
); diff --git a/frontend/src/pages/WireGuard/index.tsx b/frontend/src/pages/WireGuard/index.tsx index 6cefd3c..139209e 100644 --- a/frontend/src/pages/WireGuard/index.tsx +++ b/frontend/src/pages/WireGuard/index.tsx @@ -10,6 +10,7 @@ import { IconEdit, IconLink, IconZip, + IconFolder, } from "@tabler/icons-react"; import EasyModal from "ez-modal-react"; import { useState } from "react"; @@ -30,6 +31,7 @@ import WireGuardClientModal from "src/modals/WireGuardClientModal"; import WireGuardServerModal from "src/modals/WireGuardServerModal"; import WireGuardLinkedServersModal from "src/modals/WireGuardLinkedServersModal"; import WireGuardQRModal from "src/modals/WireGuardQRModal"; +import WireGuardFileManagerModal from "src/modals/WireGuardFileManagerModal"; function formatBytes(bytes: number | null): string { if (bytes === null || bytes === 0) return "0 B"; @@ -160,6 +162,14 @@ function WireGuard() { downloadWgConfigZip(id, cleanName); }; + const handleManageFiles = (client: any) => { + EasyModal.show(WireGuardFileManagerModal, { + clientId: client.id, + clientName: client.name, + ipv4Address: client.ipv4Address + }); + }; + return (
{/* Page Header */} @@ -407,6 +417,16 @@ function WireGuard() { > +