Wire CP-78xx probe discovery, relay phone-probe commands, and a chat follow-up message so store desk phones get registration, switch, and provisioning detail alongside DECT and WAN diagnostics. Co-authored-by: Cursor <cursoragent@cursor.com>
388 lines
14 KiB
JavaScript
388 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
||
// dect-relay-agent/index.js
|
||
//
|
||
// Data-center-resident WebSocket client that bridges the CollabSupport
|
||
// bot (running in the public cloud) to Cisco DBS-210 DECT base
|
||
// stations on the private 10.x/8 corporate network.
|
||
//
|
||
// Runtime shape:
|
||
// 1. On startup, dial `wss://<bot>/dect-relay/ws` with the shared
|
||
// bearer token from DECT_RELAY_AGENT_TOKEN.
|
||
// 2. Send a `hello` frame declaring version + hostname + supported
|
||
// command types.
|
||
// 3. Loop waiting for command frames from the bot. Dispatch each
|
||
// into the (already-tested) integrations/cisco-dect/ modules
|
||
// shared with the bot's own spike CLI (scripts/testDectBase.js).
|
||
// 4. Reply with `{id, ok, result|error, elapsedMs}` per command.
|
||
// 5. On disconnect, reconnect with exponential backoff. Restart the
|
||
// cycle from step 2 (a fresh hello) so the bot's registry is
|
||
// always in sync with the agent's actual capabilities.
|
||
//
|
||
// This agent does NOT store DECT credentials in transit — the bearer
|
||
// token is per-agent. DBS-210 admin creds live only in THIS process's
|
||
// .env and never leave the DC.
|
||
|
||
import 'dotenv/config';
|
||
import WebSocket from 'ws';
|
||
import os from 'node:os';
|
||
import { createDectClient } from '../integrations/cisco-dect/client.js';
|
||
import {
|
||
triggerReboot,
|
||
triggerRebootChain,
|
||
triggerFactoryReset,
|
||
triggerReconfigureDectTree,
|
||
} from '../integrations/cisco-dect/probes.js';
|
||
import {
|
||
parseStatusXml,
|
||
summarizeBaseHealth,
|
||
} from '../integrations/cisco-dect/statusXml.js';
|
||
import { inventoryStatusXml } from '../integrations/cisco-dect/statusXmlInventory.js';
|
||
import { createMppPhoneClient } from '../integrations/cisco-mpp-phone/client.js';
|
||
import { runPhoneProbe } from '../integrations/cisco-mpp-phone/probes.js';
|
||
import { parseStatusXml as parsePhoneStatusXml, summarizePhoneHealth } from '../integrations/cisco-mpp-phone/statusXml.js';
|
||
import { parseStatusJson, summarizePhoneHealthFromJson } from '../integrations/cisco-mpp-phone/statusJson.js';
|
||
|
||
// ─── Config ─────────────────────────────────────────────────────────
|
||
|
||
const CFG = {
|
||
botUrl: process.env.DECT_RELAY_BOT_URL,
|
||
token: process.env.DECT_RELAY_AGENT_TOKEN,
|
||
hostname: process.env.DECT_RELAY_AGENT_HOSTNAME || os.hostname(),
|
||
dectUser: process.env.DECT_ADMIN_USER || 'admin',
|
||
dectPass: process.env.DECT_ADMIN_PASSWORD,
|
||
dectTimeout: Number(process.env.DECT_ADMIN_TIMEOUT_MS) || 30_000,
|
||
phoneUser: process.env.PHONE_ADMIN_USER || 'admin',
|
||
phonePass: process.env.PHONE_ADMIN_PASSWORD,
|
||
phoneTimeout: Number(process.env.PHONE_ADMIN_TIMEOUT_MS) || 15_000,
|
||
reconnectMaxMs: Number(process.env.DECT_RELAY_RECONNECT_MAX_MS) || 30_000,
|
||
};
|
||
|
||
const AGENT_VERSION = '0.3.0';
|
||
const CAPABILITIES = [
|
||
'collect',
|
||
'collect-raw',
|
||
'reboot',
|
||
'force-reboot',
|
||
'reboot-chain',
|
||
'force-reboot-chain',
|
||
'factory-reset',
|
||
'reconfigure-tree',
|
||
'phone-probe',
|
||
'phone-probe-raw',
|
||
];
|
||
|
||
const HEARTBEAT_INTERVAL_MS = 30_000;
|
||
|
||
// ─── Logging (dependency-free; agent runs standalone) ───────────────
|
||
|
||
function log(scope, msg, level = 'info') {
|
||
const ts = new Date().toISOString();
|
||
const line = `[${ts}] [${level.toUpperCase()}] [${scope}] ${msg}`;
|
||
if (level === 'error' || level === 'warn') console.error(line);
|
||
else console.log(line);
|
||
}
|
||
|
||
// ─── Startup validation ─────────────────────────────────────────────
|
||
|
||
function assertConfig() {
|
||
const missing = [];
|
||
if (!CFG.botUrl) missing.push('DECT_RELAY_BOT_URL');
|
||
if (!CFG.token) missing.push('DECT_RELAY_AGENT_TOKEN');
|
||
if (!CFG.dectPass) missing.push('DECT_ADMIN_PASSWORD');
|
||
if (missing.length > 0) {
|
||
console.error(`Missing required env: ${missing.join(', ')}. See .env.example.`);
|
||
process.exit(1);
|
||
}
|
||
if (!CFG.botUrl.startsWith('wss://') && !CFG.botUrl.startsWith('ws://')) {
|
||
console.error(`DECT_RELAY_BOT_URL must start with wss:// (or ws:// for local dev). Got: ${CFG.botUrl}`);
|
||
process.exit(1);
|
||
}
|
||
if (CFG.botUrl.startsWith('ws://') && !/(^|\.)localhost/.test(CFG.botUrl) && !/127\.0\.0\.1/.test(CFG.botUrl)) {
|
||
log('startup', `⚠️ DECT_RELAY_BOT_URL is plain ws:// against a non-local host — bearer token would be sent in cleartext`, 'warn');
|
||
}
|
||
}
|
||
|
||
// ─── Reconnect loop ─────────────────────────────────────────────────
|
||
|
||
let currentWs = null;
|
||
let heartbeatTimer = null;
|
||
let reconnectAttempt = 0;
|
||
let shuttingDown = false;
|
||
|
||
function scheduleReconnect() {
|
||
if (shuttingDown) return;
|
||
reconnectAttempt += 1;
|
||
// Exponential backoff with jitter: 1s, 2s, 4s, 8s… capped at
|
||
// reconnectMaxMs, plus 0–1000ms jitter to de-sync herds when
|
||
// multiple agents restart at once (future-proofing — today there's
|
||
// only one).
|
||
const base = Math.min(1000 * 2 ** (reconnectAttempt - 1), CFG.reconnectMaxMs);
|
||
const jitter = Math.floor(Math.random() * 1000);
|
||
const delay = base + jitter;
|
||
log('reconnect', `Attempt #${reconnectAttempt} in ${delay}ms`);
|
||
setTimeout(connect, delay);
|
||
}
|
||
|
||
function stopHeartbeat() {
|
||
if (heartbeatTimer) {
|
||
clearInterval(heartbeatTimer);
|
||
heartbeatTimer = null;
|
||
}
|
||
}
|
||
|
||
function connect() {
|
||
if (shuttingDown) return;
|
||
log('connect', `Dialing ${CFG.botUrl}`);
|
||
|
||
const ws = new WebSocket(CFG.botUrl, {
|
||
// Preferred auth path: standard Authorization header. Some
|
||
// reverse proxies strip it on WS upgrades; the bot accepts the
|
||
// Sec-WebSocket-Protocol fallback too, but header is cleaner.
|
||
headers: { Authorization: `Bearer ${CFG.token}` },
|
||
// Handshake grace period. Bot's WSS layer should accept
|
||
// instantly, but corporate proxies can be slow.
|
||
handshakeTimeout: 15_000,
|
||
});
|
||
currentWs = ws;
|
||
|
||
ws.on('open', () => {
|
||
reconnectAttempt = 0;
|
||
log('connect', 'Connected — sending hello');
|
||
send({
|
||
type: 'hello',
|
||
agentVersion: AGENT_VERSION,
|
||
hostname: CFG.hostname,
|
||
capabilities: CAPABILITIES,
|
||
});
|
||
startHeartbeat();
|
||
});
|
||
|
||
ws.on('message', (raw) => handleMessage(raw));
|
||
|
||
ws.on('close', (code, reason) => {
|
||
log('connect', `Socket closed (code=${code} reason="${reason.toString()}")`);
|
||
stopHeartbeat();
|
||
currentWs = null;
|
||
scheduleReconnect();
|
||
});
|
||
|
||
ws.on('error', (err) => {
|
||
// 'error' can fire BEFORE 'close' on handshake failures (401,
|
||
// TLS problems, DNS). Log it and let 'close' handle reconnection.
|
||
log('connect', `Socket error: ${err.message}`, 'warn');
|
||
});
|
||
}
|
||
|
||
function startHeartbeat() {
|
||
stopHeartbeat();
|
||
heartbeatTimer = setInterval(() => {
|
||
if (currentWs && currentWs.readyState === WebSocket.OPEN) {
|
||
// JSON-level ping — bot replies with `{type:'pong', at:...}`.
|
||
// We also let the underlying ws library exchange its own
|
||
// ping/pong frames; belt-and-suspenders because some proxies
|
||
// strip WS control frames.
|
||
send({ type: 'ping', at: Date.now() });
|
||
try { currentWs.ping(); } catch { /* ignore */ }
|
||
}
|
||
}, HEARTBEAT_INTERVAL_MS);
|
||
if (heartbeatTimer.unref) heartbeatTimer.unref();
|
||
}
|
||
|
||
function send(obj) {
|
||
if (!currentWs || currentWs.readyState !== WebSocket.OPEN) return false;
|
||
try {
|
||
currentWs.send(JSON.stringify(obj));
|
||
return true;
|
||
} catch (err) {
|
||
log('send', `Failed to send frame: ${err.message}`, 'warn');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ─── Command dispatch ───────────────────────────────────────────────
|
||
|
||
async function handleMessage(raw) {
|
||
let msg;
|
||
try {
|
||
msg = JSON.parse(raw.toString('utf8'));
|
||
} catch {
|
||
log('dispatch', `Ignoring non-JSON frame (${raw.length} bytes)`, 'warn');
|
||
return;
|
||
}
|
||
if (!msg || typeof msg !== 'object') return;
|
||
|
||
// Server-initiated JSON ping — reply with pong (also refreshes the
|
||
// bot-side lastPongAt timestamp).
|
||
if (msg.type === 'ping') {
|
||
send({ type: 'pong', at: Date.now() });
|
||
return;
|
||
}
|
||
if (msg.type === 'pong') return; // no-op; we just want to see it come back
|
||
|
||
// Everything else must have an id and a command type.
|
||
if (!msg.id) {
|
||
log('dispatch', `Frame missing id: ${JSON.stringify(msg).slice(0, 120)}`, 'warn');
|
||
return;
|
||
}
|
||
if (!msg.type) {
|
||
replyError(msg.id, 'MALFORMED', 'command frame missing `type`');
|
||
return;
|
||
}
|
||
|
||
const phoneCmd = msg.type === 'phone-probe' || msg.type === 'phone-probe-raw';
|
||
const targetIp = msg.targetIp || msg.baseIp;
|
||
if (phoneCmd) {
|
||
if (!targetIp) {
|
||
replyError(msg.id, 'MALFORMED', 'phone command frame missing `targetIp`');
|
||
return;
|
||
}
|
||
} else if (!msg.baseIp) {
|
||
replyError(msg.id, 'MALFORMED', 'command frame missing `baseIp`');
|
||
return;
|
||
}
|
||
|
||
const started = Date.now();
|
||
try {
|
||
const result = await dispatch(msg);
|
||
replyOk(msg.id, result, Date.now() - started);
|
||
} catch (err) {
|
||
const code = err?.code || 'AGENT_EXCEPTION';
|
||
const label = phoneCmd ? targetIp : msg.baseIp;
|
||
log('dispatch', `Command ${msg.type} for ${label} failed: ${err.message}`, 'warn');
|
||
replyError(msg.id, code, err.message, { stack: err.stack?.split('\n')[0] });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Route one command to the right helper. Every branch returns a
|
||
* plain JS object that will be JSON-serialized as the `result`
|
||
* field of the reply frame.
|
||
*/
|
||
async function dispatch(cmd) {
|
||
if (cmd.type === 'phone-probe' || cmd.type === 'phone-probe-raw') {
|
||
const host = cmd.targetIp || cmd.baseIp;
|
||
const clientOpts = {
|
||
host,
|
||
timeoutMs: CFG.phoneTimeout,
|
||
};
|
||
if (CFG.phonePass) {
|
||
clientOpts.user = CFG.phoneUser;
|
||
clientOpts.password = CFG.phonePass;
|
||
}
|
||
const client = createMppPhoneClient(clientOpts);
|
||
const includeBodies = cmd.type === 'phone-probe-raw';
|
||
const probeResult = await runPhoneProbe(client, { includeBodies });
|
||
const parsed = probeResult.statusJson
|
||
? parseStatusJson(probeResult.statusJson)
|
||
: (probeResult.statusXml ? parsePhoneStatusXml(probeResult.statusXml) : null);
|
||
const verdict = parsed
|
||
? (probeResult.statusJson ? summarizePhoneHealthFromJson(parsed) : summarizePhoneHealth(parsed))
|
||
: null;
|
||
return {
|
||
...probeResult,
|
||
parsed,
|
||
verdict,
|
||
byteLength: probeResult.statusXml
|
||
? Buffer.byteLength(probeResult.statusXml, 'utf8')
|
||
: null,
|
||
};
|
||
}
|
||
|
||
const client = createDectClient({
|
||
host: cmd.baseIp,
|
||
user: CFG.dectUser,
|
||
password: CFG.dectPass,
|
||
timeoutMs: CFG.dectTimeout,
|
||
});
|
||
|
||
switch (cmd.type) {
|
||
case 'collect':
|
||
case 'collect-raw': {
|
||
const started = Date.now();
|
||
const resp = await client.get('/admin/status.xml');
|
||
const elapsedMs = Date.now() - started;
|
||
if (resp.status !== 200 || typeof resp.data !== 'string' || !resp.data.trim()) {
|
||
const err = new Error(`base returned status=${resp.status} (${resp.data?.length || 0} bytes)`);
|
||
err.code = 'BASE_BAD_STATUS';
|
||
throw err;
|
||
}
|
||
const rawXml = resp.data;
|
||
const parsed = parseStatusXml(rawXml);
|
||
const verdict = summarizeBaseHealth(parsed);
|
||
const base = { parsed, verdict, fetchedInMs: elapsedMs };
|
||
const wantRaw = cmd.includeRaw === true
|
||
|| cmd.includeRaw === 'true'
|
||
|| cmd.type === 'collect-raw';
|
||
if (wantRaw) {
|
||
return {
|
||
...base,
|
||
rawXml,
|
||
byteLength: Buffer.byteLength(rawXml, 'utf8'),
|
||
sectionInventory: inventoryStatusXml(rawXml),
|
||
};
|
||
}
|
||
return base;
|
||
}
|
||
|
||
// All mutating actions are one-shot GETs (see integrations/cisco-
|
||
// dect/probes.js). They come back as either `{dryRun:true,...}`
|
||
// (which we never pass here — dryRun is always false from the
|
||
// bot) or `{dryRun:false, kind, planned, result}`. We surface
|
||
// `planned` + `result` so the bot can log the CSRF'd URL and
|
||
// whether the base responded 200.
|
||
case 'reboot': return await triggerReboot(client, { forced: false, dryRun: false });
|
||
case 'force-reboot': return await triggerReboot(client, { forced: true, dryRun: false });
|
||
case 'reboot-chain': return await triggerRebootChain(client, { forced: false, dryRun: false });
|
||
case 'force-reboot-chain': return await triggerRebootChain(client, { forced: true, dryRun: false });
|
||
case 'factory-reset': return await triggerFactoryReset(client, { dryRun: false });
|
||
case 'reconfigure-tree': return await triggerReconfigureDectTree(client, { dryRun: false });
|
||
|
||
default: {
|
||
const err = new Error(`unknown command type: ${cmd.type}`);
|
||
err.code = 'UNKNOWN_COMMAND';
|
||
throw err;
|
||
}
|
||
}
|
||
}
|
||
|
||
function replyOk(id, result, elapsedMs) {
|
||
send({ id, ok: true, result, elapsedMs });
|
||
}
|
||
|
||
function replyError(id, code, message, detail = null) {
|
||
send({ id, ok: false, error: { code, message, ...(detail || {}) } });
|
||
}
|
||
|
||
// ─── Signal handling ────────────────────────────────────────────────
|
||
|
||
async function shutdown(reason) {
|
||
if (shuttingDown) return;
|
||
shuttingDown = true;
|
||
log('shutdown', `Shutting down — ${reason}`);
|
||
stopHeartbeat();
|
||
if (currentWs) {
|
||
try { currentWs.close(1000, 'agent shutdown'); } catch { /* ignore */ }
|
||
}
|
||
// Give the close frame a moment to flush before exit. 500ms is
|
||
// enough for local + LAN cases and doesn't meaningfully delay
|
||
// container restarts.
|
||
setTimeout(() => process.exit(0), 500);
|
||
}
|
||
|
||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||
process.on('uncaughtException', (err) => {
|
||
log('uncaught', `${err.message}\n${err.stack}`, 'error');
|
||
shutdown('uncaughtException');
|
||
});
|
||
process.on('unhandledRejection', (reason) => {
|
||
const msg = reason instanceof Error ? `${reason.message}\n${reason.stack}` : String(reason);
|
||
log('uncaught', msg, 'error');
|
||
shutdown('unhandledRejection');
|
||
});
|
||
|
||
// ─── Start ──────────────────────────────────────────────────────────
|
||
|
||
assertConfig();
|
||
log('startup', `dect-relay-agent v${AGENT_VERSION} — hostname=${CFG.hostname}, bot=${CFG.botUrl}`);
|
||
connect();
|