netanalyzer/remoteAgent.js
Joseph McQueen 7081221352 feat(agent): support corporate CA bundles for wss:// TLS verification
Add WS_TLS_CA_FILE and WS_TLS_REJECT_UNAUTHORIZED so the remote agent can
trust internal PKI chains instead of failing with "unable to verify the
first certificate". Apply the same TLS options to proxied HTTPS calls and
document CA bundle mounting in compose and deploy READMEs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 14:00:46 -04:00

170 lines
4.2 KiB
JavaScript

const fs = require('fs');
const https = require('https');
const WebSocket = require('ws');
const axios = require('axios');
require('dotenv').config();
const WS_URL = process.env.WS_URL;
const WS_TOKEN = process.env.WS_TOKEN;
if (!WS_URL) {
console.error('❌ WS_URL is not set in .env');
process.exit(1);
}
const INITIAL_BACKOFF_MS = 2000;
const MAX_BACKOFF_MS = 60000;
const PROXY_TIMEOUT_MS = 30000;
let ws = null;
let reconnectAttempts = 0;
let shuttingDown = false;
/**
* TLS options for wss:// and for HTTPS calls the agent proxies (SIW, MDM, …).
*
* Preferred: mount your corporate root + intermediate CA(s) as a PEM bundle
* and point WS_TLS_CA_FILE at it. That keeps verification on.
*
* Escape hatch (trusted networks only): WS_TLS_REJECT_UNAUTHORIZED=false
*/
function readTlsOptions() {
const tls = {};
const caFile = process.env.WS_TLS_CA_FILE;
if (caFile) {
try {
tls.ca = fs.readFileSync(caFile, 'utf8');
console.log(`🔒 Using custom CA bundle: ${caFile}`);
} catch (err) {
console.error(`❌ Failed to read WS_TLS_CA_FILE (${caFile}): ${err.message}`);
process.exit(1);
}
}
if (process.env.WS_TLS_REJECT_UNAUTHORIZED !== undefined) {
const reject =
process.env.WS_TLS_REJECT_UNAUTHORIZED !== 'false' &&
process.env.WS_TLS_REJECT_UNAUTHORIZED !== '0';
tls.rejectUnauthorized = reject;
if (!reject) {
console.warn(
'⚠️ WS_TLS_REJECT_UNAUTHORIZED=false — TLS certificate verification is DISABLED.'
);
}
}
return Object.keys(tls).length ? tls : null;
}
const TLS_OPTIONS = readTlsOptions();
const HTTPS_AGENT = TLS_OPTIONS ? new https.Agent(TLS_OPTIONS) : undefined;
/**
* WebSocket client options: optional Bearer auth + optional TLS trust config.
*/
function buildClientOptions() {
const options = {};
if (WS_TOKEN) {
options.headers = { Authorization: `Bearer ${WS_TOKEN}` };
}
if (TLS_OPTIONS) {
Object.assign(options, TLS_OPTIONS);
}
return Object.keys(options).length ? options : undefined;
}
function connect() {
console.log(`🔄 Connecting to ${WS_URL}...`);
ws = new WebSocket(WS_URL, buildClientOptions());
ws.on('open', () => {
console.log('✅ Remote Agent connected to StoreHealthAnalyzer');
reconnectAttempts = 0;
});
ws.on('message', async data => {
let request;
try {
request = JSON.parse(data);
if (request.action !== 'proxyRequest') return;
console.log(`🔄 Proxying ${request.method || 'GET'} ${request.url}`);
const response = await axios({
method: request.method || 'GET',
url: request.url,
headers: request.headers || {},
auth: request.auth || undefined,
data: request.body || undefined,
timeout: PROXY_TIMEOUT_MS,
httpsAgent: HTTPS_AGENT,
});
ws.send(
JSON.stringify({
requestId: request.requestId,
status: response.status,
data: response.data,
headers: response.headers,
})
);
} catch (err) {
console.error('Proxy error:', err.message);
ws.send(
JSON.stringify({
requestId: request ? request.requestId : null,
error: err.message,
status: err.response?.status || 500,
data: err.response?.data || null,
})
);
}
});
ws.on('close', code => {
console.log(`❌ Disconnected (code: ${code}).`);
if (!shuttingDown) scheduleReconnect();
});
ws.on('error', err => {
console.error('WebSocket error:', err.message);
});
}
function scheduleReconnect() {
reconnectAttempts++;
const backoff = Math.min(
INITIAL_BACKOFF_MS * Math.pow(1.5, reconnectAttempts - 1),
MAX_BACKOFF_MS
);
console.log(
`⏳ Reconnecting in ${Math.round(backoff / 1000)}s... (attempt ${reconnectAttempts})`
);
setTimeout(connect, backoff);
}
connect();
function shutdownRemote(signal) {
console.log(`🛑 ${signal} received. Shutting down remote agent...`);
shuttingDown = true;
if (ws) {
try {
ws.close();
} catch (_e) {
// ignore close errors during shutdown
}
}
process.exit(0);
}
process.on('SIGINT', () => shutdownRemote('SIGINT'));
process.on('SIGTERM', () => shutdownRemote('SIGTERM'));