mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 01:27:11 +00:00
2947 lines
113 KiB
JavaScript
2947 lines
113 KiB
JavaScript
/**
|
|
* BetterDesk Console - Self-Update Service
|
|
*
|
|
* Commit-based update system. Compares locally tracked commit SHA with
|
|
* the HEAD of the configured GitHub branch. Downloads changed files,
|
|
* categorises them by component (console / server / agent / scripts),
|
|
* applies updates, and restarts affected services.
|
|
*
|
|
* GitHub repo: UNITRONIX/BetterDesk
|
|
* Tracking: data/.update_sha (deployed commit SHA)
|
|
*
|
|
* Flow:
|
|
* 1. GET /repos/{owner}/{repo}/commits/{branch} → remote HEAD SHA
|
|
* 2. Compare with local .update_sha
|
|
* 3. GET /repos/{owner}/{repo}/compare/{local}...{remote} → changed files
|
|
* 4. Categorise: console / server / scripts / agent / other
|
|
* 5. Backup current console files → data/backups/pre-update-{ts}/
|
|
* 6. Download & overwrite changed files for all supported changed components
|
|
* 7. npm install if package.json changed
|
|
* 8. Restart affected services (systemd / NSSM)
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const https = require('https');
|
|
const { execSync, execFileSync } = require('child_process');
|
|
const config = require('../config/config');
|
|
const { createConsoleDeployGraph } = require('../lib/consoleDeployGraph');
|
|
const { resolveChildPath, resolvePathUnderRoot, existsConfinedChild, removeConfinedChild } = require('../lib/safePath');
|
|
const { runConsoleNpmInstall } = require('../lib/consoleNpmInstall');
|
|
const {
|
|
NON_CRITICAL_UPDATE_FAILURES,
|
|
isNonCriticalUpdateFailure,
|
|
isPhantomRepairFailure,
|
|
splitUpdateFailures,
|
|
} = require('../lib/updateFailurePolicy');
|
|
const {
|
|
assessServerBinaryDeployCapability,
|
|
deployServerBinaryAtomic,
|
|
resolveDeployScriptPath,
|
|
} = require('../lib/linuxServerBinaryDeploy');
|
|
const { resolveLastUpdateResultForDisplay } = require('../lib/updateResultStore');
|
|
|
|
const GITHUB_OWNER = process.env.UPDATE_GITHUB_OWNER || 'UNITRONIX';
|
|
const GITHUB_REPO = process.env.UPDATE_GITHUB_REPO || 'BetterDesk';
|
|
const GITHUB_API = 'https://api.github.com';
|
|
|
|
/** Update channel → GitHub branch mapping */
|
|
const UPDATE_CHANNELS = Object.freeze({
|
|
stable: { id: 'stable', branch: 'main', label: 'Stable' },
|
|
development: { id: 'development', branch: 'dev', label: 'Development' },
|
|
});
|
|
|
|
function getGithubBranch() {
|
|
const branch = String(process.env.UPDATE_GITHUB_BRANCH || 'main').trim();
|
|
return branch || 'main';
|
|
}
|
|
|
|
function branchToChannel(branch) {
|
|
if (branch === 'dev') return 'development';
|
|
return 'stable';
|
|
}
|
|
|
|
function getUpdateChannelInfo() {
|
|
const branch = getGithubBranch();
|
|
const channel = branchToChannel(branch);
|
|
return {
|
|
channel,
|
|
branch,
|
|
label: UPDATE_CHANNELS[channel]?.label || channel,
|
|
owner: process.env.UPDATE_GITHUB_OWNER || GITHUB_OWNER,
|
|
repo: process.env.UPDATE_GITHUB_REPO || GITHUB_REPO,
|
|
};
|
|
}
|
|
|
|
const USER_AGENT = `BetterDesk-Console/${config.appVersion}`;
|
|
const BACKUP_DIR = path.join(config.dataDir, 'backups');
|
|
const SHA_FILE = path.join(config.dataDir, '.update_sha');
|
|
const IMAGE_COMMIT_FILE = path.join(__dirname, '..', '.image-commit'); // baked at image build
|
|
const ROOT_DIR = path.join(__dirname, '..'); // web-nodejs/
|
|
const IS_WINDOWS = process.platform === 'win32';
|
|
|
|
/**
|
|
* Repo checkout: ROOT_DIR = web-nodejs/, project root = parent directory.
|
|
* Flat Linux install: console files live directly under ROOT_DIR (e.g.
|
|
* /opt/BetterDeskConsole) with betterdesk-server/ beside services/.
|
|
*/
|
|
function resolveProjectRoot() {
|
|
const parentAsRepo = path.join(ROOT_DIR, '..');
|
|
const flatServerMod = path.join(ROOT_DIR, 'betterdesk-server', 'go.mod');
|
|
if (fs.existsSync(flatServerMod)) {
|
|
return ROOT_DIR;
|
|
}
|
|
return parentAsRepo;
|
|
}
|
|
|
|
const PROJECT_ROOT = resolveProjectRoot();
|
|
|
|
function setUpdateChannel(channelId) {
|
|
const channel = UPDATE_CHANNELS[channelId];
|
|
if (!channel) {
|
|
throw new Error(`Invalid update channel: ${channelId}`);
|
|
}
|
|
const envPath = path.join(ROOT_DIR, '.env');
|
|
const previousBranch = getGithubBranch();
|
|
const existing = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : '';
|
|
const { upsertEnvKey } = require('../lib/envMerge');
|
|
const updated = upsertEnvKey(existing, 'UPDATE_GITHUB_BRANCH', channel.branch);
|
|
fs.writeFileSync(envPath, updated, { mode: 0o600 });
|
|
process.env.UPDATE_GITHUB_BRANCH = channel.branch;
|
|
return {
|
|
channel: channel.id,
|
|
branch: channel.branch,
|
|
label: channel.label,
|
|
previousBranch,
|
|
previousChannel: branchToChannel(previousBranch),
|
|
};
|
|
}
|
|
|
|
// Optional GitHub personal-access token (60 req/h without, 5 000 with)
|
|
const GITHUB_TOKEN = process.env.UPDATE_GITHUB_TOKEN || '';
|
|
|
|
// ---------- component definitions ----------
|
|
const COMPONENTS = {
|
|
console: {
|
|
prefix: 'web-nodejs/',
|
|
label: 'Web Console',
|
|
localRoot: ROOT_DIR,
|
|
service: IS_WINDOWS ? 'BetterDeskConsole' : 'betterdesk-console',
|
|
autoUpdate: true
|
|
},
|
|
server: {
|
|
prefix: 'betterdesk-server/',
|
|
label: 'Go Server',
|
|
localRoot: path.join(PROJECT_ROOT, 'betterdesk-server'),
|
|
service: IS_WINDOWS ? 'BetterDeskServer' : 'betterdesk-server',
|
|
autoUpdate: true
|
|
},
|
|
agent: {
|
|
prefix: 'betterdesk-agent/',
|
|
label: 'Agent library',
|
|
localRoot: null,
|
|
service: IS_WINDOWS ? 'BetterDeskAgent' : 'betterdesk-agent',
|
|
autoUpdate: false
|
|
},
|
|
supportAgent: {
|
|
prefix: 'betterdesk-support-agent/',
|
|
label: 'Support Agent (Generator)',
|
|
localRoot: null,
|
|
service: null,
|
|
autoUpdate: false
|
|
},
|
|
scripts: {
|
|
// matched by exact file names, not prefix
|
|
files: [
|
|
'betterdesk.sh', 'betterdesk.ps1', 'betterdesk-docker.sh',
|
|
'docker-compose.yml', 'docker-compose.single.yml', 'docker-compose.quick.yml',
|
|
'Dockerfile', 'Dockerfile.server', 'Dockerfile.console'
|
|
],
|
|
label: 'Scripts & Docker',
|
|
localRoot: PROJECT_ROOT,
|
|
service: null,
|
|
autoUpdate: true
|
|
}
|
|
};
|
|
|
|
// paths that are never downloaded during an update
|
|
// CRITICAL: anything that holds local runtime state MUST be excluded here.
|
|
// Overwriting live SQLite WAL/SHM files corrupts the database
|
|
// ("database disk image is malformed") — see issue #123.
|
|
const EXCLUDE_PATTERNS = [
|
|
/^\.github\//,
|
|
/^archive\//,
|
|
/^docs\//,
|
|
/^screenshots\//,
|
|
/^dev_modules\//,
|
|
/^tasks\//,
|
|
/^sdks\//,
|
|
/^bridges\//,
|
|
/node_modules\//,
|
|
/\.exe$/,
|
|
/^betterdesk-server\/betterdesk-server/, // compiled binaries
|
|
// --- Runtime state (never overwrite, even if accidentally committed) ---
|
|
/^web-nodejs\/data\//, // entire data dir is local state
|
|
/(^|\/)data\//, // any nested data/ dir (server, agent)
|
|
/\.sqlite3?$/, // .sqlite, .sqlite3
|
|
/\.sqlite3?-(shm|wal|journal)$/, // SQLite sidecar files
|
|
/\.db$/, // .db files (auth.db, etc.)
|
|
/\.db-(shm|wal|journal)$/, // SQLite WAL/SHM/journal sidecars
|
|
/(^|\/)\.session_secret$/,
|
|
/(^|\/)\.update_sha$/,
|
|
/(^|\/)\.api_key$/,
|
|
/(^|\/)\.admin_credentials$/,
|
|
/(^|\/)\.force_password_update$/,
|
|
/(^|\/)\.env(\.|$)/ // .env, .env.local, etc.
|
|
];
|
|
|
|
/** Paths in a commit diff that should refresh agent-source/ and rebuild bundles. */
|
|
const AGENT_REBUILD_TRIGGER_PATHS = [
|
|
/^betterdesk-support-agent\//,
|
|
/^betterdesk-agent\//,
|
|
/^betterdesk-server\//,
|
|
/^web-nodejs\/services\/agentBuildWorker\.js$/,
|
|
/^web-nodejs\/services\/agentBundleConnection\.js$/,
|
|
/^web-nodejs\/services\/agentBundleService\.js$/,
|
|
/^web-nodejs\/routes\/generator\.routes\.js$/,
|
|
/^scripts\/install-build-toolchain\.sh$/,
|
|
/^betterdesk\.sh$/,
|
|
];
|
|
|
|
function shouldQueueAgentRebuild(changedData) {
|
|
const all = Object.values(changedData?.grouped || {}).flat();
|
|
return all.some((f) => AGENT_REBUILD_TRIGGER_PATHS.some((rx) => rx.test(f.path)));
|
|
}
|
|
|
|
/**
|
|
* List all blob paths in the repo tree at ref (commit SHA or branch).
|
|
*/
|
|
async function ghListRepoBlobPaths(ref) {
|
|
const data = await ghGet(
|
|
`/repos/${GITHUB_OWNER}/${GITHUB_REPO}/git/trees/${encodeURIComponent(ref)}?recursive=1`
|
|
);
|
|
return (data.tree || [])
|
|
.filter((entry) => entry.type === 'blob' && entry.path && !isExcluded(entry.path))
|
|
.map((entry) => entry.path);
|
|
}
|
|
|
|
// ======================== HTTP Helpers ===================================
|
|
|
|
/**
|
|
* HTTPS GET → parsed JSON. Follows one redirect.
|
|
*/
|
|
function ghGet(urlPath) {
|
|
return new Promise((resolve, reject) => {
|
|
const url = urlPath.startsWith('https://') ? new URL(urlPath) : new URL(urlPath, GITHUB_API);
|
|
const headers = { 'User-Agent': USER_AGENT, 'Accept': 'application/vnd.github+json' };
|
|
if (GITHUB_TOKEN) headers['Authorization'] = `Bearer ${GITHUB_TOKEN}`;
|
|
|
|
const req = https.get({ hostname: url.hostname, path: url.pathname + url.search, headers }, (res) => {
|
|
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
return ghGet(res.headers.location).then(resolve, reject);
|
|
}
|
|
const chunks = [];
|
|
res.on('data', (c) => chunks.push(c));
|
|
res.on('end', () => {
|
|
const body = Buffer.concat(chunks).toString();
|
|
if (res.statusCode >= 400) {
|
|
return reject(new Error(`GitHub API ${res.statusCode}: ${body.slice(0, 200)}`));
|
|
}
|
|
try { resolve(JSON.parse(body)); }
|
|
catch (_e) { reject(new Error('Invalid JSON from GitHub API')); }
|
|
});
|
|
});
|
|
req.on('error', reject);
|
|
req.setTimeout(15000, () => { req.destroy(); reject(new Error('GitHub API timeout')); });
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Download raw file content from GitHub (binary-safe).
|
|
*/
|
|
function ghDownloadFile(owner, repo, ref, filePath) {
|
|
const url = `https://raw.githubusercontent.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(ref)}/${filePath}`;
|
|
return new Promise((resolve, reject) => {
|
|
const headers = { 'User-Agent': USER_AGENT };
|
|
if (GITHUB_TOKEN) headers['Authorization'] = `Bearer ${GITHUB_TOKEN}`;
|
|
|
|
const follow = (target) => {
|
|
const req = https.get(target, { headers }, (res) => {
|
|
if ((res.statusCode === 301 || res.statusCode === 302) && res.headers.location) {
|
|
return follow(res.headers.location);
|
|
}
|
|
if (res.statusCode !== 200) {
|
|
return reject(new Error(`Download failed (${res.statusCode}): ${filePath}`));
|
|
}
|
|
const chunks = [];
|
|
res.on('data', (c) => chunks.push(c));
|
|
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
});
|
|
req.on('error', reject);
|
|
req.setTimeout(30000, () => { req.destroy(); reject(new Error(`Download timeout: ${filePath}`)); });
|
|
};
|
|
follow(url);
|
|
});
|
|
}
|
|
|
|
// ======================== Docker image deployment ========================
|
|
|
|
function shasMatch(a, b) {
|
|
if (!a || !b) return false;
|
|
if (a === b) return true;
|
|
return a.startsWith(b.slice(0, 7)) || b.startsWith(a.slice(0, 7));
|
|
}
|
|
|
|
function isDockerDeployment() {
|
|
return !!config.isDocker;
|
|
}
|
|
|
|
/**
|
|
* Pre-built GHCR images ship console/server binaries without a full Go tree.
|
|
* In-app GitHub file download + compile must be disabled in that mode (#158).
|
|
*/
|
|
function isImageBasedDockerDeployment() {
|
|
if (!isDockerDeployment()) return false;
|
|
const mode = (process.env.BETTERDESK_UPDATE_MODE || '').trim().toLowerCase();
|
|
if (mode === 'source') return false;
|
|
if (mode === 'image') return true;
|
|
return !fs.existsSync(path.join(PROJECT_ROOT, 'betterdesk-server', 'go.mod'));
|
|
}
|
|
|
|
function getImageEmbeddedSHA() {
|
|
const fromEnv = (process.env.BETTERDESK_IMAGE_SHA || '').trim();
|
|
if (/^[0-9a-f]{7,40}$/i.test(fromEnv) && fromEnv !== 'unknown') return fromEnv;
|
|
try {
|
|
if (fs.existsSync(IMAGE_COMMIT_FILE)) {
|
|
const sha = fs.readFileSync(IMAGE_COMMIT_FILE, 'utf8').trim();
|
|
if (/^[0-9a-f]{7,40}$/i.test(sha) && sha !== 'unknown') return sha;
|
|
}
|
|
} catch (_e) { /* unreadable marker */ }
|
|
return null;
|
|
}
|
|
|
|
function getDockerUpdateInstructions() {
|
|
const tag = (process.env.BETTERDESK_IMAGE_TAG || 'latest').trim() || 'latest';
|
|
const owner = (process.env.UPDATE_GITHUB_OWNER || GITHUB_OWNER).toLowerCase();
|
|
return {
|
|
summary: 'Pull and recreate the published container images.',
|
|
commands: [
|
|
'docker compose pull',
|
|
'docker compose up -d'
|
|
],
|
|
images: [
|
|
`ghcr.io/${owner}/betterdesk-console:${tag}`,
|
|
`ghcr.io/${owner}/betterdesk-server:${tag}`
|
|
],
|
|
composeHint: 'docker-compose.quick.yml'
|
|
};
|
|
}
|
|
|
|
function withDeploymentMeta(result) {
|
|
const channelInfo = getUpdateChannelInfo();
|
|
const base = {
|
|
...result,
|
|
updateChannel: channelInfo.channel,
|
|
githubBranch: channelInfo.branch,
|
|
githubOwner: channelInfo.owner,
|
|
githubRepo: channelInfo.repo,
|
|
};
|
|
const dockerImageMode = isImageBasedDockerDeployment();
|
|
if (!dockerImageMode) {
|
|
return { ...base, deploymentMode: 'native' };
|
|
}
|
|
return {
|
|
...base,
|
|
deploymentMode: 'docker-image',
|
|
dockerImageMode: true,
|
|
imageSHA: getImageEmbeddedSHA(),
|
|
dockerUpdate: getDockerUpdateInstructions(),
|
|
inAppUpdateSupported: false
|
|
};
|
|
}
|
|
|
|
/**
|
|
* On Docker image startup, prefer the image-embedded commit over a stale
|
|
* data/.update_sha left by a previous in-app update attempt (#158).
|
|
*/
|
|
function bootstrapDockerImageDeployment() {
|
|
if (!isImageBasedDockerDeployment()) return;
|
|
clearServerBinaryStale();
|
|
const imageSha = getImageEmbeddedSHA();
|
|
if (!imageSha) {
|
|
console.warn('[UPDATE] Docker image mode: no embedded build SHA — pull a current GHCR image for accurate version checks');
|
|
return;
|
|
}
|
|
let volumeSha = null;
|
|
try {
|
|
if (fs.existsSync(SHA_FILE)) volumeSha = fs.readFileSync(SHA_FILE, 'utf8').trim();
|
|
} catch (_e) { /* ok */ }
|
|
if (!shasMatch(volumeSha, imageSha)) {
|
|
saveLocalSHA(imageSha);
|
|
console.log(
|
|
`[UPDATE] Docker: synced commit baseline to image (${imageSha.slice(0, 7)}`
|
|
+ `${volumeSha ? `, was ${volumeSha.slice(0, 7)}` : ''})`
|
|
);
|
|
}
|
|
|
|
// External image update (docker compose pull) — drop stale in-app panel errors (#192).
|
|
try {
|
|
resolveLastUpdateResultForDisplay(config.dataDir, {
|
|
rootDir: ROOT_DIR,
|
|
localSHA: imageSha,
|
|
remoteSHA: imageSha,
|
|
});
|
|
} catch (err) {
|
|
console.warn(`[UPDATE] Could not clear stale update result: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
// ======================== SHA Tracking ===================================
|
|
|
|
function getLocalSHA() {
|
|
if (isImageBasedDockerDeployment()) {
|
|
const imageSha = getImageEmbeddedSHA();
|
|
if (imageSha) return imageSha;
|
|
// Legacy GHCR images without an embedded commit: ignore a polluted
|
|
// data/.update_sha left by a previous in-app update attempt (#158).
|
|
return null;
|
|
}
|
|
if (fs.existsSync(SHA_FILE)) {
|
|
const sha = fs.readFileSync(SHA_FILE, 'utf8').trim();
|
|
if (/^[0-9a-f]{7,40}$/i.test(sha)) return sha;
|
|
}
|
|
// Fall back to git if available
|
|
try {
|
|
const sha = execSync('git rev-parse HEAD', { cwd: PROJECT_ROOT, timeout: 5000, stdio: 'pipe' })
|
|
.toString().trim();
|
|
if (/^[0-9a-f]{40}$/i.test(sha)) { saveLocalSHA(sha); return sha; }
|
|
} catch (_e) { /* no git */ }
|
|
return null;
|
|
}
|
|
|
|
function saveLocalSHA(sha) {
|
|
if (!/^[0-9a-f]{7,40}$/i.test(sha)) return;
|
|
fs.mkdirSync(path.dirname(SHA_FILE), { recursive: true });
|
|
fs.writeFileSync(SHA_FILE, sha.trim() + '\n');
|
|
}
|
|
|
|
// ---- Server binary staleness marker ---------------------------------------
|
|
// When a server update applies the Go source files (including dependency
|
|
// bumps in go.mod/go.sum) but the binary cannot be rebuilt or deployed
|
|
// (Issue #154 classifies that step as non-critical so the SHA is still
|
|
// saved), the running binary is left behind — potentially missing the very
|
|
// security fix that triggered the update. We persist a marker so the panel
|
|
// can surface a clear "server binary is out of date" warning and offer an
|
|
// explicit rebuild, instead of silently reporting "up to date".
|
|
const SERVER_STALE_FILE = path.join(config.dataDir, '.server_binary_stale');
|
|
|
|
function markServerBinaryStale(info = {}) {
|
|
try {
|
|
fs.mkdirSync(path.dirname(SERVER_STALE_FILE), { recursive: true });
|
|
fs.writeFileSync(SERVER_STALE_FILE, JSON.stringify({
|
|
stale: true,
|
|
reason: info.reason || 'unknown',
|
|
detail: info.detail || null,
|
|
sha: info.sha || null,
|
|
since: new Date().toISOString()
|
|
}, null, 2));
|
|
} catch (_e) { /* best-effort marker */ }
|
|
}
|
|
|
|
function clearServerBinaryStale() {
|
|
try { if (fs.existsSync(SERVER_STALE_FILE)) fs.unlinkSync(SERVER_STALE_FILE); } catch (_e) { /* ok */ }
|
|
}
|
|
|
|
function getServerBinaryStatus() {
|
|
if (isImageBasedDockerDeployment()) {
|
|
return { stale: false, dockerMode: true };
|
|
}
|
|
try {
|
|
if (fs.existsSync(SERVER_STALE_FILE)) {
|
|
const data = JSON.parse(fs.readFileSync(SERVER_STALE_FILE, 'utf8'));
|
|
return {
|
|
stale: true,
|
|
reason: data.reason || null,
|
|
detail: data.detail || null,
|
|
sha: data.sha || null,
|
|
since: data.since || null
|
|
};
|
|
}
|
|
} catch (_e) { /* corrupt marker — treat as healthy */ }
|
|
return { stale: false };
|
|
}
|
|
|
|
async function getRemoteHeadSHA() {
|
|
const data = await ghGet(`/repos/${GITHUB_OWNER}/${GITHUB_REPO}/commits/${getGithubBranch()}`);
|
|
return {
|
|
sha: data.sha,
|
|
message: (data.commit?.message || '').split('\n')[0],
|
|
date: data.commit?.committer?.date || data.commit?.author?.date || '',
|
|
author: data.commit?.author?.name || ''
|
|
};
|
|
}
|
|
|
|
function getLocalVersion() {
|
|
const versionFile = path.join(PROJECT_ROOT, 'VERSION');
|
|
if (fs.existsSync(versionFile)) {
|
|
const v = fs.readFileSync(versionFile, 'utf8').trim();
|
|
if (v) return v;
|
|
}
|
|
return config.appVersion;
|
|
}
|
|
|
|
// ======================== Classify ======================================
|
|
|
|
function classifyFile(filepath) {
|
|
if (COMPONENTS.scripts.files.includes(filepath)) return 'scripts';
|
|
for (const [name, comp] of Object.entries(COMPONENTS)) {
|
|
if (comp.prefix && filepath.startsWith(comp.prefix)) return name;
|
|
}
|
|
return 'other';
|
|
}
|
|
|
|
function isExcluded(filepath) {
|
|
return EXCLUDE_PATTERNS.some(rx => rx.test(filepath));
|
|
}
|
|
|
|
/**
|
|
* Defense-in-depth: refuse to write to any path that maps to runtime state,
|
|
* even if the file somehow slipped past EXCLUDE_PATTERNS earlier in the
|
|
* pipeline. Prevents reintroducing issue #123 (corrupted SQLite WAL/SHM
|
|
* after update overwrote live state files).
|
|
*
|
|
* @param {string} fullPath Absolute destination path on disk.
|
|
* @returns {boolean}
|
|
*/
|
|
function isProtectedRuntimePath(fullPath) {
|
|
if (!fullPath) return false;
|
|
const normalized = fullPath.replace(/\\/g, '/');
|
|
if (/\/web-nodejs\/data(\/|$)/.test(normalized)) return true;
|
|
if (/\/data\/(db_v2|address_book|peer)\b/.test(normalized)) return true;
|
|
const base = path.basename(normalized);
|
|
if (/\.sqlite3?$/.test(base)) return true;
|
|
if (/\.sqlite3?-(shm|wal|journal)$/.test(base)) return true;
|
|
if (/\.db$/.test(base)) return true;
|
|
if (/\.db-(shm|wal|journal)$/.test(base)) return true;
|
|
if (['.session_secret', '.update_sha', '.api_key', '.admin_credentials', '.force_password_update'].includes(base)) return true;
|
|
if (/^\.env(\..+)?$/.test(base)) return true;
|
|
return false;
|
|
}
|
|
|
|
// ======================== Server Build Support ===========================
|
|
|
|
let _updateInProgress = false;
|
|
|
|
/**
|
|
* Run a shell command as a promise (non-blocking unlike execSync).
|
|
* Prefer spawnPromise() when arguments are known — avoids shell interpolation.
|
|
*/
|
|
function execPromise(cmd, opts = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const { exec } = require('child_process');
|
|
exec(cmd, { maxBuffer: 5 * 1024 * 1024, ...opts }, (err, stdout, stderr) => {
|
|
if (err) {
|
|
err.stderr = stderr;
|
|
err.stdout = stdout;
|
|
return reject(err);
|
|
}
|
|
resolve({ stdout, stderr });
|
|
});
|
|
});
|
|
}
|
|
|
|
function spawnPromise(command, args, opts = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const { spawn } = require('child_process');
|
|
const proc = spawn(command, args, { shell: false, ...opts });
|
|
let stdout = '';
|
|
let stderr = '';
|
|
proc.stdout?.on('data', (chunk) => { stdout += chunk; });
|
|
proc.stderr?.on('data', (chunk) => { stderr += chunk; });
|
|
proc.on('error', reject);
|
|
proc.on('close', (code) => {
|
|
if (code !== 0) {
|
|
const err = new Error(`${command} exited with code ${code}`);
|
|
err.stderr = stderr;
|
|
err.stdout = stdout;
|
|
return reject(err);
|
|
}
|
|
resolve({ stdout, stderr });
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Copy directory recursively.
|
|
*/
|
|
function copyDirRecursive(src, dest) {
|
|
fs.mkdirSync(dest, { recursive: true });
|
|
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
const srcPath = path.join(src, entry.name);
|
|
const destPath = path.join(dest, entry.name);
|
|
if (entry.isDirectory()) {
|
|
copyDirRecursive(srcPath, destPath);
|
|
} else {
|
|
fs.copyFileSync(srcPath, destPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
function getGoVersionNumber(versionOutput) {
|
|
const text = String(versionOutput || '');
|
|
const goMatch = text.match(/go(\d+(?:\.\d+){1,2})/i);
|
|
if (goMatch) return goMatch[1];
|
|
const genericMatch = text.match(/(\d+\.\d+(?:\.\d+)?)/);
|
|
return genericMatch ? genericMatch[1] : null;
|
|
}
|
|
|
|
function quoteCommand(cmd) {
|
|
if (!cmd) return '';
|
|
return /\s/.test(cmd) ? `"${cmd}"` : cmd;
|
|
}
|
|
|
|
function shellQuote(value) {
|
|
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
}
|
|
|
|
function stripIncompatibleGoApiTLSArgs(value, compact = false) {
|
|
const stripped = String(value || '')
|
|
.replace(/[ \t]+-(?:tls-api|force-https)(?=(?:\s|$))/g, '');
|
|
return compact ? stripped.replace(/[ \t]{2,}/g, ' ').trim() : stripped;
|
|
}
|
|
|
|
function readTextFilePrivileged(filePath) {
|
|
try {
|
|
return fs.readFileSync(filePath, 'utf8');
|
|
} catch (err) {
|
|
if (!IS_WINDOWS && (err.code === 'EACCES' || err.code === 'EPERM')) {
|
|
return execSync(`sudo cat ${shellQuote(filePath)}`, { timeout: 5000, stdio: 'pipe' }).toString();
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function writeTextFilePrivileged(filePath, content) {
|
|
try {
|
|
fs.writeFileSync(filePath, content);
|
|
} catch (err) {
|
|
if (!IS_WINDOWS && (err.code === 'EACCES' || err.code === 'EPERM')) {
|
|
execSync(`sudo tee ${shellQuote(filePath)} >/dev/null`, {
|
|
input: content,
|
|
timeout: 5000,
|
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
});
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function runPrivileged(command, options = {}) {
|
|
const prefix = !IS_WINDOWS && typeof process.getuid === 'function' && process.getuid() !== 0 ? 'sudo -n ' : '';
|
|
return execSync(prefix + command, options);
|
|
}
|
|
|
|
/**
|
|
* Keep the Go REST API on plain HTTP even when signal/relay TLS is enabled.
|
|
* RustDesk clients call the consolidated Go API (21121) over HTTP for heartbeat,
|
|
* sysinfo, login and address-book endpoints; -tls-api breaks that contract.
|
|
*/
|
|
function sanitizeGoServerServiceConfig() {
|
|
const result = { changed: false, changes: [], error: null };
|
|
|
|
try {
|
|
if (IS_WINDOWS) {
|
|
const serviceName = COMPONENTS.server.service;
|
|
const args = execSync(`nssm get "${serviceName}" AppParameters 2>nul`, {
|
|
timeout: 5000,
|
|
stdio: 'pipe'
|
|
}).toString();
|
|
const cleanArgs = stripIncompatibleGoApiTLSArgs(args, true);
|
|
if (cleanArgs !== args.trim()) {
|
|
execFileSync('nssm', ['set', serviceName, 'AppParameters', cleanArgs], {
|
|
timeout: 5000,
|
|
stdio: 'pipe'
|
|
});
|
|
result.changed = true;
|
|
result.changes.push('removed Go API TLS flags from NSSM service parameters');
|
|
}
|
|
|
|
try {
|
|
const consoleService = COMPONENTS.console.service;
|
|
const envRaw = execSync(`nssm get "${consoleService}" AppEnvironmentExtra 2>nul`, {
|
|
timeout: 5000,
|
|
stdio: 'pipe'
|
|
}).toString();
|
|
const cleanEnv = envRaw
|
|
.replace(/HBBS_API_URL=https:\/\/localhost/g, 'HBBS_API_URL=http://localhost')
|
|
.replace(/BETTERDESK_API_URL=https:\/\/localhost/g, 'BETTERDESK_API_URL=http://localhost');
|
|
if (cleanEnv !== envRaw) {
|
|
execFileSync('nssm', ['set', consoleService, 'AppEnvironmentExtra', cleanEnv], {
|
|
timeout: 5000,
|
|
stdio: 'pipe'
|
|
});
|
|
result.changed = true;
|
|
result.changes.push('kept console Go API URLs on HTTP in NSSM environment');
|
|
}
|
|
} catch (_e) { /* console service may not exist */ }
|
|
|
|
return result;
|
|
}
|
|
|
|
const serviceName = COMPONENTS.server.service;
|
|
let fragmentPath = execSync(`systemctl show ${shellQuote(serviceName)} --property=FragmentPath --value 2>/dev/null || true`, {
|
|
timeout: 5000,
|
|
stdio: 'pipe'
|
|
}).toString().trim();
|
|
if (!fragmentPath) fragmentPath = `/etc/systemd/system/${serviceName}.service`;
|
|
if (!fs.existsSync(fragmentPath)) return result;
|
|
|
|
const original = readTextFilePrivileged(fragmentPath);
|
|
let clean = stripIncompatibleGoApiTLSArgs(original)
|
|
.replace(/Environment=HBBS_API_URL=https:\/\/localhost/g, 'Environment=HBBS_API_URL=http://localhost')
|
|
.replace(/Environment=BETTERDESK_API_URL=https:\/\/localhost/g, 'Environment=BETTERDESK_API_URL=http://localhost');
|
|
|
|
if (clean !== original) {
|
|
writeTextFilePrivileged(fragmentPath, clean);
|
|
runPrivileged('systemctl daemon-reload', { timeout: 10000, stdio: 'pipe' });
|
|
result.changed = true;
|
|
result.changes.push('removed Go API TLS flags from systemd service');
|
|
}
|
|
} catch (err) {
|
|
result.error = err.message || String(err);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function createGoInfo(version, binPath, source) {
|
|
const versionNumber = getGoVersionNumber(version);
|
|
const meetsMinimum = !!versionNumber && compareGoVersion(versionNumber, GO_MIN_VERSION) >= 0;
|
|
return {
|
|
available: true,
|
|
version,
|
|
versionNumber,
|
|
binPath,
|
|
source,
|
|
meetsMinimum,
|
|
needsUpgrade: !meetsMinimum
|
|
};
|
|
}
|
|
|
|
function probeGoBinary(binPath, source) {
|
|
if (!binPath) return null;
|
|
try {
|
|
const version = execFileSync(binPath, ['version'], {
|
|
timeout: 10000,
|
|
stdio: 'pipe'
|
|
}).toString().trim();
|
|
return createGoInfo(version, binPath, source);
|
|
} catch (_e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if Go toolchain is available.
|
|
* Searches PATH first, then well-known install locations (snap, tarball,
|
|
* Homebrew, vendored toolchain). Returns the absolute path so callers can
|
|
* `exec` it even when the console process started without a complete PATH.
|
|
*
|
|
* @returns {{ available: boolean, version: string|null, binPath: string|null, source: string|null }}
|
|
*/
|
|
function checkGoAvailable() {
|
|
const found = [];
|
|
const seen = new Set();
|
|
const addCandidate = (binPath, source) => {
|
|
if (!binPath) return;
|
|
const key = path.resolve(binPath === 'go' ? binPath : binPath.toLowerCase());
|
|
if (seen.has(key)) return;
|
|
seen.add(key);
|
|
const info = probeGoBinary(binPath, source);
|
|
if (info) found.push(info);
|
|
};
|
|
|
|
// 1. Try the regular PATH lookup first
|
|
try {
|
|
let binPath = null;
|
|
try {
|
|
binPath = execSync(IS_WINDOWS ? 'where go' : 'command -v go', {
|
|
timeout: 5000, stdio: 'pipe'
|
|
}).toString().split(/\r?\n/)[0].trim() || null;
|
|
} catch (_e) { /* ok */ }
|
|
addCandidate(binPath || 'go', 'path');
|
|
} catch (_e) { /* fall through */ }
|
|
|
|
// 2. Scan well-known install locations
|
|
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
const localGoBin = path.join(config.dataDir, 'go-toolchain', 'go', 'bin', IS_WINDOWS ? 'go.exe' : 'go');
|
|
const candidates = IS_WINDOWS
|
|
? [
|
|
localGoBin,
|
|
'C:\\Go\\bin\\go.exe',
|
|
'C:\\Program Files\\Go\\bin\\go.exe',
|
|
path.join(home, 'go', 'bin', 'go.exe'),
|
|
path.join(home, '.local', 'go', 'bin', 'go.exe')
|
|
]
|
|
: [
|
|
localGoBin,
|
|
'/usr/local/go/bin/go',
|
|
'/snap/bin/go',
|
|
'/opt/go/bin/go',
|
|
'/usr/lib/go/bin/go',
|
|
'/usr/lib/go-1.22/bin/go',
|
|
'/usr/lib/go-1.23/bin/go',
|
|
'/usr/lib/go-1.24/bin/go',
|
|
path.join(home, 'go', 'bin', 'go'),
|
|
path.join(home, '.local', 'go', 'bin', 'go'),
|
|
'/opt/homebrew/bin/go',
|
|
'/usr/local/bin/go'
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
if (!candidate || !fs.existsSync(candidate)) continue;
|
|
const source = candidate === localGoBin ? 'vendored' : 'system';
|
|
addCandidate(candidate, source);
|
|
}
|
|
|
|
if (found.length) {
|
|
found.sort((a, b) => {
|
|
if (a.meetsMinimum !== b.meetsMinimum) return a.meetsMinimum ? -1 : 1;
|
|
const versionDiff = compareGoVersion(b.versionNumber || b.version, a.versionNumber || a.version);
|
|
if (versionDiff !== 0) return versionDiff;
|
|
if (a.source === 'vendored' && b.source !== 'vendored') return -1;
|
|
if (b.source === 'vendored' && a.source !== 'vendored') return 1;
|
|
return 0;
|
|
});
|
|
return found[0];
|
|
}
|
|
|
|
return {
|
|
available: false,
|
|
version: null,
|
|
versionNumber: null,
|
|
binPath: null,
|
|
source: null,
|
|
meetsMinimum: false,
|
|
needsUpgrade: false
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Writable Go module/build cache under the console data directory.
|
|
* The betterdesk system user often has HOME=/var/lib/betterdesk, which may be
|
|
* root-owned or missing — Go then fails with "could not create module cache".
|
|
*/
|
|
function getGoCacheDirs() {
|
|
const base = path.join(config.dataDir, 'go-cache');
|
|
return {
|
|
modCache: path.join(base, 'mod'),
|
|
buildCache: path.join(base, 'build'),
|
|
};
|
|
}
|
|
|
|
function ensureGoCacheDirs() {
|
|
const { modCache, buildCache } = getGoCacheDirs();
|
|
fs.mkdirSync(modCache, { recursive: true });
|
|
fs.mkdirSync(buildCache, { recursive: true });
|
|
return { modCache, buildCache };
|
|
}
|
|
|
|
/**
|
|
* Wrap an exec environment so a manually located `go` binary is on PATH.
|
|
*/
|
|
function buildEnvWithGo(goBinPath) {
|
|
const env = { ...process.env, CGO_ENABLED: '0' };
|
|
if (goBinPath && goBinPath !== 'go') {
|
|
const goBinDir = path.dirname(goBinPath);
|
|
const sep = IS_WINDOWS ? ';' : ':';
|
|
env.PATH = goBinDir + sep + (env.PATH || '');
|
|
}
|
|
try {
|
|
const { modCache, buildCache } = ensureGoCacheDirs();
|
|
env.GOMODCACHE = modCache;
|
|
env.GOCACHE = buildCache;
|
|
// betterdesk user's HOME is often /var/lib/betterdesk (root-owned).
|
|
// Go also writes toolchain/sumdb state under $HOME/go — redirect HOME.
|
|
env.HOME = config.dataDir;
|
|
} catch (err) {
|
|
console.warn(`[UPDATE] Could not prepare Go cache dirs: ${err.message}`);
|
|
}
|
|
return env;
|
|
}
|
|
|
|
/**
|
|
* Detect the installed Go server binary path from the system service.
|
|
* @returns {string|null}
|
|
*/
|
|
function detectServerBinaryPath() {
|
|
// 1. Explicit environment variable
|
|
if (process.env.BETTERDESK_SERVER_BINARY) {
|
|
const p = process.env.BETTERDESK_SERVER_BINARY;
|
|
if (fs.existsSync(p)) return p;
|
|
}
|
|
|
|
// 2. Read from systemd / NSSM service definition
|
|
try {
|
|
if (IS_WINDOWS) {
|
|
const out = execSync('nssm get BetterDeskServer Application 2>nul', {
|
|
timeout: 5000, stdio: 'pipe'
|
|
}).toString().trim();
|
|
if (out && fs.existsSync(out)) return out;
|
|
} else {
|
|
const raw = execSync(
|
|
'systemctl show betterdesk-server --property=ExecStart --value 2>/dev/null || true',
|
|
{ timeout: 5000, stdio: 'pipe' }
|
|
).toString().trim();
|
|
// ExecStart value may look like: /opt/rustdesk/betterdesk-server --flag ...
|
|
const binPath = raw.replace(/^\{[^}]*path=/, '').replace(/\s*;.*$/, '').split(/\s+/)[0];
|
|
if (binPath && fs.existsSync(binPath)) return binPath;
|
|
}
|
|
} catch (_e) { /* service may not be installed */ }
|
|
|
|
// 3. Well-known installation paths
|
|
const candidates = IS_WINDOWS
|
|
? [
|
|
'C:\\betterdesk\\betterdesk-server.exe',
|
|
'C:\\Program Files\\BetterDesk\\betterdesk-server.exe',
|
|
path.join(PROJECT_ROOT, 'betterdesk-server', 'betterdesk-server.exe')
|
|
]
|
|
: [
|
|
'/opt/rustdesk/betterdesk-server',
|
|
'/opt/betterdesk/betterdesk-server',
|
|
'/usr/local/bin/betterdesk-server',
|
|
path.join(PROJECT_ROOT, 'betterdesk-server', 'betterdesk-server')
|
|
];
|
|
|
|
for (const p of candidates) {
|
|
if (fs.existsSync(p)) return p;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Ensure full Go server source code is present locally.
|
|
*
|
|
* When `opts.force` is false (default) and go.mod already exists, the source is
|
|
* assumed present and only the per-file compare diff is applied elsewhere.
|
|
*
|
|
* When `opts.force` is true, the COMPLETE source tree is (re)downloaded even if
|
|
* go.mod exists. This is required because GitHub's compare API caps its `files`
|
|
* array at 300 entries: for large updates the diff is truncated, so changed
|
|
* dependency files (e.g. codec/ws.go, peer/map.go, auth/ldap.go, auth/oidc.go)
|
|
* may never be downloaded. The result is an inconsistent on-disk source where
|
|
* updated callers (signal/ws.go, api/server.go) reference symbols that are
|
|
* missing from stale callees, breaking `go build` (issue #158). Forcing a full
|
|
* sync before every compile/rebuild guarantees source consistency.
|
|
*
|
|
* @param {string} remoteSHA
|
|
* @param {{ force?: boolean }} [opts]
|
|
* @returns {Promise<{ strategy: string, filesDownloaded: number }>}
|
|
*/
|
|
async function ensureServerSource(remoteSHA, opts = {}) {
|
|
const serverDir = COMPONENTS.server.localRoot;
|
|
const goModPath = path.join(serverDir, 'go.mod');
|
|
if (!opts.force && fs.existsSync(goModPath)) {
|
|
return { strategy: 'incremental', filesDownloaded: 0 };
|
|
}
|
|
|
|
fs.mkdirSync(serverDir, { recursive: true });
|
|
|
|
// --- Try git clone --depth=1 (fastest) ---
|
|
try {
|
|
const tmpDir = path.join(config.dataDir, '_tmp_server_clone');
|
|
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
|
|
const repoUrl = GITHUB_TOKEN
|
|
? `https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_OWNER}/${GITHUB_REPO}.git`
|
|
: `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}.git`;
|
|
|
|
execSync(
|
|
`git clone --depth=1 --single-branch --branch "${getGithubBranch()}" "${repoUrl}" "${tmpDir}"`,
|
|
{ timeout: 120000, stdio: 'pipe' }
|
|
);
|
|
|
|
const srcDir = path.join(tmpDir, 'betterdesk-server');
|
|
if (fs.existsSync(srcDir)) {
|
|
copyDirRecursive(srcDir, serverDir);
|
|
}
|
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_e) { /* ok */ }
|
|
return { strategy: 'git-clone', filesDownloaded: -1 };
|
|
} catch (_e) {
|
|
/* git not available or clone failed — fall through to API */
|
|
}
|
|
|
|
// --- Fallback: GitHub tree API + raw file downloads ---
|
|
const tree = await ghGet(`/repos/${GITHUB_OWNER}/${GITHUB_REPO}/git/trees/${remoteSHA}?recursive=1`);
|
|
const serverFiles = (tree.tree || []).filter(t =>
|
|
t.path.startsWith('betterdesk-server/') &&
|
|
t.type === 'blob' &&
|
|
!EXCLUDE_PATTERNS.some(rx => rx.test(t.path))
|
|
);
|
|
|
|
let downloaded = 0;
|
|
for (const file of serverFiles) {
|
|
try {
|
|
const localPath = file.path.slice(COMPONENTS.server.prefix.length);
|
|
const dest = path.join(serverDir, localPath);
|
|
if (isProtectedRuntimePath(dest)) {
|
|
console.warn(`[UPDATE] Refusing to overwrite runtime state file: ${file.path}`);
|
|
continue;
|
|
}
|
|
const content = await ghDownloadFile(GITHUB_OWNER, GITHUB_REPO, remoteSHA, file.path);
|
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
fs.writeFileSync(dest, content);
|
|
downloaded++;
|
|
} catch (err) {
|
|
console.error(`[UPDATE] Failed to download ${file.path}: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
return { strategy: 'api-download', filesDownloaded: downloaded };
|
|
}
|
|
|
|
/** GitHub compare API returns at most this many changed files (issue #158, #173). */
|
|
const GITHUB_COMPARE_FILE_LIMIT = 300;
|
|
|
|
function getConsoleDeployGraph() {
|
|
const modPath = require.resolve('../lib/consoleDeployGraph');
|
|
delete require.cache[modPath];
|
|
const { createConsoleDeployGraph: createGraph } = require('../lib/consoleDeployGraph');
|
|
return createGraph(ROOT_DIR);
|
|
}
|
|
|
|
const _consoleDeployGraph = createConsoleDeployGraph(ROOT_DIR);
|
|
const {
|
|
isConsoleDeployLocalPath,
|
|
resolveConsoleRequire,
|
|
isResolvedByIndexModule,
|
|
collectConsoleRequiredFiles,
|
|
CONSOLE_INTEGRITY_SEEDS,
|
|
} = _consoleDeployGraph;
|
|
|
|
function isCompareLikelyTruncated(fileCount) {
|
|
return Number(fileCount) >= GITHUB_COMPARE_FILE_LIMIT;
|
|
}
|
|
|
|
function resolveConsoleLocalPath(localPath) {
|
|
return resolvePathUnderRoot(ROOT_DIR, localPath);
|
|
}
|
|
|
|
async function downloadConsoleFile(remoteSHA, localPath) {
|
|
const repoPath = `${COMPONENTS.console.prefix}${localPath}`;
|
|
const content = await ghDownloadFile(GITHUB_OWNER, GITHUB_REPO, remoteSHA, repoPath);
|
|
const dest = resolveConsoleLocalPath(localPath);
|
|
if (isProtectedRuntimePath(dest)) {
|
|
throw new Error(`Refusing to write protected runtime path: ${localPath}`);
|
|
}
|
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
fs.writeFileSync(dest, content);
|
|
return dest;
|
|
}
|
|
|
|
/**
|
|
* Download the full web-nodejs tree when the GitHub compare diff is truncated.
|
|
* Mirrors ensureServerSource() for issue #158.
|
|
*/
|
|
async function ensureConsoleSource(remoteSHA, opts = {}) {
|
|
const marker = path.join(ROOT_DIR, 'server.js');
|
|
if (!opts.force && fs.existsSync(marker)) {
|
|
return { strategy: 'incremental', filesDownloaded: 0, filesSkipped: 0 };
|
|
}
|
|
|
|
const tree = await ghGet(
|
|
`/repos/${GITHUB_OWNER}/${GITHUB_REPO}/git/trees/${encodeURIComponent(remoteSHA)}?recursive=1`
|
|
);
|
|
if (tree.truncated) {
|
|
console.warn('[UPDATE] GitHub tree listing was truncated — console full sync may be incomplete');
|
|
}
|
|
|
|
const prefix = COMPONENTS.console.prefix;
|
|
const consolePaths = (tree.tree || [])
|
|
.filter(entry => entry.type === 'blob' && entry.path && entry.path.startsWith(prefix))
|
|
.map(entry => entry.path);
|
|
|
|
let downloaded = 0;
|
|
let skipped = 0;
|
|
const failed = [];
|
|
for (const repoPath of consolePaths) {
|
|
const localPath = repoPath.slice(prefix.length);
|
|
if (!isConsoleDeployLocalPath(localPath)) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
try {
|
|
await downloadConsoleFile(remoteSHA, localPath);
|
|
downloaded++;
|
|
} catch (err) {
|
|
failed.push({ path: repoPath, error: err.message });
|
|
console.error(`[UPDATE] Failed to download ${repoPath}: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
return { strategy: 'full-tree', filesDownloaded: downloaded, filesSkipped: skipped, failed };
|
|
}
|
|
|
|
async function repairMissingConsoleFiles(remoteSHA, changedConsoleFiles = []) {
|
|
const graph = getConsoleDeployGraph();
|
|
const required = graph.collectConsoleRequiredFiles(changedConsoleFiles);
|
|
const removedPaths = new Set(
|
|
(changedConsoleFiles || [])
|
|
.filter((f) => f?.status === 'removed' && f.localPath)
|
|
.map((f) => f.localPath)
|
|
);
|
|
const repaired = [];
|
|
const failed = [];
|
|
for (const localPath of required) {
|
|
if (removedPaths.has(localPath)) continue;
|
|
if (!graph.isConsoleDeployLocalPath(localPath)) continue;
|
|
if (graph.isResolvedByIndexModule(localPath)) continue;
|
|
const dest = resolveConsoleLocalPath(localPath);
|
|
if (fs.existsSync(dest)) continue;
|
|
try {
|
|
await downloadConsoleFile(remoteSHA, localPath);
|
|
repaired.push(localPath);
|
|
} catch (err) {
|
|
const repoPath = `web-nodejs/${localPath}`;
|
|
failed.push({
|
|
path: repoPath,
|
|
error: err.message,
|
|
nonCritical: isPhantomRepairFailure(repoPath, ROOT_DIR),
|
|
});
|
|
console.error(`[UPDATE] Failed to repair ${localPath}: ${err.message}`);
|
|
}
|
|
}
|
|
return { repaired, checked: required.size, failed };
|
|
}
|
|
|
|
/**
|
|
* Build the Go server binary from local source.
|
|
* Uses async exec to avoid blocking the Node.js event loop.
|
|
*
|
|
* @returns {Promise<{ success: boolean, binaryPath: string|null, error?: string, duration?: number }>}
|
|
*/
|
|
async function buildGoServer(preferredGoBinPath = null) {
|
|
const serverDir = COMPONENTS.server.localRoot;
|
|
if (!fs.existsSync(path.join(serverDir, 'go.mod'))) {
|
|
return { success: false, binaryPath: null, error: 'go.mod not found — server source incomplete' };
|
|
}
|
|
|
|
const goCheck = preferredGoBinPath
|
|
? (probeGoBinary(preferredGoBinPath, 'vendored') || checkGoAvailable())
|
|
: checkGoAvailable();
|
|
if (!goCheck.available) {
|
|
return { success: false, binaryPath: null, error: 'Go toolchain not installed. Install Go from https://go.dev/dl/' };
|
|
}
|
|
if (!goCheck.meetsMinimum) {
|
|
return {
|
|
success: false,
|
|
binaryPath: null,
|
|
error: `Go ${GO_MIN_VERSION}+ is required. Found ${goCheck.version || 'unknown Go version'}.`
|
|
};
|
|
}
|
|
|
|
const binaryName = IS_WINDOWS ? 'betterdesk-server.exe' : 'betterdesk-server';
|
|
const outputPath = path.join(serverDir, binaryName);
|
|
const start = Date.now();
|
|
const goBin = goCheck.binPath || 'go';
|
|
const buildEnv = buildEnvWithGo(goBin);
|
|
|
|
try {
|
|
await spawnPromise(goBin, ['mod', 'download'], {
|
|
cwd: serverDir,
|
|
timeout: 120000,
|
|
env: buildEnv
|
|
});
|
|
|
|
await spawnPromise(
|
|
goBin,
|
|
['build', '-trimpath', '-ldflags=-s -w', '-o', binaryName, '.'],
|
|
{ cwd: serverDir, timeout: 600000, env: buildEnv }
|
|
);
|
|
|
|
if (!fs.existsSync(outputPath)) {
|
|
return { success: false, binaryPath: null, error: 'Build completed but binary not found' };
|
|
}
|
|
|
|
return { success: true, binaryPath: outputPath, duration: Date.now() - start, goVersion: goCheck.version, goSource: goCheck.source };
|
|
} catch (err) {
|
|
const stderr = (err.stderr || '').toString().slice(0, 500);
|
|
return { success: false, binaryPath: null, error: `Build failed: ${stderr || err.message}`.trim() };
|
|
}
|
|
}
|
|
|
|
// ---------- Vendored Go toolchain bootstrap ----------
|
|
|
|
const GO_TOOLCHAIN_DIR = path.join(config.dataDir, 'go-toolchain');
|
|
// Minimum Go version required to build the server (must match
|
|
// betterdesk-server/go.mod). The actual point release is selected at
|
|
// install time from the live go.dev manifest.
|
|
const GO_MIN_VERSION = '1.23.0';
|
|
|
|
function getToolchainKey() {
|
|
const arch = process.arch === 'arm64' ? 'arm64' : 'amd64';
|
|
if (IS_WINDOWS) return { os: 'windows', arch, kind: 'archive' };
|
|
if (process.platform === 'darwin') return { os: 'darwin', arch, kind: 'archive' };
|
|
return { os: 'linux', arch, kind: 'archive' };
|
|
}
|
|
|
|
/**
|
|
* Compare semantic-ish Go versions ("go1.23.4" or "1.23.4").
|
|
* Returns >0 if a > b, 0 if equal, <0 if a < b.
|
|
*/
|
|
function compareGoVersion(a, b) {
|
|
const norm = (v) => String(v || '').replace(/^go/, '').split(/[^\d]+/).map(Number).filter(Number.isFinite);
|
|
const aa = norm(a), bb = norm(b);
|
|
const len = Math.max(aa.length, bb.length);
|
|
for (let i = 0; i < len; i++) {
|
|
const x = aa[i] || 0, y = bb[i] || 0;
|
|
if (x !== y) return x - y;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function goBuildCacheDir() {
|
|
return path.join(config.dataDir || path.join(__dirname, '..', 'data'), 'build-cache');
|
|
}
|
|
|
|
function goEnvForBin(binPath) {
|
|
const goroot = path.dirname(path.dirname(binPath));
|
|
const cacheRoot = goBuildCacheDir();
|
|
const gocache = path.join(cacheRoot, 'gocache');
|
|
const gomodcache = path.join(cacheRoot, 'gomod');
|
|
try {
|
|
fs.mkdirSync(gocache, { recursive: true });
|
|
fs.mkdirSync(gomodcache, { recursive: true });
|
|
} catch (_) { /* best effort */ }
|
|
return {
|
|
...process.env,
|
|
GOROOT: goroot,
|
|
GO111MODULE: 'off',
|
|
GOCACHE: gocache,
|
|
GOMODCACHE: gomodcache,
|
|
HOME: cacheRoot,
|
|
PATH: `${path.dirname(binPath)}:${process.env.PATH || ''}`,
|
|
};
|
|
}
|
|
|
|
/** Verify stdlib can compile — go list is unreliable on some Go installs. */
|
|
function goStdlibHealthy(binPath) {
|
|
if (!binPath || !fs.existsSync(binPath)) return false;
|
|
const goEnv = goEnvForBin(binPath);
|
|
try {
|
|
execFileSync(binPath, ['version'], { timeout: 10000, stdio: 'pipe', env: goEnv });
|
|
} catch {
|
|
return false;
|
|
}
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bd-go-probe-'));
|
|
try {
|
|
const src = path.join(tmpDir, 'probe.go');
|
|
const out = path.join(tmpDir, IS_WINDOWS ? 'probe.exe' : 'probe');
|
|
fs.writeFileSync(src, 'package main\nimport _ "encoding/json"\nfunc main() {}\n');
|
|
execFileSync(binPath, ['build', '-o', out, src], {
|
|
timeout: 120000,
|
|
stdio: 'pipe',
|
|
env: goEnv,
|
|
cwd: tmpDir,
|
|
});
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
} finally {
|
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) { /* ok */ }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Download a binary file via HTTPS (with up to 5 redirects).
|
|
* @returns {Promise<Buffer>}
|
|
*/
|
|
function httpsDownload(url, redirects = 5) {
|
|
return new Promise((resolve, reject) => {
|
|
if (redirects < 0) return reject(new Error('Too many redirects'));
|
|
const req = https.get(url, { headers: { 'User-Agent': USER_AGENT } }, (res) => {
|
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
res.resume();
|
|
return resolve(httpsDownload(res.headers.location, redirects - 1));
|
|
}
|
|
if (res.statusCode !== 200) {
|
|
res.resume();
|
|
return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
|
|
}
|
|
const chunks = [];
|
|
res.on('data', (c) => chunks.push(c));
|
|
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
res.on('error', reject);
|
|
});
|
|
req.on('error', reject);
|
|
req.setTimeout(600000, () => req.destroy(new Error('Download timeout')));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Resolve the latest stable Go release for this OS/arch by querying
|
|
* https://go.dev/dl/?mode=json. Returns the asset metadata including
|
|
* the canonical SHA-256 sum so the download can be verified securely.
|
|
*/
|
|
async function resolveGoRelease(opts = {}) {
|
|
// Go 1.26.x ships incomplete stdlib on some hosts; cap agent/server builds at 1.25.x.
|
|
const maxVersion = opts.maxVersion || '1.25.99';
|
|
const key = getToolchainKey();
|
|
const data = await httpsDownload('https://go.dev/dl/?mode=json');
|
|
const releases = JSON.parse(data.toString('utf8'));
|
|
if (!Array.isArray(releases) || !releases.length) {
|
|
throw new Error('go.dev manifest empty');
|
|
}
|
|
// Pick the highest stable release that meets GO_MIN_VERSION (optionally capped).
|
|
let stable = releases
|
|
.filter(r => r.stable && compareGoVersion(r.version, GO_MIN_VERSION) >= 0);
|
|
if (maxVersion) {
|
|
stable = stable.filter(r => compareGoVersion(r.version, maxVersion) <= 0);
|
|
}
|
|
stable.sort((a, b) => compareGoVersion(b.version, a.version));
|
|
const target = stable[0] || releases[0];
|
|
const ext = key.os === 'windows' ? 'zip' : 'tar.gz';
|
|
const file = (target.files || []).find(f =>
|
|
f.os === key.os && f.arch === key.arch && f.kind === 'archive' && f.filename && f.filename.endsWith(ext)
|
|
);
|
|
if (!file || !file.sha256 || !file.filename) {
|
|
throw new Error(`No Go ${target.version} archive for ${key.os}/${key.arch}`);
|
|
}
|
|
return {
|
|
version: target.version,
|
|
filename: file.filename,
|
|
sha256: file.sha256,
|
|
size: file.size || 0,
|
|
url: `https://go.dev/dl/${file.filename}`
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Install (or refresh) the Go toolchain into data/go-toolchain/.
|
|
* Serialized — concurrent callers share one download/extract.
|
|
*/
|
|
let _installGoInFlight = null;
|
|
|
|
async function installGoToolchain(onProgress, opts = {}) {
|
|
if (_installGoInFlight) return _installGoInFlight;
|
|
_installGoInFlight = _installGoToolchainBody(onProgress, opts).finally(() => {
|
|
_installGoInFlight = null;
|
|
});
|
|
return _installGoInFlight;
|
|
}
|
|
|
|
async function _installGoToolchainBody(onProgress, opts = {}) {
|
|
const log = (phase, detail) => { try { (onProgress || (() => {}))(phase, detail); } catch (_e) { /* ignore */ } };
|
|
const goRoot = path.join(GO_TOOLCHAIN_DIR, 'go');
|
|
const goBin = path.join(goRoot, 'bin', IS_WINDOWS ? 'go.exe' : 'go');
|
|
|
|
fs.mkdirSync(GO_TOOLCHAIN_DIR, { recursive: true });
|
|
|
|
let installed = null;
|
|
if (fs.existsSync(goBin)) {
|
|
installed = probeGoBinary(goBin, 'vendored');
|
|
if (installed && !goStdlibHealthy(goBin)) {
|
|
log('repairing', 'vendored Go stdlib incomplete — reinstalling');
|
|
installed = null;
|
|
try { fs.rmSync(goRoot, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
let release;
|
|
try {
|
|
log('resolving', 'go.dev/dl');
|
|
release = await resolveGoRelease(opts);
|
|
} catch (err) {
|
|
if (installed?.meetsMinimum && goStdlibHealthy(goBin)) {
|
|
log('ready', installed.version);
|
|
return { success: true, binPath: goBin, version: installed.version, reused: true };
|
|
}
|
|
return { success: false, binPath: null, version: null, error: `Cannot resolve Go release: ${err.message}` };
|
|
}
|
|
|
|
if (installed?.versionNumber && compareGoVersion(installed.versionNumber, release.version) >= 0) {
|
|
if (goStdlibHealthy(goBin)) {
|
|
log('ready', installed.version);
|
|
return { success: true, binPath: goBin, version: installed.version, reused: true };
|
|
}
|
|
log('repairing', 'vendored Go stdlib incomplete — reinstalling');
|
|
try { fs.rmSync(goRoot, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
|
|
installed = null;
|
|
}
|
|
|
|
if (installed?.version) {
|
|
log('updating', `${installed.versionNumber || installed.version} → ${release.version}`);
|
|
}
|
|
|
|
const archivePath = path.join(GO_TOOLCHAIN_DIR, release.filename);
|
|
try {
|
|
log('downloading', `${release.version} (${Math.round((release.size || 0) / 1048576)} MB)`);
|
|
const buf = await httpsDownload(release.url);
|
|
|
|
const crypto = require('crypto');
|
|
const sha = crypto.createHash('sha256').update(buf).digest('hex');
|
|
if (sha.toLowerCase() !== release.sha256.toLowerCase()) {
|
|
return {
|
|
success: false, binPath: null, version: null,
|
|
error: `Go toolchain checksum mismatch (expected ${release.sha256.slice(0, 12)}…, got ${sha.slice(0, 12)}…)`
|
|
};
|
|
}
|
|
fs.writeFileSync(archivePath, buf);
|
|
log('extracting', release.filename);
|
|
|
|
if (fs.existsSync(goRoot)) {
|
|
try { fs.rmSync(goRoot, { recursive: true, force: true }); } catch (_e) { /* ignore */ }
|
|
}
|
|
|
|
if (IS_WINDOWS) {
|
|
await execPromise(
|
|
`powershell -NoProfile -Command "Expand-Archive -Force -Path '${archivePath.replace(/'/g, "''")}' -DestinationPath '${GO_TOOLCHAIN_DIR.replace(/'/g, "''")}'"`,
|
|
{ timeout: 300000 }
|
|
);
|
|
} else {
|
|
await spawnPromise('tar', ['-xzf', archivePath, '-C', GO_TOOLCHAIN_DIR], { timeout: 300000 });
|
|
}
|
|
|
|
try { fs.unlinkSync(archivePath); } catch (_e) { /* ignore */ }
|
|
|
|
if (!fs.existsSync(goBin)) {
|
|
return { success: false, binPath: null, version: null, error: 'Extraction succeeded but go binary not found' };
|
|
}
|
|
if (!IS_WINDOWS) {
|
|
try { fs.chmodSync(goBin, 0o755); } catch (_e) { /* ignore */ }
|
|
}
|
|
|
|
const v = execSync(`"${goBin}" version`, { timeout: 5000, stdio: 'pipe' }).toString().trim();
|
|
if (!goStdlibHealthy(goBin)) {
|
|
return { success: false, binPath: null, version: null, error: 'Go toolchain extracted but stdlib verification failed' };
|
|
}
|
|
log('ready', v);
|
|
return { success: true, binPath: goBin, version: v };
|
|
} catch (err) {
|
|
try { if (fs.existsSync(archivePath)) fs.unlinkSync(archivePath); } catch (_e) { /* ignore */ }
|
|
return { success: false, binPath: null, version: null, error: err.message || String(err) };
|
|
}
|
|
}
|
|
|
|
/** Refresh Linux sudoers/permissions before server binary deploy (issue #182). */
|
|
function syncLinuxPanelUpdatePrivileges() {
|
|
if (IS_WINDOWS) return { skipped: true, reason: 'not-linux' };
|
|
try {
|
|
const modPath = require.resolve('../scripts/linux-ensure-console-user');
|
|
delete require.cache[modPath];
|
|
return require('../scripts/linux-ensure-console-user').ensureLinuxConsoleServiceUser();
|
|
} catch (err) {
|
|
console.warn(`[UPDATE] Linux privilege sync warning: ${err.message}`);
|
|
return { error: err.message || String(err) };
|
|
}
|
|
}
|
|
|
|
function deployServerBinaryPrivileged(builtBinaryPath, targetPath) {
|
|
const scriptPath = resolveDeployScriptPath(ROOT_DIR);
|
|
if (!fs.existsSync(scriptPath)) {
|
|
return { success: false, error: 'Privileged deploy helper not installed' };
|
|
}
|
|
|
|
const payload = JSON.stringify({
|
|
source: builtBinaryPath,
|
|
target: targetPath,
|
|
consoleRoot: ROOT_DIR,
|
|
projectRoot: PROJECT_ROOT,
|
|
serverSourceRoot: COMPONENTS.server.localRoot,
|
|
});
|
|
|
|
try {
|
|
const prefix = !IS_WINDOWS && typeof process.getuid === 'function' && process.getuid() !== 0
|
|
? 'sudo -n '
|
|
: '';
|
|
const out = execSync(`${prefix}${shellQuote(scriptPath)}`, {
|
|
input: payload,
|
|
timeout: 120000,
|
|
encoding: 'utf8',
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
}).trim();
|
|
const parsed = JSON.parse(out || '{}');
|
|
if (!parsed.success) {
|
|
return { success: false, error: parsed.error || 'Privileged deploy failed' };
|
|
}
|
|
return {
|
|
success: true,
|
|
backupPath: parsed.backupPath || null,
|
|
method: 'privileged',
|
|
};
|
|
} catch (err) {
|
|
let detail = err.message || String(err);
|
|
if (err.stdout) {
|
|
try {
|
|
const parsed = JSON.parse(String(err.stdout).trim());
|
|
if (parsed.error) detail = parsed.error;
|
|
} catch (_e) { /* ignore */ }
|
|
}
|
|
return { success: false, error: detail };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deploy the compiled binary to the service installation path.
|
|
* Creates a timestamped backup of the existing binary first.
|
|
*
|
|
* @param {string} builtBinaryPath Path to the newly compiled binary
|
|
* @param {string} targetPath Service binary path
|
|
* @returns {{ success: boolean, backupPath?: string, error?: string, method?: string }}
|
|
*/
|
|
function deployServerBinary(builtBinaryPath, targetPath) {
|
|
if (!builtBinaryPath || !fs.existsSync(builtBinaryPath)) {
|
|
return { success: false, error: 'Compiled binary not found' };
|
|
}
|
|
if (!targetPath) {
|
|
return { success: false, error: 'Target binary path not detected — set BETTERDESK_SERVER_BINARY env var' };
|
|
}
|
|
|
|
if (IS_WINDOWS) {
|
|
let backupPath = null;
|
|
if (fs.existsSync(targetPath)) {
|
|
backupPath = targetPath + '.bak.' + Date.now();
|
|
try {
|
|
fs.copyFileSync(targetPath, backupPath);
|
|
} catch (err) {
|
|
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(builtBinaryPath, stagingPath);
|
|
if (fs.existsSync(targetPath)) {
|
|
const lockedAside = targetPath + '.old.' + Date.now();
|
|
try { fs.renameSync(targetPath, lockedAside); } catch (_e) { /* may fail if not locked */ }
|
|
}
|
|
try {
|
|
fs.renameSync(stagingPath, targetPath);
|
|
} catch (renameErr) {
|
|
try {
|
|
fs.copyFileSync(stagingPath, targetPath);
|
|
try { fs.unlinkSync(stagingPath); } catch (_e) { /* ok */ }
|
|
} catch (copyErr) {
|
|
throw renameErr.code === 'ETXTBSY' ? renameErr : copyErr;
|
|
}
|
|
}
|
|
return { success: true, backupPath, method: 'direct' };
|
|
} 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}` };
|
|
}
|
|
}
|
|
|
|
const capability = assessServerBinaryDeployCapability(targetPath, { consoleRoot: ROOT_DIR });
|
|
if (capability.ready && capability.method === 'privileged') {
|
|
return deployServerBinaryPrivileged(builtBinaryPath, targetPath);
|
|
}
|
|
|
|
const direct = deployServerBinaryAtomic(builtBinaryPath, targetPath);
|
|
if (direct.success) {
|
|
return { ...direct, method: 'direct' };
|
|
}
|
|
|
|
if (/EACCES|EPERM|permission denied/i.test(direct.error || '')) {
|
|
const privileged = deployServerBinaryPrivileged(builtBinaryPath, targetPath);
|
|
if (privileged.success) return privileged;
|
|
}
|
|
|
|
return direct;
|
|
}
|
|
|
|
/**
|
|
* Determine the expected binary asset name for this platform+arch on GitHub Releases.
|
|
* @returns {string}
|
|
*/
|
|
function getReleaseBinaryName() {
|
|
const arch = process.arch === 'arm64' ? 'arm64' : 'amd64';
|
|
if (IS_WINDOWS) return `betterdesk-server-windows-${arch}.exe`;
|
|
const os = process.platform === 'darwin' ? 'darwin' : 'linux';
|
|
return `betterdesk-server-${os}-${arch}`;
|
|
}
|
|
|
|
/**
|
|
* Check if a pre-built binary is available on GitHub Releases.
|
|
* Looks for the latest release, then for a binary asset matching the current OS/arch.
|
|
*
|
|
* @returns {Promise<{ available: boolean, downloadUrl: string|null, releaseName: string|null, releaseTag: string|null, assetSize: number|null }>}
|
|
*/
|
|
async function checkPrebuiltAvailable() {
|
|
try {
|
|
const release = await ghGet(`/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases/latest`);
|
|
if (!release || !release.assets || !release.assets.length) {
|
|
return { available: false, downloadUrl: null, releaseName: null, releaseTag: null, assetSize: null };
|
|
}
|
|
|
|
const binaryName = getReleaseBinaryName();
|
|
// Also check common alternative names (without os-arch suffix for Windows)
|
|
const altNames = IS_WINDOWS
|
|
? [binaryName, 'betterdesk-server.exe']
|
|
: [binaryName, `betterdesk-server-${process.platform === 'darwin' ? 'darwin' : 'linux'}`];
|
|
|
|
const asset = release.assets.find(a =>
|
|
altNames.some(name => a.name === name || a.name.toLowerCase() === name.toLowerCase())
|
|
);
|
|
|
|
if (asset) {
|
|
return {
|
|
available: true,
|
|
downloadUrl: asset.browser_download_url,
|
|
releaseName: release.name || release.tag_name,
|
|
releaseTag: release.tag_name,
|
|
assetSize: asset.size || null
|
|
};
|
|
}
|
|
|
|
return { available: false, downloadUrl: null, releaseName: release.name, releaseTag: release.tag_name, assetSize: null };
|
|
} catch (_e) {
|
|
return { available: false, downloadUrl: null, releaseName: null, releaseTag: null, assetSize: null };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Download a pre-built binary from a URL.
|
|
* Validates the download is non-empty and reasonable size.
|
|
*
|
|
* @param {string} downloadUrl
|
|
* @returns {Promise<{ success: boolean, binaryPath: string|null, error?: string, size?: number }>}
|
|
*/
|
|
async function downloadPrebuiltBinary(downloadUrl) {
|
|
if (!downloadUrl || typeof downloadUrl !== 'string' || !downloadUrl.startsWith('https://')) {
|
|
return { success: false, binaryPath: null, error: 'Invalid download URL' };
|
|
}
|
|
|
|
const serverDir = COMPONENTS.server.localRoot;
|
|
fs.mkdirSync(serverDir, { recursive: true });
|
|
|
|
const binaryName = IS_WINDOWS ? 'betterdesk-server.exe' : 'betterdesk-server';
|
|
const outputPath = path.join(serverDir, binaryName);
|
|
|
|
try {
|
|
const data = await new Promise((resolve, reject) => {
|
|
const headers = { 'User-Agent': USER_AGENT, 'Accept': 'application/octet-stream' };
|
|
if (GITHUB_TOKEN) headers['Authorization'] = `Bearer ${GITHUB_TOKEN}`;
|
|
|
|
const follow = (target, redirects = 0) => {
|
|
if (redirects > 5) return reject(new Error('Too many redirects'));
|
|
const url = new URL(target);
|
|
const mod = url.protocol === 'https:' ? https : require('http');
|
|
const req = mod.get({ hostname: url.hostname, path: url.pathname + url.search, headers }, (res) => {
|
|
if ((res.statusCode === 301 || res.statusCode === 302) && res.headers.location) {
|
|
return follow(res.headers.location, redirects + 1);
|
|
}
|
|
if (res.statusCode !== 200) {
|
|
return reject(new Error(`Download failed: HTTP ${res.statusCode}`));
|
|
}
|
|
const chunks = [];
|
|
res.on('data', c => chunks.push(c));
|
|
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
});
|
|
req.on('error', reject);
|
|
req.setTimeout(120000, () => { req.destroy(); reject(new Error('Download timeout (120s)')); });
|
|
};
|
|
follow(downloadUrl);
|
|
});
|
|
|
|
// Sanity check: binary should be at least 1MB
|
|
if (!data || data.length < 1024 * 1024) {
|
|
return { success: false, binaryPath: null, error: `Downloaded file too small (${data ? data.length : 0} bytes) — likely not a valid binary` };
|
|
}
|
|
|
|
fs.writeFileSync(outputPath, data);
|
|
if (!IS_WINDOWS) {
|
|
try { fs.chmodSync(outputPath, 0o755); } catch (_e) { /* ok */ }
|
|
}
|
|
|
|
return { success: true, binaryPath: outputPath, size: data.length };
|
|
} catch (err) {
|
|
return { success: false, binaryPath: null, error: `Binary download failed: ${err.message}` };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get server update readiness info for the UI.
|
|
* Returns information about all available update strategies.
|
|
*/
|
|
function getServerUpdateInfo() {
|
|
const goInfo = checkGoAvailable();
|
|
const binaryPath = detectServerBinaryPath();
|
|
const sourcePresent = fs.existsSync(path.join(COMPONENTS.server.localRoot || '', 'go.mod'));
|
|
const vendoredGoPath = path.join(GO_TOOLCHAIN_DIR, 'go', 'bin', IS_WINDOWS ? 'go.exe' : 'go');
|
|
const vendoredGoInstalled = fs.existsSync(vendoredGoPath);
|
|
|
|
return {
|
|
goAvailable: goInfo.available,
|
|
goVersion: goInfo.version,
|
|
goVersionNumber: goInfo.versionNumber,
|
|
goPath: goInfo.binPath,
|
|
goSource: goInfo.source, // 'path' | 'system' | 'vendored' | null
|
|
goMeetsMinimum: goInfo.meetsMinimum,
|
|
goNeedsUpgrade: goInfo.needsUpgrade,
|
|
goMinimumVersion: GO_MIN_VERSION,
|
|
vendoredGoInstalled,
|
|
canInstallGo: true, // toolchain bootstrap is always available
|
|
binaryPath,
|
|
sourcePresent,
|
|
canAutoUpdate: true,
|
|
// Platform info for binary matching
|
|
platform: IS_WINDOWS ? 'windows' : process.platform,
|
|
arch: process.arch === 'arm64' ? 'arm64' : 'amd64',
|
|
expectedBinary: getReleaseBinaryName()
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check pre-built binary availability (async — called separately from getServerUpdateInfo).
|
|
*/
|
|
async function getPrebuiltInfo() {
|
|
return checkPrebuiltAvailable();
|
|
}
|
|
|
|
function getAutoUpdateComponents(changedData) {
|
|
const grouped = changedData?.grouped || {};
|
|
return ['console', 'scripts', 'server'].filter(component => grouped[component]?.length > 0);
|
|
}
|
|
|
|
// ======================== Public API ====================================
|
|
|
|
/**
|
|
* Check for updates by comparing local commit SHA with remote HEAD.
|
|
*/
|
|
async function checkForUpdates() {
|
|
bootstrapDockerImageDeployment();
|
|
|
|
const localVersion = getLocalVersion();
|
|
const localSHA = getLocalSHA();
|
|
const remote = await getRemoteHeadSHA();
|
|
const dockerImageMode = isImageBasedDockerDeployment();
|
|
|
|
// No baseline yet → establish one (native installs only)
|
|
if (!localSHA) {
|
|
if (dockerImageMode) {
|
|
return withDeploymentMeta({
|
|
localVersion,
|
|
localSHA: null,
|
|
remoteSHA: remote.sha,
|
|
updateAvailable: false,
|
|
commitsBehind: 0,
|
|
latestMessage: remote.message,
|
|
latestDate: remote.date,
|
|
latestAuthor: remote.author,
|
|
components: {},
|
|
dockerShaUnknown: true
|
|
});
|
|
}
|
|
saveLocalSHA(remote.sha);
|
|
return withDeploymentMeta({
|
|
localVersion,
|
|
localSHA: remote.sha,
|
|
remoteSHA: remote.sha,
|
|
updateAvailable: false,
|
|
baselineEstablished: true,
|
|
commitsBehind: 0,
|
|
latestMessage: remote.message,
|
|
latestDate: remote.date,
|
|
latestAuthor: remote.author,
|
|
components: {}
|
|
});
|
|
}
|
|
|
|
// Already at HEAD
|
|
if (localSHA.startsWith(remote.sha.slice(0, 7)) || remote.sha.startsWith(localSHA.slice(0, 7)) || localSHA === remote.sha) {
|
|
try {
|
|
resolveLastUpdateResultForDisplay(config.dataDir, {
|
|
rootDir: ROOT_DIR,
|
|
localSHA,
|
|
remoteSHA: remote.sha,
|
|
});
|
|
} catch (_) { /* best-effort stale banner cleanup */ }
|
|
|
|
return withDeploymentMeta({
|
|
localVersion,
|
|
localSHA,
|
|
remoteSHA: remote.sha,
|
|
updateAvailable: false,
|
|
commitsBehind: 0,
|
|
latestMessage: remote.message,
|
|
latestDate: remote.date,
|
|
latestAuthor: remote.author,
|
|
components: {}
|
|
});
|
|
}
|
|
|
|
// Compare
|
|
let compare;
|
|
try {
|
|
compare = await ghGet(`/repos/${GITHUB_OWNER}/${GITHUB_REPO}/compare/${localSHA}...${remote.sha}`);
|
|
} catch (err) {
|
|
// SHA may have been force-pushed away
|
|
return withDeploymentMeta({
|
|
localVersion,
|
|
localSHA,
|
|
remoteSHA: remote.sha,
|
|
updateAvailable: true,
|
|
commitsBehind: -1,
|
|
latestMessage: remote.message,
|
|
latestDate: remote.date,
|
|
latestAuthor: remote.author,
|
|
components: {},
|
|
compareError: err.message
|
|
});
|
|
}
|
|
|
|
const files = (compare.files || []).filter(f => !isExcluded(f.filename));
|
|
const componentSummary = {};
|
|
for (const file of files) {
|
|
const comp = classifyFile(file.filename);
|
|
if (!componentSummary[comp]) {
|
|
componentSummary[comp] = {
|
|
changed: true,
|
|
fileCount: 0,
|
|
label: COMPONENTS[comp]?.label || 'Other',
|
|
autoUpdate: COMPONENTS[comp]?.autoUpdate ?? false
|
|
};
|
|
}
|
|
componentSummary[comp].fileCount++;
|
|
}
|
|
|
|
return withDeploymentMeta({
|
|
localVersion,
|
|
localSHA,
|
|
remoteSHA: remote.sha,
|
|
updateAvailable: files.length > 0,
|
|
commitsBehind: compare.total_commits || (compare.commits || []).length,
|
|
latestMessage: remote.message,
|
|
latestDate: remote.date,
|
|
latestAuthor: remote.author,
|
|
components: componentSummary
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get detailed list of changed files between local SHA and the given remote SHA.
|
|
* Returns files grouped by component plus a flat list and recent commits.
|
|
*/
|
|
async function getChangedFiles(remoteSHA) {
|
|
const localSHA = getLocalSHA();
|
|
if (!localSHA) throw new Error('No local baseline SHA — run update check first');
|
|
if (!/^[0-9a-f]{7,40}$/i.test(remoteSHA)) throw new Error('Invalid remote SHA');
|
|
|
|
const compare = await ghGet(`/repos/${GITHUB_OWNER}/${GITHUB_REPO}/compare/${localSHA}...${remoteSHA}`);
|
|
const files = (compare.files || []).filter(f => !isExcluded(f.filename));
|
|
|
|
const grouped = { console: [], server: [], agent: [], supportAgent: [], scripts: [], other: [] };
|
|
|
|
for (const f of files) {
|
|
const comp = classifyFile(f.filename);
|
|
const entry = {
|
|
path: f.filename,
|
|
status: f.status || 'modified',
|
|
sha: f.sha || '',
|
|
component: comp
|
|
};
|
|
if (comp === 'console') {
|
|
entry.localPath = f.filename.slice(COMPONENTS.console.prefix.length);
|
|
} else if (comp === 'scripts') {
|
|
entry.localPath = f.filename;
|
|
}
|
|
(grouped[comp] || grouped.other).push(entry);
|
|
}
|
|
|
|
return {
|
|
files: files.map(f => ({
|
|
path: f.filename,
|
|
status: f.status || 'modified',
|
|
component: classifyFile(f.filename)
|
|
})),
|
|
grouped,
|
|
totalFiles: files.length,
|
|
compareTruncated: isCompareLikelyTruncated(files.length),
|
|
commitsBehind: compare.total_commits || (compare.commits || []).length,
|
|
commits: (compare.commits || []).slice(-30).reverse().map(c => ({
|
|
sha: c.sha?.slice(0, 7),
|
|
message: (c.commit?.message || '').split('\n')[0],
|
|
date: c.commit?.committer?.date || '',
|
|
author: c.commit?.author?.name || ''
|
|
}))
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a pre-update backup of console files that will be changed.
|
|
*/
|
|
async function createPreUpdateBackup(allFiles) {
|
|
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
const backupName = `pre-update-${ts}`;
|
|
const backupPath = resolveChildPath(path.resolve(BACKUP_DIR), backupName);
|
|
fs.mkdirSync(backupPath, { recursive: true });
|
|
|
|
const localVersion = getLocalVersion();
|
|
const localSHA = getLocalSHA();
|
|
let backedUp = 0;
|
|
|
|
for (const file of allFiles) {
|
|
if (file.component !== 'console' || !file.localPath) continue;
|
|
const src = resolveConsoleLocalPath(file.localPath);
|
|
if (fs.existsSync(src)) {
|
|
const dest = resolvePathUnderRoot(backupPath, file.localPath);
|
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
fs.copyFileSync(src, dest);
|
|
backedUp++;
|
|
}
|
|
}
|
|
|
|
fs.writeFileSync(resolveChildPath(backupPath, 'manifest.json'), JSON.stringify({
|
|
version: localVersion,
|
|
sha: localSHA,
|
|
timestamp: new Date().toISOString(),
|
|
filesBackedUp: backedUp,
|
|
files: allFiles.filter(f => f.component === 'console' && f.localPath).map(f => f.localPath)
|
|
}, null, 2));
|
|
|
|
// Auto-prune old backups based on retention setting.
|
|
// Resolution order: DB setting `backup_retention_count` → env var
|
|
// BACKUP_RETENTION_COUNT → 0 (keep all). Operator-controlled.
|
|
try {
|
|
let retention = 0;
|
|
try {
|
|
const db = require('./database');
|
|
const dbVal = await db.getSetting('backup_retention_count');
|
|
if (dbVal !== null && dbVal !== undefined && dbVal !== '') {
|
|
retention = parseInt(dbVal, 10);
|
|
}
|
|
} catch (_e) { /* DB unavailable — fall through to env */ }
|
|
if (!Number.isFinite(retention) || retention <= 0) {
|
|
retention = parseInt(process.env.BACKUP_RETENTION_COUNT, 10);
|
|
}
|
|
if (Number.isFinite(retention) && retention > 0) {
|
|
const pruneResult = pruneBackups(retention);
|
|
if (pruneResult.deleted.length) {
|
|
console.log(`[UPDATE] Pruned ${pruneResult.deleted.length} old backup(s) (retention=${retention})`);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn(`[UPDATE] Auto-prune failed: ${err.message}`);
|
|
}
|
|
|
|
return { backupPath, backedUp };
|
|
}
|
|
|
|
/**
|
|
* Merge missing .env keys from .env.example with resolved install paths (issue #158).
|
|
* @returns {{ mode: string, added: string[] }|null}
|
|
*/
|
|
function mergeConsoleEnvAfterUpdate() {
|
|
const envExample = path.join(ROOT_DIR, '.env.example');
|
|
const envTarget = path.join(ROOT_DIR, '.env');
|
|
if (!fs.existsSync(envExample) || !fs.existsSync(envTarget)) return null;
|
|
|
|
const existing = fs.readFileSync(envTarget, 'utf8');
|
|
const { mergeEnvFile, buildEnvSubstitutions } = require('../lib/envMerge');
|
|
const subs = buildEnvSubstitutions({ existingContent: existing, config });
|
|
return mergeEnvFile({
|
|
targetPath: envTarget,
|
|
templatePath: envExample,
|
|
freshInstall: false,
|
|
substitutions: subs
|
|
});
|
|
}
|
|
|
|
/** In-place service definition patch (TLS API flags, HTTP URLs, console user). */
|
|
function patchServiceDefinitions() {
|
|
const goPatch = sanitizeGoServerServiceConfig();
|
|
if (process.platform !== 'linux') {
|
|
return goPatch;
|
|
}
|
|
try {
|
|
const modPath = require.resolve('../scripts/linux-ensure-console-user');
|
|
delete require.cache[modPath];
|
|
const { ensureLinuxConsoleServiceUser } = require('../scripts/linux-ensure-console-user');
|
|
const consolePatch = ensureLinuxConsoleServiceUser();
|
|
if (consolePatch.changed) {
|
|
goPatch.changed = true;
|
|
goPatch.changes.push(...(consolePatch.changes || []));
|
|
}
|
|
if (consolePatch.error) {
|
|
goPatch.consoleUserError = consolePatch.error;
|
|
}
|
|
if (typeof consolePatch.permissionsOk === 'boolean') {
|
|
goPatch.consolePermissionsOk = consolePatch.permissionsOk;
|
|
}
|
|
} catch (err) {
|
|
goPatch.consoleUserError = err.message || String(err);
|
|
goPatch.consolePermissionsOk = false;
|
|
}
|
|
return goPatch;
|
|
}
|
|
|
|
/** Run additive DB/security migrations + verify after console file update. */
|
|
function runPostConsoleSecurityHooks() {
|
|
const out = { verify: null, error: null };
|
|
try {
|
|
execSync('node scripts/security-patch-verify.js', {
|
|
cwd: ROOT_DIR,
|
|
timeout: 120000,
|
|
stdio: 'pipe',
|
|
});
|
|
out.verify = 'ok';
|
|
} catch (err) {
|
|
out.verify = 'failed';
|
|
out.error = (err.stderr && err.stderr.toString()) || err.message || String(err);
|
|
console.warn(`[UPDATE] Security verify warning: ${out.error}`);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Apply update — download changed files, run npm install if needed,
|
|
* update SHA tracking file.
|
|
*
|
|
* @param {string} remoteSHA
|
|
* @param {object} changedData Output of getChangedFiles()
|
|
* @param {object} opts
|
|
* @param {boolean} opts.createBackup default true
|
|
* @param {string} opts.serverStrategy default 'auto'
|
|
*/
|
|
async function applyUpdate(remoteSHA, changedData, opts = {}) {
|
|
if (isImageBasedDockerDeployment()) {
|
|
const hint = getDockerUpdateInstructions().commands.join(' && ');
|
|
throw new Error(`In-app updates are disabled in Docker image deployments. Update containers instead: ${hint}`);
|
|
}
|
|
if (_updateInProgress) throw new Error('Another update is already in progress');
|
|
_updateInProgress = true;
|
|
|
|
try {
|
|
const { createBackup = true } = opts;
|
|
const selectedComponents = getAutoUpdateComponents(changedData);
|
|
|
|
let backupInfo = null;
|
|
if (createBackup) {
|
|
const allFiles = Object.values(changedData.grouped).flat();
|
|
backupInfo = await createPreUpdateBackup(allFiles);
|
|
}
|
|
|
|
const results = {
|
|
applied: [],
|
|
failed: [],
|
|
removed: [],
|
|
skipped: [],
|
|
npmInstalled: false,
|
|
servicesRestarted: [],
|
|
servicesFailed: [],
|
|
backupPath: backupInfo?.backupPath || null,
|
|
backedUp: backupInfo?.backedUp || 0,
|
|
needsConsoleRestart: false,
|
|
needsServerRestart: false,
|
|
needsAgentRestart: false,
|
|
selectedComponents,
|
|
shaSaved: false
|
|
};
|
|
|
|
// ---- Console files ----
|
|
if (selectedComponents.includes('console') && changedData.grouped.console?.length) {
|
|
const consoleFiles = changedData.grouped.console;
|
|
const useFullConsoleSync = !!changedData.compareTruncated;
|
|
|
|
if (useFullConsoleSync) {
|
|
console.warn(
|
|
`[UPDATE] Console compare diff hit the ${GITHUB_COMPARE_FILE_LIMIT}-file GitHub cap`
|
|
+ ' — performing full console tree sync (#173)'
|
|
);
|
|
try {
|
|
const sync = await ensureConsoleSource(remoteSHA, { force: true });
|
|
results.consoleSync = sync;
|
|
results.applied.push('web-nodejs/(full-tree-sync)');
|
|
for (const failure of sync.failed || []) {
|
|
results.failed.push({ file: failure.path, error: failure.error });
|
|
}
|
|
} catch (err) {
|
|
results.failed.push({ file: 'console-source', error: err.message });
|
|
}
|
|
} else {
|
|
for (const file of consoleFiles) {
|
|
try {
|
|
if (file.status === 'removed') {
|
|
const localFile = resolveConsoleLocalPath(file.localPath);
|
|
if (isProtectedRuntimePath(localFile)) { results.skipped.push(file.path); continue; }
|
|
if (fs.existsSync(localFile)) { fs.unlinkSync(localFile); results.removed.push(file.path); }
|
|
continue;
|
|
}
|
|
if (!isConsoleDeployLocalPath(file.localPath)) {
|
|
results.skipped.push(file.path);
|
|
continue;
|
|
}
|
|
const dest = resolveConsoleLocalPath(file.localPath);
|
|
if (isProtectedRuntimePath(dest)) {
|
|
console.warn(`[UPDATE] Refusing to overwrite runtime state file: ${file.path}`);
|
|
results.skipped.push(file.path);
|
|
continue;
|
|
}
|
|
await downloadConsoleFile(remoteSHA, file.localPath);
|
|
results.applied.push(file.path);
|
|
} catch (err) {
|
|
results.failed.push({ file: file.path, error: err.message });
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
const repair = await repairMissingConsoleFiles(remoteSHA, consoleFiles);
|
|
results.consoleRepaired = repair;
|
|
for (const localPath of repair.repaired || []) {
|
|
results.applied.push(`web-nodejs/${localPath} (repair)`);
|
|
}
|
|
for (const failure of repair.failed || []) {
|
|
results.failed.push({
|
|
file: failure.path,
|
|
error: failure.error,
|
|
nonCritical: failure.nonCritical,
|
|
});
|
|
}
|
|
} catch (err) {
|
|
results.failed.push({ file: 'console-repair', error: err.message });
|
|
}
|
|
|
|
// npm install when package.json changed
|
|
if (consoleFiles.some(f => f.localPath === 'package.json')) {
|
|
const npmResult = runConsoleNpmInstall({ rootDir: ROOT_DIR, dataDir: config.dataDir, execSync });
|
|
if (npmResult.success) {
|
|
results.npmInstalled = true;
|
|
} else {
|
|
results.failed.push({
|
|
file: 'npm install',
|
|
error: npmResult.error || 'npm install failed',
|
|
nodeModulesOk: npmResult.nodeModulesOk,
|
|
nonCritical: npmResult.nodeModulesOk,
|
|
});
|
|
console.error(`[UPDATE] npm install failed: ${npmResult.error || 'unknown'}`);
|
|
}
|
|
}
|
|
results.needsConsoleRestart = true;
|
|
}
|
|
|
|
// ---- Script / Docker files ----
|
|
if (selectedComponents.includes('scripts') && changedData.grouped.scripts?.length) {
|
|
for (const file of changedData.grouped.scripts) {
|
|
try {
|
|
if (file.status === 'removed') {
|
|
const dest = path.join(PROJECT_ROOT, file.localPath);
|
|
if (isProtectedRuntimePath(dest)) { results.skipped.push(file.path); continue; }
|
|
if (fs.existsSync(dest)) { fs.unlinkSync(dest); results.removed.push(file.path); }
|
|
continue;
|
|
}
|
|
const dest = path.join(PROJECT_ROOT, file.localPath);
|
|
if (isProtectedRuntimePath(dest)) {
|
|
console.warn(`[UPDATE] Refusing to overwrite runtime state file: ${file.path}`);
|
|
results.skipped.push(file.path);
|
|
continue;
|
|
}
|
|
const content = await ghDownloadFile(GITHUB_OWNER, GITHUB_REPO, remoteSHA, file.path);
|
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
fs.writeFileSync(dest, content);
|
|
if (!IS_WINDOWS && file.localPath.endsWith('.sh')) {
|
|
try { fs.chmodSync(dest, 0o755); } catch (_e) { /* ok */ }
|
|
}
|
|
results.applied.push(file.path);
|
|
} catch (err) {
|
|
const entry = { file: file.path, error: err.message };
|
|
if (err.code === 'EACCES' || /permission denied/i.test(err.message || '')) {
|
|
entry.nonCritical = true;
|
|
console.warn(`[UPDATE] Skipping root-owned script (no write access): ${file.path}`);
|
|
}
|
|
results.failed.push(entry);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Server source files + compile/download + deploy ----
|
|
if (changedData.grouped.server?.length && selectedComponents.includes('server')) {
|
|
if (!IS_WINDOWS) {
|
|
results.linuxPrivilegeSync = syncLinuxPanelUpdatePrivileges();
|
|
}
|
|
|
|
const strategy = opts.serverStrategy || 'auto'; // 'auto', 'compile', 'download', 'install-go'
|
|
let goInfo = checkGoAvailable();
|
|
let goAvailable = goInfo.available && goInfo.meetsMinimum;
|
|
let serverBinaryPath = null;
|
|
let buildUsed = null;
|
|
let preferredGoBinPath = null;
|
|
let prebuiltInfo = null;
|
|
|
|
const getPrebuiltOnce = async () => {
|
|
if (!prebuiltInfo) prebuiltInfo = await checkPrebuiltAvailable();
|
|
return prebuiltInfo;
|
|
};
|
|
|
|
// ---- Strategy: download Go toolchain on demand ----
|
|
// Triggered explicitly ('install-go') or by auto-fallback (no Go + no
|
|
// matching pre-built binary).
|
|
let toolchainInstalled = false;
|
|
if (strategy === 'install-go' && !goAvailable) {
|
|
const tc = await installGoToolchain();
|
|
results.toolchainInstall = {
|
|
success: tc.success,
|
|
version: tc.version || null,
|
|
error: tc.error || null,
|
|
binPath: tc.binPath || null
|
|
};
|
|
if (tc.success) {
|
|
toolchainInstalled = true;
|
|
goAvailable = true;
|
|
preferredGoBinPath = tc.binPath || null;
|
|
}
|
|
} else if (strategy === 'auto' && !goAvailable) {
|
|
// Auto-fallback: only attempt toolchain install if no pre-built
|
|
// release is reachable. This keeps the default path light.
|
|
try {
|
|
const prebuilt = await getPrebuiltOnce();
|
|
if (!prebuilt.available || !prebuilt.downloadUrl) {
|
|
const tc = await installGoToolchain();
|
|
results.toolchainInstall = {
|
|
success: tc.success,
|
|
version: tc.version || null,
|
|
error: tc.error || null,
|
|
binPath: tc.binPath || null,
|
|
autoTriggered: true
|
|
};
|
|
if (tc.success) {
|
|
toolchainInstalled = true;
|
|
goAvailable = true;
|
|
preferredGoBinPath = tc.binPath || null;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('[UPDATE] auto-fallback toolchain install failed:', err.message);
|
|
}
|
|
} else if (strategy === 'compile' && !goAvailable) {
|
|
const tc = await installGoToolchain();
|
|
results.toolchainInstall = {
|
|
success: tc.success,
|
|
version: tc.version || null,
|
|
error: tc.error || null,
|
|
binPath: tc.binPath || null,
|
|
autoTriggered: true
|
|
};
|
|
if (tc.success) {
|
|
toolchainInstalled = true;
|
|
goAvailable = true;
|
|
preferredGoBinPath = tc.binPath || null;
|
|
}
|
|
}
|
|
|
|
const wantsCompile = strategy === 'compile' || strategy === 'install-go' || toolchainInstalled
|
|
|| (strategy === 'auto' && goAvailable);
|
|
|
|
// Keep local Go server source in sync for every server update path.
|
|
// Even when a pre-built binary is used, the next source build must not
|
|
// start from stale files. Force a FULL source resync (issue #158): the
|
|
// GitHub compare diff is capped at 300 files and can omit changed
|
|
// dependency files, leaving the on-disk source inconsistent and
|
|
// unbuildable. A full resync guarantees all callee files are present.
|
|
try {
|
|
const sourceResult = await ensureServerSource(remoteSHA, { force: true });
|
|
console.log(`[UPDATE] Server source: strategy=${sourceResult.strategy}, files=${sourceResult.filesDownloaded}`);
|
|
} catch (err) {
|
|
results.failed.push({ file: 'server-source', error: `Source download failed: ${err.message}` });
|
|
}
|
|
|
|
const serverDir = COMPONENTS.server.localRoot;
|
|
for (const file of changedData.grouped.server) {
|
|
try {
|
|
const localPath = file.path.slice(COMPONENTS.server.prefix.length);
|
|
const dest = path.join(serverDir, localPath);
|
|
if (file.status === 'removed') {
|
|
if (isProtectedRuntimePath(dest)) { results.skipped.push(file.path); continue; }
|
|
if (fs.existsSync(dest)) { fs.unlinkSync(dest); results.removed.push(file.path); }
|
|
continue;
|
|
}
|
|
if (isProtectedRuntimePath(dest)) {
|
|
console.warn(`[UPDATE] Refusing to overwrite runtime state file: ${file.path}`);
|
|
results.skipped.push(file.path);
|
|
continue;
|
|
}
|
|
const content = await ghDownloadFile(GITHUB_OWNER, GITHUB_REPO, remoteSHA, file.path);
|
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
fs.writeFileSync(dest, content);
|
|
results.applied.push(file.path);
|
|
} catch (err) {
|
|
const entry = { file: file.path, error: err.message };
|
|
if (err.code === 'EACCES' || /permission denied/i.test(err.message || '')) {
|
|
entry.nonCritical = true;
|
|
console.warn(`[UPDATE] Skipping root-owned server source file (no write access): ${file.path}`);
|
|
}
|
|
results.failed.push(entry);
|
|
}
|
|
}
|
|
|
|
if (wantsCompile && goAvailable) {
|
|
// ---- Strategy: Compile from source ----
|
|
const buildResult = await buildGoServer(preferredGoBinPath);
|
|
results.serverBuild = {
|
|
success: buildResult.success,
|
|
duration: buildResult.duration || 0,
|
|
error: buildResult.error || null,
|
|
method: 'compile'
|
|
};
|
|
|
|
if (buildResult.success) {
|
|
serverBinaryPath = buildResult.binaryPath;
|
|
buildUsed = 'compile';
|
|
} else {
|
|
results.failed.push({ file: 'betterdesk-server', error: buildResult.error || 'Server build failed' });
|
|
}
|
|
} else {
|
|
// ---- Strategy: Download pre-built binary ----
|
|
console.log('[UPDATE] Go not available or download strategy selected — trying pre-built binary download');
|
|
|
|
// Try to get from GitHub Releases first
|
|
let downloadResult = null;
|
|
const prebuilt = await getPrebuiltOnce();
|
|
|
|
if (prebuilt.available && prebuilt.downloadUrl) {
|
|
downloadResult = await downloadPrebuiltBinary(prebuilt.downloadUrl);
|
|
}
|
|
|
|
// If release download failed, try direct raw download of the binary from the repo tree
|
|
if (!downloadResult || !downloadResult.success) {
|
|
const binaryName = IS_WINDOWS ? 'betterdesk-server.exe' : 'betterdesk-server-linux-amd64';
|
|
const repoPath = `betterdesk-server/${binaryName}`;
|
|
try {
|
|
console.log(`[UPDATE] Trying raw binary download from repo: ${repoPath}`);
|
|
const data = await ghDownloadFile(GITHUB_OWNER, GITHUB_REPO, remoteSHA, repoPath);
|
|
if (data && data.length > 1024 * 1024) {
|
|
const serverDir = COMPONENTS.server.localRoot;
|
|
fs.mkdirSync(serverDir, { recursive: true });
|
|
const outName = IS_WINDOWS ? 'betterdesk-server.exe' : 'betterdesk-server';
|
|
const outputPath = path.join(serverDir, outName);
|
|
fs.writeFileSync(outputPath, data);
|
|
if (!IS_WINDOWS) {
|
|
try { fs.chmodSync(outputPath, 0o755); } catch (_e) { /* ok */ }
|
|
}
|
|
downloadResult = { success: true, binaryPath: outputPath, size: data.length };
|
|
}
|
|
} catch (_e) {
|
|
// Binary not in repo tree — expected
|
|
}
|
|
}
|
|
|
|
if (downloadResult && downloadResult.success) {
|
|
results.serverBuild = {
|
|
success: true,
|
|
duration: 0,
|
|
error: null,
|
|
method: 'download',
|
|
size: downloadResult.size || 0
|
|
};
|
|
serverBinaryPath = downloadResult.binaryPath;
|
|
buildUsed = 'download';
|
|
} else {
|
|
const errMsg = downloadResult?.error || 'No pre-built binary available and Go not installed';
|
|
results.serverBuild = {
|
|
success: false,
|
|
duration: 0,
|
|
error: errMsg,
|
|
method: 'download'
|
|
};
|
|
results.failed.push({ file: 'betterdesk-server', error: errMsg });
|
|
}
|
|
}
|
|
|
|
// 4. Deploy to service path (common for both strategies)
|
|
if (serverBinaryPath) {
|
|
const targetPath = detectServerBinaryPath();
|
|
const deployResult = deployServerBinary(serverBinaryPath, targetPath);
|
|
results.serverDeploy = {
|
|
success: deployResult.success,
|
|
backupPath: deployResult.backupPath || null,
|
|
error: deployResult.error || null,
|
|
method: buildUsed
|
|
};
|
|
|
|
if (deployResult.success) {
|
|
const serviceConfig = sanitizeGoServerServiceConfig();
|
|
results.serverServiceConfig = serviceConfig;
|
|
if (serviceConfig.error) {
|
|
results.failed.push({
|
|
file: 'betterdesk-server.service',
|
|
error: `Service config cleanup failed: ${serviceConfig.error}`,
|
|
nonCritical: true,
|
|
});
|
|
}
|
|
results.needsServerRestart = true;
|
|
// Fresh binary (with updated dependencies) is in place — any
|
|
// previous staleness warning no longer applies.
|
|
clearServerBinaryStale();
|
|
} else {
|
|
results.failed.push({ file: 'betterdesk-server-deploy', error: deployResult.error || 'Server deploy failed' });
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Generator agent: sync agent-source/ + queue bundle rebuilds ----
|
|
if (shouldQueueAgentRebuild(changedData)) {
|
|
try {
|
|
const agentBuildWorker = require('./agentBuildWorker');
|
|
const stageResult = await agentBuildWorker.syncFullAgentSourceFromGitHub({
|
|
remoteSHA,
|
|
download: ghDownloadFile,
|
|
listPaths: ghListRepoBlobPaths,
|
|
});
|
|
agentBuildWorker.markRebuildPending('in-app update');
|
|
results.agentSourcesStaged = stageResult.staged;
|
|
results.agentSourcePaths = stageResult.paths;
|
|
results.agentRebuildQueued = true;
|
|
console.log(
|
|
`[UPDATE] Agent source tree synced (${stageResult.staged}/${stageResult.paths} file(s));`
|
|
+ ' generator bundles queued for rebuild on console restart'
|
|
);
|
|
} catch (err) {
|
|
results.failed.push({ file: 'support-agent-source-sync', error: err.message, nonCritical: true });
|
|
console.warn(`[UPDATE] Full agent-source sync failed: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
// ---- Update SHA tracking ----
|
|
const { critical: criticalFailures, nonCritical: nonCriticalFailures } = splitUpdateFailures(
|
|
results.failed,
|
|
ROOT_DIR
|
|
);
|
|
results.criticalFailures = criticalFailures;
|
|
results.nonCriticalFailures = nonCriticalFailures;
|
|
|
|
// Security visibility: if the Go server source changed
|
|
// dependency bump shipping a security fix) but the binary could not be
|
|
// rebuilt/deployed, the running process is still the OLD binary. Persist a
|
|
// staleness marker and flag it on the result so the panel can warn the
|
|
// admin and offer an explicit rebuild instead of silently reporting
|
|
// success.
|
|
const serverSourceChanged = !!changedData.grouped.server?.length;
|
|
const serverBinaryFailed = nonCriticalFailures.some(f =>
|
|
f.file === 'betterdesk-server' || f.file === 'betterdesk-server-deploy'
|
|
);
|
|
if (serverSourceChanged && serverBinaryFailed && !results.needsServerRestart) {
|
|
const detail = (results.serverBuild && results.serverBuild.error)
|
|
|| (nonCriticalFailures.find(f => f.file === 'betterdesk-server' || f.file === 'betterdesk-server-deploy') || {}).error
|
|
|| 'Server binary could not be rebuilt';
|
|
markServerBinaryStale({ reason: 'rebuild_failed', detail, sha: remoteSHA });
|
|
results.serverBinaryStale = true;
|
|
results.serverBinaryStaleReason = detail;
|
|
console.warn(`[UPDATE] Server source updated but binary not rebuilt — attempting auto-rebuild: ${detail}`);
|
|
|
|
// Issue #158: retry build/deploy automatically before leaving the install stale.
|
|
try {
|
|
const autoRebuild = await rebuildServerBinary({ sha: remoteSHA, restart: false });
|
|
results.autoRebuild = {
|
|
success: autoRebuild.success,
|
|
error: autoRebuild.error || null,
|
|
steps: autoRebuild.steps || null
|
|
};
|
|
if (autoRebuild.success) {
|
|
clearServerBinaryStale();
|
|
results.serverBinaryStale = false;
|
|
results.serverBinaryStaleReason = null;
|
|
results.needsServerRestart = true;
|
|
console.log('[UPDATE] Auto-rebuild succeeded — server binary deployed');
|
|
} else {
|
|
console.warn(`[UPDATE] Auto-rebuild failed: ${autoRebuild.error || 'unknown'}`);
|
|
}
|
|
} catch (autoErr) {
|
|
results.autoRebuild = { success: false, error: autoErr.message };
|
|
console.warn(`[UPDATE] Auto-rebuild error: ${autoErr.message}`);
|
|
}
|
|
}
|
|
|
|
if (criticalFailures.length === 0) {
|
|
saveLocalSHA(remoteSHA);
|
|
results.shaSaved = true;
|
|
|
|
// ---- Pull remote VERSION file ----
|
|
try {
|
|
const versionContent = await ghDownloadFile(GITHUB_OWNER, GITHUB_REPO, remoteSHA, 'VERSION');
|
|
fs.writeFileSync(path.join(PROJECT_ROOT, 'VERSION'), versionContent);
|
|
} catch (_e) { /* non-critical */ }
|
|
|
|
if (nonCriticalFailures.length > 0) {
|
|
console.log(`[UPDATE] SHA saved despite ${nonCriticalFailures.length} non-critical failure(s): ${nonCriticalFailures.map(f => f.file).join(', ')}`);
|
|
}
|
|
} else {
|
|
results.skipped.push('SHA tracking (critical update steps incomplete)');
|
|
console.error(`[UPDATE] ${criticalFailures.length} critical failure(s): ${criticalFailures.map(f => `${f.file}: ${f.error}`).join(' | ')}`);
|
|
}
|
|
|
|
if (criticalFailures.length === 0) {
|
|
try {
|
|
const merged = mergeConsoleEnvAfterUpdate();
|
|
if (merged) results.envMerged = merged.added || [];
|
|
} catch (err) {
|
|
console.warn(`[UPDATE] .env merge skipped: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
if (serverSourceChanged || results.needsServerRestart || results.needsConsoleRestart) {
|
|
results.servicePatch = patchServiceDefinitions();
|
|
}
|
|
|
|
if (results.needsConsoleRestart && criticalFailures.length === 0) {
|
|
results.securityHooks = runPostConsoleSecurityHooks();
|
|
}
|
|
|
|
return results;
|
|
} finally {
|
|
_updateInProgress = false;
|
|
}
|
|
}
|
|
|
|
/** Sync full support-agent trees from GitHub at the given commit SHA. */
|
|
async function syncAgentSourceAtSha(remoteSHA) {
|
|
const agentBuildWorker = require('./agentBuildWorker');
|
|
return agentBuildWorker.syncFullAgentSourceFromGitHub({
|
|
remoteSHA,
|
|
download: ghDownloadFile,
|
|
listPaths: ghListRepoBlobPaths,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Restart a system service.
|
|
* Returns { success, service, error? }.
|
|
*/
|
|
function restartService(serviceName) {
|
|
try {
|
|
if (IS_WINDOWS) {
|
|
execSync(`nssm restart "${serviceName}"`, { timeout: 30000, stdio: 'pipe' });
|
|
} else {
|
|
runPrivileged(`systemctl restart ${shellQuote(serviceName)}`, { timeout: 30000, stdio: 'pipe' });
|
|
}
|
|
return { success: true, service: serviceName };
|
|
} catch (err) {
|
|
return { success: false, service: serviceName, error: err.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reload systemd unit definitions after editing .service files (Linux only).
|
|
*/
|
|
function daemonReload() {
|
|
if (IS_WINDOWS) {
|
|
return { success: true, skipped: true };
|
|
}
|
|
try {
|
|
runPrivileged('systemctl daemon-reload', { timeout: 10000, stdio: 'pipe' });
|
|
return { success: true };
|
|
} catch (err) {
|
|
return { success: false, error: err.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recursively compute total size in bytes of a directory.
|
|
* Returns 0 on error so the UI can still render.
|
|
*/
|
|
function getDirectorySize(dirPath) {
|
|
let total = 0;
|
|
try {
|
|
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const full = path.join(dirPath, entry.name);
|
|
try {
|
|
if (entry.isDirectory()) {
|
|
total += getDirectorySize(full);
|
|
} else if (entry.isFile()) {
|
|
total += fs.statSync(full).size;
|
|
}
|
|
} catch (_e) { /* skip unreadable entry */ }
|
|
}
|
|
} catch (_e) { /* skip unreadable dir */ }
|
|
return total;
|
|
}
|
|
|
|
/**
|
|
* List pre-update backups (newest first).
|
|
*/
|
|
function listBackups() {
|
|
if (!fs.existsSync(BACKUP_DIR)) return [];
|
|
return fs.readdirSync(BACKUP_DIR)
|
|
.filter(d => d.startsWith('pre-update-'))
|
|
.map(d => {
|
|
const dir = resolveChildPath(path.resolve(BACKUP_DIR), d);
|
|
const mPath = resolveChildPath(dir, 'manifest.json');
|
|
let m = {};
|
|
if (fs.existsSync(mPath)) {
|
|
try { m = JSON.parse(fs.readFileSync(mPath, 'utf8')); } catch (_e) { /* skip */ }
|
|
}
|
|
return {
|
|
name: d,
|
|
path: dir,
|
|
version: m.version || 'unknown',
|
|
sha: (m.sha || '').slice(0, 7),
|
|
timestamp: m.timestamp || '',
|
|
filesBackedUp: m.filesBackedUp || 0,
|
|
fileCount: m.filesBackedUp || 0,
|
|
sizeBytes: getDirectorySize(dir)
|
|
};
|
|
})
|
|
.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
}
|
|
|
|
/**
|
|
* Validate a backup directory name to prevent path traversal.
|
|
* Only allows the canonical `pre-update-{ISO-timestamp}` format.
|
|
*/
|
|
function isValidBackupName(name) {
|
|
return typeof name === 'string' && /^pre-update-[\d\-T]+$/.test(name);
|
|
}
|
|
|
|
function isValidManifestRelativePath(relPath) {
|
|
if (typeof relPath !== 'string' || relPath.length === 0 || relPath.includes('\0')) return false;
|
|
const normalized = relPath.replace(/\\/g, '/');
|
|
if (path.isAbsolute(normalized) || normalized.startsWith('/') || normalized.startsWith('..')) return false;
|
|
if (normalized.split('/').some((seg) => seg === '..')) return false;
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Recursively delete a directory. Refuses to delete anything outside
|
|
* BACKUP_DIR to defend against path-traversal bugs upstream.
|
|
*/
|
|
function deleteBackup(name) {
|
|
if (!isValidBackupName(name)) {
|
|
throw new Error('Invalid backup name');
|
|
}
|
|
const root = path.resolve(BACKUP_DIR);
|
|
if (!existsConfinedChild(root, name)) {
|
|
throw new Error('Backup not found');
|
|
}
|
|
removeConfinedChild(root, name, { recursive: true, force: true });
|
|
return { deleted: name };
|
|
}
|
|
|
|
/**
|
|
* Apply retention: keep the `keep` newest backups, delete older ones.
|
|
* keep <= 0 means "keep everything" (no-op).
|
|
*/
|
|
function pruneBackups(keep) {
|
|
const n = parseInt(keep, 10);
|
|
if (!Number.isFinite(n) || n <= 0) {
|
|
return { kept: -1, deleted: [] };
|
|
}
|
|
const all = listBackups();
|
|
if (all.length <= n) {
|
|
return { kept: n, deleted: [] };
|
|
}
|
|
const toDelete = all.slice(n);
|
|
const deleted = [];
|
|
for (const b of toDelete) {
|
|
try {
|
|
deleteBackup(b.name);
|
|
deleted.push(b.name);
|
|
} catch (err) {
|
|
console.error(`[UPDATE] Failed to prune backup ${b.name}: ${err.message}`);
|
|
}
|
|
}
|
|
return { kept: n, deleted };
|
|
}
|
|
|
|
/**
|
|
* Restore console files from a pre-update backup and revert the SHA.
|
|
*/
|
|
function restoreFromBackup(backupName) {
|
|
if (!isValidBackupName(backupName)) throw new Error('Invalid backup name');
|
|
const backupRoot = path.resolve(BACKUP_DIR);
|
|
if (!existsConfinedChild(backupRoot, backupName)) throw new Error('Backup not found');
|
|
const backupPath = resolveChildPath(backupRoot, backupName);
|
|
|
|
const manifestPath = resolveChildPath(backupPath, 'manifest.json');
|
|
if (!fs.existsSync(manifestPath)) throw new Error('Invalid backup — missing manifest');
|
|
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
let restored = 0;
|
|
for (const filePath of (manifest.files || [])) {
|
|
if (!isValidManifestRelativePath(filePath)) {
|
|
throw new Error(`Invalid path in backup manifest: ${filePath}`);
|
|
}
|
|
const src = resolvePathUnderRoot(backupPath, filePath);
|
|
const dest = resolvePathUnderRoot(ROOT_DIR, filePath);
|
|
if (fs.existsSync(src)) {
|
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
fs.copyFileSync(src, dest);
|
|
restored++;
|
|
}
|
|
}
|
|
|
|
// Revert SHA to the pre-update value
|
|
if (manifest.sha) saveLocalSHA(manifest.sha);
|
|
|
|
return { restored, version: manifest.version, sha: manifest.sha, totalFiles: (manifest.files || []).length };
|
|
}
|
|
|
|
/**
|
|
* Explicitly rebuild the Go server binary from the local source with the
|
|
* current dependency versions (go.mod/go.sum) and deploy it.
|
|
*
|
|
* This is the recovery path for the case where a previous update applied the
|
|
* server source (including security-relevant dependency bumps) but the binary
|
|
* step failed and was left stale. It is idempotent and self-contained:
|
|
* 1. sync the server source to the tracked SHA (when known),
|
|
* 2. ensure a usable Go toolchain (bootstrapping a vendored one if needed),
|
|
* 3. `go mod download` + `go build` to link the updated dependencies,
|
|
* 4. deploy the binary to the service path and sanitize the service config,
|
|
* 5. clear the staleness marker and restart the service.
|
|
*
|
|
* @param {{ sha?: string, restart?: boolean }} opts
|
|
* @returns {Promise<{ success: boolean, error?: string, steps: object }>}
|
|
*/
|
|
async function rebuildServerBinary(opts = {}) {
|
|
if (isImageBasedDockerDeployment()) {
|
|
const hint = getDockerUpdateInstructions().commands.join(' && ');
|
|
return {
|
|
success: false,
|
|
dockerMode: true,
|
|
error: `Server rebuild is not available in Docker image deployments. Update the server container instead: ${hint}`,
|
|
steps: {}
|
|
};
|
|
}
|
|
const result = { success: false, steps: {} };
|
|
const remoteSHA = opts.sha || getLocalSHA();
|
|
|
|
// 1. Sync source so the build links the latest go.mod/go.sum.
|
|
// Force a FULL resync (issue #158) so a previously truncated compare
|
|
// diff cannot leave the local source inconsistent and unbuildable.
|
|
try {
|
|
if (remoteSHA) {
|
|
const src = await ensureServerSource(remoteSHA, { force: true });
|
|
result.steps.source = { success: true, strategy: src.strategy, files: src.filesDownloaded };
|
|
} else {
|
|
result.steps.source = { success: true, strategy: 'local', files: 0 };
|
|
}
|
|
} catch (err) {
|
|
result.steps.source = { success: false, error: err.message };
|
|
result.error = `Source sync failed: ${err.message}`;
|
|
return result;
|
|
}
|
|
|
|
// 2. Ensure a Go toolchain capable of building the server.
|
|
let preferredGoBinPath = null;
|
|
const goInfo = checkGoAvailable();
|
|
if (!goInfo.available || !goInfo.meetsMinimum) {
|
|
const tc = await installGoToolchain();
|
|
result.steps.toolchain = { success: tc.success, version: tc.version || null, error: tc.error || null };
|
|
if (!tc.success) {
|
|
result.error = `Go toolchain unavailable: ${tc.error || 'install failed'}`;
|
|
markServerBinaryStale({ reason: 'no_toolchain', detail: result.error, sha: remoteSHA });
|
|
return result;
|
|
}
|
|
preferredGoBinPath = tc.binPath || null;
|
|
} else {
|
|
result.steps.toolchain = { success: true, version: goInfo.version, existing: true };
|
|
}
|
|
|
|
// 3. Build with the updated dependencies.
|
|
const build = await buildGoServer(preferredGoBinPath);
|
|
result.steps.build = {
|
|
success: build.success,
|
|
duration: build.duration || 0,
|
|
error: build.error || null,
|
|
goVersion: build.goVersion || null
|
|
};
|
|
if (!build.success) {
|
|
result.error = build.error || 'Build failed';
|
|
markServerBinaryStale({ reason: 'rebuild_failed', detail: result.error, sha: remoteSHA });
|
|
return result;
|
|
}
|
|
|
|
// 4. Deploy to the service path.
|
|
if (!IS_WINDOWS) {
|
|
result.steps.linuxPrivilegeSync = syncLinuxPanelUpdatePrivileges();
|
|
}
|
|
const targetPath = detectServerBinaryPath();
|
|
const deploy = deployServerBinary(build.binaryPath, targetPath);
|
|
result.steps.deploy = {
|
|
success: deploy.success,
|
|
backupPath: deploy.backupPath || null,
|
|
error: deploy.error || null,
|
|
targetPath
|
|
};
|
|
if (!deploy.success) {
|
|
result.error = deploy.error || 'Deploy failed';
|
|
markServerBinaryStale({ reason: 'deploy_failed', detail: result.error, sha: remoteSHA });
|
|
return result;
|
|
}
|
|
|
|
// 5. Sanitize the service config and clear the staleness marker.
|
|
result.steps.serviceConfig = sanitizeGoServerServiceConfig();
|
|
clearServerBinaryStale();
|
|
|
|
// 6. Restart the service so the new binary takes effect.
|
|
if (opts.restart !== false) {
|
|
result.steps.restart = restartService(IS_WINDOWS ? 'BetterDeskServer' : 'betterdesk-server');
|
|
}
|
|
|
|
result.success = true;
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Pre-install checks for panel update (issue #158).
|
|
* @returns {Promise<{ ready: boolean, issues: string[], warnings: string[], go: object, prebuiltAvailable: boolean, canBuildServer: boolean }>}
|
|
*/
|
|
async function runUpdatePreflight(opts = {}) {
|
|
const issues = [];
|
|
const warnings = [];
|
|
const serverUpdateRequired = !!opts.serverUpdateRequired;
|
|
|
|
if (isImageBasedDockerDeployment()) {
|
|
const hint = getDockerUpdateInstructions().commands.join(' && ');
|
|
return {
|
|
ready: false,
|
|
issues: [`Docker image deployment — use "${hint}" instead of in-app install`],
|
|
warnings: [],
|
|
go: checkGoAvailable(),
|
|
prebuiltAvailable: false,
|
|
canBuildServer: false,
|
|
dockerImageMode: true,
|
|
dockerUpdate: getDockerUpdateInstructions()
|
|
};
|
|
}
|
|
|
|
try {
|
|
execSync('node --version', { timeout: 5000, stdio: 'pipe' });
|
|
} catch (_e) {
|
|
issues.push('Node.js is not available on PATH');
|
|
}
|
|
|
|
try {
|
|
execSync('npm --version', { timeout: 5000, stdio: 'pipe' });
|
|
} catch (_e) {
|
|
warnings.push('npm is not available — console dependency install may fail');
|
|
}
|
|
|
|
try {
|
|
fs.mkdirSync(config.dataDir, { recursive: true });
|
|
fs.accessSync(config.dataDir, fs.constants.W_OK);
|
|
} catch (_e) {
|
|
issues.push(`Console data directory is not writable: ${config.dataDir}`);
|
|
}
|
|
|
|
try {
|
|
const { ensureConsoleNpmDirs } = require('../lib/consoleNpmInstall');
|
|
ensureConsoleNpmDirs(config.dataDir);
|
|
fs.accessSync(path.join(config.dataDir, 'npm-cache'), fs.constants.W_OK);
|
|
} catch (_e) {
|
|
warnings.push(`Console npm cache is not writable under ${config.dataDir}/npm-cache`);
|
|
}
|
|
|
|
if (process.platform === 'linux' && typeof process.getuid === 'function' && process.getuid() !== 0) {
|
|
try {
|
|
execSync('sudo -n systemctl --version', { timeout: 5000, stdio: 'pipe' });
|
|
} catch (_e) {
|
|
warnings.push('Passwordless sudo for systemctl is not configured — service restarts during update may fail until linux-ensure-console-user.js is run as root');
|
|
}
|
|
}
|
|
|
|
const binaryPath = detectServerBinaryPath();
|
|
if (binaryPath) {
|
|
const capability = assessServerBinaryDeployCapability(binaryPath, { consoleRoot: ROOT_DIR });
|
|
if (!capability.ready) {
|
|
warnings.push(
|
|
`Server binary directory is not writable: ${capability.targetDir || path.dirname(binaryPath)}`
|
|
+ ' — run linux-ensure-console-user.js as root once to enable privileged deploy'
|
|
);
|
|
}
|
|
} else {
|
|
warnings.push('Go server binary path could not be detected — deploy step may require manual repair');
|
|
}
|
|
|
|
let prebuiltAvailable = false;
|
|
try {
|
|
const prebuilt = await checkPrebuiltAvailable();
|
|
prebuiltAvailable = !!(prebuilt && prebuilt.available);
|
|
} catch (_e) { /* optional */ }
|
|
|
|
const goInfo = checkGoAvailable();
|
|
const canBuildServer = prebuiltAvailable || (goInfo.available && goInfo.meetsMinimum);
|
|
|
|
if (!canBuildServer) {
|
|
const msg = 'Neither a compatible Go toolchain nor a pre-built server binary is available';
|
|
if (serverUpdateRequired) {
|
|
issues.push(`${msg} — server update cannot complete`);
|
|
} else {
|
|
warnings.push(`${msg} — server compile may fail (auto-rebuild will retry)`);
|
|
}
|
|
}
|
|
|
|
try {
|
|
await getRemoteHeadSHA();
|
|
} catch (err) {
|
|
issues.push(`Cannot reach GitHub API: ${err.message}`);
|
|
}
|
|
|
|
return {
|
|
ready: issues.length === 0,
|
|
issues,
|
|
warnings,
|
|
go: goInfo,
|
|
prebuiltAvailable,
|
|
canBuildServer
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
getUpdateChannelInfo,
|
|
setUpdateChannel,
|
|
UPDATE_CHANNELS,
|
|
getGithubBranch,
|
|
checkForUpdates,
|
|
getChangedFiles,
|
|
createPreUpdateBackup,
|
|
applyUpdate,
|
|
runUpdatePreflight,
|
|
restartService,
|
|
daemonReload,
|
|
listBackups,
|
|
deleteBackup,
|
|
pruneBackups,
|
|
restoreFromBackup,
|
|
getLocalVersion,
|
|
getLocalSHA,
|
|
saveLocalSHA,
|
|
getRemoteHeadSHA,
|
|
getServerUpdateInfo,
|
|
getPrebuiltInfo,
|
|
installGoToolchain,
|
|
checkGoAvailable,
|
|
getServerBinaryStatus,
|
|
rebuildServerBinary,
|
|
mergeConsoleEnvAfterUpdate,
|
|
patchServiceDefinitions,
|
|
shouldQueueAgentRebuild,
|
|
syncAgentSourceAtSha,
|
|
goStdlibHealthy,
|
|
isImageBasedDockerDeployment,
|
|
getImageEmbeddedSHA,
|
|
bootstrapDockerImageDeployment,
|
|
getDockerUpdateInstructions,
|
|
COMPONENTS,
|
|
NON_CRITICAL_UPDATE_FAILURES,
|
|
isNonCriticalUpdateFailure,
|
|
GITHUB_COMPARE_FILE_LIMIT,
|
|
isCompareLikelyTruncated,
|
|
resolveConsoleRequire,
|
|
collectConsoleRequiredFiles,
|
|
isResolvedByIndexModule,
|
|
getConsoleDeployGraph,
|
|
splitUpdateFailures,
|
|
repairMissingConsoleFiles,
|
|
readLastUpdateResult: () => require('../lib/updateResultStore').readLastUpdateResult(config.dataDir),
|
|
ensureConsoleSource,
|
|
};
|
|
|
|
bootstrapDockerImageDeployment();
|