feat(database): add native SQLite database manager and fix wireguard admin visibility

This commit is contained in:
xtcnet 2026-03-10 10:58:08 +07:00
parent 3f0d529d14
commit b99b623355
10 changed files with 453 additions and 35 deletions

View file

@ -0,0 +1,81 @@
import db from "../db.js";
import { debug, express as logger } from "../logger.js";
const internalDatabase = {
/**
* Get all tables in the database (SQLite specific, but Knex supports raw queries for others too)
*/
async getTables() {
const knex = db();
// Attempt SQLite first, fallback to generic if using mysql/mariadb
try {
// For SQLite
const tables = await knex.raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'");
return tables.map(t => t.name).sort();
} catch (e) {
// For MySQL/MariaDB
const tables = await knex.raw("SHOW TABLES");
return tables[0].map(t => Object.values(t)[0]).sort();
}
},
/**
* Get table schema and paginated rows
*/
async getTableData(tableName, limit = 50, offset = 0) {
const knex = db();
// 1. Get Schema/PRAGMA
let schema = [];
try {
const info = await knex.raw(`PRAGMA table_info("${tableName}")`);
schema = info; // SQLite structure
} catch (e) {
// MySQL fallback
const info = await knex.raw(`DESCRIBE \`${tableName}\``);
schema = info[0];
}
// 2. Count total rows
const countResult = await knex(tableName).count("id as count").first();
const total = parseInt(countResult.count || 0, 10);
// 3. Get rows
const rows = await knex(tableName)
.select("*")
.limit(limit)
.offset(offset)
// Try ordering by ID or created_on if possible, fallback to whatever db returns
.orderBy(
schema.find(col => col.name === 'created_on' || col.Field === 'created_on') ? 'created_on' :
(schema.find(col => col.name === 'id' || col.Field === 'id') ? 'id' : undefined) || '1',
'desc'
)
.catch(() => knex(tableName).select("*").limit(limit).offset(offset)); // If order fails
return {
name: tableName,
schema,
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
};
}
};
export default internalDatabase;

View file

