mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-10-26 14:30:19 +00:00
30585a8bc7
Panel runs as betterdesk and cannot rewrite /etc/systemd/system/*.service. Write ALLOW_SHARED_NAT_INITIATOR and related flags through the fixed root broker into a service.d drop-in so Settings no longer fails with EACCES. Thanks: INSOLVE (Honorary); Marco Jakobs (@jacotec); MyNameisStitch (@MyNameisStitch); Redspin (@playerumpknow)
487 lines
16 KiB
JavaScript
487 lines
16 KiB
JavaScript
/**
|
|
* BetterDesk Console — server P2P / relay connection mode configuration.
|
|
*
|
|
* Persists global connection strategy via systemd Environment= lines or
|
|
* docker-compose.yml server environment variables.
|
|
*/
|
|
|
|
'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 {
|
|
canUsePrivilegedUpdate,
|
|
invokePrivilegedUpdate,
|
|
} = require('../lib/privilegedUpdateHelper');
|
|
|
|
const CONSOLE_ROOT = path.join(__dirname, '..');
|
|
const REPO_ROOT = path.join(CONSOLE_ROOT, '..');
|
|
const SYSTEMD_SERVER_UNIT = '/etc/systemd/system/betterdesk-server.service';
|
|
/** Drop-in written by privileged broker — overrides unit Environment= lines. */
|
|
const SYSTEMD_CONNECTION_DROPIN = '/etc/systemd/system/betterdesk-server.service.d/50-betterdesk-connection.conf';
|
|
const DOCKER_COMPOSE_PATH = path.join(REPO_ROOT, 'docker-compose.yml');
|
|
|
|
const MANAGED_ENV_KEYS = [
|
|
'P2P_FIRST',
|
|
'ALWAYS_USE_RELAY',
|
|
'P2P_FALLBACK_MS',
|
|
'SAME_NAT_RELAY',
|
|
'ALLOW_SHARED_NAT_INITIATOR',
|
|
'LOGGED_IN_ONLY_INITIATOR',
|
|
'OPERATOR_ONLY_OUTBOUND'
|
|
];
|
|
|
|
const DEFAULTS = {
|
|
mode: 'p2p_first',
|
|
p2p_fallback_ms: 2000,
|
|
same_nat_relay: true,
|
|
allow_shared_nat_initiator: false,
|
|
logged_in_only_initiator: false,
|
|
operator_only_outbound: false
|
|
};
|
|
|
|
function isDockerRuntime() {
|
|
return fs.existsSync('/.dockerenv') || process.env.DOCKER === 'true';
|
|
}
|
|
|
|
function systemdUnitExists() {
|
|
try {
|
|
return fs.existsSync(SYSTEMD_SERVER_UNIT);
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function dockerComposeExists() {
|
|
try {
|
|
return fs.existsSync(DOCKER_COMPOSE_PATH);
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detect where connection-mode settings should be read/written.
|
|
* @returns {'systemd'|'docker'|'defaults'}
|
|
*/
|
|
function detectDeploymentSource() {
|
|
if (systemdUnitExists() && !isDockerRuntime()) {
|
|
return 'systemd';
|
|
}
|
|
if (dockerComposeExists() && (isDockerRuntime() || !systemdUnitExists())) {
|
|
return 'docker';
|
|
}
|
|
if (systemdUnitExists()) {
|
|
return 'systemd';
|
|
}
|
|
return 'defaults';
|
|
}
|
|
|
|
function yn(value) {
|
|
return value ? 'Y' : 'N';
|
|
}
|
|
|
|
function parseYn(value, defaultValue) {
|
|
if (value === undefined || value === null || value === '') return defaultValue;
|
|
const v = String(value).trim().toUpperCase();
|
|
if (['Y', 'YES', '1', 'TRUE', 'ON'].includes(v)) return true;
|
|
if (['N', 'NO', '0', 'FALSE', 'OFF'].includes(v)) return false;
|
|
return defaultValue;
|
|
}
|
|
|
|
function modeFromEnvVars(env) {
|
|
const alwaysRelay = parseYn(env.ALWAYS_USE_RELAY, false);
|
|
const p2pFirst = parseYn(env.P2P_FIRST, true);
|
|
if (alwaysRelay) return 'relay_only';
|
|
if (p2pFirst === false) return 'relay_only';
|
|
return 'p2p_first';
|
|
}
|
|
|
|
function envVarsFromSettings(settings) {
|
|
const mode = settings.mode === 'relay_only' ? 'relay_only' : 'p2p_first';
|
|
const fallbackMs = Number(settings.p2p_fallback_ms);
|
|
const sameNatRelay = settings.same_nat_relay !== false;
|
|
const allowSharedNat = settings.allow_shared_nat_initiator === true;
|
|
const loggedInOnly = settings.logged_in_only_initiator === true;
|
|
const operatorOnly = settings.operator_only_outbound === true;
|
|
|
|
if (mode === 'relay_only') {
|
|
return {
|
|
P2P_FIRST: 'N',
|
|
ALWAYS_USE_RELAY: 'Y',
|
|
P2P_FALLBACK_MS: String(Number.isFinite(fallbackMs) && fallbackMs >= 0 ? fallbackMs : DEFAULTS.p2p_fallback_ms),
|
|
SAME_NAT_RELAY: yn(sameNatRelay),
|
|
ALLOW_SHARED_NAT_INITIATOR: yn(allowSharedNat),
|
|
LOGGED_IN_ONLY_INITIATOR: yn(loggedInOnly),
|
|
OPERATOR_ONLY_OUTBOUND: yn(operatorOnly)
|
|
};
|
|
}
|
|
return {
|
|
P2P_FIRST: 'Y',
|
|
ALWAYS_USE_RELAY: 'N',
|
|
P2P_FALLBACK_MS: String(Number.isFinite(fallbackMs) && fallbackMs >= 0 ? fallbackMs : DEFAULTS.p2p_fallback_ms),
|
|
SAME_NAT_RELAY: yn(sameNatRelay),
|
|
ALLOW_SHARED_NAT_INITIATOR: yn(allowSharedNat),
|
|
LOGGED_IN_ONLY_INITIATOR: yn(loggedInOnly),
|
|
OPERATOR_ONLY_OUTBOUND: yn(operatorOnly)
|
|
};
|
|
}
|
|
|
|
function parseSystemdEnvironment(content) {
|
|
const env = {};
|
|
const re = /^Environment=(.+)$/gm;
|
|
let match;
|
|
while ((match = re.exec(content)) !== null) {
|
|
let line = match[1].trim();
|
|
if ((line.startsWith('"') && line.endsWith('"')) || (line.startsWith("'") && line.endsWith("'"))) {
|
|
line = line.slice(1, -1);
|
|
}
|
|
const eq = line.indexOf('=');
|
|
if (eq <= 0) continue;
|
|
const key = line.slice(0, eq).trim();
|
|
const value = line.slice(eq + 1).trim();
|
|
if (MANAGED_ENV_KEYS.includes(key)) {
|
|
env[key] = value;
|
|
}
|
|
}
|
|
return env;
|
|
}
|
|
|
|
function patchSystemdEnvironment(content, vars) {
|
|
const lines = content.split('\n');
|
|
const managed = new Set(MANAGED_ENV_KEYS);
|
|
const out = [];
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
let skip = false;
|
|
if (trimmed.startsWith('Environment=')) {
|
|
let val = trimmed.slice('Environment='.length).trim();
|
|
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
val = val.slice(1, -1);
|
|
}
|
|
const key = val.split('=')[0];
|
|
if (managed.has(key)) skip = true;
|
|
}
|
|
if (!skip) out.push(line);
|
|
}
|
|
|
|
const insertAt = out.findIndex((l) => l.trim().startsWith('[Service]'));
|
|
let idx = insertAt >= 0 ? insertAt + 1 : out.length;
|
|
while (idx < out.length && out[idx].trim() && !out[idx].trim().startsWith('[')) {
|
|
if (out[idx].trim().startsWith('ExecStart=')) break;
|
|
idx++;
|
|
}
|
|
|
|
const envLines = MANAGED_ENV_KEYS
|
|
.filter((k) => vars[k] !== undefined && vars[k] !== '')
|
|
.map((k) => `Environment=${k}=${vars[k]}`);
|
|
|
|
out.splice(idx, 0, ...envLines);
|
|
return out.join('\n');
|
|
}
|
|
|
|
function parseDockerComposeEnvironment(content) {
|
|
const env = {};
|
|
const lines = content.split('\n');
|
|
let inServices = false;
|
|
let inServer = false;
|
|
let inEnvironment = false;
|
|
let serverIndent = 0;
|
|
|
|
for (const line of lines) {
|
|
if (/^services:\s*$/.test(line)) {
|
|
inServices = true;
|
|
continue;
|
|
}
|
|
if (!inServices) continue;
|
|
|
|
const serverMatch = line.match(/^(\s*)server:\s*$/);
|
|
if (serverMatch) {
|
|
inServer = true;
|
|
inEnvironment = false;
|
|
serverIndent = serverMatch[1].length;
|
|
continue;
|
|
}
|
|
|
|
if (!inServer) continue;
|
|
|
|
const trimmed = line.trim();
|
|
if (trimmed && !trimmed.startsWith('#')) {
|
|
const indent = line.match(/^(\s*)/)[1].length;
|
|
if (indent <= serverIndent) {
|
|
inServer = false;
|
|
inEnvironment = false;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (/^\s+environment:\s*$/.test(line)) {
|
|
inEnvironment = true;
|
|
continue;
|
|
}
|
|
|
|
if (inEnvironment && /^\s+-\s+/.test(line)) {
|
|
const raw = line.replace(/^\s+-\s+/, '').trim();
|
|
const eq = raw.indexOf('=');
|
|
if (eq <= 0) continue;
|
|
const key = raw.slice(0, eq).trim();
|
|
let value = raw.slice(eq + 1).trim();
|
|
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
value = value.slice(1, -1);
|
|
}
|
|
if (value.includes('${')) continue;
|
|
if (MANAGED_ENV_KEYS.includes(key)) {
|
|
env[key] = value;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (inEnvironment && trimmed && !/^\s+-\s+/.test(line)) {
|
|
inEnvironment = false;
|
|
}
|
|
}
|
|
return env;
|
|
}
|
|
|
|
function patchDockerComposeEnvironment(content, vars) {
|
|
const lines = content.split('\n');
|
|
let inServices = false;
|
|
let inServer = false;
|
|
let inEnvironment = false;
|
|
let serverIndent = 0;
|
|
let envStart = -1;
|
|
let envEnd = -1;
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
if (/^services:\s*$/.test(line)) {
|
|
inServices = true;
|
|
continue;
|
|
}
|
|
if (!inServices) continue;
|
|
|
|
const serverMatch = line.match(/^(\s*)server:\s*$/);
|
|
if (serverMatch) {
|
|
inServer = true;
|
|
inEnvironment = false;
|
|
serverIndent = serverMatch[1].length;
|
|
continue;
|
|
}
|
|
|
|
if (!inServer) continue;
|
|
|
|
const trimmed = line.trim();
|
|
if (trimmed && !trimmed.startsWith('#')) {
|
|
const indent = line.match(/^(\s*)/)[1].length;
|
|
if (indent <= serverIndent) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (/^\s+environment:\s*$/.test(line)) {
|
|
inEnvironment = true;
|
|
envStart = i;
|
|
envEnd = i + 1;
|
|
continue;
|
|
}
|
|
|
|
if (inEnvironment && /^\s+-\s+/.test(line)) {
|
|
envEnd = i + 1;
|
|
continue;
|
|
}
|
|
|
|
if (inEnvironment && trimmed && !/^\s+-\s+/.test(line)) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (envStart < 0) {
|
|
throw new Error('docker_compose_server_not_found');
|
|
}
|
|
|
|
const out = lines.slice(0, envStart + 1);
|
|
for (let i = envStart + 1; i < envEnd; i++) {
|
|
const raw = lines[i].replace(/^\s+-\s+/, '').trim();
|
|
const key = raw.split('=')[0];
|
|
if (!MANAGED_ENV_KEYS.includes(key)) {
|
|
out.push(lines[i]);
|
|
}
|
|
}
|
|
for (const key of MANAGED_ENV_KEYS) {
|
|
if (vars[key] !== undefined && vars[key] !== '') {
|
|
out.push(` - ${key}=${vars[key]}`);
|
|
}
|
|
}
|
|
out.push(...lines.slice(envEnd));
|
|
return out.join('\n');
|
|
}
|
|
|
|
function settingsFromEnv(env, source) {
|
|
const mode = modeFromEnvVars(env);
|
|
const fallbackRaw = env.P2P_FALLBACK_MS;
|
|
let p2pFallbackMs = DEFAULTS.p2p_fallback_ms;
|
|
if (fallbackRaw !== undefined && fallbackRaw !== '') {
|
|
const n = parseInt(fallbackRaw, 10);
|
|
if (Number.isFinite(n) && n >= 0) p2pFallbackMs = n;
|
|
}
|
|
return {
|
|
mode,
|
|
p2p_fallback_ms: p2pFallbackMs,
|
|
same_nat_relay: parseYn(env.SAME_NAT_RELAY, DEFAULTS.same_nat_relay),
|
|
allow_shared_nat_initiator: parseYn(env.ALLOW_SHARED_NAT_INITIATOR, DEFAULTS.allow_shared_nat_initiator),
|
|
logged_in_only_initiator: parseYn(env.LOGGED_IN_ONLY_INITIATOR, DEFAULTS.logged_in_only_initiator),
|
|
operator_only_outbound: parseYn(env.OPERATOR_ONLY_OUTBOUND, DEFAULTS.operator_only_outbound),
|
|
source,
|
|
writable: source !== 'defaults'
|
|
};
|
|
}
|
|
|
|
async function readSystemdSettings() {
|
|
const content = await fsp.readFile(SYSTEMD_SERVER_UNIT, 'utf8');
|
|
const env = parseSystemdEnvironment(content);
|
|
try {
|
|
const dropin = await fsp.readFile(SYSTEMD_CONNECTION_DROPIN, 'utf8');
|
|
Object.assign(env, parseSystemdEnvironment(dropin));
|
|
} catch (err) {
|
|
if (err.code !== 'ENOENT') throw err;
|
|
}
|
|
return settingsFromEnv(env, 'systemd');
|
|
}
|
|
|
|
async function readDockerSettings() {
|
|
const content = await fsp.readFile(DOCKER_COMPOSE_PATH, 'utf8');
|
|
return settingsFromEnv(parseDockerComposeEnvironment(content), 'docker');
|
|
}
|
|
|
|
async function getConnectionMode() {
|
|
const source = detectDeploymentSource();
|
|
try {
|
|
if (source === 'systemd') return await readSystemdSettings();
|
|
if (source === 'docker') return await readDockerSettings();
|
|
} catch (err) {
|
|
if (err.code !== 'ENOENT') throw err;
|
|
}
|
|
return { ...DEFAULTS, source: 'defaults', writable: false };
|
|
}
|
|
|
|
async function writeSystemdSettings(settings) {
|
|
const vars = envVarsFromSettings(settings);
|
|
|
|
// Prefer a systemd drop-in via the fixed root broker. Direct writes to the
|
|
// main unit fail with EACCES when the console runs as betterdesk (typical).
|
|
if (canUsePrivilegedUpdate()) {
|
|
const result = invokePrivilegedUpdate({
|
|
action: 'write_connection_env',
|
|
vars,
|
|
});
|
|
return {
|
|
path: result.path || SYSTEMD_CONNECTION_DROPIN,
|
|
vars,
|
|
method: 'privileged_dropin',
|
|
};
|
|
}
|
|
|
|
try {
|
|
const content = await fsp.readFile(SYSTEMD_SERVER_UNIT, 'utf8');
|
|
const next = patchSystemdEnvironment(content, vars);
|
|
await fsp.writeFile(SYSTEMD_SERVER_UNIT, next, { encoding: 'utf8', mode: 0o644 });
|
|
return { path: SYSTEMD_SERVER_UNIT, vars, method: 'unit_direct' };
|
|
} catch (err) {
|
|
if (err.code === 'EACCES' || err.code === 'EPERM') {
|
|
throw new Error(
|
|
`Cannot write systemd connection settings (permission denied on ${SYSTEMD_SERVER_UNIT}). `
|
|
+ 'Run once as root: sudo node /opt/BetterDeskConsole/scripts/linux-ensure-console-user.js '
|
|
+ 'then retry — or set ALLOW_SHARED_NAT_INITIATOR via a systemd drop-in.'
|
|
);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function writeDockerSettings(settings) {
|
|
const content = await fsp.readFile(DOCKER_COMPOSE_PATH, 'utf8');
|
|
const vars = envVarsFromSettings(settings);
|
|
const next = patchDockerComposeEnvironment(content, vars);
|
|
await fsp.writeFile(DOCKER_COMPOSE_PATH, next, { encoding: 'utf8', mode: 0o644 });
|
|
return { path: DOCKER_COMPOSE_PATH, vars };
|
|
}
|
|
|
|
async function setConnectionMode(settings) {
|
|
const source = detectDeploymentSource();
|
|
if (source === 'defaults') {
|
|
throw new Error('not_configurable');
|
|
}
|
|
|
|
const normalized = {
|
|
mode: settings.mode === 'relay_only' ? 'relay_only' : 'p2p_first',
|
|
p2p_fallback_ms: Number(settings.p2p_fallback_ms),
|
|
same_nat_relay: settings.same_nat_relay !== false,
|
|
allow_shared_nat_initiator: settings.allow_shared_nat_initiator === true,
|
|
logged_in_only_initiator: settings.logged_in_only_initiator === true,
|
|
operator_only_outbound: settings.operator_only_outbound === true
|
|
};
|
|
|
|
if (source === 'systemd') {
|
|
return { ...(await writeSystemdSettings(normalized)), source: 'systemd', settings: normalized };
|
|
}
|
|
return { ...(await writeDockerSettings(normalized)), source: 'docker', settings: normalized };
|
|
}
|
|
|
|
function restartServer() {
|
|
const source = detectDeploymentSource();
|
|
const result = { source, daemonReload: null, restarts: [] };
|
|
|
|
if (source === 'systemd') {
|
|
result.daemonReload = updateService.daemonReload();
|
|
if (!result.daemonReload.success) return result;
|
|
const comp = updateService.COMPONENTS.server;
|
|
const r = updateService.restartService(comp.service);
|
|
result.restarts.push({ component: 'server', service: comp.service, ...r });
|
|
return result;
|
|
}
|
|
|
|
if (source === 'docker') {
|
|
try {
|
|
const { execSync } = require('child_process');
|
|
execSync('docker compose up -d server', {
|
|
cwd: REPO_ROOT,
|
|
timeout: 120000,
|
|
stdio: 'pipe'
|
|
});
|
|
result.restarts.push({ component: 'server', service: 'betterdesk-server', success: true });
|
|
} catch (err) {
|
|
result.restarts.push({
|
|
component: 'server',
|
|
service: 'betterdesk-server',
|
|
success: false,
|
|
error: err.message
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
throw new Error('no_restart');
|
|
}
|
|
|
|
module.exports = {
|
|
DEFAULTS,
|
|
MANAGED_ENV_KEYS,
|
|
SYSTEMD_SERVER_UNIT,
|
|
SYSTEMD_CONNECTION_DROPIN,
|
|
detectDeploymentSource,
|
|
parseSystemdEnvironment,
|
|
patchSystemdEnvironment,
|
|
parseDockerComposeEnvironment,
|
|
patchDockerComposeEnvironment,
|
|
modeFromEnvVars,
|
|
settingsFromEnv,
|
|
envVarsFromSettings,
|
|
getConnectionMode,
|
|
setConnectionMode,
|
|
restartServer
|
|
};
|