mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 09:35:39 +00:00
a8d3259d43
- Implemented server management service providing resource snapshots, file browser, service control, and audit logging. - Added terminal proxy for WebSocket-backed PTY, allowing browser-based shell access with user authentication and role-based access control. - Created server management view with tabs for overview, terminal, file management, and services, including UI elements for displaying system metrics and managing files/services. Co-authored-by: Copilot <copilot@github.com>
198 lines
7.8 KiB
JavaScript
198 lines
7.8 KiB
JavaScript
/**
|
|
* BetterDesk Console — Server Management Routes (BETA)
|
|
*
|
|
* Page:
|
|
* GET /server-management
|
|
*
|
|
* REST API (all require server.config permission):
|
|
* GET /api/server-management/info
|
|
* GET /api/server-management/resources
|
|
* GET /api/server-management/files?path=<abs>
|
|
* GET /api/server-management/files/read?path=<abs>
|
|
* POST /api/server-management/files/write {path, content}
|
|
* POST /api/server-management/files/mkdir {path}
|
|
* POST /api/server-management/files/rename {from, to}
|
|
* POST /api/server-management/files/delete {path}
|
|
* GET /api/server-management/services
|
|
* POST /api/server-management/services/:name/:action
|
|
*
|
|
* The terminal endpoint is WebSocket-based and lives on the HTTP upgrade
|
|
* path /ws/server-management/terminal — see services/serverTerminalProxy.js.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const express = require('express');
|
|
const router = express.Router();
|
|
|
|
const { requireAuth, requirePermission } = require('../middleware/auth');
|
|
const sm = require('../services/serverManagement');
|
|
const { isPtyAvailable, activeSessionCount } = require('../services/serverTerminalProxy');
|
|
const db = require('../services/database');
|
|
|
|
const REQUIRED_PERMISSION = 'server.config';
|
|
|
|
// All endpoints below require auth + server.config permission.
|
|
const auth = [requireAuth, requirePermission(REQUIRED_PERMISSION)];
|
|
|
|
function getClientIp(req) {
|
|
const fwd = req.headers['x-forwarded-for'];
|
|
if (fwd) return String(fwd).split(',')[0].trim();
|
|
return req.ip || (req.socket && req.socket.remoteAddress) || '';
|
|
}
|
|
|
|
async function audit(req, action, details) {
|
|
try {
|
|
if (db && typeof db.logAction === 'function') {
|
|
await db.logAction(req.session && req.session.userId, action, details, getClientIp(req));
|
|
}
|
|
} catch (_) { /* never fail requests due to audit */ }
|
|
}
|
|
|
|
function sendError(res, status, message) {
|
|
res.status(status).json({ success: false, error: message });
|
|
}
|
|
|
|
// ─── Page ─────────────────────────────────────────────────────────────────────
|
|
|
|
router.get('/server-management', requireAuth, requirePermission(REQUIRED_PERMISSION), (req, res) => {
|
|
const validTabs = ['overview', 'terminal', 'files', 'services'];
|
|
const requested = String(req.query.tab || 'overview').toLowerCase();
|
|
const currentTab = validTabs.includes(requested) ? requested : 'overview';
|
|
res.render('server-management', {
|
|
title: req.t('server_mgmt.title'),
|
|
activePage: 'server-management',
|
|
currentTab
|
|
});
|
|
});
|
|
|
|
// ─── Info / capabilities ──────────────────────────────────────────────────────
|
|
|
|
router.get('/api/server-management/info', auth, (req, res) => {
|
|
res.json({
|
|
success: true,
|
|
platform: process.platform,
|
|
arch: process.arch,
|
|
nodeVersion: process.version,
|
|
pid: process.pid,
|
|
ptyAvailable: isPtyAvailable(),
|
|
terminalSessions: activeSessionCount(),
|
|
beta: true
|
|
});
|
|
});
|
|
|
|
// ─── Resources ────────────────────────────────────────────────────────────────
|
|
|
|
router.get('/api/server-management/resources', auth, (req, res) => {
|
|
try {
|
|
res.json({ success: true, snapshot: sm.getResourceSnapshot() });
|
|
} catch (err) {
|
|
sendError(res, 500, err.message || 'snapshot_failed');
|
|
}
|
|
});
|
|
|
|
// ─── Files ────────────────────────────────────────────────────────────────────
|
|
|
|
router.get('/api/server-management/files', auth, async (req, res) => {
|
|
const p = String(req.query.path || '/');
|
|
try {
|
|
const data = await sm.listDirectory(p);
|
|
res.json({ success: true, ...data, cwd: data.path, entries: data.items || [] });
|
|
} catch (err) {
|
|
sendError(res, 400, err.message || 'list_failed');
|
|
}
|
|
});
|
|
|
|
router.get('/api/server-management/files/read', auth, async (req, res) => {
|
|
const p = String(req.query.path || '');
|
|
try {
|
|
const data = await sm.readFilePreview(p);
|
|
res.json({ success: true, ...data });
|
|
} catch (err) {
|
|
sendError(res, 400, err.message || 'read_failed');
|
|
}
|
|
});
|
|
|
|
router.post('/api/server-management/files/write', auth, async (req, res) => {
|
|
const { path: p, content } = req.body || {};
|
|
if (typeof p !== 'string' || typeof content !== 'string') {
|
|
return sendError(res, 400, 'path_and_content_required');
|
|
}
|
|
try {
|
|
const result = await sm.writeFile(p, content);
|
|
await audit(req, 'server_file_write', `path=${result.path} bytes=${result.size}`);
|
|
res.json({ success: true, ...result });
|
|
} catch (err) {
|
|
sendError(res, 400, err.message || 'write_failed');
|
|
}
|
|
});
|
|
|
|
router.post('/api/server-management/files/mkdir', auth, async (req, res) => {
|
|
const p = (req.body && req.body.path) || '';
|
|
if (typeof p !== 'string' || !p.length) return sendError(res, 400, 'path_required');
|
|
try {
|
|
const result = await sm.makeDirectory(p);
|
|
await audit(req, 'server_file_mkdir', `path=${result.path}`);
|
|
res.json({ success: true, ...result });
|
|
} catch (err) {
|
|
sendError(res, 400, err.message || 'mkdir_failed');
|
|
}
|
|
});
|
|
|
|
router.post('/api/server-management/files/rename', auth, async (req, res) => {
|
|
const { from, to } = req.body || {};
|
|
if (typeof from !== 'string' || typeof to !== 'string') {
|
|
return sendError(res, 400, 'from_and_to_required');
|
|
}
|
|
try {
|
|
const result = await sm.renamePath(from, to);
|
|
await audit(req, 'server_file_rename', `from=${result.from} to=${result.to}`);
|
|
res.json({ success: true, ...result });
|
|
} catch (err) {
|
|
sendError(res, 400, err.message || 'rename_failed');
|
|
}
|
|
});
|
|
|
|
router.post('/api/server-management/files/delete', auth, async (req, res) => {
|
|
const p = (req.body && req.body.path) || '';
|
|
if (typeof p !== 'string' || !p.length) return sendError(res, 400, 'path_required');
|
|
try {
|
|
const result = await sm.deletePath(p);
|
|
await audit(req, 'server_file_delete', `path=${result.path}`);
|
|
res.json({ success: true, ...result });
|
|
} catch (err) {
|
|
sendError(res, 400, err.message || 'delete_failed');
|
|
}
|
|
});
|
|
|
|
// ─── Services ─────────────────────────────────────────────────────────────────
|
|
|
|
router.get('/api/server-management/services', auth, (req, res) => {
|
|
try {
|
|
const services = sm.listServices().map((svc) => ({
|
|
...svc,
|
|
state: svc.state || svc.active || svc.status || 'unknown',
|
|
status: svc.status || svc.sub || svc.active || 'unknown'
|
|
}));
|
|
res.json({ success: true, platform: process.platform, services });
|
|
} catch (err) {
|
|
sendError(res, 500, err.message || 'list_failed');
|
|
}
|
|
});
|
|
|
|
router.post('/api/server-management/services/:name/:action', auth, async (req, res) => {
|
|
const { name, action } = req.params;
|
|
if (!sm.SERVICE_NAME_RE.test(name)) {
|
|
return sendError(res, 400, 'invalid_service_name');
|
|
}
|
|
try {
|
|
const result = await sm.controlService(name, action);
|
|
await audit(req, 'server_service_control', `name=${name} action=${action} exit=${result.exitCode}`);
|
|
res.json({ success: true, ...result });
|
|
} catch (err) {
|
|
sendError(res, 400, err.message || 'control_failed');
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|