collabSupport/dect-relay-agent/index.js
Joseph McQueen 96b26a5aca DECT relay Phase 1: WSS hub + agent + /phonestatus follow-up
The bot runs in the public cloud and can't reach the 10.x/8 network
where DBS-210 bases live. This phase adds a data-center-resident relay
agent that dials outbound over WSS to the bot, and lets /phonestatus
post a follow-up message with per-base health after its main output
has already shipped.

Bot side (services/):
- dectRelayHub.js: WebSocket upgrade handler on /dect-relay/ws with
  bearer-token auth (constant-time compare, header + Sec-WebSocket-
  Protocol fallback for header-stripping proxies). Promise-based RPC
  API with per-call timeouts, mid-flight-disconnect rejection, and
  clean replacement of a stale agent socket when a newer one connects.
- dectDiscovery.js: pure filter that turns a phoneService result into
  a list of reachable bases. Enforces the "must be on 10.0.0.0/8"
  guardrail per requirements, dedups by IP + MAC, prefers Meraki-live
  IP over Webex-cached IP.
- dectCollectorService.js: fan-out layer over the hub. collectAll()
  runs one RPC per base in parallel with per-base error isolation —
  one bad base never fails the batch.

Phone-status integration:
- Renderer gets a dectFollowUpBaseCount opt that emits an italic
  "diagnostics loading for N base(s)..." hint inside the DECT section
  of the main message.
- New exported renderDectDiagnosticsMarkdown() renders the follow-up
  message: healthy/warning icon per base, uptime + firmware summary,
  structured Power Loss reboot line, and per-base failure hints (e.g.
  "relay accepted the request but the base did not respond in time").
- commands/phoneStatus.js discovers reachable bases synchronously
  (pure), sends the main message, then fires collectAll() and posts
  the follow-up as a separate message. Failures logged, never thrown
  back to the user.
- Chat only: HTTP callers keep their single-message contract.

Agent side (dect-relay-agent/):
- Standalone Node process with its own package.json (only ws, axios,
  dotenv). Reuses the shared integrations/cisco-dect/{client,probes,
  statusXml}.js modules from the parent workspace so there's no code
  duplication.
- Auto-reconnect with exponential backoff + jitter.
- Dispatches collect / reboot / force-reboot / reboot-chain /
  force-reboot-chain / factory-reset / reconfigure-tree.
- DECT admin credentials live ONLY on the agent (never on the bot).
  Shared bearer token gates the WSS handshake.
- README.md covers install, config, wire protocol, and safety model.

Env / infra:
- .env.example: adds DECT_RELAY_AGENT_TOKEN + optional DECT_RELAY_PATH
  and DECT_COLLECT_TIMEOUT_MS. Reframes DECT_TEST_* as the local-dev
  test harness rather than the production path.
- index.js: captures the http.Server from app.listen() and attaches
  the relay hub when DECT_RELAY_AGENT_TOKEN is set; graceful shutdown
  now closes the hub so in-flight RPCs get rejected cleanly.
- Adds "ws" to bot dependencies.

Tests (99 -> 113):
- tests/dectDiscovery.test.js: 13 cases covering the 10.x guardrail,
  MAC normalization, IP source preference, dedup, and warning shape.
- tests/dectRelayHub.test.js: 14 integration cases using a real
  ws pair on an ephemeral 127.0.0.1 port — auth (missing / wrong /
  correct via header / correct via protocol fallback), hello frame,
  RPC round-trip with correlation, agent error surfacing, concurrent
  out-of-order replies, timeout, mid-flight disconnect, replacement
  of a stale socket, and execAction routing.
- tests/renderers.test.js: 8 new cases for the DECT-follow-up loading
  hint (plural / singular / off) and the diagnostics renderer (empty,
  healthy, warning, power-loss dedup, active RTP, error hint, footer).
2026-07-02 17:03:32 -04:00

325 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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';
// ─── 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,
reconnectMaxMs: Number(process.env.DECT_RELAY_RECONNECT_MAX_MS) || 30_000,
};
const AGENT_VERSION = '0.1.0';
const CAPABILITIES = [
'collect',
'reboot',
'force-reboot',
'reboot-chain',
'force-reboot-chain',
'factory-reset',
'reconfigure-tree',
];
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 01000ms 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;
}
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';
log('dispatch', `Command ${msg.type} for ${msg.baseIp} 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) {
const client = createDectClient({
host: cmd.baseIp,
user: CFG.dectUser,
password: CFG.dectPass,
timeoutMs: CFG.dectTimeout,
});
switch (cmd.type) {
case 'collect': {
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 parsed = parseStatusXml(resp.data);
const verdict = summarizeBaseHealth(parsed);
return { parsed, verdict, fetchedInMs: elapsedMs };
}
// 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();