'use strict'; /** * Linux post-update hook: dedicated console system user (audit H-7). * Idempotent — safe to run on every update/repair. * * Usage: * node scripts/linux-ensure-console-user.js * (also loaded from services/updateService.js after panel updates) */ const fs = require('fs'); const path = require('path'); const { execFileSync, execSync } = require('child_process'); const config = require('../config/config'); const { readConsoleEnvPortSettings, consoleEnvUsesPrivilegedPorts, ensureBindCapabilityInServiceUnit, } = require('../lib/privilegedPorts'); const { resolveDeployScriptPath } = require('../lib/linuxServerBinaryDeploy'); const SVC_USER = 'betterdesk'; const CONSOLE_PATH = path.join(__dirname, '..'); const RUSTDESK_PATH = config.keysPath || config.rustdeskDir || '/opt/rustdesk'; const CONSOLE_SERVICE = 'betterdesk-console'; const SERVER_SERVICE = 'betterdesk-server'; const UPDATE_SUDOERS_PATH = '/etc/sudoers.d/betterdesk-console-updates'; const UPDATE_SUDOERS_MARKER = '# Managed by BetterDesk linux-ensure-console-user.js'; function resolveSystemctlPath() { for (const candidate of ['/usr/bin/systemctl', '/bin/systemctl']) { if (fs.existsSync(candidate)) return candidate; } return '/usr/bin/systemctl'; } function resolveJournalctlPath() { for (const candidate of ['/usr/bin/journalctl', '/bin/journalctl']) { if (fs.existsSync(candidate)) return candidate; } return '/usr/bin/journalctl'; } function buildUpdateSudoersContent() { const systemctl = resolveSystemctlPath(); const journalctl = resolveJournalctlPath(); const deployScript = resolveDeployScriptPath(CONSOLE_PATH); return [ UPDATE_SUDOERS_MARKER, `${SVC_USER} ALL=(root) NOPASSWD: ${systemctl}`, `${SVC_USER} ALL=(root) NOPASSWD: ${journalctl}`, `${SVC_USER} ALL=(root) NOPASSWD: ${deployScript}`, '', ].join('\n'); } function ensureDeployScriptExecutable() { const deployScript = resolveDeployScriptPath(CONSOLE_PATH); if (!fs.existsSync(deployScript)) { return { changed: false, reason: 'deploy script not present yet' }; } try { const mode = fs.statSync(deployScript).mode & 0o777; if ((mode & 0o111) === 0) { fs.chmodSync(deployScript, 0o755); return { changed: true, path: deployScript }; } return { changed: false, path: deployScript }; } catch (err) { return { changed: false, error: err.message || String(err) }; } } /** Install passwordless sudo for panel service restarts (Linux updates). */ function ensureConsoleUpdateSudoers() { if (!isRoot() && !canUseSudo()) { return { changed: false, skipped: true, reason: 'no root/sudo for sudoers install' }; } const desired = buildUpdateSudoersContent(); let existing = ''; try { if (fs.existsSync(UPDATE_SUDOERS_PATH)) { existing = isRoot() ? fs.readFileSync(UPDATE_SUDOERS_PATH, 'utf8') : runPrivilegedArgv('cat', [UPDATE_SUDOERS_PATH]); } } catch (_) { existing = ''; } if (existing === desired) { return { changed: false, reason: 'sudoers already current' }; } const tmp = `/tmp/betterdesk-console-updates.${Date.now()}.sudoers`; fs.writeFileSync(tmp, desired, 'utf8'); runPrivilegedArgv('visudo', ['-cf', tmp]); if (isRoot()) { fs.copyFileSync(tmp, UPDATE_SUDOERS_PATH); fs.chmodSync(UPDATE_SUDOERS_PATH, 0o440); } else { runPrivilegedArgv('cp', [tmp, UPDATE_SUDOERS_PATH]); runPrivilegedArgv('chmod', ['440', UPDATE_SUDOERS_PATH]); } try { fs.unlinkSync(tmp); } catch (_) { /* ok */ } return { changed: true, path: UPDATE_SUDOERS_PATH }; } function isRoot() { return typeof process.getuid === 'function' && process.getuid() === 0; } function canUseSudo() { if (isRoot()) return true; try { execFileSync('sudo', ['-n', resolveSystemctlPath(), '--version'], { stdio: 'pipe', timeout: 5000 }); return true; } catch (_) { return false; } } function runPrivilegedArgv(binary, args, opts = {}) { if (!isRoot() && !canUseSudo()) { throw new Error('Privileged command requires root or passwordless sudo'); } const runOpts = { encoding: opts.encoding || 'utf8', stdio: opts.stdio || 'pipe', timeout: opts.timeout || 30000, }; if (isRoot()) { return execFileSync(binary, args, runOpts); } return execFileSync('sudo', ['-n', binary, ...args], runOpts); } function readServiceFile() { try { const fragment = runPrivilegedArgv(resolveSystemctlPath(), [ 'show', CONSOLE_SERVICE, '--property=FragmentPath', '--value', ]).trim(); const servicePath = fragment || `/etc/systemd/system/${CONSOLE_SERVICE}.service`; if (!fs.existsSync(servicePath)) return { servicePath: null, content: '' }; const content = isRoot() ? fs.readFileSync(servicePath, 'utf8') : runPrivilegedArgv('cat', [servicePath]); return { servicePath, content }; } catch (_) { return { servicePath: null, content: '' }; } } function writeServiceFile(servicePath, content) { if (isRoot()) { fs.writeFileSync(servicePath, content, 'utf8'); } else { const tmp = `/tmp/${CONSOLE_SERVICE}.${Date.now()}.service`; fs.writeFileSync(tmp, content, 'utf8'); runPrivilegedArgv('cp', [tmp, servicePath]); try { fs.unlinkSync(tmp); } catch (_) { /* ok */ } } } function userExists(name) { try { execFileSync('getent', ['passwd', name], { stdio: 'pipe', timeout: 5000 }); return true; } catch (_) { return false; } } function ensureSystemUser() { if (userExists(SVC_USER)) { return; } if (!isRoot() && !canUseSudo()) { throw new Error(`System user ${SVC_USER} is missing and console cannot create it without root/sudo`); } runPrivilegedArgv('useradd', [ '-r', '-s', '/usr/sbin/nologin', '-d', '/var/lib/betterdesk', '-c', 'BetterDesk web console', SVC_USER, ]); runPrivilegedArgv('mkdir', ['-p', '/var/lib/betterdesk']); } function ensureDataDir() { const dataDir = path.join(CONSOLE_PATH, 'data'); fs.mkdirSync(dataDir, { recursive: true }); return dataDir; } /** * @returns {{ ok: boolean, error?: string, skipped?: boolean }} */ function fixSharedPermissions() { if (!isRoot() && !canUseSudo()) { return { ok: false, skipped: true, error: 'no root/sudo for permission sync' }; } try { runPrivilegedArgv('mkdir', ['-p', '/var/lib/betterdesk']); runPrivilegedArgv('mkdir', ['-p', path.join(CONSOLE_PATH, 'data')]); runPrivilegedArgv('mkdir', ['-p', path.join(CONSOLE_PATH, 'data', 'go-cache', 'mod')]); runPrivilegedArgv('mkdir', ['-p', path.join(CONSOLE_PATH, 'data', 'go-cache', 'build')]); runPrivilegedArgv('mkdir', ['-p', '/var/lib/betterdesk/.npm']); runPrivilegedArgv('chown', ['-R', `${SVC_USER}:${SVC_USER}`, '/var/lib/betterdesk']); runPrivilegedArgv('chown', ['-R', `${SVC_USER}:${SVC_USER}`, CONSOLE_PATH]); const shared = [ path.join(RUSTDESK_PATH, '.api_key'), path.join(RUSTDESK_PATH, 'id_ed25519.pub'), path.join(RUSTDESK_PATH, 'db_v2.sqlite3'), path.join(RUSTDESK_PATH, 'db_v2.sqlite3-wal'), path.join(RUSTDESK_PATH, 'db_v2.sqlite3-shm'), path.join(RUSTDESK_PATH, 'ssl', 'betterdesk.crt'), path.join(RUSTDESK_PATH, 'ssl', 'betterdesk.key'), ]; for (const filePath of shared) { if (!fs.existsSync(filePath)) continue; runPrivilegedArgv('chown', [`root:${SVC_USER}`, filePath]); runPrivilegedArgv('chmod', ['g+r', filePath]); if (filePath.includes('db_v2') || filePath.endsWith('.api_key') || filePath.includes('/ssl/')) { runPrivilegedArgv('chmod', ['g+rw', filePath]); } else { runPrivilegedArgv('chmod', ['640', filePath]); } } return { ok: true }; } catch (err) { return { ok: false, error: err.message || String(err) }; } } /** Verify the console service user can write the data directory. */ function verifyConsoleUserAccess() { if (!userExists(SVC_USER)) { return { ok: false, error: `system user ${SVC_USER} does not exist` }; } const dataDir = path.join(CONSOLE_PATH, 'data'); try { if (typeof process.getuid === 'function' && process.getuid() === 0) { execFileSync('runuser', ['-u', SVC_USER, '--', 'test', '-w', dataDir], { stdio: 'pipe', timeout: 5000, }); } else if (typeof process.getuid === 'function') { const uid = execFileSync('id', ['-u', SVC_USER], { encoding: 'utf8', stdio: 'pipe', timeout: 5000, }).trim(); if (String(process.getuid()) === uid) { fs.accessSync(dataDir, fs.constants.W_OK); } else { return { ok: false, error: `cannot verify ${SVC_USER} access from uid ${process.getuid()}` }; } } else { fs.accessSync(dataDir, fs.constants.W_OK); } return { ok: true }; } catch (_) { return { ok: false, error: `${SVC_USER} cannot write ${dataDir}` }; } } function patchServiceUserLine() { const { servicePath, content } = readServiceFile(); if (!servicePath || !content) { return { changed: false, reason: 'service unit not found' }; } const envPorts = readConsoleEnvPortSettings(path.join(CONSOLE_PATH, '.env')); const needsBindCapability = consoleEnvUsesPrivilegedPorts(envPorts); let updated = content; let changed = false; if (/^User=root/m.test(updated)) { updated = updated.replace(/^User=root/m, `User=${SVC_USER}`); changed = true; } if (needsBindCapability) { const capPatch = ensureBindCapabilityInServiceUnit(updated); updated = capPatch.content; changed = changed || capPatch.changed; } if (!changed) { if (!/^User=root/m.test(content)) { return { changed: false, reason: 'User is not root (already patched or custom)' }; } return { changed: false, reason: 'service unit already current' }; } writeServiceFile(servicePath, updated); runPrivilegedArgv(resolveSystemctlPath(), ['daemon-reload']); const result = { changed: true, user: SVC_USER, servicePath }; if (needsBindCapability) { result.bindCapability = true; } return result; } /** * @returns {{ changed: boolean, user?: string, changes: string[], error?: string, skipped?: boolean }} */ function ensureLinuxConsoleServiceUser() { const result = { changed: false, changes: [], permissionsOk: false }; if (process.platform !== 'linux') { return { ...result, skipped: true, reason: 'not-linux' }; } try { ensureDataDir(); ensureSystemUser(); const privileged = isRoot() || canUseSudo(); let perm = { ok: false, skipped: !privileged }; if (privileged) { perm = fixSharedPermissions(); const deployScript = ensureDeployScriptExecutable(); if (deployScript.changed) { result.changes.push('server binary deploy helper marked executable'); } else if (deployScript.error) { result.changes.push(`deploy helper chmod skipped: ${deployScript.error}`); } const sudoers = ensureConsoleUpdateSudoers(); if (sudoers.changed) { result.changes.push('passwordless sudo for panel updates (services + server binary deploy)'); } else if (sudoers.reason) { result.changes.push(sudoers.reason); } if (perm.ok) { result.permissionsOk = true; result.changes.push('permissions synced for betterdesk console user'); } else if (perm.error) { result.error = perm.error; } } else if (userExists(SVC_USER)) { const access = verifyConsoleUserAccess(); result.permissionsOk = access.ok; if (access.ok) { result.changes.push(`${SVC_USER} user present; data dir writable`); } else { result.changes.push(`${SVC_USER} user present; permission sync skipped (no sudo)`); result.error = access.error || 'permission sync requires root/sudo'; } } else { result.error = `System user ${SVC_USER} is missing and cannot be created without root/sudo`; } const access = verifyConsoleUserAccess(); if (access.ok) result.permissionsOk = true; // Only switch User=root → betterdesk when permissions are verified. if (result.permissionsOk && privileged) { const patch = patchServiceUserLine(); if (patch.changed) { result.changed = true; result.user = patch.user; result.changes.push(`console service User=${patch.user}`); if (patch.bindCapability) { result.changes.push('CAP_NET_BIND_SERVICE added for privileged HTTPS/HTTP ports'); } } else if (patch.reason) { result.changes.push(patch.reason); } } else if (!result.permissionsOk) { result.changes.push('skipped service User= patch until permissions are fixed'); } } catch (err) { result.error = err.message || String(err); } return result; } if (require.main === module) { const out = ensureLinuxConsoleServiceUser(); console.log(JSON.stringify(out, null, 2)); process.exit(out.error ? 1 : 0); } module.exports = { ensureLinuxConsoleServiceUser, ensureDataDir, fixSharedPermissions, verifyConsoleUserAccess, buildUpdateSudoersContent, ensureDeployScriptExecutable, resolveSystemctlPath, patchServiceUserLine, SVC_USER, };