Files
BetterDesk/web-nodejs/services/serverAttestation.js
T
UNITRONIX 8b916488b9 Complete web console i18n audit and refine server attestation.
Sync all 26 locale files to the EN+PL baseline (3432 keys), add OBSIDIAN tier strings, and ship i18n apply/audit tooling. Improve server attestation UX and convert orphaned node:test scripts to Jest so the full test suite passes.
2026-06-06 02:36:57 +02:00

680 lines
20 KiB
JavaScript

'use strict';
/**
* BetterDesk Console — Server Attestation (performance benchmark)
*
* Runs a load test against the Go signal/relay WebSocket endpoints and
* measures host CPU/RAM/disk until the 80% threshold is reached.
* Results are stored in settings and exposed as Bronze/Iron/Platinum/Titanium/Obsidian tiers.
*/
const crypto = require('crypto');
const path = require('path');
const WebSocket = require('ws');
const protobuf = require('protobufjs');
const config = require('../config/config');
const db = require('./database');
const { getResourceSnapshot } = require('./serverManagement');
const betterdeskApi = require('./betterdeskApi');
const SETTINGS_KEY = 'server_attestation_result';
const THRESHOLD = 80;
const STABILIZE_THRESHOLD = 70;
const MAX_CONNECTIONS = 500;
const MAX_TEST_MS = 10 * 60 * 1000;
const BASELINE_MS = 10_000;
const RAMP_INTERVAL_MS = 5_000;
const STABILIZE_MS = 15_000;
const BATCH_SIGNAL = 5;
const BATCH_RELAY = 3;
const CONNECT_TIMEOUT_MS = 8_000;
const ERROR_RATE_ABORT = 0.05;
const TIER_THRESHOLDS = [
{ tier: 'obsidian', min: 401 },
{ tier: 'titanium', min: 201 },
{ tier: 'platinum', min: 76 },
{ tier: 'iron', min: 26 },
{ tier: 'bronze', min: 1 }
];
let protoPromise = null;
let runState = {
running: false,
phase: 'idle',
progress: null,
abortRequested: false
};
let activePool = null;
async function loadProto() {
if (!protoPromise) {
const protoPath = path.join(__dirname, '..', 'protos', 'rendezvous.proto');
protoPromise = protobuf.load(protoPath).then((root) => ({
RendezvousMessage: root.lookupType('hbb.RendezvousMessage')
}));
}
return protoPromise;
}
function encodeHealthCheck(types, token) {
const msg = types.RendezvousMessage.create({
hc: { token: token || 'bd-bench' }
});
return types.RendezvousMessage.encode(msg).finish();
}
function encodeRequestRelay(types, uuid) {
const msg = types.RendezvousMessage.create({
requestRelay: {
id: 'bd-bench',
uuid
}
});
return types.RendezvousMessage.encode(msg).finish();
}
function determineTier(maxConnections) {
const n = Math.max(0, Math.floor(maxConnections || 0));
if (n === 0) return null;
for (const t of TIER_THRESHOLDS) {
if (n >= t.min) return t.tier;
}
return 'bronze';
}
function getMaxDiskPercent(disks) {
if (!Array.isArray(disks) || !disks.length) return 0;
let max = 0;
for (const d of disks) {
if (!d.size) continue;
const pct = (d.used / d.size) * 100;
if (pct > max) max = pct;
}
return Math.round(max * 10) / 10;
}
function parseApiHost() {
try {
const raw = config.betterdeskApiUrl || config.hbbsApiUrl || '';
const u = new URL(raw);
return u.hostname || null;
} catch (_) {
return null;
}
}
function wsScheme() {
if ((process.env.WS_USE_TLS || '').toLowerCase() === 'true') return 'wss';
// Server-side benchmark talks to local Go ports — plain ws unless forced.
return 'ws';
}
function buildWsUrlCandidates() {
const proxy = config.wsProxy || {};
const signalPort = (parseInt(process.env.WS_HBBS_PORT, 10) || proxy.hbbsPort || 21116) + 2;
const relayPort = (parseInt(process.env.WS_HBBR_PORT, 10) || proxy.hbbrPort || 21117) + 2;
const scheme = wsScheme();
const hosts = [];
const addHost = (h) => {
if (h && !hosts.includes(h)) hosts.push(h);
};
if (process.env.WS_HBBS_HOST) addHost(process.env.WS_HBBS_HOST);
if (process.env.WS_HBBR_HOST) addHost(process.env.WS_HBBR_HOST);
addHost(parseApiHost());
addHost(proxy.hbbsHost);
addHost('127.0.0.1');
addHost('localhost');
const signal = [];
const relay = [];
for (const host of hosts) {
signal.push(`${scheme}://${host}:${signalPort}`);
relay.push(`${scheme}://${host}:${relayPort}`);
}
return { signal, relay, signalPort, relayPort, hosts, scheme };
}
function getWsUrls(resolved) {
if (resolved && resolved.signal && resolved.relay) {
return resolved;
}
const c = buildWsUrlCandidates();
return {
signal: c.signal[0],
relay: c.relay[0],
host: c.hosts[0] || '127.0.0.1',
signalPort: c.signalPort,
relayPort: c.relayPort,
candidates: c
};
}
async function probeWsUrl(url, types, timeoutMs = 5000) {
let ws;
try {
ws = await connectWs(url, timeoutMs);
const payload = encodeHealthCheck(types);
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('probe timeout')), timeoutMs);
ws.once('message', () => {
clearTimeout(timer);
resolve();
});
ws.once('error', (err) => {
clearTimeout(timer);
reject(err);
});
ws.send(payload);
});
return true;
} catch (_) {
return false;
} finally {
if (ws) {
try { ws.close(); } catch (_) { /* ignore */ }
}
}
}
async function resolveWsTargets(types) {
const candidates = buildWsUrlCandidates();
let signalUrl = null;
let relayUrl = null;
const probeLog = [];
for (const url of candidates.signal) {
const ok = await probeWsUrl(url, types);
probeLog.push({ url, ok, kind: 'signal' });
if (ok) {
signalUrl = url;
break;
}
}
for (const url of candidates.relay) {
const ok = await probeWsUrl(url, types);
probeLog.push({ url, ok, kind: 'relay' });
if (ok) {
relayUrl = url;
break;
}
}
return {
signal: signalUrl,
relay: relayUrl,
host: signalUrl ? new URL(signalUrl).hostname : (candidates.hosts[0] || '127.0.0.1'),
signalPort: candidates.signalPort,
relayPort: candidates.relayPort,
scheme: candidates.scheme,
probeLog,
resolved: !!(signalUrl || relayUrl)
};
}
function estimateCapacity(baseline, snap) {
const cpuBase = baseline && baseline.cpu != null ? baseline.cpu : 0;
const memBase = baseline && baseline.memPercent != null ? baseline.memPercent : 0;
const diskBase = baseline && baseline.diskPercent != null ? baseline.diskPercent : 0;
const ignoreDisk = diskBase >= 75;
const cpuHead = Math.max(0, THRESHOLD - cpuBase);
const memHead = Math.max(0, THRESHOLD - memBase);
const diskHead = ignoreDisk ? 100 : Math.max(0, THRESHOLD - diskBase);
const totalMem = (snap && snap.memTotal) || 0;
const fromCpu = Math.floor(cpuHead / 0.35);
const fromMem = totalMem > 0
? Math.floor((memHead / 100) * totalMem / (8 * 1024 * 1024))
: Math.floor(memHead / 2);
const fromDisk = ignoreDisk ? fromMem : Math.floor(diskHead / 0.5);
const coreBased = Math.max(1, (snap && snap.cpuCount) || 1) * 12;
return Math.max(0, Math.min(fromCpu, fromMem, fromDisk, coreBased * 4));
}
function isOverThreshold(metrics, baseline) {
if (metrics.cpu >= THRESHOLD) return true;
if (metrics.memPercent >= THRESHOLD) return true;
const diskBase = baseline && baseline.diskPercent != null ? baseline.diskPercent : 0;
if (diskBase >= 75) return false;
if (metrics.diskPercent >= THRESHOLD) return true;
return false;
}
function sleep(ms, abortSignal) {
return new Promise((resolve, reject) => {
if (abortSignal && abortSignal.aborted) {
reject(new Error('aborted'));
return;
}
const timer = setTimeout(resolve, ms);
if (abortSignal) {
abortSignal.addEventListener('abort', () => {
clearTimeout(timer);
reject(new Error('aborted'));
}, { once: true });
}
});
}
function connectWs(url, timeoutMs) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url, {
handshakeTimeout: timeoutMs,
rejectUnauthorized: !config.allowSelfSignedCerts
});
const timer = setTimeout(() => {
ws.terminate();
reject(new Error('connect timeout'));
}, timeoutMs);
ws.once('open', () => {
clearTimeout(timer);
resolve(ws);
});
ws.once('error', (err) => {
clearTimeout(timer);
reject(err);
});
});
}
class ConnectionPool {
constructor() {
this.signalConns = [];
this.relayConns = [];
this.relayPairs = 0;
this.attempts = 0;
this.failures = 0;
}
get totalConnections() {
return this.signalConns.length + this.relayConns.length;
}
closeAll() {
for (const ws of [...this.signalConns, ...this.relayConns]) {
try { ws.close(); } catch (_) { /* ignore */ }
}
this.signalConns = [];
this.relayConns = [];
this.relayPairs = 0;
}
}
async function addSignalConnections(pool, types, count, urls) {
if (!urls || !urls.signal) return 0;
const added = [];
for (let i = 0; i < count; i++) {
pool.attempts += 1;
try {
const ws = await connectWs(urls.signal, CONNECT_TIMEOUT_MS);
const payload = encodeHealthCheck(types);
ws.send(payload);
ws.on('message', () => { /* health-check response */ });
pool.signalConns.push(ws);
added.push(ws);
} catch (_) {
pool.failures += 1;
}
}
return added.length;
}
async function addRelayPairs(pool, types, pairCount, urls) {
if (!urls || !urls.relay) return 0;
let addedPairs = 0;
for (let i = 0; i < pairCount; i++) {
const uuid = `bd-bench-${crypto.randomBytes(8).toString('hex')}`;
pool.attempts += 2;
try {
const ws1 = await connectWs(urls.relay, CONNECT_TIMEOUT_MS);
const ws2 = await connectWs(urls.relay, CONNECT_TIMEOUT_MS);
const payload = encodeRequestRelay(types, uuid);
ws1.send(payload);
ws2.send(payload);
pool.relayConns.push(ws1, ws2);
addedPairs += 1;
pool.relayPairs += 1;
} catch (_) {
pool.failures += 1;
}
}
return addedPairs;
}
async function collectMetrics() {
const snap = getResourceSnapshot();
let goStats = null;
try {
const res = await betterdeskApi.getServerStats();
if (res && res.success) goStats = res.data;
} catch (_) { /* optional */ }
return {
ts: Date.now(),
cpu: snap.cpu,
memPercent: snap.mem.percent,
diskPercent: getMaxDiskPercent(snap.disks),
hostname: snap.hostname,
cpuCount: snap.cpuCount,
memTotal: snap.mem.total,
goStats
};
}
function metricsFromSnapshot(snap) {
return {
ts: Date.now(),
cpu: snap.cpu,
memPercent: snap.mem.percent,
diskPercent: getMaxDiskPercent(snap.disks),
hostname: snap.hostname,
cpuCount: snap.cpuCount,
memTotal: snap.mem.total,
goStats: null
};
}
function isUnderStabilize(metrics, baseline) {
if (metrics.cpu >= STABILIZE_THRESHOLD) return false;
if (metrics.memPercent >= STABILIZE_THRESHOLD) return false;
const diskBase = baseline && baseline.diskPercent != null ? baseline.diskPercent : 0;
if (diskBase >= 75) return true;
return metrics.diskPercent < STABILIZE_THRESHOLD;
}
function errorRate(pool) {
if (!pool.attempts) return 0;
return pool.failures / pool.attempts;
}
function poolAttemptsExceeded(pool) {
return pool.attempts >= 12 && pool.totalConnections === 0;
}
async function getLastResult() {
try {
const raw = await db.getSetting(SETTINGS_KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch (_) {
return null;
}
}
async function saveResult(result) {
await db.setSetting(SETTINGS_KEY, JSON.stringify(result));
}
function buildPublicSummary(result) {
if (!result || !result.tier) {
return { tier: null, maxConnections: 0, testedAt: null, valid: false };
}
return {
tier: result.tier,
maxConnections: result.maxConnections || 0,
testedAt: result.testedAt || result.completedAt || null,
valid: result.valid !== false
};
}
function getStatus() {
return {
running: runState.running,
phase: runState.phase,
progress: runState.progress,
lastResult: runState.cachedLastResult || null
};
}
let cachedLastResult = null;
async function refreshCachedResult() {
cachedLastResult = await getLastResult();
runState.cachedLastResult = cachedLastResult;
return cachedLastResult;
}
function requestAbort() {
if (!runState.running) return false;
runState.abortRequested = true;
if (activePool) activePool.closeAll();
return true;
}
async function runLoadTest({ onProgress } = {}) {
if (runState.running) {
throw new Error('Benchmark already running');
}
runState.running = true;
runState.abortRequested = false;
runState.phase = 'baseline';
activePool = new ConnectionPool();
const startedAt = Date.now();
const rampSteps = [];
const types = await loadProto();
const abortSignal = {
aborted: false,
addEventListener: (ev, fn) => {
const check = setInterval(() => {
if (runState.abortRequested) {
clearInterval(check);
abortSignal.aborted = true;
fn();
}
}, 200);
}
};
let bestStable = { connections: 0, metrics: null };
let lastStable = { connections: 0, metrics: null };
let urls = null;
let probeLog = [];
let baseline = null;
const emit = (patch) => {
runState.progress = { ...runState.progress, ...patch };
if (typeof onProgress === 'function') onProgress(runState.progress);
};
try {
emit({
phase: 'baseline',
message: 'Collecting baseline metrics…',
connections: 0,
rampSteps: []
});
await sleep(BASELINE_MS, abortSignal);
baseline = await collectMetrics();
emit({
phase: 'probe',
message: 'Probing WebSocket endpoints…',
connections: 0,
baseline,
rampSteps: []
});
urls = await resolveWsTargets(types);
probeLog = urls.probeLog || [];
runState.phase = 'ramp-up';
emit({
phase: 'ramp-up',
message: urls.resolved
? 'Ramping up connections…'
: 'WebSocket unreachable — using capacity estimate…',
connections: 0,
baseline,
rampSteps: [],
probeLog
});
if (urls.resolved) {
while (activePool.totalConnections < MAX_CONNECTIONS) {
if (Date.now() - startedAt > MAX_TEST_MS) break;
if (runState.abortRequested) break;
await addSignalConnections(activePool, types, BATCH_SIGNAL, urls);
await addRelayPairs(activePool, types, BATCH_RELAY, urls);
await sleep(1500, abortSignal).catch(() => { /* continue */ });
const metrics = await collectMetrics();
const step = {
ts: metrics.ts,
connections: activePool.totalConnections,
signalConnections: activePool.signalConns.length,
relaySessions: activePool.relayPairs,
cpu: metrics.cpu,
mem: metrics.memPercent,
disk: metrics.diskPercent,
errorRate: errorRate(activePool)
};
rampSteps.push(step);
emit({
phase: 'ramp-up',
message: `Testing ${activePool.totalConnections} connections…`,
connections: activePool.totalConnections,
metrics,
rampSteps: [...rampSteps]
});
const successes = activePool.totalConnections;
if (successes === 0 && poolAttemptsExceeded(activePool)) {
break;
}
if (successes > 0 && errorRate(activePool) > ERROR_RATE_ABORT && activePool.attempts > 20) {
break;
}
if (isOverThreshold(metrics, baseline)) {
lastStable = {
connections: Math.max(0, activePool.totalConnections - BATCH_SIGNAL - BATCH_RELAY * 2),
metrics
};
break;
}
bestStable = { connections: activePool.totalConnections, metrics };
await sleep(RAMP_INTERVAL_MS, abortSignal);
}
}
runState.phase = 'stabilize';
emit({
phase: 'stabilize',
message: 'Stabilizing…',
connections: activePool.totalConnections,
rampSteps: [...rampSteps]
});
const stabilizeStart = Date.now();
let finalConnections = bestStable.connections;
let finalMetrics = bestStable.metrics || await collectMetrics();
while (Date.now() - stabilizeStart < STABILIZE_MS) {
if (runState.abortRequested) break;
const metrics = await collectMetrics();
if (isUnderStabilize(metrics, baseline) && lastStable.connections > 0) {
finalConnections = lastStable.connections;
finalMetrics = lastStable.metrics || metrics;
break;
}
if (!isOverThreshold(metrics, baseline)) {
finalConnections = activePool.totalConnections;
finalMetrics = metrics;
}
await sleep(3000, abortSignal).catch(() => { /* ignore */ });
}
let mode = urls && urls.resolved ? 'loadtest' : 'estimated';
if (finalConnections <= 0) {
const snap = getResourceSnapshot();
const estimated = estimateCapacity(baseline, metricsFromSnapshot(snap));
if (estimated > 0) {
finalConnections = estimated;
mode = 'estimated';
}
} else if (activePool.totalConnections > 0) {
mode = 'loadtest';
}
const maxConnections = finalConnections;
const tier = determineTier(maxConnections);
const diskBaselineHigh = baseline && baseline.diskPercent >= 75;
const result = {
valid: maxConnections > 0 && !!tier,
mode,
tier,
maxConnections,
signalConnections: activePool.signalConns.length,
relaySessions: activePool.relayPairs,
threshold: THRESHOLD,
testedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
durationMs: Date.now() - startedAt,
aborted: runState.abortRequested,
metricsHost: finalMetrics && finalMetrics.hostname,
finalMetrics: finalMetrics ? {
cpu: finalMetrics.cpu,
mem: finalMetrics.memPercent,
disk: finalMetrics.diskPercent
} : null,
baseline,
rampSteps,
targets: urls || getWsUrls(),
probeLog,
errorRate: errorRate(activePool),
diskBaselineHigh,
wsResolved: !!(urls && urls.resolved)
};
await saveResult(result);
cachedLastResult = result;
runState.cachedLastResult = result;
runState.phase = 'done';
emit({
phase: 'done',
message: 'Benchmark complete',
result,
rampSteps: [...rampSteps]
});
return result;
} finally {
runState.phase = 'cool-down';
if (activePool) activePool.closeAll();
activePool = null;
runState.running = false;
runState.abortRequested = false;
runState.progress = runState.progress
? { ...runState.progress, phase: 'idle' }
: null;
}
}
refreshCachedResult().catch(() => {});
module.exports = {
THRESHOLD,
TIER_THRESHOLDS,
determineTier,
getLastResult,
saveResult,
buildPublicSummary,
getStatus,
refreshCachedResult,
requestAbort,
runLoadTest,
getWsUrls
};