Files
BetterDesk/scripts/local-docker-smoke.js
UNITRONIX abc00b8804 feat(guest): enforce scoped guest access lifecycle
Bind guest WebSocket sessions to a single target peer, enforce grant lifecycle and atomic usage, add the guest issuer scope, and document the password-less security boundary.\n\nRefs #440\n\nThanks: INSOLVE (Honorary); Marco Jakobs (@jacotec); MyNameisStitch (@MyNameisStitch); Redspin (@playerumpknow)
2026-10-03 10:22:20 +02:00

363 lines
12 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/**
* Destructive-looking but isolated local smoke test.
*
* The test uses a temporary Docker container and named volumes with a unique
* label. It never touches the host installation paths or the project's
* production Compose container names.
*/
const http = require('http');
const https = require('https');
const { spawnSync } = require('child_process');
const ROOT = require('path').join(__dirname, '..');
const IMAGE = process.env.BETTERDESK_LOCAL_IMAGE || 'betterdesk:local';
const PASSWORD = 'BetterDesk-Local-Gate-Only-2026!';
const REQUEST_TIMEOUT_MS = 5_000;
const STARTUP_TIMEOUT_MS = 120_000;
function parseArgs(argv) {
const options = {
databaseUrl: '',
databaseType: 'sqlite',
keep: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--database-url') {
options.databaseUrl = argv[++index] || '';
} else if (arg === '--database-type') {
options.databaseType = argv[++index] || 'sqlite';
} else if (arg === '--keep') {
options.keep = true;
} else if (arg === '--help' || arg === '-h') {
options.help = true;
} else {
throw new Error(`Unknown option: ${arg}`);
}
}
if (!['sqlite', 'postgres'].includes(options.databaseType)) {
throw new Error('--database-type must be sqlite or postgres');
}
if (options.databaseType === 'postgres' && !options.databaseUrl) {
throw new Error('--database-url is required for postgres smoke tests');
}
return options;
}
function docker(args, { allowFailure = false } = {}) {
const result = spawnSync('docker', args, {
cwd: ROOT,
encoding: 'utf8',
windowsHide: true,
maxBuffer: 4 * 1024 * 1024,
});
if (!allowFailure && (result.error || result.status !== 0)) {
const detail = result.error?.message
|| String(result.stderr || result.stdout || '').trim()
|| `exit code ${result.status}`;
throw new Error(`docker ${args.join(' ')} failed: ${detail}`);
}
return result;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function request(url, options = {}) {
const target = new URL(url);
const transport = target.protocol === 'https:' ? https : http;
return new Promise((resolve, reject) => {
const req = transport.request(target, {
method: options.method || 'GET',
timeout: options.timeoutMs || REQUEST_TIMEOUT_MS,
headers: options.headers || {},
}, (res) => {
let body = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
body += chunk;
});
res.on('end', () => {
resolve({
statusCode: res.statusCode || 0,
headers: res.headers,
body,
});
});
});
req.on('timeout', () => req.destroy(new Error('request timed out')));
req.on('error', reject);
if (options.body) req.write(options.body);
req.end();
});
}
function firstCookie(headers) {
return (headers['set-cookie'] || [])
.map((cookie) => String(cookie).split(';', 1)[0])
.filter(Boolean)
.join('; ');
}
function getCsrfToken(html) {
const patterns = [
/csrfToken:\s*'([^']+)'/,
/csrfToken:\s*"([^"]+)"/,
/name=["']_csrf["'][^>]+value=["']([^"']+)["']/i,
];
for (const pattern of patterns) {
const match = pattern.exec(html);
if (match) return match[1];
}
return '';
}
async function login(panelUrl) {
const page = await request(`${panelUrl}/login`);
if (page.statusCode < 200 || page.statusCode >= 300) {
throw new Error(`login page returned HTTP ${page.statusCode}`);
}
const csrf = getCsrfToken(page.body);
if (!csrf) throw new Error('login page did not expose a CSRF token');
const cookie = firstCookie(page.headers);
const body = JSON.stringify({ username: 'admin', password: PASSWORD });
const response = await request(`${panelUrl}/api/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
'Cookie': cookie,
'X-CSRF-Token': csrf,
},
body,
});
if (response.statusCode < 200 || response.statusCode >= 300) {
throw new Error(`login returned HTTP ${response.statusCode}: ${response.body}`);
}
let payload;
try {
payload = JSON.parse(response.body);
} catch {
throw new Error(`login returned invalid JSON: ${response.body}`);
}
if (payload.success !== true) {
throw new Error(`login was rejected: ${response.body}`);
}
const loginCookie = [cookie, firstCookie(response.headers)].filter(Boolean).join('; ');
const verify = await request(`${panelUrl}/api/auth/verify`, {
headers: { Cookie: loginCookie },
});
if (verify.statusCode < 200 || verify.statusCode >= 300) {
throw new Error(`session verification returned HTTP ${verify.statusCode}`);
}
let verifyPayload;
try {
verifyPayload = JSON.parse(verify.body);
} catch {
throw new Error(`session verification returned invalid JSON: ${verify.body}`);
}
if (verifyPayload.success !== true || !verifyPayload.user) {
throw new Error(`session verification failed: ${verify.body}`);
}
}
function mappedPort(container, port) {
const result = docker(['port', container, `${port}/tcp`]);
const match = String(result.stdout).match(/:(\d+)\s*$/m);
if (!match) throw new Error(`could not determine mapped port ${port} for ${container}`);
return Number(match[1]);
}
async function waitForHealth(apiUrl, panelUrl) {
const deadline = Date.now() + STARTUP_TIMEOUT_MS;
let lastError = 'services have not started';
while (Date.now() < deadline) {
try {
const [api, panel] = await Promise.all([
request(`${apiUrl}/api/health`),
request(`${panelUrl}/health`),
]);
if (api.statusCode >= 200 && api.statusCode < 300
&& panel.statusCode >= 200 && panel.statusCode < 300) {
return;
}
lastError = `API ${api.statusCode}, panel ${panel.statusCode}`;
} catch (error) {
lastError = error.message;
}
await sleep(1_000);
}
throw new Error(`services did not become healthy: ${lastError}`);
}
async function waitForContainerHealthy(container) {
const deadline = Date.now() + 60_000;
let status = 'unknown';
while (Date.now() < deadline) {
status = String(docker([
'inspect', '--format', '{{.State.Health.Status}}', container,
]).stdout).trim();
if (status === 'healthy') return;
if (status === 'unhealthy') throw new Error('container health status is unhealthy');
await sleep(1_000);
}
throw new Error(`container health status remained ${status || 'unknown'}`);
}
function containerExec(container, args) {
return docker(['exec', container, ...args]);
}
async function run(options) {
const suffix = `${process.pid}-${Date.now()}`;
const container = `betterdesk-local-gate-${suffix}`;
const dataVolume = `betterdesk-local-gate-data-${suffix}`;
const consoleVolume = `betterdesk-local-gate-console-${suffix}`;
let apiUrl = '';
let panelUrl = '';
const environment = [
'NODE_ENV=production',
'PORT=5000',
'HOST=0.0.0.0',
'API_HOST=0.0.0.0',
'API_ENABLED=false',
'SERVER_BACKEND=betterdesk',
'BETTERDESK_API_URL=http://127.0.0.1:21121/api',
'HBBS_API_URL=http://127.0.0.1:21121/api',
'RUSTDESK_PATH=/opt/rustdesk',
'DATA_DIR=/app/data',
'DB_PATH=/opt/rustdesk/db_v2.sqlite3',
'AUTH_DB_PATH=/app/data/auth.db',
'SQLITE_AUTH_DB_MODE=',
'ADMIN_PASSWORD=' + PASSWORD,
'INIT_ADMIN_USER=admin',
'INIT_ADMIN_PASS=' + PASSWORD,
'DEFAULT_ADMIN_USERNAME=admin',
'DEFAULT_ADMIN_PASSWORD=' + PASSWORD,
'DOCKER=true',
'AGENT_BUILD_WORKER=off',
'BETTERDESK_GITHUB_TOKEN=',
'PUID=10001',
'PGID=10001',
'ENROLLMENT_MODE=managed',
];
if (options.databaseType === 'postgres') {
environment.push('DB_TYPE=postgres', `DATABASE_URL=${options.databaseUrl}`, `DB_URL=${options.databaseUrl}`);
} else {
environment.push('DB_TYPE=sqlite');
}
try {
docker(['volume', 'create', dataVolume]);
docker(['volume', 'create', consoleVolume]);
docker([
'run', '--detach',
'--name', container,
'--label', 'com.betterdesk.local-gate=true',
'--publish', '127.0.0.1::5000',
'--publish', '127.0.0.1::21121',
'--volume', `${dataVolume}:/opt/rustdesk`,
'--volume', `${consoleVolume}:/app/data`,
'--add-host', 'host.docker.internal:host-gateway',
...environment.flatMap((value) => ['--env', value]),
IMAGE,
]);
const panelPort = mappedPort(container, 5000);
const apiPort = mappedPort(container, 21121);
panelUrl = `http://127.0.0.1:${panelPort}`;
apiUrl = `http://127.0.0.1:${apiPort}`;
await waitForHealth(apiUrl, panelUrl);
await login(panelUrl);
const firstKey = String(containerExec(container, ['sh', '-c', 'cat /opt/rustdesk/.api_key']).stdout)
.trim();
if (!firstKey) throw new Error('container did not create an API key');
const protocol = spawnSync(process.execPath, [
require('path').join(__dirname, 'installer-protocol-check.js'),
'--api-url', `${apiUrl}/api/health`,
'--panel-url', `${panelUrl}/health`,
'--port', `127.0.0.1:${apiPort}`,
], {
cwd: ROOT,
encoding: 'utf8',
windowsHide: true,
maxBuffer: 2 * 1024 * 1024,
});
process.stdout.write(protocol.stdout || '');
process.stderr.write(protocol.stderr || '');
if (protocol.error || protocol.status !== 0) {
throw new Error(`installer protocol check failed with exit code ${protocol.status}`);
}
docker(['restart', container]);
const restartedPanelPort = mappedPort(container, 5000);
const restartedApiPort = mappedPort(container, 21121);
panelUrl = `http://127.0.0.1:${restartedPanelPort}`;
apiUrl = `http://127.0.0.1:${restartedApiPort}`;
await waitForHealth(apiUrl, panelUrl);
await login(panelUrl);
const secondKey = String(containerExec(container, ['sh', '-c', 'cat /opt/rustdesk/.api_key']).stdout)
.trim();
if (firstKey !== secondKey) {
throw new Error('API key changed after a container restart');
}
await waitForContainerHealthy(container);
console.log(`PASS local Docker smoke (${options.databaseType}) ${apiUrl} ${panelUrl}`);
return { apiUrl, panelUrl, container, dataVolume, consoleVolume };
} catch (error) {
const logs = docker(['logs', '--tail', '200', container], { allowFailure: true });
if (logs.stdout) process.stderr.write(logs.stdout);
if (logs.stderr) process.stderr.write(logs.stderr);
throw error;
} finally {
if (!options.keep) {
docker(['rm', '--force', container], { allowFailure: true });
docker(['volume', 'rm', '--force', dataVolume], { allowFailure: true });
docker(['volume', 'rm', '--force', consoleVolume], { allowFailure: true });
}
}
}
async function main() {
try {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
console.log('Usage: node scripts/local-docker-smoke.js [--database-type sqlite|postgres] [--database-url URL] [--keep]');
return;
}
await run(options);
} catch (error) {
console.error(`FAIL local Docker smoke: ${error.message}`);
process.exitCode = 1;
}
}
if (require.main === module) main();
module.exports = {
parseArgs,
getCsrfToken,
firstCookie,
mappedPort,
waitForHealth,
waitForContainerHealthy,
login,
run,
};