Files
BetterDesk/web-nodejs/services/advancedConfigService.js
UNITRONIX 47e3d2ce47 feat(console): coordinate service restarts and login metadata
Coordinate restart-required settings changes through authenticated, reversible transactions and synchronize legacy user last-login timestamps. Include the completed locale updates and restart-flow UI/service changes.

Refs #426

Thanks: INSOLVE (Honorary); Marco Jakobs (@jacotec); MyNameisStitch (@MyNameisStitch); Redspin (@playerumpknow)
2026-09-18 18:26:42 +02:00

364 lines
10 KiB
JavaScript

/**
* BetterDesk Console — Advanced configuration file editor
*
* Exposes a curated allowlist of server config files for in-panel editing.
* All paths are resolved at runtime; callers must use file ids, never raw paths.
*/
'use strict';
const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
const config = require('../config/config');
const updateService = require('./updateService');
const CONSOLE_ROOT = path.join(__dirname, '..');
const REPO_ROOT = path.join(CONSOLE_ROOT, '..');
const MAX_BYTES = 512 * 1024;
/** Which components to restart after editing a given file (console / server). */
const FILE_RESTART_COMPONENTS = {
'console-env': ['console'],
'console-env-local': ['console'],
'console-session-secret': ['console'],
'go-blocklist': ['server'],
'go-audit-log': ['server'],
'systemd-console': ['console'],
'systemd-server': ['server'],
'docker-supervisord': ['console']
};
const SYSTEMD_FILE_IDS = new Set(['systemd-console', 'systemd-server']);
function resolveAuditLogPath() {
const fromEnv = process.env.AUDIT_LOG_FILE;
if (fromEnv && path.isAbsolute(fromEnv)) return fromEnv;
const candidates = [
'/var/log/betterdesk/audit.jsonl',
path.join(config.keysPath, 'audit.jsonl'),
path.join(config.rustdeskDir || config.keysPath, 'audit.jsonl')
];
for (const p of candidates) {
try {
if (fs.existsSync(p)) return p;
} catch (_) { /* ignore */ }
}
return '/var/log/betterdesk/audit.jsonl';
}
/**
* Fixed catalog of BetterDesk configuration files (always listed in the UI).
* Paths match production installers (betterdesk.sh / betterdesk.ps1) and repo layout.
*/
const FILE_CATALOG = [
{
id: 'console-env',
path: () => path.join(CONSOLE_ROOT, '.env'),
category: 'console',
requiresRestart: 'console'
},
{
id: 'console-env-local',
path: () => path.join(CONSOLE_ROOT, '.env.local'),
category: 'console',
requiresRestart: 'console',
canCreate: true
},
{
id: 'console-session-secret',
path: () => path.join(config.dataDir, '.session_secret'),
category: 'console',
requiresRestart: 'console',
canCreate: true
},
{
id: 'go-blocklist',
path: () => path.join(config.keysPath, 'blocklist.txt'),
category: 'goserver',
requiresRestart: 'goserver',
canCreate: true
},
{
id: 'go-audit-log',
path: () => resolveAuditLogPath(),
category: 'goserver',
requiresRestart: 'goserver',
canCreate: true
},
{
id: 'systemd-console',
path: () => '/etc/systemd/system/betterdesk-console.service',
category: 'system',
requiresRestart: 'systemd',
platform: 'linux'
},
{
id: 'systemd-server',
path: () => '/etc/systemd/system/betterdesk-server.service',
category: 'system',
requiresRestart: 'systemd',
platform: 'linux'
},
{
id: 'build-env',
path: () => process.env.BETTERDESK_BUILD_ENV_FILE || '/etc/betterdesk/build.env',
category: 'system',
requiresRestart: 'none',
canCreate: true,
platform: 'linux'
},
{
id: 'docker-supervisord',
path: () => path.join(REPO_ROOT, 'docker', 'supervisord.conf'),
category: 'console',
requiresRestart: 'console'
},
{
id: 'docker-compose',
path: () => path.join(REPO_ROOT, 'docker-compose.yml'),
category: 'console',
requiresRestart: 'console'
}
];
function getDefinition(id) {
if (typeof id !== 'string' || !id.length) return null;
return FILE_CATALOG.find((f) => f.id === id) || null;
}
function isPlatformAllowed(def) {
if (!def.platform) return true;
return process.platform === def.platform;
}
async function statEntry(def) {
const abs = def.path();
try {
const st = await fsp.stat(abs);
return {
exists: true,
size: st.size,
mtime: st.mtime.toISOString(),
readable: true,
writable: await canWrite(abs, st)
};
} catch (err) {
if (err.code === 'ENOENT') {
return {
exists: false,
size: 0,
mtime: null,
readable: false,
writable: !!def.canCreate
};
}
return {
exists: false,
size: 0,
mtime: null,
readable: false,
writable: false,
error: err.message
};
}
}
async function canWrite(abs, st) {
try {
await fsp.access(abs, fs.constants.W_OK);
return true;
} catch (_) {
if (st && st.isFile()) return false;
const dir = path.dirname(abs);
try {
await fsp.access(dir, fs.constants.W_OK);
return true;
} catch (_) {
return false;
}
}
}
/**
* List catalog entries with filesystem metadata.
*/
async function listFiles() {
const items = [];
for (const def of FILE_CATALOG) {
if (!isPlatformAllowed(def)) continue;
const abs = def.path();
const meta = await statEntry(def);
items.push({
id: def.id,
path: abs,
category: def.category,
requiresRestart: def.requiresRestart,
canCreate: !!def.canCreate,
...meta
});
}
return items;
}
async function readFile(id) {
const def = getDefinition(id);
if (!def || !isPlatformAllowed(def)) {
throw new Error('unknown_file');
}
const abs = path.resolve(def.path());
let st;
try {
st = await fsp.stat(abs);
} catch (err) {
if (err.code === 'ENOENT') throw new Error('not_found');
throw err;
}
if (!st.isFile()) throw new Error('not_a_file');
if (st.size > MAX_BYTES) throw new Error('file_too_large');
const buf = await fsp.readFile(abs);
if (buf.includes(0)) throw new Error('binary_file');
return {
id: def.id,
path: abs,
size: st.size,
mtime: st.mtime.toISOString(),
requiresRestart: def.requiresRestart,
content: buf.toString('utf8')
};
}
async function writeBackup(abs) {
try {
if (!fs.existsSync(abs)) return null;
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = `${abs}.bak.${stamp}`;
await fsp.copyFile(abs, backupPath);
return backupPath;
} catch (_) {
return null;
}
}
async function writeFile(id, content) {
const def = getDefinition(id);
if (!def || !isPlatformAllowed(def)) {
throw new Error('unknown_file');
}
if (typeof content !== 'string') throw new Error('invalid_content');
const byteLen = Buffer.byteLength(content, 'utf8');
if (byteLen > MAX_BYTES) throw new Error('file_too_large');
const abs = path.resolve(def.path());
let exists = false;
try {
const st = await fsp.stat(abs);
if (!st.isFile()) throw new Error('not_a_file');
exists = true;
if (st.size > MAX_BYTES) throw new Error('file_too_large');
} catch (err) {
if (err.code === 'ENOENT') {
if (!def.canCreate) throw new Error('not_found');
} else {
throw err;
}
}
if (!exists && !def.canCreate) throw new Error('not_found');
const access = await statEntry(def);
if (!access.writable) throw new Error('not_writable');
const backupPath = exists ? await writeBackup(abs) : null;
const parent = path.dirname(abs);
await fsp.mkdir(parent, { recursive: true });
await fsp.writeFile(abs, content, { encoding: 'utf8', mode: 0o600 });
const st = await fsp.stat(abs);
return {
id: def.id,
path: abs,
size: st.size,
mtime: st.mtime.toISOString(),
backupPath,
requiresRestart: def.requiresRestart,
created: !exists
};
}
/**
* Restore the backup created by writeFile(). The backup name is accepted only
* when it belongs to the catalog entry and uses the generated backup suffix.
*/
async function restoreBackup(id, backupPath, created = false) {
const def = getDefinition(id);
if (!def || !isPlatformAllowed(def)) throw new Error('unknown_file');
if (created) {
try { await fsp.unlink(path.resolve(def.path())); } catch (err) {
if (err.code !== 'ENOENT') throw err;
}
return { id, path: path.resolve(def.path()), removed: true };
}
if (typeof backupPath !== 'string' || !backupPath.startsWith(`${path.resolve(def.path())}.bak.`)) {
throw new Error('invalid_backup');
}
const target = path.resolve(def.path());
const backup = path.resolve(backupPath);
if (backup !== backupPath || !fs.existsSync(backup)) throw new Error('invalid_backup');
await fsp.copyFile(backup, target);
return { id, path: target };
}
/**
* Restart services related to a config file. Systemd unit edits run
* daemon-reload first on Linux.
*/
function restartForFile(id) {
const def = getDefinition(id);
if (!def || !isPlatformAllowed(def)) {
throw new Error('unknown_file');
}
const components = FILE_RESTART_COMPONENTS[id];
if (!components || !components.length) {
throw new Error('no_restart');
}
const result = {
fileId: id,
daemonReload: null,
restarts: [],
needsConsolePoll: false
};
if (SYSTEMD_FILE_IDS.has(id) && process.platform === 'linux') {
result.daemonReload = updateService.daemonReload();
if (!result.daemonReload.success) {
return result;
}
}
for (const key of components) {
const comp = updateService.COMPONENTS[key];
if (!comp || !comp.service) continue;
const r = updateService.restartService(comp.service);
result.restarts.push({ component: key, service: comp.service, ...r });
if (key === 'console') result.needsConsolePoll = true;
}
return result;
}
module.exports = {
MAX_BYTES,
listFiles,
readFile,
writeFile,
restoreBackup,
restartForFile,
getDefinition
};