Files
BetterDesk/web-nodejs/services/agentClientBuildWorker.js
T
UNITRONIX ed96e94806 refactor(agent): enhance session management and input handling
- Updated session control mechanisms to ensure proper handling of remote input and clipboard operations.
- Introduced session authorization checks to validate operator permissions before starting desktop sessions.
- Improved input injection logic to prevent unauthorized access during active sessions.
- Added new capabilities for managing session flags and controls, ensuring a more robust and secure desktop experience.
- Enhanced error handling and logging for better traceability of session-related actions.
2026-08-06 00:50:02 +02:00

568 lines
21 KiB
JavaScript

/**
* BetterDesk Console — Agent Client build worker (Tauri + Go sidecar)
*
* Builds branded betterdesk-agent-client installers when generator bundles
* have product_type=agent-client.
*/
'use strict';
const fs = require('fs');
const fsp = fs.promises;
const path = require('path');
const crypto = require('crypto');
const { spawn } = require('child_process');
const db = require('./database');
const bundleService = require('./agentBundleService');
const config = require('../config/config');
const {
PRODUCT_TYPES,
normalizeProductType,
isQueuedBuildStatus,
} = require('../lib/generatorBuildTypes');
try {
const envFile = process.env.BETTERDESK_BUILD_ENV_FILE || '/etc/betterdesk/build.env';
if (fs.existsSync(envFile)) {
const txt = fs.readFileSync(envFile, 'utf8');
for (const line of txt.split(/\r?\n/)) {
const m = /^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line);
if (!m) continue;
const [, key, val] = m;
if (process.env[key] === undefined) process.env[key] = val;
}
}
} catch (e) {
console.warn('[agentClientBuildWorker] could not load build env file:', e.message);
}
const BUILD_USER = process.env.BUILD_USER || 'betterdesk';
const BUILD_CACHE_DIR = process.env.CARGO_TARGET_DIR
|| process.env.BUILD_CACHE_DIR
|| path.join(config.dataDir || '/opt/BetterDeskConsole/data', 'build-cache');
const WORK_ROOT = path.join(BUILD_CACHE_DIR, 'agent-client-work');
const AGENT_LIB_CACHE = path.join(BUILD_CACHE_DIR, 'agent-client-lib');
const ARTIFACT_ROOT = process.env.AGENT_CLIENT_ARTIFACT_DIR
|| path.join(config.dataDir || '/opt/BetterDeskConsole/data', 'agent-client-builds');
const POLL_INTERVAL_MS = parseInt(process.env.AGENT_CLIENT_BUILD_POLL_MS || '8000', 10);
const BUILD_TIMEOUT_MS = parseInt(process.env.AGENT_CLIENT_BUILD_TIMEOUT_MS || (45 * 60 * 1000), 10);
const REPO_ROOT = path.resolve(__dirname, '..', '..');
function _resolveSourceRoot() {
if (process.env.AGENT_CLIENT_SOURCE_DIR) return process.env.AGENT_CLIENT_SOURCE_DIR;
const candidates = [
path.join(REPO_ROOT, 'betterdesk-agent-client'),
'/opt/BetterDeskConsole/agent-source/betterdesk-agent-client',
];
for (const c of candidates) {
if (fs.existsSync(path.join(c, 'src-tauri', 'Cargo.toml'))) return c;
}
return candidates[0];
}
function _resolveAgentLibRoot() {
if (process.env.AGENT_LIB_DIR) return process.env.AGENT_LIB_DIR;
const candidates = [
path.join(REPO_ROOT, 'betterdesk-agent'),
'/opt/BetterDeskConsole/agent-source/betterdesk-agent',
];
for (const c of candidates) {
if (fs.existsSync(path.join(c, 'go.mod'))) return c;
}
return candidates[0];
}
const SOURCE_ROOT = _resolveSourceRoot();
const AGENT_LIB_ROOT = _resolveAgentLibRoot();
const CARGO_HOME = process.env.CARGO_HOME || `/home/${BUILD_USER}/.cargo`;
function _resolveBin(candidates) {
for (const c of candidates) {
if (fs.existsSync(c)) return c;
}
return candidates[0];
}
const CARGO_BIN = process.env.CARGO_BIN || _resolveBin([
`${CARGO_HOME}/bin/cargo`,
'/usr/local/bin/cargo',
'/usr/bin/cargo',
]);
const NPM_BIN = process.env.NPM_BIN || _resolveBin([
'/usr/bin/npm',
'/usr/local/bin/npm',
]);
const BASE_BUILD_ENV = {
PATH: `${CARGO_HOME}/bin:/usr/local/bin:/usr/bin:/bin`,
HOME: `/home/${BUILD_USER}`,
CARGO_HOME,
RUSTUP_HOME: process.env.RUSTUP_HOME || `/home/${BUILD_USER}/.rustup`,
CARGO_TARGET_DIR: BUILD_CACHE_DIR,
DEBIAN_FRONTEND: 'noninteractive',
LANG: 'C.UTF-8',
APPIMAGE_EXTRACT_AND_RUN: '1',
NO_STRIP: '1',
};
const BUILD_PROFILES = {
'windows/x64/portable': {
os: 'windows', target: 'x86_64-pc-windows-msvc', bundles: ['nsis'], artifact: 'exe', runner: 'cargo-xwin',
},
'windows/x64/installed': {
os: 'windows', target: 'x86_64-pc-windows-msvc', bundles: ['msi'], artifact: 'msi', runner: 'cargo-xwin',
},
'linux/x64/portable': { os: 'linux', target: null, bundles: [], artifact: 'tgz' },
'linux/x64/appimage': { os: 'linux', target: null, bundles: ['appimage'], artifact: 'appimage' },
'linux/x64/installed': { os: 'linux', target: null, bundles: ['deb'], artifact: 'deb' },
'linux/x64/rpm': { os: 'linux', target: null, bundles: ['rpm'], artifact: 'rpm' },
};
const SIDECAR_NAMES = {
'linux/x64': 'betterdesk-agent-x86_64-unknown-linux-gnu',
'windows/x64': 'betterdesk-agent-x86_64-pc-windows-msvc.exe',
};
function _isAgentClientBundle(bundle) {
return normalizeProductType(bundle?.product_type) === PRODUCT_TYPES.AGENT_CLIENT;
}
let _pollTimer = null;
let _running = false;
let _activeBuilds = 0;
function _run(cmd, args, opts = {}) {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
cwd: opts.cwd,
env: { ...BASE_BUILD_ENV, ...(opts.env || {}) },
stdio: ['ignore', 'pipe', 'pipe'],
});
let out = '';
child.stdout.on('data', (d) => { out += d.toString(); });
child.stderr.on('data', (d) => { out += d.toString(); });
const timer = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error(`timeout after ${BUILD_TIMEOUT_MS}ms`));
}, BUILD_TIMEOUT_MS);
child.on('error', (e) => { clearTimeout(timer); reject(e); });
child.on('close', (code) => {
clearTimeout(timer);
if (code === 0) resolve(out);
else reject(new Error(`${cmd} exited ${code}: ${out.slice(-4000)}`));
});
});
}
async function _copyDir(src, dest) {
const SKIP = new Set(['node_modules', 'target', 'dist', '.git', 'data']);
await fsp.mkdir(dest, { recursive: true });
const entries = await fsp.readdir(src, { withFileTypes: true });
for (const ent of entries) {
if (SKIP.has(ent.name)) continue;
const s = path.join(src, ent.name);
const d = path.join(dest, ent.name);
if (ent.isDirectory()) await _copyDir(s, d);
else await fsp.copyFile(s, d);
}
}
function _agentClientBranding(branding) {
const b = { ...(branding || {}) };
delete b.enrollment_token;
delete b.has_enrollment_token;
delete b.enrollment_token_masked;
return {
product_name: b.product_name || b.company_name || 'BetterDesk Agent',
company_name: b.company_name || 'BetterDesk',
tagline: b.short_text || b.tagline || '',
short_text: b.short_text || b.tagline || '',
support_email: b.contact_email || b.support_email || '',
contact_email: b.contact_email || b.support_email || '',
support_phone: b.contact_phone || b.support_phone || '',
contact_phone: b.contact_phone || b.support_phone || '',
contact_url: b.contact_url || '',
primary_color: b.primary_color || '#2563eb',
accent_color: b.accent_color || '#0ea5e9',
logo_data_url: b.logo_data_url || '',
default_language: b.default_lang || b.default_language || 'en',
default_lang: b.default_lang || b.default_language || 'en',
allow_unattended: !!b.allow_unattended,
server_address: b.server_address || b.server?.address || '',
server_key: b.server_key || b.server?.public_key || '',
bundle_id: b.bundle_id || '',
server: b.server || undefined,
};
}
async function _ensureAgentLib() {
if (!fs.existsSync(path.join(AGENT_LIB_ROOT, 'go.mod'))) {
throw new Error(`betterdesk-agent source missing at ${AGENT_LIB_ROOT}`);
}
if (fs.existsSync(AGENT_LIB_CACHE)) {
await fsp.rm(AGENT_LIB_CACHE, { recursive: true, force: true });
}
await _copyDir(AGENT_LIB_ROOT, AGENT_LIB_CACHE);
return AGENT_LIB_CACHE;
}
async function _buildSidecar(profile, destPath) {
const agentBuildWorker = require('./agentBuildWorker');
const goBin = typeof agentBuildWorker.getGoBin === 'function'
? agentBuildWorker.getGoBin()
: null;
if (!goBin) throw new Error('Go toolchain not available for sidecar build');
const agentLib = await _ensureAgentLib();
const goos = profile.os === 'windows' ? 'windows' : 'linux';
const goarch = 'amd64';
await fsp.mkdir(path.dirname(destPath), { recursive: true });
await _run(goBin, [
'build', '-ldflags', '-s -w',
'-o', destPath,
'.',
], {
cwd: agentLib,
env: { CGO_ENABLED: '0', GOOS: goos, GOARCH: goarch },
});
if (profile.os === 'linux') {
await fsp.chmod(destPath, 0o755);
}
}
async function _materialiseWorkDir(hash, branding, profile) {
const workDir = path.join(WORK_ROOT, hash.slice(0, 16));
if (fs.existsSync(workDir)) {
await fsp.rm(workDir, { recursive: true, force: true });
}
await _copyDir(SOURCE_ROOT, workDir);
const sidecarKey = `${profile.os}/${profile.target ? 'x64' : 'x64'}`;
const sidecarName = SIDECAR_NAMES[sidecarKey]
|| (profile.os === 'windows'
? 'betterdesk-agent-x86_64-pc-windows-msvc.exe'
: 'betterdesk-agent-x86_64-unknown-linux-gnu');
const binariesDir = path.join(workDir, 'src-tauri', 'binaries');
await fsp.mkdir(binariesDir, { recursive: true });
const sidecarDest = path.join(binariesDir, sidecarName);
await _buildSidecar(profile, sidecarDest);
const agentLibSibling = path.join(path.dirname(workDir), 'betterdesk-agent');
if (fs.existsSync(agentLibSibling)) {
await fsp.rm(agentLibSibling, { recursive: true, force: true });
}
await _copyDir(AGENT_LIB_ROOT, agentLibSibling);
const resDir = path.join(workDir, 'src-tauri', 'resources');
await fsp.mkdir(resDir, { recursive: true });
await fsp.writeFile(
path.join(resDir, 'branding.json'),
JSON.stringify(_agentClientBranding(branding), null, 2),
'utf8'
);
return workDir;
}
async function _findArtifact(workDir, profile, key) {
const bundleBase = profile.target
? path.join(BUILD_CACHE_DIR, profile.target, 'release', 'bundle')
: path.join(BUILD_CACHE_DIR, 'release', 'bundle');
const releaseDir = profile.target
? path.join(BUILD_CACHE_DIR, profile.target, 'release')
: path.join(BUILD_CACHE_DIR, 'release');
if (profile.artifact === 'deb') {
const debDir = path.join(bundleBase, 'deb');
const files = fs.existsSync(debDir) ? await fsp.readdir(debDir) : [];
const deb = files.find((f) => f.endsWith('.deb'));
if (deb) return path.join(debDir, deb);
}
if (profile.artifact === 'rpm') {
const rpmDir = path.join(bundleBase, 'rpm');
const files = fs.existsSync(rpmDir) ? await fsp.readdir(rpmDir) : [];
const rpm = files.find((f) => f.endsWith('.rpm'));
if (rpm) return path.join(rpmDir, rpm);
}
if (profile.artifact === 'appimage') {
const aiDir = path.join(bundleBase, 'appimage');
const files = fs.existsSync(aiDir) ? await fsp.readdir(aiDir) : [];
const ai = files.find((f) => f.endsWith('.AppImage'));
if (ai) return path.join(aiDir, ai);
}
if (profile.artifact === 'msi') {
const msiDir = path.join(bundleBase, 'msi');
const files = fs.existsSync(msiDir) ? await fsp.readdir(msiDir) : [];
const msi = files.find((f) => f.endsWith('.msi'));
if (msi) return path.join(msiDir, msi);
}
if (profile.artifact === 'exe') {
const nsisDir = path.join(bundleBase, 'nsis');
const files = fs.existsSync(nsisDir) ? await fsp.readdir(nsisDir) : [];
const exe = files.find((f) => f.endsWith('-setup.exe') || f.endsWith('.exe'));
if (exe) return path.join(nsisDir, exe);
for (const n of ['betterdesk-agent-client.exe', 'BetterDesk Agent.exe']) {
const p = path.join(releaseDir, n);
if (fs.existsSync(p)) return p;
}
}
if (profile.artifact === 'tgz') {
const names = ['betterdesk-agent-client', 'BetterDesk Agent'];
for (const n of names) {
const bin = path.join(releaseDir, n);
if (fs.existsSync(bin)) {
const stage = path.join(workDir, 'dist-portable');
await fsp.mkdir(stage, { recursive: true });
await fsp.copyFile(bin, path.join(stage, n));
await fsp.copyFile(
path.join(workDir, 'src-tauri', 'binaries', SIDECAR_NAMES['linux/x64']),
path.join(stage, SIDECAR_NAMES['linux/x64'])
);
const tarPath = path.join(ARTIFACT_ROOT, `${key.replace(/\//g, '-')}.tar.gz`);
await fsp.mkdir(path.dirname(tarPath), { recursive: true });
await _run('tar', ['-czf', tarPath, '-C', stage, '.']);
return tarPath;
}
}
}
throw new Error(`artifact not found for ${key}`);
}
async function _sha256(filePath) {
return new Promise((resolve, reject) => {
const h = crypto.createHash('sha256');
const s = fs.createReadStream(filePath);
s.on('error', reject);
s.on('data', (d) => h.update(d));
s.on('end', () => resolve(h.digest('hex')));
});
}
async function _findBundleForHash(hash) {
const all = await db.listAgentBundles({ includeRevoked: true });
return all.find((b) => b.branding_hash === hash) || null;
}
async function _listPendingAgentClientBuilds(limit) {
const bundles = await db.listAgentBundles();
const out = [];
for (const b of bundles) {
if (b.revoked || !_isAgentClientBundle(b)) continue;
const builds = await db.listAgentBundleBuildsForHash(b.branding_hash);
for (const r of builds) {
if (isQueuedBuildStatus(r.status)) out.push(r);
if (out.length >= limit) break;
}
if (out.length >= limit) break;
}
return out;
}
async function _hasAgentClientBuildInProgress() {
if (_activeBuilds > 0) return true;
const bundles = await db.listAgentBundles();
for (const b of bundles) {
if (!_isAgentClientBundle(b) || b.revoked) continue;
const builds = await db.listAgentBundleBuildsForHash(b.branding_hash);
if (builds.some((r) => r.status === 'building')) return true;
}
return false;
}
async function enqueueBuildsForHash(brandingHash, { force = false } = {}) {
const platforms = bundleService.PLATFORMS || [];
for (const p of platforms) {
const existing = await db.getAgentBundleBuild({
brandingHash, platform: p.platform, arch: p.arch, format: p.format,
});
if (!force && existing && (existing.status === 'ready' || existing.status === 'building')) {
continue;
}
await db.upsertAgentBundleBuild({
brandingHash,
platform: p.platform,
arch: p.arch,
format: p.format,
status: 'queued',
artifactPath: existing?.artifact_path || null,
artifactSize: existing?.artifact_size || 0,
artifactSha256: existing?.artifact_sha256 || null,
errorMessage: '',
});
}
}
async function requeueAllBundleBuilds() {
const bundles = await db.listAgentBundles({ includeRevoked: false });
const hashes = [...new Set(
bundles
.filter((bundle) => !bundle.revoked && _isAgentClientBundle(bundle))
.map((bundle) => bundle.branding_hash)
.filter(Boolean)
)];
for (const hash of hashes) {
await enqueueBuildsForHash(hash, { force: true });
}
return { bundles: hashes.length };
}
async function rebuildBundleById(bundleId) {
const row = await db.getAgentBundle(bundleId);
if (!row) return { success: false, error: 'not_found' };
if (!_isAgentClientBundle(row)) {
return { success: false, error: 'not_agent_client' };
}
if (!row.branding_hash) return { success: false, error: 'missing_hash' };
await enqueueBuildsForHash(row.branding_hash, { force: true });
return { success: true, platforms: (bundleService.PLATFORMS || []).length };
}
async function requeuePlatformBuild(brandingHash, platform, arch, format) {
if (!brandingHash || !platform || !arch || !format) {
return { success: false, error: 'missing_args' };
}
const allowed = (bundleService.PLATFORMS || []).some(
(p) => p.platform === platform && p.arch === arch && p.format === format
);
if (!allowed) return { success: false, error: 'unsupported_platform' };
const bundle = await _findBundleForHash(brandingHash);
if (!bundle || !_isAgentClientBundle(bundle)) {
return { success: false, error: 'not_agent_client' };
}
await db.upsertAgentBundleBuild({
brandingHash,
platform,
arch,
format,
status: 'queued',
artifactPath: null,
artifactSize: 0,
artifactSha256: null,
errorMessage: '',
});
return { success: true };
}
async function _runOne(buildRow) {
const key = `${buildRow.platform}/${buildRow.arch}/${buildRow.format}`;
const profile = BUILD_PROFILES[key];
if (!profile) throw new Error(`unsupported profile ${key}`);
const bundleRow = await _findBundleForHash(buildRow.branding_hash);
if (!bundleRow || !_isAgentClientBundle(bundleRow)) {
throw new Error('not an agent-client bundle');
}
const branding = JSON.parse(bundleRow.branding || '{}');
console.log(`[agentClientBuildWorker] build start ${key} hash=${buildRow.branding_hash.slice(0, 12)}`);
const workDir = await _materialiseWorkDir(buildRow.branding_hash, branding, profile);
await _run(NPM_BIN, ['ci', '--no-audit', '--no-fund'], { cwd: workDir });
const npmTauriArgs = ['run', 'tauri', 'build'];
if (profile.runner) npmTauriArgs.push('--runner', profile.runner);
if (profile.target || profile.bundles.length) {
npmTauriArgs.push('--');
if (profile.target) npmTauriArgs.push('--target', profile.target);
if (profile.bundles.length) npmTauriArgs.push('--bundles', profile.bundles.join(','));
}
await _run(NPM_BIN, npmTauriArgs, { cwd: workDir });
const built = await _findArtifact(workDir, profile, key);
const destDir = path.join(ARTIFACT_ROOT, buildRow.branding_hash.slice(0, 16));
await fsp.mkdir(destDir, { recursive: true });
const destName = path.basename(built);
const destPath = path.join(destDir, destName);
if (built !== destPath) await fsp.copyFile(built, destPath);
const stat = await fsp.stat(destPath);
const sha = await _sha256(destPath);
await db.upsertAgentBundleBuild({
brandingHash: buildRow.branding_hash,
platform: buildRow.platform,
arch: buildRow.arch,
format: buildRow.format,
status: 'ready',
artifactPath: destPath,
artifactSize: stat.size,
artifactSha256: sha,
errorMessage: '',
});
console.log(`[agentClientBuildWorker] build ready ${key} (${(stat.size / 1024 / 1024).toFixed(2)} MB)`);
}
async function _tick() {
if (_running || _activeBuilds > 0) return;
if (await _hasAgentClientBuildInProgress()) return;
_running = true;
try {
const pending = await _listPendingAgentClientBuilds(1);
if (!pending.length) return;
const row = pending[0];
await db.upsertAgentBundleBuild({
brandingHash: row.branding_hash,
platform: row.platform,
arch: row.arch,
format: row.format,
status: 'building',
artifactPath: row.artifact_path || null,
artifactSize: row.artifact_size || 0,
artifactSha256: row.artifact_sha256 || null,
errorMessage: '',
});
_activeBuilds++;
try {
await _runOne(row);
} catch (e) {
const msg = e.message || String(e);
console.error(`[agentClientBuildWorker] build FAILED ${row.platform}/${row.format}: ${msg}`);
await db.upsertAgentBundleBuild({
brandingHash: row.branding_hash,
platform: row.platform,
arch: row.arch,
format: row.format,
status: 'failed',
artifactPath: null,
artifactSize: 0,
artifactSha256: null,
errorMessage: msg.slice(0, 2000),
});
} finally {
_activeBuilds--;
}
} finally {
_running = false;
}
}
function startWorker() {
if (_pollTimer) return;
if (!fs.existsSync(path.join(SOURCE_ROOT, 'src-tauri', 'Cargo.toml'))) {
console.warn('[agentClientBuildWorker] betterdesk-agent-client source not found — worker disabled');
return;
}
fsp.mkdir(WORK_ROOT, { recursive: true }).catch(() => {});
fsp.mkdir(ARTIFACT_ROOT, { recursive: true }).catch(() => {});
_pollTimer = setInterval(() => {
_tick().catch((e) => console.error('[agentClientBuildWorker] tick error:', e.message));
}, POLL_INTERVAL_MS);
console.log(`[agentClientBuildWorker] started source=${SOURCE_ROOT} poll=${POLL_INTERVAL_MS}ms`);
}
function stopWorker() {
if (_pollTimer) clearInterval(_pollTimer);
_pollTimer = null;
}
module.exports = {
startWorker,
stopWorker,
enqueueBuildsForHash,
requeueAllBundleBuilds,
rebuildBundleById,
requeuePlatformBuild,
_internals: {
isAgentClientBundle: _isAgentClientBundle,
listPendingBuilds: _listPendingAgentClientBuilds,
},
};