Adds the server/app half of a remote-diagnostics system so Claude (via a local MCP gateway, added separately) can read a server's full state to diagnose problems: logs, errors, queue/app state, redacted config, system health. All read-only; security review folded in as hard requirements. Architecture (design-verified): - A SECOND, independent RemoteServer instance (diagnosticMode) on its own port (default 9110, bind 127.0.0.1) with NO capture/input callbacks, so screen capture and sendInputEvent are structurally unreachable from the diagnostic path. The existing screen-share server (port 9100) is byte-for-byte unchanged. - lib/remote-server.js: a `host` bind option, a post-auth `diag-request` branch that delegates to onDiagnosticRequest and replies with a reqId-correlated `diag-response`, a `diagnosticMode` guard (capture never spawns), a timing-safe token compare (length-guarded), and getLastAccess(). - lib/diagnostics-agent.js: handle(op,args) enforces a HARDCODED read-only op whitelist as the sole authority — no write/exec op, no run_health_check. - lib/diagnostics-collectors.js: pure, dependency-injected collectors (server_health one-shot hub, read_log, list_errors, get_queue_state, get_history, get_config_redacted, get_system_info, list_logs, get_app_events, get_rotation_state, get_health) reusing support-bundle + stats. Security (all mandatory, implemented): - Redaction gates every off-box payload. support-bundle now adds webhookUrl + diagToken to CRED_KEYS, and exports redactLogText (value-scrub of live secret strings + pattern-scrub of Discord webhooks / Bearer / api_key= / cookies / sess ids) + valueScrub + collectSecretValues. read_log takes a logical NAME (no path traversal); doodstream-debug.log is excluded from the readable set (it logs live api-key-bearing HTML). grep is length-capped (ReDoS guard). - diagnostics config subtree (enabled/port/token/label/codeIssuedAt/bindAddress) defaults OFF, bind 127.0.0.1; deep-merge makes it migration-free. The server-owned token is preserved against renderer clobber in both save-global-settings handlers; the diagnostics:* IPC is the only mutator. - app.requestSingleInstanceLock() (also the approved fix #6) so a relaunch can't EADDRINUSE-kill the agent and two instances can't clobber the config. main.js: buildDiagnosticCode (mhu1_ base64url, no host embedded), start/stop + auto-start-on-launch + stop-on-quit, the four diagnostics:* IPC handlers. renderer: a "Diagnose-Zugriff" settings subtab (toggle, port, bind-address, copyable connection code + regenerate, status, read-only security note). 382 tests pass (incl. 11 new: collectors+redaction, agent whitelist, and a live RemoteServer protocol test proving auth->diag-response correlation, that a diagnostic client never triggers the capture window, and brute-force lockout). Lint clean (0 errors). Smoke boots identically to baseline. The standalone MCP gateway package + operator setup docs land in a follow-up commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
209 lines
5.5 KiB
JavaScript
209 lines
5.5 KiB
JavaScript
const { WebSocketServer } = require('ws');
|
|
const crypto = require('crypto');
|
|
|
|
function timingSafeEqualStr(a, b) {
|
|
const x = Buffer.from(String(a == null ? '' : a));
|
|
const y = Buffer.from(String(b == null ? '' : b));
|
|
return x.length === y.length && crypto.timingSafeEqual(x, y);
|
|
}
|
|
|
|
class RemoteServer {
|
|
constructor() {
|
|
this._wss = null;
|
|
this._clients = new Map(); // ws -> { id, role, authenticated }
|
|
this._config = null;
|
|
this._failedAttempts = new Map(); // ip -> { count, blockedUntil }
|
|
this._lastAccess = null;
|
|
}
|
|
|
|
start(opts) {
|
|
return new Promise((resolve, reject) => {
|
|
this._config = opts;
|
|
|
|
const wssOpts = { port: opts.port };
|
|
if (opts.host) wssOpts.host = opts.host;
|
|
this._wss = new WebSocketServer(wssOpts, () => {
|
|
resolve();
|
|
});
|
|
|
|
this._wss.on('error', (err) => {
|
|
reject(err);
|
|
});
|
|
|
|
this._wss.on('connection', (ws, req) => {
|
|
this._handleConnection(ws, req);
|
|
});
|
|
});
|
|
}
|
|
|
|
stop() {
|
|
if (this._wss) {
|
|
for (const [ws] of this._clients) {
|
|
ws.close(1000, 'Server shutting down');
|
|
}
|
|
this._clients.clear();
|
|
this._wss.close();
|
|
this._wss = null;
|
|
}
|
|
}
|
|
|
|
getClientCount() {
|
|
let count = 0;
|
|
for (const [, client] of this._clients) {
|
|
if (client.authenticated) count++;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
getPort() {
|
|
if (this._wss && this._wss.address()) {
|
|
return this._wss.address().port;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
_handleConnection(ws, req) {
|
|
const ip = req.socket.remoteAddress || 'unknown';
|
|
|
|
if (this._isBlocked(ip)) {
|
|
ws.close(4003, 'Too many failed attempts');
|
|
return;
|
|
}
|
|
|
|
const clientId = crypto.randomUUID();
|
|
this._clients.set(ws, { id: clientId, role: null, authenticated: false });
|
|
|
|
let authReceived = false;
|
|
const authTimeout = setTimeout(() => {
|
|
if (!authReceived) {
|
|
ws.close(4001, 'Auth timeout');
|
|
this._clients.delete(ws);
|
|
}
|
|
}, 5000);
|
|
|
|
ws.on('message', (raw) => {
|
|
let msg;
|
|
try { msg = JSON.parse(raw); } catch { return; }
|
|
|
|
const client = this._clients.get(ws);
|
|
if (!client) return;
|
|
|
|
if (!client.authenticated) {
|
|
authReceived = true;
|
|
clearTimeout(authTimeout);
|
|
|
|
if (msg.type === 'auth' && timingSafeEqualStr(msg.token, this._config.token)) {
|
|
client.authenticated = true;
|
|
client.role = this._config.diagnosticMode ? 'diagnostic' : (msg.role || 'viewer');
|
|
this._lastAccess = Date.now();
|
|
ws.send(JSON.stringify({ type: 'auth-ok', clientId }));
|
|
|
|
if (!this._config.diagnosticMode && this.getClientCount() === 1) {
|
|
this._config.onCreateCaptureWindow();
|
|
}
|
|
} else {
|
|
this._recordFailedAttempt(ip);
|
|
ws.close(4002, 'Invalid token');
|
|
this._clients.delete(ws);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (this._config.diagnosticMode) {
|
|
if (msg.type === 'diag-request' && typeof this._config.onDiagnosticRequest === 'function') {
|
|
this._lastAccess = Date.now();
|
|
this._config.onDiagnosticRequest(msg, client, (payload) => {
|
|
this.sendToClient(client.id, { type: 'diag-response', reqId: msg.reqId, ...payload });
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (msg.type === 'offer' || msg.type === 'ice-candidate') {
|
|
msg.clientId = client.id;
|
|
msg.role = client.role;
|
|
this._config.onSignalingToCapture(msg);
|
|
}
|
|
});
|
|
|
|
ws.on('close', () => {
|
|
clearTimeout(authTimeout);
|
|
const client = this._clients.get(ws);
|
|
const wasAuthenticated = client && client.authenticated;
|
|
this._clients.delete(ws);
|
|
|
|
if (wasAuthenticated && !this._config.diagnosticMode) {
|
|
this._config.onSignalingToCapture({
|
|
type: 'client-disconnected',
|
|
clientId: client.id
|
|
});
|
|
|
|
if (this.getClientCount() === 0) {
|
|
this._config.onDestroyCaptureWindow();
|
|
}
|
|
}
|
|
});
|
|
|
|
ws.on('error', () => {
|
|
clearTimeout(authTimeout);
|
|
const client = this._clients.get(ws);
|
|
const wasAuthenticated = client && client.authenticated;
|
|
this._clients.delete(ws);
|
|
|
|
if (wasAuthenticated && !this._config.diagnosticMode) {
|
|
this._config.onSignalingToCapture({
|
|
type: 'client-disconnected',
|
|
clientId: client.id
|
|
});
|
|
if (this.getClientCount() === 0) {
|
|
this._config.onDestroyCaptureWindow();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
getLastAccess() {
|
|
return this._lastAccess;
|
|
}
|
|
|
|
sendToClient(clientId, data) {
|
|
for (const [ws, client] of this._clients) {
|
|
if (client.id === clientId && client.authenticated) {
|
|
ws.send(JSON.stringify(data));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
broadcast(data) {
|
|
const msg = JSON.stringify(data);
|
|
for (const [ws, client] of this._clients) {
|
|
if (client.authenticated && ws.readyState === 1) {
|
|
ws.send(msg);
|
|
}
|
|
}
|
|
}
|
|
|
|
_isBlocked(ip) {
|
|
const entry = this._failedAttempts.get(ip);
|
|
if (!entry) return false;
|
|
if (entry.blockedUntil && Date.now() < entry.blockedUntil) return true;
|
|
if (entry.blockedUntil && Date.now() >= entry.blockedUntil) {
|
|
this._failedAttempts.delete(ip);
|
|
return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
_recordFailedAttempt(ip) {
|
|
const entry = this._failedAttempts.get(ip) || { count: 0, blockedUntil: null };
|
|
entry.count++;
|
|
if (entry.count >= 5) {
|
|
entry.blockedUntil = Date.now() + 60000;
|
|
}
|
|
this._failedAttempts.set(ip, entry);
|
|
}
|
|
}
|
|
|
|
module.exports = RemoteServer;
|