Adds the connect-by-code side of remote diagnostics and closes two real secret-leak vectors that an end-to-end gateway<->agent test surfaced. Gateway (gateway/, local stdio MCP, Claude connects once): - 14 read-only tools (server_health hub, read_log, list_logs, list_errors, get_queue_state, get_history, get_config_redacted, get_system_info, get_rotation_state, get_app_events + connect/disconnect/list/current). - The HOST is always supplied by the operator, never taken from the code. - TLS fingerprint pinning is enforced in the socket 'open' handler BEFORE the token is sent (wss opt-in); plain ws is loopback-only. - registry.json (holds bearer tokens) is gitignored; only an empty example ships. Security hardening (gates every off-box payload): - redactLogText now scrubs opaque bearer/token-family secrets that are NOT stored config credentials (e.g. a session token a hoster returns inside an error string): bare token/auth_token/refresh_token/session_token + standalone "Bearer <opaque>". Benign "token bucket" prose is left intact. - get_config_redacted deep-redacts every string leaf (JSON-safe, per-leaf, so the cookie/sess line patterns can't gobble across a compact-JSON field) and drops the history subtree (served by get_history with its own per-error redaction). This plugs leaks via globalSettings.pendingQueue[].error etc. Bind-address safety: - _safeDiagBindAddress() forces the diagnostic agent to 127.0.0.1/::1; the 0.0.0.0 UI option is removed. Direct LAN/Internet bind stays disabled until encrypted transport (wss) exists — remote access goes through an SSH/VPN tunnel to loopback. (Never plaintext ws:// on all interfaces.) Tests: end-to-end gateway<->agent gate (connect -> server_health/read_log/ get_config_redacted, asserts zero secret leakage, rejects doodstream log, path traversal and write ops); + redaction regression tests in the main suite. 385 app tests + 9 gateway tests pass; lint 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
220 lines
6.0 KiB
JavaScript
220 lines
6.0 KiB
JavaScript
import WebSocket from 'ws';
|
|
import { randomUUID } from 'node:crypto';
|
|
|
|
const AUTH_TIMEOUT_MS = 6000;
|
|
const REQUEST_TIMEOUT_MS = 30000;
|
|
|
|
const CLOSE_CODE_GUIDANCE = {
|
|
4001: 'auth timeout, retry',
|
|
4002: 'stale or rotated code — re-copy the current code from the server',
|
|
4003: 'brute-force lockout, wait 60s',
|
|
};
|
|
|
|
function normalizeFingerprint(fp) {
|
|
if (typeof fp !== 'string') return '';
|
|
return fp.replace(/:/g, '').toLowerCase();
|
|
}
|
|
|
|
function mapSocketError(err) {
|
|
const code = err && err.code;
|
|
if (code === 'ECONNREFUSED') {
|
|
return 'app not running / wrong port / inbound firewall closed';
|
|
}
|
|
if (code === 'ETIMEDOUT') {
|
|
return 'firewall DROP / NAT not forwarded / tunnel down';
|
|
}
|
|
return (err && err.message) ? err.message : String(err);
|
|
}
|
|
|
|
function mapCloseBeforeAuth(code, sawOpen) {
|
|
if (CLOSE_CODE_GUIDANCE[code]) return CLOSE_CODE_GUIDANCE[code];
|
|
if (sawOpen) {
|
|
return 'old app version without the diagnostic agent — update that server';
|
|
}
|
|
return `connection closed before auth (code ${code})`;
|
|
}
|
|
|
|
export class AgentClient {
|
|
constructor({ host, port, token, fp }) {
|
|
this.host = host;
|
|
this.port = port;
|
|
this.token = token;
|
|
this.fp = fp;
|
|
this.connected = false;
|
|
this.clientId = null;
|
|
this.ws = null;
|
|
this.authed = false;
|
|
this.sawOpen = false;
|
|
this._pending = new Map();
|
|
this._authResolve = null;
|
|
this._authReject = null;
|
|
this._authTimer = null;
|
|
}
|
|
|
|
connect() {
|
|
return new Promise((resolve, reject) => {
|
|
this._authResolve = resolve;
|
|
this._authReject = reject;
|
|
|
|
const secure = typeof this.fp === 'string' && this.fp.length > 0;
|
|
const scheme = secure ? 'wss' : 'ws';
|
|
const url = `${scheme}://${this.host}:${this.port}`;
|
|
|
|
const pinned = secure ? normalizeFingerprint(this.fp) : '';
|
|
const options = secure ? { rejectUnauthorized: false } : undefined;
|
|
|
|
let ws;
|
|
try {
|
|
ws = options ? new WebSocket(url, options) : new WebSocket(url);
|
|
} catch (err) {
|
|
this._failAuth(mapSocketError(err));
|
|
return;
|
|
}
|
|
this.ws = ws;
|
|
|
|
this._authTimer = setTimeout(() => {
|
|
this._failAuth('auth timeout, retry');
|
|
try { ws.close(); } catch {}
|
|
}, AUTH_TIMEOUT_MS);
|
|
|
|
ws.on('open', () => {
|
|
this.sawOpen = true;
|
|
if (secure) {
|
|
const sock = ws._socket;
|
|
const cert = sock && typeof sock.getPeerCertificate === 'function'
|
|
? sock.getPeerCertificate()
|
|
: null;
|
|
const actual = normalizeFingerprint(cert && cert.fingerprint256);
|
|
if (!actual || actual !== pinned) {
|
|
this._failAuth('server cert changed (reinstalled?) — re-copy the code');
|
|
try { ws.close(); } catch {}
|
|
return;
|
|
}
|
|
}
|
|
this._send({ type: 'auth', token: this.token, role: 'diagnostic' });
|
|
});
|
|
|
|
ws.on('message', (raw) => this._onMessage(raw));
|
|
|
|
ws.on('error', (err) => {
|
|
if (!this.authed) {
|
|
this._failAuth(mapSocketError(err));
|
|
} else {
|
|
this._rejectAllPending(mapSocketError(err));
|
|
}
|
|
});
|
|
|
|
ws.on('close', (code) => {
|
|
this.connected = false;
|
|
if (!this.authed) {
|
|
this._failAuth(mapCloseBeforeAuth(code, this.sawOpen));
|
|
} else {
|
|
this._rejectAllPending(`connection closed (code ${code})`);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
_onMessage(raw) {
|
|
let msg;
|
|
try {
|
|
msg = JSON.parse(raw.toString());
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
if (msg.type === 'auth-ok') {
|
|
this.authed = true;
|
|
this.connected = true;
|
|
this.clientId = msg.clientId ?? null;
|
|
if (this._authTimer) {
|
|
clearTimeout(this._authTimer);
|
|
this._authTimer = null;
|
|
}
|
|
if (this._authResolve) {
|
|
const r = this._authResolve;
|
|
this._authResolve = null;
|
|
this._authReject = null;
|
|
r({ clientId: this.clientId });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (msg.type === 'diag-response' && msg.reqId) {
|
|
const entry = this._pending.get(msg.reqId);
|
|
if (!entry) return;
|
|
this._pending.delete(msg.reqId);
|
|
clearTimeout(entry.timer);
|
|
if (msg.ok) {
|
|
entry.resolve({ ok: true, data: msg.data });
|
|
} else {
|
|
entry.resolve({ ok: false, error: msg.error ?? 'unknown agent error' });
|
|
}
|
|
}
|
|
}
|
|
|
|
request(op, args) {
|
|
return new Promise((resolve) => {
|
|
if (!this.connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
resolve({ ok: false, error: 'not connected to a diagnostic agent' });
|
|
return;
|
|
}
|
|
const reqId = randomUUID();
|
|
const timer = setTimeout(() => {
|
|
if (this._pending.has(reqId)) {
|
|
this._pending.delete(reqId);
|
|
resolve({ ok: false, error: `request timed out after ${REQUEST_TIMEOUT_MS}ms (op: ${op})` });
|
|
}
|
|
}, REQUEST_TIMEOUT_MS);
|
|
this._pending.set(reqId, { resolve, timer });
|
|
try {
|
|
this._send({ type: 'diag-request', reqId, op, args: args ?? {} });
|
|
} catch (err) {
|
|
this._pending.delete(reqId);
|
|
clearTimeout(timer);
|
|
resolve({ ok: false, error: mapSocketError(err) });
|
|
}
|
|
});
|
|
}
|
|
|
|
close() {
|
|
this.connected = false;
|
|
this.authed = false;
|
|
if (this._authTimer) {
|
|
clearTimeout(this._authTimer);
|
|
this._authTimer = null;
|
|
}
|
|
this._rejectAllPending('connection closed by client');
|
|
if (this.ws) {
|
|
try { this.ws.removeAllListeners(); } catch {}
|
|
try { this.ws.close(); } catch {}
|
|
this.ws = null;
|
|
}
|
|
}
|
|
|
|
_send(obj) {
|
|
this.ws.send(JSON.stringify(obj));
|
|
}
|
|
|
|
_failAuth(error) {
|
|
if (this._authTimer) {
|
|
clearTimeout(this._authTimer);
|
|
this._authTimer = null;
|
|
}
|
|
if (this._authReject) {
|
|
const rej = this._authReject;
|
|
this._authResolve = null;
|
|
this._authReject = null;
|
|
rej(new Error(error));
|
|
}
|
|
}
|
|
|
|
_rejectAllPending(error) {
|
|
for (const [reqId, entry] of this._pending) {
|
|
clearTimeout(entry.timer);
|
|
entry.resolve({ ok: false, error });
|
|
this._pending.delete(reqId);
|
|
}
|
|
}
|
|
}
|