Files
BetterDesk/web-nodejs/services/advancedConfigService.js
T
UNITRONIX 60d0227445 fix(settings): show Advanced tab file list and expand config catalog
Utils.api already unwraps API data, so the file list was always empty.
Add fixed paths for session secret, audit log, build.env, and Docker files.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 02:59:43 +02:00

340 lines
9.2 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
};
}
/**
* 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,
restartForFile,
getDefinition
};