mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-10-26 22:37:07 +00:00
856002d389
Allow the split console to read and write the P2P, relay, NAT, and initiator policy through the Go server's shared database API. Apply validated changes atomically at runtime, keep native systemd and compose paths unchanged, and cover the flow with regression tests and Docker image checks. Refs #432 Thanks: INSOLVE (Honorary); Marco Jakobs (@jacotec); MyNameisStitch (@MyNameisStitch); Redspin (@playerumpknow)
538 lines
17 KiB
JavaScript
538 lines
17 KiB
JavaScript
/**
|
|
* BetterDesk Console — server P2P / relay connection mode configuration.
|
|
*
|
|
* Persists global connection strategy via the split-Docker Go API, 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 { apiClient } = require('./betterdeskApi');
|
|
|
|
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 isDockerSplitDeployment() {
|
|
return config.isDocker === true
|
|
&& String(process.env.BETTERDESK_DOCKER_LAYOUT || '').trim().toLowerCase() === 'split';
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
function settingsFromApi(data) {
|
|
const fallback = Number(data?.p2p_fallback_ms);
|
|
return {
|
|
mode: data?.mode === 'relay_only' ? 'relay_only' : 'p2p_first',
|
|
p2p_fallback_ms: Number.isFinite(fallback) && fallback >= 0 && fallback <= 30000
|
|
? fallback
|
|
: DEFAULTS.p2p_fallback_ms,
|
|
same_nat_relay: data?.same_nat_relay !== false,
|
|
allow_shared_nat_initiator: data?.allow_shared_nat_initiator === true,
|
|
logged_in_only_initiator: data?.logged_in_only_initiator === true,
|
|
operator_only_outbound: data?.operator_only_outbound === true,
|
|
source: 'go-api',
|
|
writable: true,
|
|
};
|
|
}
|
|
|
|
async function readSplitDockerSettings() {
|
|
const response = await apiClient.get('/connection/config');
|
|
return settingsFromApi(response.data?.config || response.data || {});
|
|
}
|
|
|
|
async function getConnectionMode() {
|
|
if (isDockerSplitDeployment()) {
|
|
try {
|
|
return await readSplitDockerSettings();
|
|
} catch (err) {
|
|
console.error('Failed to read split-Docker connection settings:', err.message);
|
|
return { ...DEFAULTS, source: 'go-api', writable: false };
|
|
}
|
|
}
|
|
|
|
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 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 (isDockerSplitDeployment()) {
|
|
const response = await apiClient.put('/connection/config', normalized);
|
|
const data = response.data || {};
|
|
const persisted = data.config || data;
|
|
return {
|
|
source: 'go-api',
|
|
settings: settingsFromApi(persisted),
|
|
restart: null,
|
|
restartRequired: null,
|
|
dockerMode: true,
|
|
};
|
|
}
|
|
|
|
const source = detectDeploymentSource();
|
|
if (source === 'defaults') {
|
|
throw new Error('not_configurable');
|
|
}
|
|
|
|
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,
|
|
isDockerSplitDeployment,
|
|
modeFromEnvVars,
|
|
settingsFromEnv,
|
|
settingsFromApi,
|
|
envVarsFromSettings,
|
|
getConnectionMode,
|
|
setConnectionMode,
|
|
restartServer
|
|
};
|