mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-10-25 22:10:49 +00:00
abc00b8804
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)
363 lines
12 KiB
JavaScript
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,
|
|
};
|