mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-10-26 14:30:19 +00:00
be17b3077e
Prefer console-local VERSION/package.json for ldflags so a stale root-owned /opt/VERSION cannot pin rebuilt binaries to an old semver. Fall back binary backups to a writable data dir when /opt/rustdesk denies bak writes (EACCES). Thanks: INSOLVE (Honorary); Marco Jakobs (@jacotec); MyNameisStitch (@MyNameisStitch); Redspin (@playerumpknow)
255 lines
8.8 KiB
JavaScript
255 lines
8.8 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const SERVER_BINARY_NAME = 'betterdesk-server';
|
|
const ALLOWED_TARGET_DIRS = [
|
|
'/opt/rustdesk',
|
|
'/opt/betterdesk',
|
|
'/usr/local/bin',
|
|
];
|
|
|
|
function resolveDeployScriptPath(consoleRoot) {
|
|
return path.join(consoleRoot, 'scripts/linux-deploy-server-binary.js');
|
|
}
|
|
|
|
function resolveServerSourceRoot(consoleRoot, projectRoot) {
|
|
const flat = path.join(consoleRoot, 'betterdesk-server');
|
|
if (fs.existsSync(path.join(flat, 'go.mod'))) {
|
|
return flat;
|
|
}
|
|
return path.join(projectRoot, 'betterdesk-server');
|
|
}
|
|
|
|
function normalizeDeployPath(value) {
|
|
if (!value || typeof value !== 'string') return null;
|
|
const trimmed = value.trim();
|
|
if (!path.isAbsolute(trimmed)) return null;
|
|
return path.resolve(trimmed);
|
|
}
|
|
|
|
function resolveRealFile(filePath) {
|
|
const normalized = normalizeDeployPath(filePath);
|
|
if (!normalized || !fs.existsSync(normalized)) {
|
|
throw new Error(`Source file not found: ${filePath}`);
|
|
}
|
|
const real = fs.realpathSync(normalized);
|
|
if (!fs.statSync(real).isFile()) {
|
|
throw new Error(`Source is not a regular file: ${filePath}`);
|
|
}
|
|
return real;
|
|
}
|
|
|
|
function buildAllowedDeployTargets(options = {}) {
|
|
const targets = new Set();
|
|
for (const dir of ALLOWED_TARGET_DIRS) {
|
|
targets.add(path.join(dir, SERVER_BINARY_NAME));
|
|
}
|
|
|
|
const keysPath = options.keysPath || options.rustdeskDir;
|
|
if (keysPath) {
|
|
targets.add(path.join(path.resolve(keysPath), SERVER_BINARY_NAME));
|
|
}
|
|
|
|
const fromEnv = process.env.BETTERDESK_SERVER_BINARY;
|
|
if (fromEnv) {
|
|
const normalized = normalizeDeployPath(fromEnv);
|
|
if (normalized) targets.add(normalized);
|
|
}
|
|
|
|
if (options.extraTarget) {
|
|
const normalized = normalizeDeployPath(options.extraTarget);
|
|
if (normalized) targets.add(normalized);
|
|
}
|
|
|
|
if (options.projectRoot) {
|
|
targets.add(path.join(path.resolve(options.projectRoot), 'betterdesk-server', SERVER_BINARY_NAME));
|
|
}
|
|
|
|
if (options.consoleRoot) {
|
|
targets.add(path.join(path.resolve(options.consoleRoot), 'betterdesk-server', SERVER_BINARY_NAME));
|
|
}
|
|
|
|
return targets;
|
|
}
|
|
|
|
function isPathInside(parent, child) {
|
|
const relative = path.relative(parent, child);
|
|
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
}
|
|
|
|
function validateDeployRequest(sourcePath, targetPath, options = {}) {
|
|
const consoleRoot = path.resolve(options.consoleRoot || path.join(__dirname, '..'));
|
|
const projectRoot = path.resolve(options.projectRoot || path.join(consoleRoot, '..'));
|
|
const serverSourceRoot = path.resolve(
|
|
options.serverSourceRoot || resolveServerSourceRoot(consoleRoot, projectRoot)
|
|
);
|
|
|
|
const sourceReal = resolveRealFile(sourcePath);
|
|
const serverRootReal = fs.realpathSync(serverSourceRoot);
|
|
if (!isPathInside(serverRootReal, sourceReal)) {
|
|
throw new Error(`Source must be under ${serverRootReal}`);
|
|
}
|
|
if (path.basename(sourceReal) !== SERVER_BINARY_NAME) {
|
|
throw new Error(`Source must be named ${SERVER_BINARY_NAME}`);
|
|
}
|
|
|
|
const stat = fs.statSync(sourceReal);
|
|
if (stat.size < 512 * 1024) {
|
|
throw new Error('Source binary is unexpectedly small');
|
|
}
|
|
|
|
const targetNormalized = normalizeDeployPath(targetPath);
|
|
if (!targetNormalized) {
|
|
throw new Error('Target path must be absolute');
|
|
}
|
|
if (path.basename(targetNormalized) !== SERVER_BINARY_NAME) {
|
|
throw new Error(`Target must be named ${SERVER_BINARY_NAME}`);
|
|
}
|
|
|
|
const allowedTargets = buildAllowedDeployTargets({
|
|
keysPath: options.keysPath,
|
|
rustdeskDir: options.rustdeskDir,
|
|
extraTarget: options.extraTarget,
|
|
projectRoot: options.projectRoot,
|
|
consoleRoot: options.consoleRoot,
|
|
});
|
|
const targetReal = fs.existsSync(targetNormalized)
|
|
? fs.realpathSync(targetNormalized)
|
|
: targetNormalized;
|
|
|
|
let allowed = false;
|
|
for (const candidate of allowedTargets) {
|
|
const candidateReal = fs.existsSync(candidate)
|
|
? fs.realpathSync(candidate)
|
|
: path.resolve(candidate);
|
|
if (candidateReal === targetReal || candidateReal === targetNormalized) {
|
|
allowed = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!allowed) {
|
|
throw new Error(`Target path is not allowed: ${targetNormalized}`);
|
|
}
|
|
|
|
return {
|
|
sourceReal,
|
|
targetPath: targetNormalized,
|
|
serverSourceRoot: serverRootReal,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Atomic replace for a running Linux executable (rename over directory entry).
|
|
* @param {string} sourceReal
|
|
* @param {string} targetPath
|
|
* @param {{ backupDir?: string }} [options] Optional writable dir when target dir denies bak writes
|
|
* @returns {{ success: boolean, backupPath?: string|null, error?: string }}
|
|
*/
|
|
function deployServerBinaryAtomic(sourceReal, targetPath, options = {}) {
|
|
let backupPath = null;
|
|
if (fs.existsSync(targetPath)) {
|
|
const stamp = Date.now();
|
|
const primaryBak = `${targetPath}.bak.${stamp}`;
|
|
try {
|
|
fs.copyFileSync(targetPath, primaryBak);
|
|
backupPath = primaryBak;
|
|
} catch (err) {
|
|
const permDenied = err && (err.code === 'EACCES' || err.code === 'EPERM'
|
|
|| /permission denied/i.test(String(err.message || '')));
|
|
const backupDir = options.backupDir ? path.resolve(options.backupDir) : null;
|
|
if (permDenied && backupDir) {
|
|
try {
|
|
fs.mkdirSync(backupDir, { recursive: true });
|
|
backupPath = path.join(backupDir, `${path.basename(targetPath)}.bak.${stamp}`);
|
|
fs.copyFileSync(targetPath, backupPath);
|
|
} catch (fallbackErr) {
|
|
return { success: false, error: `Backup failed: ${fallbackErr.message}` };
|
|
}
|
|
} else {
|
|
return { success: false, error: `Backup failed: ${err.message}` };
|
|
}
|
|
}
|
|
}
|
|
|
|
const stagingPath = `${targetPath}.new.${process.pid}.${Date.now()}`;
|
|
try {
|
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
fs.copyFileSync(sourceReal, stagingPath);
|
|
try { fs.chmodSync(stagingPath, 0o755); } catch (_e) { /* ok */ }
|
|
|
|
try {
|
|
fs.renameSync(stagingPath, targetPath);
|
|
} catch (renameErr) {
|
|
try {
|
|
fs.copyFileSync(stagingPath, targetPath);
|
|
try { fs.unlinkSync(stagingPath); } catch (_e) { /* ok */ }
|
|
try { fs.chmodSync(targetPath, 0o755); } catch (_e) { /* ok */ }
|
|
} catch (copyErr) {
|
|
throw renameErr.code === 'ETXTBSY' ? renameErr : copyErr;
|
|
}
|
|
}
|
|
return { success: true, backupPath };
|
|
} catch (err) {
|
|
try { if (fs.existsSync(stagingPath)) fs.unlinkSync(stagingPath); } catch (_e) { /* ok */ }
|
|
if (backupPath && fs.existsSync(backupPath) && !fs.existsSync(targetPath)) {
|
|
try { fs.copyFileSync(backupPath, targetPath); } catch (_e) { /* critical */ }
|
|
}
|
|
return { success: false, error: `Deploy failed: ${err.message}` };
|
|
}
|
|
}
|
|
|
|
function canWriteDirectory(dirPath) {
|
|
try {
|
|
fs.accessSync(dirPath, fs.constants.W_OK);
|
|
return true;
|
|
} catch (_e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function canUsePrivilegedDeploy(scriptPath) {
|
|
if (process.platform !== 'linux') return false;
|
|
if (typeof process.getuid === 'function' && process.getuid() === 0) {
|
|
return true;
|
|
}
|
|
// A binary built in the console user's writable tree cannot safely be
|
|
// promoted to a root-owned path without a separately verified signature.
|
|
// Require the operator to run the deploy helper as root instead.
|
|
void scriptPath;
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @returns {{ ready: boolean, method?: 'direct'|'privileged', scriptPath?: string, targetDir?: string }}
|
|
*/
|
|
function assessServerBinaryDeployCapability(targetPath, options = {}) {
|
|
const normalized = normalizeDeployPath(targetPath);
|
|
if (!normalized) {
|
|
return { ready: false };
|
|
}
|
|
const targetDir = path.dirname(normalized);
|
|
if (canWriteDirectory(targetDir)) {
|
|
return { ready: true, method: 'direct', targetDir };
|
|
}
|
|
const consoleRoot = options.consoleRoot || path.join(__dirname, '..');
|
|
const scriptPath = resolveDeployScriptPath(consoleRoot);
|
|
if (canUsePrivilegedDeploy(scriptPath)) {
|
|
return { ready: true, method: 'privileged', scriptPath, targetDir };
|
|
}
|
|
return { ready: false, scriptPath, targetDir };
|
|
}
|
|
|
|
module.exports = {
|
|
SERVER_BINARY_NAME,
|
|
ALLOWED_TARGET_DIRS,
|
|
resolveDeployScriptPath,
|
|
resolveServerSourceRoot,
|
|
buildAllowedDeployTargets,
|
|
validateDeployRequest,
|
|
deployServerBinaryAtomic,
|
|
assessServerBinaryDeployCapability,
|
|
canUsePrivilegedDeploy,
|
|
};
|