/** * 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 };