mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 17:45:42 +00:00
cef2ab7c8e
Correlate RelayResponse via pending UUID/initiator store and panel CIDRs; stop FindByIP ticket misdelivery. Opt-in ALLOW_SHARED_NAT_INITIATOR for stock multi-device CGNAT without peer identity inheritance. Refs #399 Thanks: INSOLVE (Honorary); Marco Jakobs (@jacotec); MyNameisStitch (@MyNameisStitch); Redspin (@playerumpknow)
425 lines
13 KiB
JavaScript
425 lines
13 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 CONSOLE_ROOT = path.join(__dirname, '..');
|
|
const REPO_ROOT = path.join(CONSOLE_ROOT, '..');
|
|
const SYSTEMD_SERVER_UNIT = '/etc/systemd/system/betterdesk-server.service';
|
|
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'];
|
|
|
|
const DEFAULTS = {
|
|
mode: 'p2p_first',
|
|
p2p_fallback_ms: 2000,
|
|
same_nat_relay: true,
|
|
allow_shared_nat_initiator: 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;
|
|
|
|
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)
|
|
};
|
|
}
|
|
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)
|
|
};
|
|
}
|
|
|
|
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),
|
|
source,
|
|
writable: source !== 'defaults'
|
|
};
|
|
}
|
|
|
|
async function readSystemdSettings() {
|
|
const content = await fsp.readFile(SYSTEMD_SERVER_UNIT, 'utf8');
|
|
return settingsFromEnv(parseSystemdEnvironment(content), '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 content = await fsp.readFile(SYSTEMD_SERVER_UNIT, 'utf8');
|
|
const vars = envVarsFromSettings(settings);
|
|
const next = patchSystemdEnvironment(content, vars);
|
|
await fsp.writeFile(SYSTEMD_SERVER_UNIT, next, { encoding: 'utf8', mode: 0o644 });
|
|
return { path: SYSTEMD_SERVER_UNIT, vars };
|
|
}
|
|
|
|
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
|
|
};
|
|
|
|
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,
|
|
detectDeploymentSource,
|
|
parseSystemdEnvironment,
|
|
patchSystemdEnvironment,
|
|
parseDockerComposeEnvironment,
|
|
patchDockerComposeEnvironment,
|
|
modeFromEnvVars,
|
|
envVarsFromSettings,
|
|
getConnectionMode,
|
|
setConnectionMode,
|
|
restartServer
|
|
};
|