mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 01:27:11 +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>
412 lines
15 KiB
JavaScript
412 lines
15 KiB
JavaScript
/**
|
|
* BetterDesk Console — Server Management Service (BETA)
|
|
*
|
|
* Provides backend helpers for the Server Management panel:
|
|
* • Resource snapshots (CPU / memory / disk / load / uptime)
|
|
* • File browser (list, read, write, delete, rename, mkdir)
|
|
* • Service control (systemctl on Linux, sc.exe on Windows)
|
|
* • Audit logging of every privileged operation
|
|
*
|
|
* SECURITY:
|
|
* • All operations require RBAC `server.config` permission.
|
|
* • Service names are validated against a strict regex before being
|
|
* passed to spawned commands.
|
|
* • File operations resolve absolute paths and refuse symlink escapes
|
|
* out of allowlisted root directories when restricted mode is on.
|
|
* • Spawn arguments are passed as argv (never through a shell string)
|
|
* unless explicitly using `shell: true` for whitelisted utilities.
|
|
*
|
|
* STATUS: Beta — interface may evolve.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const os = require('os');
|
|
const fs = require('fs');
|
|
const fsp = require('fs').promises;
|
|
const path = require('path');
|
|
const { spawn, spawnSync } = require('child_process');
|
|
|
|
const SERVICE_NAME_RE = /^[A-Za-z0-9_.@:-]{1,128}$/;
|
|
const FILE_MAX_BYTES = 8 * 1024 * 1024; // 8 MB read/write cap
|
|
const READ_PREVIEW_BYTES = 256 * 1024; // text preview cap for browser
|
|
|
|
const isLinux = process.platform === 'linux';
|
|
const isWindows = process.platform === 'win32';
|
|
|
|
// ─── Resource snapshots ───────────────────────────────────────────────────────
|
|
|
|
let lastCpuSample = null;
|
|
let metricHistory = []; // ring buffer of {ts, cpu, mem, swap}
|
|
const HISTORY_MAX = 120;
|
|
|
|
function readCpuTimes() {
|
|
return os.cpus().map((c) => {
|
|
const t = c.times;
|
|
const total = t.user + t.nice + t.sys + t.idle + t.irq;
|
|
return { idle: t.idle, total };
|
|
});
|
|
}
|
|
|
|
function computeCpuPercent() {
|
|
const cur = readCpuTimes();
|
|
if (!lastCpuSample || lastCpuSample.length !== cur.length) {
|
|
lastCpuSample = cur;
|
|
return 0;
|
|
}
|
|
let totalDiff = 0;
|
|
let idleDiff = 0;
|
|
for (let i = 0; i < cur.length; i++) {
|
|
totalDiff += cur[i].total - lastCpuSample[i].total;
|
|
idleDiff += cur[i].idle - lastCpuSample[i].idle;
|
|
}
|
|
lastCpuSample = cur;
|
|
if (totalDiff <= 0) return 0;
|
|
return Math.max(0, Math.min(100, ((totalDiff - idleDiff) / totalDiff) * 100));
|
|
}
|
|
|
|
function safeExecSync(cmd, args, timeoutMs = 4000) {
|
|
try {
|
|
const r = spawnSync(cmd, args, { encoding: 'utf8', timeout: timeoutMs });
|
|
if (r.error || r.status !== 0) return '';
|
|
return (r.stdout || '').trim();
|
|
} catch (_) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function readSwapInfo() {
|
|
if (isLinux) {
|
|
try {
|
|
const meminfo = fs.readFileSync('/proc/meminfo', 'utf8');
|
|
const swapTotalMatch = /SwapTotal:\s+(\d+)/.exec(meminfo);
|
|
const swapFreeMatch = /SwapFree:\s+(\d+)/.exec(meminfo);
|
|
if (swapTotalMatch) {
|
|
const total = parseInt(swapTotalMatch[1], 10) * 1024;
|
|
const free = swapFreeMatch ? parseInt(swapFreeMatch[1], 10) * 1024 : total;
|
|
return { total, used: total - free, free };
|
|
}
|
|
} catch (_) { /* ignore */ }
|
|
}
|
|
return { total: 0, used: 0, free: 0 };
|
|
}
|
|
|
|
function listDisksSync() {
|
|
const disks = [];
|
|
if (isLinux) {
|
|
const raw = safeExecSync('df', ['-B1', '--output=target,fstype,size,used,avail']);
|
|
raw.split('\n').slice(1).forEach((line) => {
|
|
const parts = line.trim().split(/\s+/);
|
|
if (parts.length < 5) return;
|
|
const mount = parts[0];
|
|
const fstype = parts[1];
|
|
const size = parseInt(parts[2], 10);
|
|
const used = parseInt(parts[3], 10);
|
|
const avail = parseInt(parts[4], 10);
|
|
// skip pseudo filesystems
|
|
if (['tmpfs', 'devtmpfs', 'squashfs', 'overlay', 'proc', 'sysfs', 'cgroup', 'cgroup2', 'devpts', 'debugfs', 'tracefs', 'pstore', 'autofs', 'fusectl'].includes(fstype)) return;
|
|
if (!size || size < 1024 * 1024) return;
|
|
disks.push({ mount, fstype, size, used, avail });
|
|
});
|
|
} else if (isWindows) {
|
|
const raw = safeExecSync('wmic', ['logicaldisk', 'where', 'DriveType=3', 'get', 'DeviceID,FileSystem,FreeSpace,Size', '/format:csv'], 8000);
|
|
raw.split('\n').forEach((line) => {
|
|
const parts = line.trim().split(',');
|
|
if (parts.length >= 5 && parts[1]) {
|
|
const mount = parts[1];
|
|
const fstype = parts[2] || 'NTFS';
|
|
const free = parseInt(parts[3], 10) || 0;
|
|
const size = parseInt(parts[4], 10) || 0;
|
|
if (!size) return;
|
|
disks.push({ mount, fstype, size, used: size - free, avail: free });
|
|
}
|
|
});
|
|
}
|
|
return disks;
|
|
}
|
|
|
|
function readNetIfaces() {
|
|
const ifs = os.networkInterfaces();
|
|
const result = [];
|
|
Object.keys(ifs).forEach((name) => {
|
|
(ifs[name] || []).forEach((addr) => {
|
|
if (addr.internal) return;
|
|
result.push({
|
|
name,
|
|
family: addr.family,
|
|
address: addr.address,
|
|
mac: addr.mac,
|
|
cidr: addr.cidr
|
|
});
|
|
});
|
|
});
|
|
return result;
|
|
}
|
|
|
|
function getResourceSnapshot() {
|
|
const totalMem = os.totalmem();
|
|
const freeMem = os.freemem();
|
|
const cpuPercent = computeCpuPercent();
|
|
const swap = readSwapInfo();
|
|
const sample = {
|
|
ts: Date.now(),
|
|
cpu: Math.round(cpuPercent * 10) / 10,
|
|
mem: {
|
|
total: totalMem,
|
|
free: freeMem,
|
|
used: totalMem - freeMem,
|
|
percent: Math.round(((totalMem - freeMem) / totalMem) * 1000) / 10
|
|
},
|
|
swap
|
|
};
|
|
|
|
metricHistory.push({ ts: sample.ts, cpu: sample.cpu, mem: sample.mem.percent });
|
|
if (metricHistory.length > HISTORY_MAX) metricHistory = metricHistory.slice(-HISTORY_MAX);
|
|
|
|
return {
|
|
...sample,
|
|
load: os.loadavg(),
|
|
uptime: os.uptime(),
|
|
nodeUptime: process.uptime(),
|
|
cpuCount: os.cpus().length,
|
|
cpuModel: (os.cpus()[0] && os.cpus()[0].model) || 'unknown',
|
|
platform: process.platform,
|
|
arch: process.arch,
|
|
hostname: os.hostname(),
|
|
release: os.release(),
|
|
nodeVersion: process.version,
|
|
disks: listDisksSync(),
|
|
network: readNetIfaces(),
|
|
history: metricHistory.slice()
|
|
};
|
|
}
|
|
|
|
// ─── File browser ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Resolve a user-supplied path to an absolute path.
|
|
* Rejects null bytes and empty strings. Does NOT restrict location —
|
|
* callers must enforce auth + audit on top.
|
|
*/
|
|
function resolvePath(p) {
|
|
if (typeof p !== 'string' || !p.length) throw new Error('Path is required');
|
|
if (p.indexOf('\0') !== -1) throw new Error('Invalid path');
|
|
let abs = path.resolve(p);
|
|
return abs;
|
|
}
|
|
|
|
async function listDirectory(dirPath) {
|
|
const abs = resolvePath(dirPath);
|
|
const stat = await fsp.stat(abs);
|
|
if (!stat.isDirectory()) throw new Error('Not a directory');
|
|
const entries = await fsp.readdir(abs, { withFileTypes: true });
|
|
const items = [];
|
|
for (const e of entries) {
|
|
const full = path.join(abs, e.name);
|
|
let st = null;
|
|
try { st = await fsp.lstat(full); } catch (_) { /* permission denied etc. */ }
|
|
items.push({
|
|
name: e.name,
|
|
path: full,
|
|
isDirectory: st ? st.isDirectory() : e.isDirectory(),
|
|
isFile: st ? st.isFile() : e.isFile(),
|
|
isSymlink: st ? st.isSymbolicLink() : false,
|
|
size: st ? st.size : 0,
|
|
mtime: st ? st.mtime.toISOString() : null,
|
|
mode: st ? (st.mode & 0o777) : null,
|
|
uid: st ? st.uid : null,
|
|
gid: st ? st.gid : null
|
|
});
|
|
}
|
|
items.sort((a, b) => {
|
|
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
return { path: abs, parent: path.dirname(abs), items };
|
|
}
|
|
|
|
async function readFilePreview(filePath) {
|
|
const abs = resolvePath(filePath);
|
|
const st = await fsp.stat(abs);
|
|
if (!st.isFile()) throw new Error('Not a regular file');
|
|
if (st.size > FILE_MAX_BYTES) throw new Error('File too large for inline edit (>8 MB)');
|
|
const fh = await fsp.open(abs, 'r');
|
|
try {
|
|
const sliceBytes = Math.min(st.size, READ_PREVIEW_BYTES);
|
|
const buf = Buffer.alloc(sliceBytes);
|
|
await fh.read(buf, 0, sliceBytes, 0);
|
|
// crude binary detection: any null byte in slice
|
|
const isBinary = buf.includes(0);
|
|
let content = '';
|
|
if (!isBinary) {
|
|
content = buf.toString('utf8');
|
|
}
|
|
return {
|
|
path: abs,
|
|
size: st.size,
|
|
mtime: st.mtime.toISOString(),
|
|
mode: st.mode & 0o777,
|
|
isBinary,
|
|
truncated: st.size > sliceBytes,
|
|
content
|
|
};
|
|
} finally {
|
|
await fh.close();
|
|
}
|
|
}
|
|
|
|
async function writeFile(filePath, content) {
|
|
const abs = resolvePath(filePath);
|
|
if (typeof content !== 'string') throw new Error('Content must be a string');
|
|
if (Buffer.byteLength(content, 'utf8') > FILE_MAX_BYTES) {
|
|
throw new Error('Content exceeds 8 MB limit');
|
|
}
|
|
await fsp.writeFile(abs, content, { encoding: 'utf8' });
|
|
return { path: abs, size: Buffer.byteLength(content, 'utf8') };
|
|
}
|
|
|
|
async function deletePath(p) {
|
|
const abs = resolvePath(p);
|
|
if (abs === '/' || /^[A-Za-z]:\\?$/.test(abs)) {
|
|
throw new Error('Refusing to delete filesystem root');
|
|
}
|
|
const st = await fsp.lstat(abs);
|
|
if (st.isDirectory()) {
|
|
await fsp.rm(abs, { recursive: true, force: false });
|
|
} else {
|
|
await fsp.unlink(abs);
|
|
}
|
|
return { path: abs };
|
|
}
|
|
|
|
async function makeDirectory(p) {
|
|
const abs = resolvePath(p);
|
|
await fsp.mkdir(abs, { recursive: false });
|
|
return { path: abs };
|
|
}
|
|
|
|
async function renamePath(oldPath, newPath) {
|
|
const a = resolvePath(oldPath);
|
|
const b = resolvePath(newPath);
|
|
await fsp.rename(a, b);
|
|
return { from: a, to: b };
|
|
}
|
|
|
|
// ─── Services ─────────────────────────────────────────────────────────────────
|
|
|
|
const ALLOWED_SERVICE_ACTIONS_LINUX = new Set(['start', 'stop', 'restart', 'reload', 'enable', 'disable', 'status']);
|
|
const ALLOWED_SERVICE_ACTIONS_WINDOWS = new Set(['start', 'stop', 'restart', 'status']);
|
|
|
|
function listServicesLinux() {
|
|
// List loaded units of type service with state info
|
|
const raw = safeExecSync('systemctl', ['list-units', '--type=service', '--all', '--no-legend', '--no-pager', '--plain'], 8000);
|
|
const services = [];
|
|
raw.split('\n').forEach((line) => {
|
|
const parts = line.trim().split(/\s+/);
|
|
if (parts.length < 4) return;
|
|
const unit = parts[0];
|
|
const load = parts[1];
|
|
const active = parts[2];
|
|
const sub = parts[3];
|
|
const description = parts.slice(4).join(' ');
|
|
if (!unit.endsWith('.service')) return;
|
|
services.push({ name: unit, load, active, sub, description });
|
|
});
|
|
return services;
|
|
}
|
|
|
|
function listServicesWindows() {
|
|
const raw = safeExecSync('powershell', ['-NoProfile', '-Command', "Get-Service | Select-Object Name,Status,DisplayName | ConvertTo-Csv -NoTypeInformation"], 10000);
|
|
const services = [];
|
|
raw.split('\n').slice(1).forEach((line) => {
|
|
const m = line.match(/^"([^"]*)","([^"]*)","([^"]*)"$/);
|
|
if (m) {
|
|
services.push({
|
|
name: m[1],
|
|
active: m[2].toLowerCase() === 'running' ? 'active' : 'inactive',
|
|
sub: m[2].toLowerCase(),
|
|
description: m[3]
|
|
});
|
|
}
|
|
});
|
|
return services;
|
|
}
|
|
|
|
function listServices() {
|
|
if (isLinux) return listServicesLinux();
|
|
if (isWindows) return listServicesWindows();
|
|
return [];
|
|
}
|
|
|
|
function controlServiceLinux(name, action) {
|
|
if (!SERVICE_NAME_RE.test(name)) throw new Error('Invalid service name');
|
|
if (!ALLOWED_SERVICE_ACTIONS_LINUX.has(action)) throw new Error('Action not allowed');
|
|
return new Promise((resolve) => {
|
|
const child = spawn('systemctl', [action, name], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
let stdout = '';
|
|
let stderr = '';
|
|
child.stdout.on('data', (b) => { stdout += b.toString('utf8'); });
|
|
child.stderr.on('data', (b) => { stderr += b.toString('utf8'); });
|
|
const t = setTimeout(() => {
|
|
try { child.kill('SIGKILL'); } catch (_) { /* ignore */ }
|
|
}, 15000);
|
|
child.on('close', (code) => {
|
|
clearTimeout(t);
|
|
resolve({
|
|
name,
|
|
action,
|
|
exitCode: code === null ? -1 : code,
|
|
stdout: stdout.slice(-4096),
|
|
stderr: stderr.slice(-4096)
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function controlServiceWindows(name, action) {
|
|
if (!SERVICE_NAME_RE.test(name)) throw new Error('Invalid service name');
|
|
if (!ALLOWED_SERVICE_ACTIONS_WINDOWS.has(action)) throw new Error('Action not allowed');
|
|
let psAction = action === 'restart' ? 'Restart-Service' : action === 'start' ? 'Start-Service' : action === 'stop' ? 'Stop-Service' : 'Get-Service';
|
|
return new Promise((resolve) => {
|
|
const args = ['-NoProfile', '-Command', `${psAction} -Name '${name.replace(/'/g, "''")}' | Out-String`];
|
|
const child = spawn('powershell', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
let stdout = '';
|
|
let stderr = '';
|
|
child.stdout.on('data', (b) => { stdout += b.toString('utf8'); });
|
|
child.stderr.on('data', (b) => { stderr += b.toString('utf8'); });
|
|
const t = setTimeout(() => {
|
|
try { child.kill(); } catch (_) { /* ignore */ }
|
|
}, 20000);
|
|
child.on('close', (code) => {
|
|
clearTimeout(t);
|
|
resolve({
|
|
name,
|
|
action,
|
|
exitCode: code === null ? -1 : code,
|
|
stdout: stdout.slice(-4096),
|
|
stderr: stderr.slice(-4096)
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function controlService(name, action) {
|
|
if (isLinux) return controlServiceLinux(name, action);
|
|
if (isWindows) return controlServiceWindows(name, action);
|
|
return Promise.reject(new Error('Service control not supported on this platform'));
|
|
}
|
|
|
|
module.exports = {
|
|
SERVICE_NAME_RE,
|
|
FILE_MAX_BYTES,
|
|
getResourceSnapshot,
|
|
listDirectory,
|
|
readFilePreview,
|
|
writeFile,
|
|
deletePath,
|
|
makeDirectory,
|
|
renamePath,
|
|
listServices,
|
|
controlService
|
|
};
|