@ -205,7 +205,7 @@ const internalWireguard = {
/**
* Get all clients with live status and interface name correlation
*/
async getClients(knex, access) {
async getClients(knex, access, accessData) {
await this.getOrCreateInterface(knex); // Ensure structure exists
const query = knex("wg_client")
@ -214,7 +214,7 @@ const internalWireguard = {
.orderBy("wg_client.created_on", "desc");
// Filter by owner if not admin
if (access && !access.token.hasScope("admin")) {
if (access && (!accessData || accessData.permission_visibility !== "all")) {
query.andWhere("wg_client.owner_user_id", access.token.getUserId(1));
}
@ -265,7 +265,7 @@ const internalWireguard = {
/**
* Create a new WireGuard client
*/
async createClient(knex, data, access) {
async createClient(knex, data, access, accessData) {
const iface = data.interface_id
? await knex("wg_interface").where("id", data.interface_id).first()
: await this.getOrCreateInterface(knex);
@ -307,9 +307,9 @@ const internalWireguard = {
/**
* Delete a WireGuard client
*/
async deleteClient(knex, clientId, access) {
async deleteClient(knex, clientId, access, accessData) {
const query = knex("wg_client").where("id", clientId);
if (access && !access.token.hasScope("admin")) {
if (access && (!accessData || accessData.permission_visibility !== "all")) {
query.andWhere("owner_user_id", access.token.getUserId(1));
}
const client = await query.first();
@ -326,9 +326,9 @@ const internalWireguard = {
/**
* Toggle a WireGuard client enabled/disabled
*/
async toggleClient(knex, clientId, enabled, access) {
async toggleClient(knex, clientId, enabled, access, accessData) {
const query = knex("wg_client").where("id", clientId);
if (access && !access.token.hasScope("admin")) {
if (access && (!accessData || accessData.permission_visibility !== "all")) {
query.andWhere("owner_user_id", access.token.getUserId(1));
}
const client = await query.first();
@ -349,9 +349,9 @@ const internalWireguard = {
/**
* Update a WireGuard client
*/
async updateClient(knex, clientId, data, access) {
async updateClient(knex, clientId, data, access, accessData) {
const query = knex("wg_client").where("id", clientId);
if (access && !access.token.hasScope("admin")) {
if (access && (!accessData || accessData.permission_visibility !== "all")) {
query.andWhere("owner_user_id", access.token.getUserId(1));
}
const client = await query.first();
@ -412,7 +412,7 @@ const internalWireguard = {
/**
* Create a new WireGuard Interface Endpoint
*/
async createInterface(knex, data, access) {
async createInterface(knex, data, access, accessData) {
const existingIfaces = await knex("wg_interface").select("name", "listen_port");
const newIndex = existingIfaces.length;
@ -468,9 +468,9 @@ const internalWireguard = {
/**
* Update an existing Interface
*/
async updateInterface(knex, id, data, access) {
async updateInterface(knex, id, data, access, accessData) {
const query = knex("wg_interface").where("id", id);
if (access && !access.token.hasScope("admin")) {
if (access && (!accessData || accessData.permission_visibility !== "all")) {
query.andWhere("owner_user_id", access.token.getUserId(1));
}
const iface = await query.first();
@ -491,9 +491,9 @@ const internalWireguard = {
/**
* Delete an interface
*/
async deleteInterface(knex, id, access) {
async deleteInterface(knex, id, access, accessData) {
const query = knex("wg_interface").where("id", id);
if (access && !access.token.hasScope("admin")) {
if (access && (!accessData || accessData.permission_visibility !== "all")) {
query.andWhere("owner_user_id", access.token.getUserId(1));
}
const iface = await query.first();
@ -516,10 +516,10 @@ const internalWireguard = {
/**
* Update Peering Links between WireGuard Interfaces
*/
async updateInterfaceLinks(knex, id, linkedServers, access) {
async updateInterfaceLinks(knex, id, linkedServers, access, accessData) {
// Verify ownership
const query = knex("wg_interface").where("id", id);
if (access && !access.token.hasScope("admin")) {
if (access && (!accessData || accessData.permission_visibility !== "all")) {
query.andWhere("owner_user_id", access.token.getUserId(1));
}
const iface = await query.first();
@ -544,10 +544,10 @@ const internalWireguard = {
/**
* Get the WireGuard interfaces info
*/
async getInterfacesInfo(knex, access) {
async getInterfacesInfo(knex, access, accessData) {
const query = knex("wg_interface").select("*");
// Filter by owner if not admin
if (access && !access.token.hasScope("admin")) {
if (access && (!accessData || accessData.permission_visibility !== "all")) {
query.andWhere("owner_user_id", access.token.getUserId(1));
}
const ifaces = await query;

View file

@ -0,0 +1,82 @@
import express from "express";
import internalDatabase from "../../internal/database.js";
import jwtdecode from "../../lib/express/jwt-decode.js";
import { debug, express as logger } from "../../logger.js";
const router = express.Router({
caseSensitive: true,
strict: true,
mergeParams: true,
});
router.use(jwtdecode());
/**
* Middleware to strictly ensure only Super Admins can access this route
*/
const requireSuperAdmin = async (req, res, next) => {
try {
const accessData = await res.locals.access.can("proxy_hosts:list");
if (!accessData || accessData.permission_visibility !== "all") {
return res.status(403).json({ error: { message: "Forbidden: Super Admin only" } });
}
next();
} catch (err) {
next(err);
}
};
router.use(requireSuperAdmin);
/**
* GET /api/database/tables
* List all tables in the database
*/
router.get("/tables", async (req, res, next) => {
try {
const tables = await internalDatabase.getTables();
res.status(200).json(tables);
} catch (err) {
debug(logger, `GET ${req.path} error: ${err.message}`);
next(err);
}
});
/**
* GET /api/database/tables/:name
* Get table schema and data rows
*/
router.get("/tables/:name", async (req, res, next) => {
try {
const limit = parseInt(req.query.limit, 10) || 50;
const offset = parseInt(req.query.offset, 10) || 0;
const name = req.params.name;
const data = await internalDatabase.getTableData(name, limit, offset);
res.status(200).json(data);
} catch (err) {
debug(logger, `GET ${req.path} error: ${err.message}`);
next(err);
}
});
/**
* 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;

View file

@ -16,6 +16,7 @@ import tokensRoutes from "./tokens.js";
import usersRoutes from "./users.js";
import versionRoutes from "./version.js";
import wireguardRoutes from "./wireguard.js";
import databaseRoutes from "./api/database.js";
const router = express.Router({
caseSensitive: true,
@ -56,6 +57,7 @@ router.use("/nginx/streams", streamsRoutes);
router.use("/nginx/access-lists", accessListsRoutes);
router.use("/nginx/certificates", certificatesHostsRoutes);
router.use("/wireguard", wireguardRoutes);
router.use("/database", databaseRoutes);
/**
* API 404 for all other routes

View file

@ -22,7 +22,8 @@ router.get("/", async (_req, res, next) => {
try {
const knex = db();
const access = res.locals.access;
const ifaces = await internalWireguard.getInterfacesInfo(knex, access);
const accessData = await access.can("proxy_hosts:list");
const ifaces = await internalWireguard.getInterfacesInfo(knex, access, accessData);
res.status(200).json(ifaces);
} catch (err) {
next(err);
@ -37,7 +38,8 @@ router.post("/", async (req, res, next) => {
try {
const knex = db();
const access = res.locals.access;
const iface = await internalWireguard.createInterface(knex, req.body, access);
const accessData = await access.can("proxy_hosts:create");
const iface = await internalWireguard.createInterface(knex, req.body, access, accessData);
await internalAuditLog.add(access, {
action: "created",
object_type: "wireguard-server",
@ -58,7 +60,8 @@ router.put("/:id", async (req, res, next) => {
try {
const knex = db();
const access = res.locals.access;
const iface = await internalWireguard.updateInterface(knex, req.params.id, req.body, access);
const accessData = await access.can("proxy_hosts:update");
const iface = await internalWireguard.updateInterface(knex, req.params.id, req.body, access, accessData);
await internalAuditLog.add(access, {
action: "updated",
object_type: "wireguard-server",
@ -79,7 +82,8 @@ router.delete("/:id", async (req, res, next) => {
try {
const knex = db();
const access = res.locals.access;
const result = await internalWireguard.deleteInterface(knex, req.params.id, access);
const accessData = await access.can("proxy_hosts:delete");
const result = await internalWireguard.deleteInterface(knex, req.params.id, access, accessData);
await internalAuditLog.add(access, {
action: "deleted",
object_type: "wireguard-server",
@ -100,7 +104,8 @@ router.post("/:id/links", async (req, res, next) => {
try {
const knex = db();
const access = res.locals.access;
const result = await internalWireguard.updateInterfaceLinks(knex, req.params.id, req.body.linked_servers || [], access);
const accessData = await access.can("proxy_hosts:update");
const result = await internalWireguard.updateInterfaceLinks(knex, req.params.id, req.body.linked_servers || [], access, accessData);
await internalAuditLog.add(access, {
action: "updated",
object_type: "wireguard-server-links",
@ -121,7 +126,8 @@ router.get("/client", async (_req, res, next) => {
try {
const knex = db();
const access = res.locals.access;
const clients = await internalWireguard.getClients(knex, access);
const accessData = await access.can("proxy_hosts:list");
const clients = await internalWireguard.getClients(knex, access, accessData);
res.status(200).json(clients);
} catch (err) {
next(err);
@ -136,7 +142,8 @@ router.post("/client", async (req, res, next) => {
try {
const knex = db();
const access = res.locals.access;
const client = await internalWireguard.createClient(knex, req.body, access);
const accessData = await access.can("proxy_hosts:create");
const client = await internalWireguard.createClient(knex, req.body, access, accessData);
await internalAuditLog.add(access, {
action: "created",
object_type: "wireguard-client",
@ -157,8 +164,9 @@ router.get("/client/:id", 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 (!access.token.hasScope("admin")) {
if (accessData.permission_visibility !== "all") {
query.andWhere("owner_user_id", access.token.getUserId(1));
}
const client = await query.first();
@ -179,7 +187,8 @@ router.put("/client/:id", async (req, res, next) => {
try {
const knex = db();
const access = res.locals.access;
const client = await internalWireguard.updateClient(knex, req.params.id, req.body, access);
const accessData = await access.can("proxy_hosts:update");
const client = await internalWireguard.updateClient(knex, req.params.id, req.body, access, accessData);
await internalAuditLog.add(access, {
action: "updated",
object_type: "wireguard-client",
@ -200,7 +209,8 @@ router.delete("/client/:id", async (req, res, next) => {
try {
const knex = db();
const access = res.locals.access;
const result = await internalWireguard.deleteClient(knex, req.params.id, access);
const accessData = await access.can("proxy_hosts:delete");
const result = await internalWireguard.deleteClient(knex, req.params.id, access, accessData);
await internalAuditLog.add(access, {
action: "deleted",
object_type: "wireguard-client",
@ -221,7 +231,8 @@ router.post("/client/:id/enable", async (req, res, next) => {
try {
const knex = db();
const access = res.locals.access;
const client = await internalWireguard.toggleClient(knex, req.params.id, true, access);
const accessData = await access.can("proxy_hosts:update");
const client = await internalWireguard.toggleClient(knex, req.params.id, true, access, accessData);
await internalAuditLog.add(access, {
action: "enabled",
object_type: "wireguard-client",
@ -242,7 +253,8 @@ router.post("/client/:id/disable", async (req, res, next) => {
try {
const knex = db();
const access = res.locals.access;
const client = await internalWireguard.toggleClient(knex, req.params.id, false, access);
const accessData = await access.can("proxy_hosts:update");
const client = await internalWireguard.toggleClient(knex, req.params.id, false, access, accessData);
await internalAuditLog.add(access, {
action: "disabled",
object_type: "wireguard-client",
@ -263,8 +275,9 @@ router.get("/client/:id/configuration", 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 (!access.token.hasScope("admin")) {
if (accessData.permission_visibility !== "all") {
query.andWhere("owner_user_id", access.token.getUserId(1));
}
const client = await query.first();
@ -289,8 +302,9 @@ router.get("/client/:id/qrcode.svg", 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 (!access.token.hasScope("admin")) {
if (accessData.permission_visibility !== "all") {
query.andWhere("owner_user_id", access.token.getUserId(1));
}
const client = await query.first();
@ -313,8 +327,9 @@ router.get("/client/:id/configuration.zip", 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 (!access.token.hasScope("admin")) {
if (accessData.permission_visibility !== "all") {
query.andWhere("owner_user_id", access.token.getUserId(1));
}
const client = await query.first();

View file

@ -26,6 +26,7 @@ const RedirectionHosts = lazy(() => import("src/pages/Nginx/RedirectionHosts"));
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"));
function Router() {
const health = useHealth();
@ -73,6 +74,7 @@ function Router() {
<Route path="/nginx/stream" element={<Streams />} />
<Route path="/" element={<Dashboard />} />
<Route path="/wireguard" element={<WireGuard />} />
<Route path="/database" element={<DatabaseManager />} />
</Routes>
</Suspense>
</SiteContainer>

View file

@ -0,0 +1,21 @@
import * as api from "./base";
export async function getTables(): Promise<string[]> {
return await api.get({
url: "/database/tables",
});
}
export async function getTableData(tableName: string, offset: number = 0, limit: number = 50): Promise<any> {
return await api.get({
url: `/database/tables/${tableName}`,
params: { offset, limit },
});
}
export async function executeQuery(query: string): Promise<any> {
return await api.post({
url: "/database/query",
data: { query },
});
}

View file

@ -62,3 +62,4 @@ export * from "./uploadCertificate";
export * from "./validateCertificate";
export * from "./twoFactor";
export * from "./wireguard";
export * from "./database";

View file

@ -102,10 +102,23 @@ const menuItems: MenuItem[] = [
permissionSection: ADMIN,
},
{
to: "/settings",
icon: IconSettings,
label: "settings",
label: "tools",
permissionSection: ADMIN,
items: [
{
to: "/settings",
label: "settings",
permissionSection: ADMIN,
permission: VIEW,
},
{
to: "/database",
label: "database-manager",
permissionSection: ADMIN,
permission: VIEW,
}
],
},
];

View file

@ -0,0 +1,201 @@
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 { useQuery } from "@tanstack/react-query";
import { executeQuery, 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<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({
queryKey: ["database-tables"],
queryFn: getTables,
});
const { data: tableData, isLoading: tableDataLoading } = useQuery({
queryKey: ["database-table", activeTable, 0, 50],
queryFn: () => getTableData(activeTable as string, 0, 50),
enabled: !!activeTable && activeTab === "tables",
});
// Select the first table by default when tables are loaded
if (tables && tables.length > 0 && !activeTable) {
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[], schema?: any[]) => {
if (!data || data.length === 0) return <div className="text-muted p-3">No data</div>;
const columns = schema ? schema.map((s: any) => s.name || s.Field) : Object.keys(data[0]);
return (
<div className="table-responsive">
<Table striped bordered hover size="sm" className="mb-0 text-nowrap">
<thead>
<tr>
{columns.map((col: string) => (
<th key={col}>{col}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row: any, i: number) => (
<tr key={i}>
{columns.map((col: string) => (
<td key={`${i}-${col}`}>
{row[col] === null ? (
<span className="text-muted">NULL</span>
) : typeof row[col] === "object" ? (
JSON.stringify(row[col])
) : (
String(row[col])
)}
</td>
))}
</tr>
))}
</tbody>
</Table>
</div>
);
};
return (
<HasPermission section={ADMIN} permission={VIEW}>
<Container className="my-4" fluid>
<div className="d-flex align-items-center mb-4">
<IconDatabase size={28} className="me-2 text-primary" />
<h2 className="mb-0">Database Manager</h2>
<Badge bg="danger" className="ms-3 rounded-pill text-uppercase" style={{ letterSpacing: "1px" }}>
Super Admin Only
</Badge>
</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>
<Col md={3}>
<Card className="shadow-sm">
<Card.Header className="bg-light fw-bold">Tables</Card.Header>
<div className="list-group list-group-flush" style={{ maxHeight: "70vh", overflowY: "auto" }}>
{tablesLoading && <div className="p-3"><Loading /></div>}
{tables?.map((table: string) => (
<button
key={table}
className={`list-group-item list-group-item-action ${activeTable === table ? "active" : ""}`}
onClick={() => setActiveTable(table)}
>
{table}
</button>
))}
</div>
</Card>
</Col>
<Col md={9}>
<Card className="shadow-sm">
<Card.Header className="bg-light d-flex justify-content-between align-items-center">
<h5 className="mb-0 fw-bold">{activeTable || "Select a table"}</h5>
{tableData && <Badge bg="secondary">{tableData.total} rows</Badge>}
</Card.Header>
<Card.Body className="p-0" style={{ maxHeight: "70vh", overflowY: "auto" }}>
{tableDataLoading ? (
<div className="p-5 d-flex justify-content-center"><Loading /></div>
) : (
tableData && renderTableData(tableData.rows, tableData.schema)
)}
</Card.Body>
</Card>
</Col>
</Row>
)}
{activeTab === "query" && (
<Card className="shadow-sm">
<Card.Header className="bg-light 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) ? renderTableData(queryResult) : (
<pre className="p-3 bg-light rounded border">
{JSON.stringify(queryResult, null, 2)}
</pre>
)}
</div>
)}
</Card.Body>
</Card>
)}
</Container>
</HasPermission>
);
}