Multi-Hoster-Upload/gateway/agent-client.js
Administrator d69e5c39bf feat(diagnostics): MCP gateway + harden redaction so no secret ever leaves the box
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>
2026-06-19 17:39:31 +02:00

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);
}
}
}