From 96b26a5acae00c9d3472cec7222eed10dc5c7ba2 Mon Sep 17 00:00:00 2001 From: Joseph McQueen Date: Thu, 2 Jul 2026 17:03:32 -0400 Subject: [PATCH] DECT relay Phase 1: WSS hub + agent + /phonestatus follow-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .env.example | 34 +- commands/phoneStatus.js | 57 ++- dect-relay-agent/.env.example | 48 +++ dect-relay-agent/README.md | 126 ++++++ dect-relay-agent/index.js | 325 +++++++++++++++ dect-relay-agent/package.json | 19 + index.js | 42 +- package-lock.json | 9 +- package.json | 3 +- services/dectCollectorService.js | 162 ++++++++ services/dectDiscovery.js | 129 ++++++ services/dectRelayHub.js | 472 ++++++++++++++++++++++ services/renderers/phoneStatusRenderer.js | 106 ++++- tests/dectDiscovery.test.js | 169 ++++++++ tests/dectRelayHub.test.js | 351 ++++++++++++++++ tests/renderers.test.js | 159 +++++++- 16 files changed, 2192 insertions(+), 19 deletions(-) create mode 100644 dect-relay-agent/.env.example create mode 100644 dect-relay-agent/README.md create mode 100644 dect-relay-agent/index.js create mode 100644 dect-relay-agent/package.json create mode 100644 services/dectCollectorService.js create mode 100644 services/dectDiscovery.js create mode 100644 services/dectRelayHub.js create mode 100644 tests/dectDiscovery.test.js create mode 100644 tests/dectRelayHub.test.js diff --git a/.env.example b/.env.example index b36e293..c396169 100644 --- a/.env.example +++ b/.env.example @@ -217,18 +217,16 @@ BACKDOOR_USERNAME=monitor BACKDOOR_PASSWORD=... # ----------------------------------------------------------------------------- -# Cisco DBS-210 DECT base station — LOCAL TEST HARNESS -# Used only by scripts/testDectBase.js during the spike phase (see -# integrations/cisco-dect/). Not wired into the bot yet — this is for -# reverse-engineering the DBS-210 web UI (reboot, PRT pull, syslog, -# config export) against a single lab base before scaling to a -# per-store relay agent. +# Cisco DBS-210 DECT — LOCAL DEV TEST HARNESS +# Used only by scripts/testDectBase.js when iterating locally against a +# lab base. NOT read by the bot at runtime — the bot never talks to a +# DBS-210 directly. Actual production DBS-210 access lives in the +# DECT_RELAY_* env below and is executed by dect-relay-agent/. # # Cisco's guidance: use the DECT serviceability password (Control Hub # → Calling → Features → DECT Networks → Manage → Manage DECT # serviceability password). Our tenant is configured to share a -# single password across all bases in the fleet, so this one credential -# will work fleet-wide once we add the relay layer. +# single password across all bases in the fleet. # ----------------------------------------------------------------------------- DECT_TEST_BASE_IP=10.0.0.100 DECT_TEST_USER=admin @@ -237,6 +235,26 @@ DECT_TEST_PASSWORD=your-dect-serviceability-password # on syslog/PRT downloads; 30s is a good starting point. DECT_TEST_TIMEOUT_MS=30000 +# ----------------------------------------------------------------------------- +# DECT Relay Hub — WSS endpoint for the data-center relay agent +# The bot process runs in the public cloud and can't reach 10.x. The +# relay agent (see dect-relay-agent/) runs INSIDE the DC, dials +# outbound over WSS to this bot, and executes any DECT command the bot +# pushes. Feature-gated: without DECT_RELAY_AGENT_TOKEN, the WSS +# endpoint is not attached and /phonestatus's DECT follow-up + the +# future /dectstatus command return "relay not connected". +# +# Rotate DECT_RELAY_AGENT_TOKEN on both sides at once. Suggested +# generation: `openssl rand -hex 32`. +# ----------------------------------------------------------------------------- +DECT_RELAY_AGENT_TOKEN= +# Optional. Default: /dect-relay/ws. Change only if you also change +# DECT_RELAY_BOT_URL on the agent side to match. +# DECT_RELAY_PATH=/dect-relay/ws +# Optional. Per-base collect() RPC timeout. Corporate proxies can make +# DBS-210 reads slow; 15s is comfortable, 30s is generous. +# DECT_COLLECT_TIMEOUT_MS=15000 + # ----------------------------------------------------------------------------- # Notes # ----------------------------------------------------------------------------- diff --git a/commands/phoneStatus.js b/commands/phoneStatus.js index ddcd3c7..8328cee 100644 --- a/commands/phoneStatus.js +++ b/commands/phoneStatus.js @@ -8,11 +8,16 @@ import { randomUUID } from 'node:crypto'; import { collectPhoneStatus } from '../services/phoneService.js'; -import { renderPhoneStatusMarkdown } from '../services/renderers/phoneStatusRenderer.js'; +import { + renderPhoneStatusMarkdown, + renderDectDiagnosticsMarkdown, +} from '../services/renderers/phoneStatusRenderer.js'; import { buildIgmpFixCard } from './igmpFix.js'; import { pendingIgmpFixes } from '../utils/pendingIgmpFixes.js'; import { extractRequester } from '../utils/requester.js'; import { logger } from '../utils/logger.js'; +import { discoverDectBases } from '../services/dectDiscovery.js'; +import { collectAll } from '../services/dectCollectorService.js'; export async function handlePhoneStatus(bot, trigger) { logger('phone:status', 'Handler entered', 'debug'); @@ -54,13 +59,45 @@ export async function handlePhoneStatus(bot, trigger) { return; } + // Discover reachable DECT bases BEFORE rendering so we can tell + // the renderer how many bases the follow-up will cover. Discovery + // is a pure filter over what phoneService already fetched — no + // network calls, so it doesn't slow the main output. Only chat + // triggers get a follow-up; HTTP callers keep the single-message + // contract they had before. + const dectFollowUpEnabled = !!trigger.person; + const { bases: reachableBases, warnings: discoveryWarnings } = dectFollowUpEnabled + ? discoverDectBases(data) + : { bases: [], warnings: [] }; + if (discoveryWarnings.length > 0) { + logger( + 'phone:status', + `DECT discovery warnings for store ${storeNum}: ${discoveryWarnings.map((w) => w.reason).join('; ')}`, + 'warn', + ); + } + const reply = renderPhoneStatusMarkdown(data, { storeNum, detailed: isDetailed, footer: true, + dectFollowUpBaseCount: reachableBases.length, }); await bot.say('markdown', reply || 'No data available.'); + // Kick off DECT follow-up. Fire-and-forget from this handler's + // perspective — the awaits inside runDectFollowUp() are just so + // failures get logged with a stable scope, they don't propagate + // back to the user's original /phonestatus call. If the relay is + // offline or a base is unreachable we still post the follow-up + // (with per-base error lines) so the user isn't left wondering + // where the promised diagnostics went. + if (dectFollowUpEnabled && reachableBases.length > 0) { + runDectFollowUp(bot, storeNum, reachableBases).catch((err) => { + logger('phone:status', `DECT follow-up failed for store ${storeNum}: ${err.message}`, 'error'); + }); + } + // IGMP-snooping remediation card — only when (a) the multicast // summary flagged deviation AND (b) we know the networkId (can't // fix what we can't address) AND (c) the invocation came from @@ -110,3 +147,21 @@ export async function handlePhoneStatus(bot, trigger) { await bot.say('markdown', `Error collecting phone status: ${err.message}`); } } + +/** + * Run the DECT-diagnostics follow-up as a separate message in the + * same room. Only invoked from chat triggers. Errors are logged + * (never thrown up) — the /phonestatus main output has already been + * sent by the time we get here, so a follow-up crash shouldn't leave + * the user with a broken chat experience. + * + * Renderer emits an empty string only when the results list is empty + * — which shouldn't happen because we already checked reachableBases + * .length > 0 at the call site, but we still guard against it here. + */ +async function runDectFollowUp(bot, storeNum, bases) { + const results = await collectAll(bases); + const md = renderDectDiagnosticsMarkdown(results, { storeNum }); + if (!md) return; + await bot.say('markdown', md); +} diff --git a/dect-relay-agent/.env.example b/dect-relay-agent/.env.example new file mode 100644 index 0000000..fdaa6d0 --- /dev/null +++ b/dect-relay-agent/.env.example @@ -0,0 +1,48 @@ +# ============================================================================= +# DECT Relay Agent — data-center-resident bridge to Cisco DBS-210 bases +# ============================================================================= +# +# This agent runs INSIDE the corporate network (has route to 10.x/8) +# and dials outbound over WSS to the CollabSupport bot. The bot +# process itself runs in the public cloud and can't reach 10.x +# directly; this agent is the only thing that can talk to a DBS-210. +# +# See README.md in this folder for run instructions. + +# ─── Where to dial the bot ─────────────────────────────────────────── +# +# Full WSS URL to the bot's DECT relay endpoint. Must be wss:// (never +# ws:// — the bearer token would be visible in cleartext). The path +# defaults to /dect-relay/ws to match the bot's DECT_RELAY_PATH env +# on the other side; only change here if you've changed it there too. +DECT_RELAY_BOT_URL=wss://your-bot-host.example.com/dect-relay/ws + +# Shared bearer token — MUST match the bot's DECT_RELAY_AGENT_TOKEN +# exactly. Rotate both sides at once to avoid a lockout window. +# Suggested generation: `openssl rand -hex 32` +DECT_RELAY_AGENT_TOKEN=replace-with-shared-secret + +# Optional friendly identifier reported to the bot on hello. +# Shows up in the bot's logs and eventually /dectstatus admin views. +# Defaults to os.hostname() if unset. +# DECT_RELAY_AGENT_HOSTNAME=dc-dect-relay-01 + +# ─── DBS-210 admin credentials ─────────────────────────────────────── +# +# Cisco tenants share ONE serviceability password across all bases +# in the fleet (configured in Control Hub → Calling → Features → +# DECT Networks → Manage → Manage DECT serviceability password), so +# a single credential works for every 10.x base this agent can reach. +DECT_ADMIN_USER=admin +DECT_ADMIN_PASSWORD=replace-with-dect-serviceability-password + +# Per-request HTTPS timeout when talking to a DBS-210. Bases going +# through a corporate proxy can be slow — 30s is comfortable, 15s +# is aggressive. +DECT_ADMIN_TIMEOUT_MS=30000 + +# ─── Optional tuning ───────────────────────────────────────────────── +# +# How long to wait between reconnect attempts when the bot socket +# drops. Uses exponential backoff up to this cap. +# DECT_RELAY_RECONNECT_MAX_MS=30000 diff --git a/dect-relay-agent/README.md b/dect-relay-agent/README.md new file mode 100644 index 0000000..2d9818b --- /dev/null +++ b/dect-relay-agent/README.md @@ -0,0 +1,126 @@ +# DECT Relay Agent + +Bridges the CollabSupport bot (public cloud) to Cisco DBS-210 DECT base stations on the private `10.0.0.0/8` corporate network. + +## Why it exists + +The bot process runs in the public cloud and can't reach `10.x`. This agent runs inside the data center, dials outbound over WSS to the bot, and executes any DECT command (collect status, reboot, factory-reset, etc.) the bot pushes to it. + +Only one agent is expected to run at a time. If a second agent connects, the bot assumes it's a legitimate restart, closes the old socket, and adopts the new one. + +## Prerequisites + +- Node.js ≥ 20 +- Route from the agent host to `10.0.0.0/8` on TCP 443 +- Route from the agent host to the bot's public HTTPS endpoint +- The DECT serviceability password (Control Hub → Calling → Features → DECT Networks → Manage → Manage DECT serviceability password) + +## Install + +```bash +cd dect-relay-agent +npm install +``` + +`ws`, `axios`, and `dotenv` are the only runtime dependencies. The agent imports the shared `integrations/cisco-dect/` modules from the parent repo via relative paths, so the parent workspace must be present on disk. + +## Configure + +```bash +cp .env.example .env +$EDITOR .env +``` + +Required values: + +| Var | Meaning | +|---|---| +| `DECT_RELAY_BOT_URL` | Full WSS URL to the bot's DECT relay endpoint (`wss://your-bot-host/dect-relay/ws`) | +| `DECT_RELAY_AGENT_TOKEN` | Shared bearer token — MUST match the bot's `DECT_RELAY_AGENT_TOKEN` exactly | +| `DECT_ADMIN_USER` | Usually `admin` | +| `DECT_ADMIN_PASSWORD` | Fleet-wide serviceability password | + +Generate a fresh token: `openssl rand -hex 32`. Rotate on both sides at once — the bot compares tokens with `timingSafeEqual` and will reject any drift with a 401 on the WSS upgrade. + +## Run + +```bash +npm start +``` + +You should see: + +``` +[startup] dect-relay-agent v0.1.0 — hostname=..., bot=wss://... +[connect] Dialing wss://.../dect-relay/ws +[connect] Connected — sending hello +``` + +And on the bot side: + +``` +[dect:relay-hub] Agent connected from ... +[dect:relay-hub] Agent hello: version=0.1.0 host=... caps=collect,reboot,... +``` + +## Wire protocol + +All frames are JSON, one per WebSocket message. + +**Agent → Bot on connect:** +```json +{ "type": "hello", + "agentVersion": "0.1.0", + "hostname": "dc-dect-relay-01", + "capabilities": ["collect","reboot","force-reboot","reboot-chain", + "force-reboot-chain","factory-reset","reconfigure-tree"] } +``` + +**Bot → Agent (command):** +```json +{ "id": "cmd_", "type": "collect", "baseIp": "10.4.11.87" } +{ "id": "cmd_", "type": "reboot", "baseIp": "10.4.11.87" } +``` + +**Agent → Bot (reply):** +```json +{ "id": "cmd_", "ok": true, "elapsedMs": 812, + "result": { "parsed": { ... }, "verdict": { "healthy": true, ... } } } + +{ "id": "cmd_", "ok": false, + "error": { "code": "DIGEST_401", "message": "Base rejected credentials" } } +``` + +**Heartbeat (both directions, every 30s):** +```json +{ "type": "ping", "at": 1720000000000 } +{ "type": "pong", "at": 1720000000000 } +``` + +The bot terminates the socket if no `pong` arrives within 90s; the agent auto-reconnects with exponential backoff (1s / 2s / 4s / … capped at 30s + 0-1000ms jitter). + +## Safety guarantees + +- DECT admin credentials NEVER leave this agent. The bot only knows the WSS bearer token. +- All mutating actions (reboot, factory-reset, reconfigure-tree) are only executed when the bot explicitly issues the corresponding command frame. The agent has no autonomous logic. +- The agent enforces no policy — the bot decides who can reboot what. See the bot's audit log for the full record of actions taken (`igmp:audit` style scopes in daily log files). +- The agent quarantines mutating actions from probes via the exact same safety model as the CLI tool (`integrations/cisco-dect/probes.js` — GET-triggered actions are only reachable via explicit `trigger*` helpers, never via a generic path fetcher). + +## Deploying as a container + +A `Dockerfile` isn't included yet — production deployment shape is TBD. Minimum viable: + +```Dockerfile +FROM node:20-alpine +WORKDIR /app +# The agent imports from ../integrations/cisco-dect/, so copy the +# whole workspace (or at least these two paths). +COPY package.json package-lock.json ./ +COPY dect-relay-agent ./dect-relay-agent +COPY integrations/cisco-dect ./integrations/cisco-dect +COPY utils/httpDigestAuth.js ./utils/httpDigestAuth.js +RUN cd dect-relay-agent && npm ci --omit=dev +CMD ["node", "dect-relay-agent/index.js"] +``` + +Set the env vars from `.env.example` via your orchestrator's secret store, not baked into the image. diff --git a/dect-relay-agent/index.js b/dect-relay-agent/index.js new file mode 100644 index 0000000..5671c1b --- /dev/null +++ b/dect-relay-agent/index.js @@ -0,0 +1,325 @@ +#!/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:///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 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; + } + 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(); diff --git a/dect-relay-agent/package.json b/dect-relay-agent/package.json new file mode 100644 index 0000000..259704f --- /dev/null +++ b/dect-relay-agent/package.json @@ -0,0 +1,19 @@ +{ + "name": "dect-relay-agent", + "version": "0.1.0", + "description": "Data-center-resident agent that bridges the CollabSupport bot (cloud) to Cisco DBS-210 DECT base stations on the private 10.x network.", + "type": "module", + "private": true, + "main": "index.js", + "scripts": { + "start": "node index.js" + }, + "dependencies": { + "axios": "^1.13.6", + "dotenv": "^17.3.1", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20" + } +} diff --git a/index.js b/index.js index 1b7b1ec..1e6cacb 100644 --- a/index.js +++ b/index.js @@ -35,6 +35,7 @@ import { } from './commands/igmpFix.js'; import { pendingIgmpFixes } from './utils/pendingIgmpFixes.js'; import { extractRequester } from './utils/requester.js'; +import { getDectRelayHub } from './services/dectRelayHub.js'; import { getCommand, ALL_HTTP_COMMAND_KEYS, @@ -529,7 +530,13 @@ framework.hears(/.*/, async (bot, trigger) => { } }, null, 1); -app.listen(PORT, () => { +// Capture the http.Server so we can attach the DECT relay WebSocket +// upgrade handler to it after listen(). Express's app.listen() returns +// the underlying Node http.Server — previously we discarded that +// handle because HTTP-only routes don't need it. Adding WSS support +// changes that: the ws package hooks into the raw 'upgrade' event on +// the http.Server, so we need the reference here. +const httpServer = app.listen(PORT, () => { logger('server', `🚀 Express server running on port ${PORT}`); console.log(`🚀 HTTP API server listening on http://localhost:${PORT}`); @@ -552,6 +559,24 @@ app.listen(PORT, () => { } }); +// DECT relay hub — accepts ONE long-lived WSS connection from the +// data-center relay agent. Gated on DECT_RELAY_AGENT_TOKEN being set: +// if not configured, we log a warning and skip attaching so the bot +// stays usable for everything else (Jira poller, Meraki, Webex admin +// commands). See services/dectRelayHub.js for the wire protocol and +// dect-relay-agent/README.md for how to run the DC-side process. +if (process.env.DECT_RELAY_AGENT_TOKEN) { + const relayHub = getDectRelayHub(); + relayHub.attachTo(httpServer); + logger('startup', `DECT relay hub listening for agent at ${process.env.DECT_RELAY_PATH || '/dect-relay/ws'}`); +} else { + logger( + 'startup', + 'DECT relay disabled — set DECT_RELAY_AGENT_TOKEN in .env (and share the same value with dect-relay-agent) to enable /phonestatus DECT follow-up + future /dectstatus command', + 'warn', + ); +} + // ────────────────────────────────────────────── // Cron Jobs // ────────────────────────────────────────────── @@ -636,9 +661,20 @@ async function shutdown(reason, exitCode) { logger('shutdown', 'Webex Framework stopped'); } catch (err) { logger('shutdown', `Error during framework.stop(): ${err.message}`, 'error'); - } finally { - process.exit(exitCode); } + // Close the DECT relay hub so in-flight RPCs get rejected with + // DISCONNECTED and the agent's socket gets a clean 1001 shutdown + // frame (instead of a ripped-out TCP that would leave the agent + // reconnecting in a loop until its keepalive times out). + if (process.env.DECT_RELAY_AGENT_TOKEN) { + try { + await getDectRelayHub().close(); + logger('shutdown', 'DECT relay hub closed'); + } catch (err) { + logger('shutdown', `Error during DECT relay hub close: ${err.message}`, 'warn'); + } + } + process.exit(exitCode); } process.on('SIGINT', () => shutdown('SIGINT', 0)); diff --git a/package-lock.json b/package-lock.json index 85661ac..86661b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ "form-data": "^4.0.5", "graphql-request": "^7.4.0", "node-cron": "^4.2.1", - "webex-node-bot-framework": "^2.5.1" + "webex-node-bot-framework": "^2.5.1", + "ws": "^8.21.0" }, "devDependencies": { "nodemon": "^3.1.4" @@ -12451,9 +12452,9 @@ } }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index d051df0..2aa917e 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "form-data": "^4.0.5", "graphql-request": "^7.4.0", "node-cron": "^4.2.1", - "webex-node-bot-framework": "^2.5.1" + "webex-node-bot-framework": "^2.5.1", + "ws": "^8.21.0" }, "devDependencies": { "nodemon": "^3.1.4" diff --git a/services/dectCollectorService.js b/services/dectCollectorService.js new file mode 100644 index 0000000..af5d724 --- /dev/null +++ b/services/dectCollectorService.js @@ -0,0 +1,162 @@ +// src/services/dectCollectorService.js +// +// Fan-out layer over the DECT relay hub. Callers hand it a list of +// bases (from services/dectDiscovery.js), it dispatches one RPC per +// base in parallel and returns a normalized per-base result array. +// +// Kept intentionally thin: it doesn't render, it doesn't decide what +// to do with warnings, it doesn't touch Meraki. Whoever calls this +// (the /phonestatus follow-up, the /dectstatus command in Phase 2, +// the Jira poller in Phase 3) owns presentation. + +import { getDectRelayHub, RelayErrorCodes } from './dectRelayHub.js'; +import { logger } from '../utils/logger.js'; + +const LOG_SCOPE = 'dect:collector'; + +const DEFAULT_TIMEOUT_MS = Number(process.env.DECT_COLLECT_TIMEOUT_MS) || 15_000; + +/** + * @typedef {object} BaseTarget + * @property {string} mac + * @property {string} ip + * @property {string} name + */ + +/** + * @typedef {object} BaseCollectResult + * @property {BaseTarget} base + * @property {boolean} ok + * @property {object|null} data parsed status object (when ok) + * @property {object|null} verdict { healthy, warnings, info } (when ok) + * @property {number|null} elapsedMs + * @property {object|null} error { code, message } (when !ok) + */ + +/** + * Run `collect` against every base in the list, in parallel. One base + * failing (timeout, offline, bad creds) does NOT fail the batch — + * that base's entry just has ok:false. Ordering of returned entries + * matches the input. + * + * @param {BaseTarget[]} bases + * @param {object} [opts] + * @param {number} [opts.timeoutMs] per-base RPC timeout override + * @param {object} [opts.hub] inject a hub for tests + * @returns {Promise} + */ +export async function collectAll(bases, opts = {}) { + const list = Array.isArray(bases) ? bases : []; + if (list.length === 0) return []; + const hub = opts.hub || getDectRelayHub(); + const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS; + + logger(LOG_SCOPE, `Fanning out collect() to ${list.length} base(s)`, 'debug'); + + const results = await Promise.all(list.map((base) => collectOne(hub, base, timeoutMs))); + + const okCount = results.filter((r) => r.ok).length; + logger(LOG_SCOPE, `Collect finished: ${okCount}/${list.length} succeeded`, 'debug'); + return results; +} + +/** + * Single-base variant. Mostly here for the eventual /dectstatus + * command's individual "refresh this base" flow — collectAll uses it + * internally. + */ +export async function collectOne(hub, base, timeoutMs = DEFAULT_TIMEOUT_MS) { + if (!base?.ip) { + return { + base, ok: false, data: null, verdict: null, elapsedMs: null, + error: { code: 'NO_IP', message: 'base has no IP address' }, + }; + } + const started = Date.now(); + try { + const { result, elapsedMs } = await hub.collect(base.ip, { timeoutMs }); + return { + base, + ok: true, + data: result?.parsed || result || null, + verdict: result?.verdict || null, + elapsedMs: elapsedMs ?? (Date.now() - started), + error: null, + }; + } catch (err) { + // We keep the code+message split so renderers can decide whether + // to show a hint ("relay is offline" vs "wrong password" are very + // different remediations). + const code = err?.code || 'UNKNOWN'; + return { + base, + ok: false, + data: null, + verdict: null, + elapsedMs: Date.now() - started, + error: { + code, + message: err?.message || String(err), + // For NOT_CONNECTED there's no per-base fix — surface a hint. + hint: hintFor(code), + }, + }; + } +} + +/** + * Run one of the mutating actions against a base. Same envelope shape + * as collectOne (ok / error / elapsedMs) so callers can log it + * uniformly. Actions handled here mirror the CLI script's subcommands. + * + * @param {BaseTarget} base + * @param {string} action 'reboot' | 'force-reboot' | 'reboot-chain' | + * 'force-reboot-chain' | 'factory-reset' | + * 'reconfigure-tree' + * @param {object} [opts] + * @param {number} [opts.timeoutMs] + * @param {object} [opts.hub] + */ +export async function execAction(base, action, opts = {}) { + if (!base?.ip) { + return { + base, ok: false, elapsedMs: null, + error: { code: 'NO_IP', message: 'base has no IP address' }, + }; + } + const hub = opts.hub || getDectRelayHub(); + const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS; + const started = Date.now(); + try { + const { result, elapsedMs } = await hub.execAction(base.ip, action, {}, { timeoutMs }); + return { + base, ok: true, action, + elapsedMs: elapsedMs ?? (Date.now() - started), + result: result || null, + error: null, + }; + } catch (err) { + const code = err?.code || 'UNKNOWN'; + return { + base, ok: false, action, + elapsedMs: Date.now() - started, + error: { + code, message: err?.message || String(err), + hint: hintFor(code), + }, + }; + } +} + +function hintFor(code) { + switch (code) { + case RelayErrorCodes.NOT_CONNECTED: + return 'DECT relay agent is not connected. Check that dect-relay-agent is running in the data center.'; + case RelayErrorCodes.TIMEOUT: + return 'Relay accepted the request but the base did not respond in time. The base may be offline, rebooting, or unreachable.'; + case RelayErrorCodes.DISCONNECTED: + return 'Relay agent disconnected while this command was in flight. Try again in a moment.'; + default: + return null; + } +} diff --git a/services/dectDiscovery.js b/services/dectDiscovery.js new file mode 100644 index 0000000..a96a9f3 --- /dev/null +++ b/services/dectDiscovery.js @@ -0,0 +1,129 @@ +// src/services/dectDiscovery.js +// +// Turn "the list of DECT basestations we already know about for a +// store" into "the list of bases the DECT relay should actually try +// to talk to". Pure, no I/O — the input comes straight from +// collectPhoneStatus() output (or /phone/devices/build), so this +// module just filters and normalizes. +// +// The single hard rule enforced here is the 10.x network guard: every +// production DECT base at AE lives on the 10.0.0.0/8 corporate +// network. Anything with a different first octet is either a +// leftover, a mis-inventoried device, or the base has been swapped +// out and not yet re-Merakied — either way the relay should NOT try +// to talk to it (a random 192.168.x.x on some client's laptop is not +// something we want to Digest-auth into). We flag those cases as +// warnings so the caller can surface them. + +// Cisco DECT MAC OUI prefixes (first three octets of the MAC). +// Not enforced hard — some fleets have odd MACs — but used as a +// tie-breaker when the Webex API's baseStation entries are noisy. +// Kept exported so tests + future callers can extend. +export const CISCO_DECT_MAC_OUI_PREFIXES = new Set([ + '6cab05', // observed on lab DBS-210-3PC + '00040f', // classic Cisco DECT range +]); + +/** + * Discover reachable DBS-210 bases for a store from a collectPhoneStatus + * result. + * + * @param {object} phoneStatus collectPhoneStatus() output + * @returns {object} discovery { bases: [...], warnings: [...] } + * - bases: [{ mac, ip, name, source }] ready to hand to the relay + * - warnings: [{ mac, ip, reason }] bases we deliberately excluded + */ +export function discoverDectBases(phoneStatus) { + const bases = []; + const warnings = []; + const seenIps = new Set(); + const seenMacs = new Set(); + + const raw = Array.isArray(phoneStatus?.dectBasestations) + ? phoneStatus.dectBasestations + : []; + + for (const base of raw) { + // Pick the best IP source. Meraki's live client scan is more + // trustworthy than the Webex API record (which lags device DHCP + // renewals), so we prefer it. Webex's ipAddress is the fallback. + const ip = pickIp(base); + const mac = normalizeMac(base.mac); + const name = base.name || base.displayName || `Basestation ${mac || '?'}`; + + if (!mac) { + warnings.push({ mac: null, ip, reason: 'base has no MAC address in inventory' }); + continue; + } + if (!ip) { + warnings.push({ mac, ip: null, reason: 'no IP address available (base may be unreachable)' }); + continue; + } + if (!isTenDotIp(ip)) { + warnings.push({ + mac, + ip, + reason: `base IP ${ip} is not on the corporate 10.0.0.0/8 network; skipping (production bases should always be 10.x)`, + }); + continue; + } + if (seenIps.has(ip)) { + warnings.push({ mac, ip, reason: `duplicate IP ${ip} in discovery result — keeping the first entry` }); + continue; + } + if (seenMacs.has(mac)) { + warnings.push({ mac, ip, reason: `duplicate MAC ${mac} in discovery result — keeping the first entry` }); + continue; + } + seenIps.add(ip); + seenMacs.add(mac); + + bases.push({ + mac, + ip, + name, + // Track where the IP came from — useful in logs if a base is + // reachable via one source but not the other. + source: base.meraki?.ip ? 'meraki' : 'webex', + }); + } + + return { bases, warnings }; +} + +// ─── Helpers ──────────────────────────────────────────────────────── + +/** + * Test whether an IP string is on 10.0.0.0/8 (i.e. first octet is 10). + * Accepts plain IPv4 dotted strings; anything else returns false + * (we're intentionally conservative here — CIDRs, IPv6, hostnames all + * fall through to "not on 10.x" so the relay never touches them). + */ +export function isTenDotIp(value) { + if (typeof value !== 'string') return false; + const m = value.trim().match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (!m) return false; + const octets = [m[1], m[2], m[3], m[4]].map(Number); + if (octets.some((o) => o < 0 || o > 255)) return false; + return octets[0] === 10; +} + +/** + * Normalize a MAC address to lowercase-colon-separated form + * (`aa:bb:cc:dd:ee:ff`). Returns null if the input doesn't look like + * a 12-hex-nibble MAC. + */ +export function normalizeMac(mac) { + if (!mac || typeof mac !== 'string') return null; + const hex = mac.replace(/[^0-9a-fA-F]/g, '').toLowerCase(); + if (hex.length !== 12) return null; + return hex.match(/../g).join(':'); +} + +function pickIp(base) { + const merakiIp = base?.meraki?.ip; + if (typeof merakiIp === 'string' && merakiIp.trim()) return merakiIp.trim(); + const webexIp = base?.ipAddress; + if (typeof webexIp === 'string' && webexIp.trim() && webexIp !== '—') return webexIp.trim(); + return null; +} diff --git a/services/dectRelayHub.js b/services/dectRelayHub.js new file mode 100644 index 0000000..59a8534 --- /dev/null +++ b/services/dectRelayHub.js @@ -0,0 +1,472 @@ +// src/services/dectRelayHub.js +// +// Bot-side of the DECT relay: a WebSocket server that accepts ONE +// long-lived connection from a data-center-resident relay agent, plus +// a promise-based RPC API for the rest of the bot to call ("collect +// this base's status", "reboot this base"). The agent — which lives +// in `dect-relay-agent/` in this repo — makes the actual HTTPS Digest +// calls to DBS-210 base stations on the private 10.x network. +// +// Why a WebSocket at all: the bot runs in the public cloud and can't +// reach 10.x. The agent runs in the DC and can, but the DC can't +// accept unsolicited inbound connections. WSS solves both sides: the +// agent dials outbound to the bot (traversing NAT / proxy just like +// any HTTPS request), and once the socket is up the bot can push +// commands whenever it wants. +// +// Only ONE agent is expected to connect. If a second agent dials in, +// we assume it's a legitimate restart (agent redeployed, network +// blip, etc.), close the old socket, and replace it with the new one. +// This is safe because the RPC pending-map is drained + rejected on +// disconnect — any in-flight command reports back "relay disconnected" +// rather than silently hanging. +// +// Auth is a static bearer token shared between bot .env and agent +// .env. That's fine for a single trusted agent — WSS gives us +// transport-level confidentiality, and rotating the token is a +// two-line env change. If we ever need multiple agents we'd swap +// this for per-agent tokens plus an agent-id → base-list registry. + +import { WebSocketServer } from 'ws'; +import { randomUUID, timingSafeEqual } from 'node:crypto'; + +import { logger } from '../utils/logger.js'; + +const LOG_SCOPE = 'dect:relay-hub'; + +const DEFAULTS = { + path: '/dect-relay/ws', + rpcTimeoutMs: 15_000, // per-command default; callers can override + heartbeatIntervalMs: 30_000, + heartbeatIdleTimeoutMs: 90_000, // treat socket as dead if no pong in this long +}; + +// Error codes surfaced back to callers via rejected RPC promises. +// Keeping them string-typed (not numeric) so log lines stay readable. +export const RelayErrorCodes = Object.freeze({ + NOT_CONNECTED: 'RELAY_NOT_CONNECTED', + DISCONNECTED: 'RELAY_DISCONNECTED_MID_RPC', + TIMEOUT: 'RELAY_RPC_TIMEOUT', + AGENT_ERROR: 'RELAY_AGENT_ERROR', // agent returned {ok:false, error:{...}} + MALFORMED: 'RELAY_MALFORMED_REPLY', +}); + +/** + * Structured error thrown by RPC calls. Carrying a code + optional + * detail lets callers branch on it (e.g. render "relay offline" + * differently from "base returned 401") without regex-matching on + * .message strings. + */ +export class DectRelayError extends Error { + constructor(code, message, detail = null) { + super(message); + this.name = 'DectRelayError'; + this.code = code; + this.detail = detail; + } +} + +/** + * The hub itself. Not a singleton class — the module exports one + * default instance below and that's what the bot uses. Keeping it + * class-shaped anyway so tests can spin up an isolated hub with an + * ephemeral port and its own token. + */ +export class DectRelayHub { + constructor({ token, path = DEFAULTS.path } = {}) { + if (!token || typeof token !== 'string') { + throw new Error('DectRelayHub: token is required'); + } + this._token = Buffer.from(token, 'utf8'); + this._path = path; + this._socket = null; + this._hello = null; // last hello frame from the agent + this._pending = new Map(); // cmdId → { resolve, reject, timer } + this._lastPongAt = 0; + this._heartbeatTimer = null; + this._wss = null; + } + + /** True when there's a live agent socket we can command. */ + isConnected() { + return !!(this._socket && this._socket.readyState === 1 /* OPEN */); + } + + /** + * Snapshot of the current connection state. Safe to expose over a + * health endpoint or /dectstatus admin page — no secrets in here. + */ + status() { + return { + connected: this.isConnected(), + agent: this._hello ? { ...this._hello } : null, + inFlight: this._pending.size, + lastPongMsAgo: this._lastPongAt ? Date.now() - this._lastPongAt : null, + }; + } + + /** + * Attach the WebSocket upgrade handler to a Node http.Server. Must + * be called during startup, AFTER app.listen() returns the http + * server. Express doesn't upgrade sockets itself, so we hook the + * 'upgrade' event manually and route just our path — anything else + * (e.g. a future webhook that needs its own upgrade) can add its + * own listener without conflict. + * + * @param {import('node:http').Server} httpServer + */ + attachTo(httpServer) { + if (this._wss) throw new Error('DectRelayHub: already attached'); + + // noServer:true → we do the upgrade dance manually so we can + // enforce auth BEFORE ws does its handshake. Otherwise ws would + // 101 first and then we'd have to close, which is uglier + wastes + // a round-trip on every unauthorized probe. + this._wss = new WebSocketServer({ noServer: true }); + + httpServer.on('upgrade', (req, socket, head) => { + // Only handle our path; leave others alone so future upgrades + // don't collide. + const url = req.url || ''; + // Match with or without a trailing slash / query string. + const cleanPath = url.split('?')[0].replace(/\/$/, ''); + if (cleanPath !== this._path.replace(/\/$/, '')) return; + + if (!this._checkAuth(req)) { + logger(LOG_SCOPE, `Unauthorized upgrade attempt from ${req.socket.remoteAddress}`, 'warn'); + socket.write('HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n'); + socket.destroy(); + return; + } + + this._wss.handleUpgrade(req, socket, head, (ws) => this._adoptAgent(ws, req)); + }); + + logger(LOG_SCOPE, `WebSocket upgrade handler attached at ${this._path}`); + } + + /** + * Constant-time bearer-token comparison. The bearer must be sent + * as `Authorization: Bearer ` on the WSS upgrade request. + * We also accept `Sec-WebSocket-Protocol: bearer.` as a + * fallback because some proxies strip Authorization headers on + * upgrade requests — this hides the token in a spec-compliant + * subprotocol string that isn't logged by most access logs. + */ + _checkAuth(req) { + const presented = extractBearer(req); + if (!presented) return false; + const buf = Buffer.from(presented, 'utf8'); + if (buf.length !== this._token.length) return false; + try { + return timingSafeEqual(buf, this._token); + } catch { + return false; + } + } + + _adoptAgent(ws, req) { + const from = req.socket.remoteAddress || 'unknown'; + + // Replace any existing socket: we only want ONE agent at a time. + if (this._socket) { + logger(LOG_SCOPE, `Replacing existing agent socket with new connection from ${from}`, 'warn'); + try { this._socket.close(1000, 'replaced by newer agent'); } catch { /* ignore */ } + this._socket = null; + this._hello = null; + } + + this._socket = ws; + this._lastPongAt = Date.now(); + logger(LOG_SCOPE, `Agent connected from ${from}`); + + // Every handler closes over `ws` so we can distinguish the socket + // that fired the event from `this._socket` — critical when a + // second agent replaces the first: the replaced socket's async + // 'close' event will fire AFTER we've swapped in the new socket, + // and without this guard it would wipe out the replacement. + ws.on('message', (raw) => this._onMessage(ws, raw)); + ws.on('close', (code, reasonBuf) => this._onSocketClose(ws, code, reasonBuf?.toString?.() || '')); + ws.on('error', (err) => logger(LOG_SCOPE, `Agent socket error: ${err.message}`, 'error')); + ws.on('pong', () => { if (ws === this._socket) this._lastPongAt = Date.now(); }); + + this._startHeartbeat(); + } + + _startHeartbeat() { + this._stopHeartbeat(); + this._heartbeatTimer = setInterval(() => { + if (!this.isConnected()) return; + // If we haven't seen a pong in too long, assume dead. Node's ws + // client won't detect a wedged TCP for many minutes; this + // heartbeat is how we recover in under 90s. + if (Date.now() - this._lastPongAt > DEFAULTS.heartbeatIdleTimeoutMs) { + logger(LOG_SCOPE, 'No pong in idle timeout — terminating agent socket', 'warn'); + try { this._socket.terminate(); } catch { /* ignore */ } + return; + } + try { this._socket.ping(); } catch { /* ignore, will surface via 'error' */ } + }, DEFAULTS.heartbeatIntervalMs); + // Don't hold the event loop open on process exit. + if (this._heartbeatTimer.unref) this._heartbeatTimer.unref(); + } + + _stopHeartbeat() { + if (this._heartbeatTimer) { + clearInterval(this._heartbeatTimer); + this._heartbeatTimer = null; + } + } + + _onSocketClose(sourceSocket, code, reason) { + // If this event is for a socket that's already been replaced by + // a newer connection, log at debug and skip the state reset — + // otherwise we'd wipe out the replacement socket we just adopted. + if (this._socket && this._socket !== sourceSocket) { + logger(LOG_SCOPE, `Ignoring close from replaced socket (code=${code})`, 'debug'); + return; + } + logger(LOG_SCOPE, `Agent disconnected (code=${code}, reason="${reason}")`); + this._socket = null; + this._hello = null; + this._stopHeartbeat(); + // Reject any in-flight RPCs so callers don't hang forever. + for (const [, entry] of this._pending) { + clearTimeout(entry.timer); + entry.reject(new DectRelayError( + RelayErrorCodes.DISCONNECTED, + 'DECT relay disconnected while command was in flight', + )); + } + this._pending.clear(); + } + + _onMessage(sourceSocket, raw) { + // Drop late messages from a replaced socket (see _onSocketClose). + if (sourceSocket !== this._socket) return; + let msg; + try { + msg = JSON.parse(raw.toString('utf8')); + } catch { + logger(LOG_SCOPE, `Ignoring non-JSON frame from agent (${raw.length} bytes)`, 'warn'); + return; + } + if (!msg || typeof msg !== 'object') { + logger(LOG_SCOPE, 'Ignoring non-object frame from agent', 'warn'); + return; + } + + if (msg.type === 'hello') { + this._hello = { + agentVersion: msg.agentVersion || 'unknown', + hostname: msg.hostname || null, + capabilities: Array.isArray(msg.capabilities) ? msg.capabilities.slice() : [], + receivedAt: new Date().toISOString(), + }; + logger(LOG_SCOPE, `Agent hello: version=${this._hello.agentVersion} host=${this._hello.hostname} caps=${this._hello.capabilities.join(',')}`); + return; + } + if (msg.type === 'ping') { + // Explicit JSON-level ping (in addition to the ws-level ping + // frames). Reply with an equivalent pong so a symmetric agent + // can verify liveness without relying on the ws framing. + this._sendRaw({ type: 'pong', at: Date.now() }); + return; + } + if (msg.type === 'pong') { + this._lastPongAt = Date.now(); + return; + } + + // Otherwise it's an RPC reply for a pending command. + if (!msg.id) { + logger(LOG_SCOPE, `Ignoring frame with no id and unknown type ${msg.type}`, 'warn'); + return; + } + const entry = this._pending.get(msg.id); + if (!entry) { + // Late reply after timeout — safe to drop. + logger(LOG_SCOPE, `Reply for unknown/expired cmd id ${msg.id} — dropping`, 'debug'); + return; + } + this._pending.delete(msg.id); + clearTimeout(entry.timer); + + if (msg.ok === true) { + entry.resolve({ result: msg.result, elapsedMs: msg.elapsedMs || null }); + } else if (msg.ok === false) { + const err = msg.error || {}; + entry.reject(new DectRelayError( + err.code || RelayErrorCodes.AGENT_ERROR, + err.message || 'Agent reported an error', + err, + )); + } else { + entry.reject(new DectRelayError( + RelayErrorCodes.MALFORMED, + `Agent reply missing ok field for id ${msg.id}`, + msg, + )); + } + } + + _sendRaw(obj) { + if (!this.isConnected()) return false; + try { + this._socket.send(JSON.stringify(obj)); + return true; + } catch (err) { + logger(LOG_SCOPE, `Failed to send frame: ${err.message}`, 'warn'); + return false; + } + } + + /** + * Send an RPC command to the agent. Returns { result, elapsedMs } + * on success, throws DectRelayError on failure. Timeouts and + * disconnects are surfaced as rejections — callers should always + * try/catch or use .catch(). + * + * @param {object} payload command frame WITHOUT id (added here) + * @param {object} [opts] + * @param {number} [opts.timeoutMs] per-call override; default 15s + */ + rpc(payload, { timeoutMs = DEFAULTS.rpcTimeoutMs } = {}) { + return new Promise((resolve, reject) => { + if (!this.isConnected()) { + reject(new DectRelayError( + RelayErrorCodes.NOT_CONNECTED, + 'DECT relay is not connected — data-center agent may be offline', + )); + return; + } + const id = `cmd_${randomUUID()}`; + const timer = setTimeout(() => { + this._pending.delete(id); + reject(new DectRelayError( + RelayErrorCodes.TIMEOUT, + `DECT relay RPC ${payload.type || '(no type)'} timed out after ${timeoutMs}ms`, + )); + }, timeoutMs); + // Do NOT unref this timer — we want the process to stay alive + // until every in-flight RPC has resolved or timed out. + + this._pending.set(id, { resolve, reject, timer }); + + const ok = this._sendRaw({ id, ...payload }); + if (!ok) { + this._pending.delete(id); + clearTimeout(timer); + reject(new DectRelayError( + RelayErrorCodes.NOT_CONNECTED, + 'Failed to send frame (socket may have just closed)', + )); + } + }); + } + + /** Convenience: fetch parsed status.xml for a given base IP. */ + collect(baseIp, opts) { + return this.rpc({ type: 'collect', baseIp }, opts); + } + + /** + * Convenience: execute one of the mutating actions the agent + * exposes (reboot / force-reboot / reboot-chain / force-reboot-chain + * / factory-reset / reconfigure-tree). The agent is the audit + * boundary for these — the bot's own audit sink STILL records the + * intent (see commands/dectStatus.js in Phase 2), but the agent + * logs the actual HTTP call. + */ + execAction(baseIp, action, extra = {}, opts) { + return this.rpc({ type: action, baseIp, ...extra }, opts); + } + + /** + * Shut everything down. Called from graceful-shutdown paths. + * Safe to call when nothing is attached. + * + * Terminates every socket the WSS layer is still tracking rather + * than relying on graceful close — a straggling client (e.g. an + * agent whose TCP is wedged after a NAT reboot) would otherwise + * block `wss.close()`'s callback and hang the shutdown path. + */ + async close() { + this._stopHeartbeat(); + if (this._socket) { + try { this._socket.close(1001, 'bot shutting down'); } catch { /* ignore */ } + this._socket = null; + } + if (this._wss) { + // Force-close any client the wss is still tracking. Without + // this, wss.close() waits indefinitely for all clients to + // disconnect on their own — fine in the happy path, but tests + // and NAT wedges both cause hangs. + for (const client of this._wss.clients) { + try { client.terminate(); } catch { /* ignore */ } + } + await new Promise((res) => this._wss.close(() => res())); + this._wss = null; + } + for (const [, entry] of this._pending) { + clearTimeout(entry.timer); + entry.reject(new DectRelayError( + RelayErrorCodes.DISCONNECTED, + 'Bot shutting down', + )); + } + this._pending.clear(); + } +} + +// ─── Helpers ──────────────────────────────────────────────────────── + +/** + * Pull the bearer token out of the upgrade request. Order tried: + * 1. `Authorization: Bearer ` header (canonical). + * 2. `Sec-WebSocket-Protocol: bearer.` (proxy-friendly). + * Returns null if neither is present or well-formed. + */ +function extractBearer(req) { + const auth = req.headers['authorization']; + if (typeof auth === 'string') { + const m = auth.match(/^Bearer\s+(\S+)\s*$/i); + if (m) return m[1]; + } + const proto = req.headers['sec-websocket-protocol']; + if (typeof proto === 'string') { + for (const part of proto.split(',')) { + const trimmed = part.trim(); + if (trimmed.startsWith('bearer.')) return trimmed.slice('bearer.'.length); + } + } + return null; +} + +// ─── Default singleton for the bot to use ─────────────────────────── + +let _defaultHub = null; + +/** + * Return the process-wide DectRelayHub, constructing it on first + * access using env config. Throws if DECT_RELAY_AGENT_TOKEN is not + * set — surfaces the missing config at startup rather than silently + * being non-functional. + */ +export function getDectRelayHub() { + if (_defaultHub) return _defaultHub; + const token = process.env.DECT_RELAY_AGENT_TOKEN; + if (!token) { + throw new Error( + 'DECT_RELAY_AGENT_TOKEN is not set. Add it to your .env and share ' + + 'the same value with the dect-relay-agent. Until then, DECT commands ' + + 'will fail with RELAY_NOT_CONNECTED.', + ); + } + _defaultHub = new DectRelayHub({ token, path: process.env.DECT_RELAY_PATH || DEFAULTS.path }); + return _defaultHub; +} + +// Test-only: reset the singleton. Not exported from an index barrel; +// only imported by unit tests that need isolation. +export function _resetDectRelayHubForTests() { _defaultHub = null; } diff --git a/services/renderers/phoneStatusRenderer.js b/services/renderers/phoneStatusRenderer.js index 5130c5a..2b20afe 100644 --- a/services/renderers/phoneStatusRenderer.js +++ b/services/renderers/phoneStatusRenderer.js @@ -29,10 +29,16 @@ import { simpleTimeAgo, formatBytes } from '../../utils/time.js'; * @param {string} opts.storeNum 2-6 digit store id (header text) * @param {boolean} [opts.detailed=false] * @param {boolean} [opts.footer=true] + * @param {number} [opts.dectFollowUpBaseCount=0] + * When > 0, emits an "⏳ DECT base data loading for N base(s)…" + * line inside the DECT Basestations section. Signals to the reader + * that a follow-up message with base-station diagnostics is on the + * way. Chat handler passes this after the base count comes back + * from discoverDectBases(); poller and HTTP callers pass 0. * @returns {string} markdown, whitespace-trimmed and ready to send. */ export function renderPhoneStatusMarkdown(data, opts = {}) { - const { storeNum, detailed = false, footer = true } = opts; + const { storeNum, detailed = false, footer = true, dectFollowUpBaseCount = 0 } = opts; let reply = `**Phone Status - Store ${storeNum}**\n\n`; @@ -190,6 +196,15 @@ export function renderPhoneStatusMarkdown(data, opts = {}) { }); reply += '\n'; } + + // DECT relay follow-up notice. Only shown when the caller has + // told us a follow-up is actually in-flight (chat handler, after + // discoverDectBases returned a non-empty list). Silent for HTTP / + // Jira surfaces where a follow-up doesn't happen. + if (dectFollowUpBaseCount > 0) { + const n = dectFollowUpBaseCount; + reply += `_⏳ Base-station diagnostics loading for ${n} base${n === 1 ? '' : 's'} — a follow-up message will arrive shortly._\n\n`; + } } if (detailed) { @@ -202,3 +217,92 @@ export function renderPhoneStatusMarkdown(data, opts = {}) { return reply.trim(); } + +// ─── DECT base-station diagnostics (follow-up message) ────────────── +// +// Separate exported renderer for the follow-up message that arrives +// ~10-30s after the main /phonestatus output. Input is the array +// returned by services/dectCollectorService.collectAll(): per-base +// { ok, data (parsed status), verdict, error } records. +// +// Chat surface stays compact — most operators only need to see the +// exceptional stuff (warnings, recent power-loss reboots). Firmware +// / emergency numbers / detailed reboot log stay behind the future +// /dectstatus command where the full CLI-style dump makes more sense. + +/** + * @param {Array} results collectAll() output + * @param {object} opts + * @param {string} opts.storeNum + * @param {boolean} [opts.footer=true] + * @returns {string} markdown, whitespace-trimmed. Empty string when + * the input list is empty (caller shouldn't send a message + * in that case). + */ +export function renderDectDiagnosticsMarkdown(results, opts = {}) { + const { storeNum, footer = true } = opts; + const list = Array.isArray(results) ? results : []; + if (list.length === 0) return ''; + + let out = `**DECT Base Station Diagnostics — Store ${storeNum}**\n\n`; + + for (const r of list) { + out += renderOneBase(r); + out += '\n'; + } + + if (footer) { + out += `\n*Base diagnostics pulled at ${new Date().toLocaleTimeString()} via the DECT relay. Use \`/dectstatus ${storeNum}\` for full details and reboot/factory-reset controls.*`; + } + return out.trim(); +} + +function renderOneBase(r) { + const label = r.base?.name || `Basestation ${r.base?.mac || '?'}`; + const ip = r.base?.ip || '?'; + if (!r.ok) { + return `⚠️ **${label}** (${ip}) — collect failed: ${r.error?.message || 'unknown error'}` + + (r.error?.hint ? `\n _${r.error.hint}_\n` : '\n'); + } + + const data = r.data || {}; + const verdict = r.verdict || {}; + const uptimeText = data.time?.operatingTime || '?'; + const fw = data.firmware?.version || '?'; + const conflict = data.conflictInfo && data.conflictInfo !== 'No Conflict' + ? ` • RF conflict: ${data.conflictInfo}` : ''; + const role = data.multiCell?.role ? ` • role: ${data.multiCell.role}` : ''; + + // Header line uses a checkmark or warning depending on verdict. + const icon = verdict.healthy ? '✅' : '⚠️'; + let out = `${icon} **${label}** (${ip}) — uptime ${uptimeText} • fw ${fw}${role}${conflict}\n`; + + // Most-recent Power Loss reboot (if any in the last-6 log) is the + // highest-signal thing we can surface here. Anything else falls + // under "warnings" below. + const powerLoss = (data.rebootLog || []).find((entry) => entry.reasonCode === 80); + if (powerLoss) { + out += ` ⚡ Recent power loss: ${powerLoss.at} (reboot #${powerLoss.sequence})\n`; + } + + // Warnings from summarizeBaseHealth() are already user-facing + // strings; render as a bulleted list under the header. + if (Array.isArray(verdict.warnings) && verdict.warnings.length > 0) { + for (const w of verdict.warnings) { + // Skip the power-loss warning if we already surfaced the + // structured line above — avoids duplication. + if (powerLoss && /power.?loss/i.test(w)) continue; + out += ` ⚠️ ${w}\n`; + } + } + + // RTP: only show if there's an active session — usually the + // diagnostic reader cares whether a call is up right now, not + // that this base has served 2 total calls since boot. + if ((data.rtp?.current || 0) > 0) { + out += ` 📞 ${data.rtp.current} active RTP session(s)\n`; + } + + return out; +} + diff --git a/tests/dectDiscovery.test.js b/tests/dectDiscovery.test.js new file mode 100644 index 0000000..2ea0327 --- /dev/null +++ b/tests/dectDiscovery.test.js @@ -0,0 +1,169 @@ +// Unit tests for services/dectDiscovery.js. Pure — no network, no fs. +// The interesting cases are all around the 10.x guardrail and the +// dedup/priority logic when Meraki and Webex report different IPs +// for the same base. + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + discoverDectBases, + isTenDotIp, + normalizeMac, +} from '../services/dectDiscovery.js'; + +// ─── Helpers ──────────────────────────────────────────────────────── + +const fixture = (overrides = {}) => ({ + dectBasestations: [], + ...overrides, +}); + +const base = (attrs = {}) => ({ + mac: '6c:ab:05:f6:28:19', + name: 'Basestation A', + ipAddress: '—', + meraki: {}, + ...attrs, +}); + +// ─── isTenDotIp ───────────────────────────────────────────────────── + +test('isTenDotIp: accepts 10.x/8 addresses', () => { + assert.equal(isTenDotIp('10.0.0.1'), true); + assert.equal(isTenDotIp('10.255.255.254'), true); + assert.equal(isTenDotIp('10.4.11.87'), true); +}); + +test('isTenDotIp: rejects non-10.x addresses', () => { + assert.equal(isTenDotIp('192.168.1.164'), false); + assert.equal(isTenDotIp('172.16.0.1'), false); + assert.equal(isTenDotIp('11.0.0.1'), false); + assert.equal(isTenDotIp('100.0.0.1'), false); +}); + +test('isTenDotIp: rejects malformed input', () => { + assert.equal(isTenDotIp(null), false); + assert.equal(isTenDotIp(''), false); + assert.equal(isTenDotIp('10.'), false); + assert.equal(isTenDotIp('10.0.0'), false); + assert.equal(isTenDotIp('10.0.0.256'), false); + assert.equal(isTenDotIp('10.0.0.1.5'), false); + assert.equal(isTenDotIp('not-an-ip'), false); + assert.equal(isTenDotIp(10), false); +}); + +// ─── normalizeMac ─────────────────────────────────────────────────── + +test('normalizeMac: handles various input formats', () => { + assert.equal(normalizeMac('6cab05f62819'), '6c:ab:05:f6:28:19'); + assert.equal(normalizeMac('6C:AB:05:F6:28:19'), '6c:ab:05:f6:28:19'); + assert.equal(normalizeMac('6c-ab-05-f6-28-19'), '6c:ab:05:f6:28:19'); + assert.equal(normalizeMac('6cab.05f6.2819'), '6c:ab:05:f6:28:19'); +}); + +test('normalizeMac: rejects bad input', () => { + assert.equal(normalizeMac(null), null); + assert.equal(normalizeMac(''), null); + assert.equal(normalizeMac('not-a-mac'), null); + assert.equal(normalizeMac('6cab05f62819aa'), null); // 14 hex chars +}); + +// ─── discoverDectBases ────────────────────────────────────────────── + +test('discoverDectBases: empty input returns empty result (never throws)', () => { + assert.deepEqual(discoverDectBases({}), { bases: [], warnings: [] }); + assert.deepEqual(discoverDectBases(null), { bases: [], warnings: [] }); + assert.deepEqual(discoverDectBases({ dectBasestations: null }), { bases: [], warnings: [] }); +}); + +test('discoverDectBases: happy path — one Meraki-enriched base on 10.x', () => { + const result = discoverDectBases(fixture({ + dectBasestations: [ + base({ meraki: { ip: '10.4.11.87' } }), + ], + })); + assert.equal(result.bases.length, 1); + assert.equal(result.warnings.length, 0); + assert.deepEqual(result.bases[0], { + mac: '6c:ab:05:f6:28:19', + ip: '10.4.11.87', + name: 'Basestation A', + source: 'meraki', + }); +}); + +test('discoverDectBases: prefers Meraki IP over Webex IP', () => { + const result = discoverDectBases(fixture({ + dectBasestations: [ + base({ + ipAddress: '10.4.11.100', // Webex-reported (potentially stale) + meraki: { ip: '10.4.11.87' }, // Meraki-reported (live) + }), + ], + })); + assert.equal(result.bases[0].ip, '10.4.11.87'); + assert.equal(result.bases[0].source, 'meraki'); +}); + +test('discoverDectBases: falls back to Webex IP when no Meraki data', () => { + const result = discoverDectBases(fixture({ + dectBasestations: [ + base({ ipAddress: '10.4.11.87', meraki: {} }), + ], + })); + assert.equal(result.bases[0].ip, '10.4.11.87'); + assert.equal(result.bases[0].source, 'webex'); +}); + +test('discoverDectBases: 10.x guardrail rejects non-corporate IPs with a warning', () => { + const result = discoverDectBases(fixture({ + dectBasestations: [ + base({ mac: 'aa:bb:cc:dd:ee:01', meraki: { ip: '192.168.1.164' } }), + base({ mac: 'aa:bb:cc:dd:ee:02', meraki: { ip: '172.16.0.100' } }), + base({ mac: 'aa:bb:cc:dd:ee:03', meraki: { ip: '10.4.11.87' } }), + ], + })); + assert.equal(result.bases.length, 1); + assert.equal(result.bases[0].ip, '10.4.11.87'); + assert.equal(result.warnings.length, 2); + // Both warnings should reference the offending IPs and the 10.x rule. + assert.match(result.warnings[0].reason, /192\.168\.1\.164/); + assert.match(result.warnings[0].reason, /10\.0\.0\.0\/8/); + assert.match(result.warnings[1].reason, /172\.16\.0\.100/); +}); + +test('discoverDectBases: rejects bases with no MAC (inventory bug)', () => { + const result = discoverDectBases(fixture({ + dectBasestations: [ + base({ mac: null, meraki: { ip: '10.4.11.87' } }), + ], + })); + assert.equal(result.bases.length, 0); + assert.equal(result.warnings.length, 1); + assert.match(result.warnings[0].reason, /no MAC/i); +}); + +test('discoverDectBases: rejects bases with no IP anywhere', () => { + const result = discoverDectBases(fixture({ + dectBasestations: [ + base({ ipAddress: '—', meraki: {} }), + ], + })); + assert.equal(result.bases.length, 0); + assert.equal(result.warnings.length, 1); + assert.match(result.warnings[0].reason, /no IP address/i); +}); + +test('discoverDectBases: dedups by IP and MAC (keeps first)', () => { + const result = discoverDectBases(fixture({ + dectBasestations: [ + base({ mac: 'aa:bb:cc:dd:ee:01', meraki: { ip: '10.4.11.87' }, name: 'first' }), + base({ mac: 'aa:bb:cc:dd:ee:02', meraki: { ip: '10.4.11.87' }, name: 'duplicate-ip' }), + base({ mac: 'aa:bb:cc:dd:ee:01', meraki: { ip: '10.4.11.88' }, name: 'duplicate-mac' }), + ], + })); + assert.equal(result.bases.length, 1); + assert.equal(result.bases[0].name, 'first'); + assert.equal(result.warnings.length, 2); +}); diff --git a/tests/dectRelayHub.test.js b/tests/dectRelayHub.test.js new file mode 100644 index 0000000..f17f695 --- /dev/null +++ b/tests/dectRelayHub.test.js @@ -0,0 +1,351 @@ +// Integration tests for services/dectRelayHub.js. +// +// These spin up a real HTTP server on an ephemeral port, attach the +// hub, and connect a real `ws` client that plays the role of the +// dect-relay-agent. This gives us end-to-end coverage of the auth +// path, the wire protocol, RPC correlation, timeouts, and clean +// disconnect handling — none of which we can meaningfully test with +// pure mocks. +// +// Every test creates its own hub + server so they can run in parallel +// without port conflicts. All servers are torn down in the test's +// finally block so a failing test can't leak file descriptors. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import WebSocket from 'ws'; + +import { DectRelayHub, RelayErrorCodes, DectRelayError } from '../services/dectRelayHub.js'; + +const TEST_TOKEN = 'super-secret-token-for-tests'; + +// ─── Test harness ─────────────────────────────────────────────────── + +/** + * Spin up an HTTP server with the hub attached on an ephemeral port. + * Returns { hub, port, closeAll }. Caller MUST call closeAll() (in a + * try/finally) to release the port + socket handles. + */ +async function makeHub(token = TEST_TOKEN) { + const server = http.createServer((req, res) => { + res.writeHead(404); res.end(); + }); + const hub = new DectRelayHub({ token }); + hub.attachTo(server); + await new Promise((res) => server.listen(0, '127.0.0.1', res)); + const { port } = server.address(); + return { + hub, + port, + async closeAll() { + await hub.close(); + await new Promise((res) => server.close(() => res())); + }, + }; +} + +/** + * Open a WebSocket client to the hub. Optional bearer overrides the + * default token — useful for the "wrong token" test. + */ +function connectAgent(port, { bearer = TEST_TOKEN, useProtocol = false } = {}) { + const url = `ws://127.0.0.1:${port}/dect-relay/ws`; + const opts = useProtocol + ? { headers: {}, protocol: `bearer.${bearer}` } + : { headers: { Authorization: `Bearer ${bearer}` } }; + return new WebSocket(url, opts.protocol ? opts.protocol : undefined, { + headers: opts.headers, + handshakeTimeout: 3000, + }); +} + +function waitOpen(ws) { + return new Promise((resolve, reject) => { + ws.once('open', resolve); + ws.once('error', reject); + }); +} + +function waitClose(ws) { + return new Promise((resolve) => ws.once('close', (code, reason) => resolve({ code, reason: reason?.toString() || '' }))); +} + +// A tiny agent that immediately replies to every command with the +// given handler. Handler receives the parsed inbound message and +// returns either { ok:true, result:{...} } or throws. +function attachAutoAgent(ws, handler) { + ws.on('message', async (raw) => { + const msg = JSON.parse(raw.toString('utf8')); + if (msg.type === 'ping') { ws.send(JSON.stringify({ type: 'pong', at: Date.now() })); return; } + if (!msg.id) return; + try { + const result = await handler(msg); + ws.send(JSON.stringify({ id: msg.id, ok: true, result, elapsedMs: 1 })); + } catch (err) { + ws.send(JSON.stringify({ + id: msg.id, ok: false, + error: { code: err.code || 'AUTO_AGENT_ERR', message: err.message }, + })); + } + }); +} + +// ─── isConnected / status ─────────────────────────────────────────── + +test('hub: isConnected is false with no agent', async () => { + const { hub, closeAll } = await makeHub(); + try { + assert.equal(hub.isConnected(), false); + assert.equal(hub.status().connected, false); + assert.equal(hub.status().agent, null); + assert.equal(hub.status().inFlight, 0); + } finally { + await closeAll(); + } +}); + +test('hub: rpc without connection rejects with NOT_CONNECTED', async () => { + const { hub, closeAll } = await makeHub(); + try { + await assert.rejects( + hub.collect('10.0.0.100'), + (err) => err instanceof DectRelayError && err.code === RelayErrorCodes.NOT_CONNECTED, + ); + } finally { + await closeAll(); + } +}); + +// ─── Auth ────────────────────────────────────────────────────────── + +test('hub: rejects upgrade with no bearer', async () => { + const { port, closeAll } = await makeHub(); + try { + const ws = new WebSocket(`ws://127.0.0.1:${port}/dect-relay/ws`, { + handshakeTimeout: 3000, + }); + // Server writes a raw 401 before the WS handshake completes. + // ws throws 'Unexpected server response: 401' as an error. + await assert.rejects(waitOpen(ws), /401/); + } finally { + await closeAll(); + } +}); + +test('hub: rejects upgrade with wrong bearer', async () => { + const { port, closeAll } = await makeHub(); + try { + const ws = connectAgent(port, { bearer: 'wrong-token' }); + await assert.rejects(waitOpen(ws), /401/); + } finally { + await closeAll(); + } +}); + +test('hub: accepts upgrade with correct bearer via Authorization header', async () => { + const { hub, port, closeAll } = await makeHub(); + try { + const ws = connectAgent(port); + await waitOpen(ws); + // Give the hub a tick to record the adoption. + await new Promise((r) => setImmediate(r)); + assert.equal(hub.isConnected(), true); + ws.close(); + await waitClose(ws); + } finally { + await closeAll(); + } +}); + +test('hub: accepts upgrade via Sec-WebSocket-Protocol fallback', async () => { + const { hub, port, closeAll } = await makeHub(); + try { + const ws = connectAgent(port, { useProtocol: true }); + await waitOpen(ws); + await new Promise((r) => setImmediate(r)); + assert.equal(hub.isConnected(), true); + ws.close(); + await waitClose(ws); + } finally { + await closeAll(); + } +}); + +// ─── Hello frame ──────────────────────────────────────────────────── + +test('hub: records agent hello frame into status()', async () => { + const { hub, port, closeAll } = await makeHub(); + try { + const ws = connectAgent(port); + await waitOpen(ws); + ws.send(JSON.stringify({ + type: 'hello', agentVersion: '9.9.9', hostname: 'test-host', + capabilities: ['collect', 'reboot'], + })); + // Wait until hub processes it (message events are queued). + await new Promise((r) => setTimeout(r, 20)); + const s = hub.status(); + assert.equal(s.connected, true); + assert.equal(s.agent.agentVersion, '9.9.9'); + assert.equal(s.agent.hostname, 'test-host'); + assert.deepEqual(s.agent.capabilities, ['collect', 'reboot']); + ws.close(); + await waitClose(ws); + } finally { + await closeAll(); + } +}); + +// ─── RPC correlation ──────────────────────────────────────────────── + +test('hub: RPC round-trip resolves with agent result', async () => { + const { hub, port, closeAll } = await makeHub(); + try { + const ws = connectAgent(port); + await waitOpen(ws); + attachAutoAgent(ws, async (msg) => { + assert.equal(msg.type, 'collect'); + assert.equal(msg.baseIp, '10.4.11.87'); + return { parsed: { device: { macAddress: 'aa:bb:cc:dd:ee:ff' } }, verdict: { healthy: true } }; + }); + const { result } = await hub.collect('10.4.11.87'); + assert.equal(result.parsed.device.macAddress, 'aa:bb:cc:dd:ee:ff'); + assert.equal(result.verdict.healthy, true); + ws.close(); + await waitClose(ws); + } finally { + await closeAll(); + } +}); + +test('hub: RPC error from agent surfaces as DectRelayError with code', async () => { + const { hub, port, closeAll } = await makeHub(); + try { + const ws = connectAgent(port); + await waitOpen(ws); + attachAutoAgent(ws, async () => { + const err = new Error('base rejected credentials'); + err.code = 'DIGEST_401'; + throw err; + }); + await assert.rejects( + hub.collect('10.4.11.87'), + (err) => err instanceof DectRelayError && err.code === 'DIGEST_401' + && /base rejected credentials/i.test(err.message), + ); + } finally { + await closeAll(); + } +}); + +test('hub: multiple concurrent RPCs correlate by id, not order', async () => { + const { hub, port, closeAll } = await makeHub(); + try { + const ws = connectAgent(port); + await waitOpen(ws); + // Delay short IPs less than long IPs, deliberately reversing + // response order relative to send order. + ws.on('message', (raw) => { + const msg = JSON.parse(raw.toString('utf8')); + if (!msg.id) return; + const delay = msg.baseIp === '10.0.0.1' ? 40 : 5; + setTimeout(() => { + ws.send(JSON.stringify({ + id: msg.id, ok: true, result: { echo: msg.baseIp }, elapsedMs: delay, + })); + }, delay); + }); + const [a, b] = await Promise.all([ + hub.collect('10.0.0.1'), // slower + hub.collect('10.0.0.2'), // faster + ]); + assert.equal(a.result.echo, '10.0.0.1'); + assert.equal(b.result.echo, '10.0.0.2'); + } finally { + await closeAll(); + } +}); + +// ─── Timeout ──────────────────────────────────────────────────────── + +test('hub: RPC that never gets a reply times out with TIMEOUT code', async () => { + const { hub, port, closeAll } = await makeHub(); + try { + const ws = connectAgent(port); + await waitOpen(ws); + // Silent agent: acknowledge nothing. + ws.on('message', () => { /* intentionally do nothing */ }); + await assert.rejects( + hub.collect('10.0.0.1', { timeoutMs: 50 }), + (err) => err instanceof DectRelayError && err.code === RelayErrorCodes.TIMEOUT, + ); + } finally { + await closeAll(); + } +}); + +// ─── Mid-flight disconnect ────────────────────────────────────────── + +test('hub: agent disconnect mid-RPC rejects the pending promise', async () => { + const { hub, port, closeAll } = await makeHub(); + try { + const ws = connectAgent(port); + await waitOpen(ws); + // Close the socket the moment we receive a command. + ws.on('message', () => ws.close(1000, 'test')); + await assert.rejects( + hub.collect('10.0.0.1', { timeoutMs: 2000 }), + (err) => err instanceof DectRelayError && err.code === RelayErrorCodes.DISCONNECTED, + ); + } finally { + await closeAll(); + } +}); + +// ─── Second agent replaces first ──────────────────────────────────── + +test('hub: second agent connection replaces the first (with clean close)', async () => { + const { hub, port, closeAll } = await makeHub(); + try { + const wsA = connectAgent(port); + await waitOpen(wsA); + const closedA = waitClose(wsA); + + const wsB = connectAgent(port); + await waitOpen(wsB); + + // wsA should have been closed by the hub with reason "replaced". + const closeInfo = await closedA; + assert.equal(closeInfo.code, 1000); + assert.match(closeInfo.reason, /replaced/i); + + // The hub is still connected — to wsB now. + assert.equal(hub.isConnected(), true); + wsB.close(); + await waitClose(wsB); + } finally { + await closeAll(); + } +}); + +// ─── execAction routing ──────────────────────────────────────────── + +test('hub: execAction routes action name into type field', async () => { + const { hub, port, closeAll } = await makeHub(); + try { + const ws = connectAgent(port); + await waitOpen(ws); + let observed = null; + attachAutoAgent(ws, async (msg) => { + observed = msg; + return { ok: 'done' }; + }); + await hub.execAction('10.0.0.1', 'reboot', { forced: false }); + assert.equal(observed.type, 'reboot'); + assert.equal(observed.baseIp, '10.0.0.1'); + assert.equal(observed.forced, false); + } finally { + await closeAll(); + } +}); diff --git a/tests/renderers.test.js b/tests/renderers.test.js index 7f0bc2d..549f3ef 100644 --- a/tests/renderers.test.js +++ b/tests/renderers.test.js @@ -10,7 +10,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { renderPhoneStatusMarkdown } from '../services/renderers/phoneStatusRenderer.js'; +import { + renderPhoneStatusMarkdown, + renderDectDiagnosticsMarkdown, +} from '../services/renderers/phoneStatusRenderer.js'; import { renderAvStatusMarkdown } from '../services/renderers/avStatusRenderer.js'; // Timestamp exactly 3 hours in the past — makes `simpleTimeAgo` @@ -248,3 +251,157 @@ test('av renderer: Atlas AMP with vitals renders temps + fan + amps', () => { assert.match(md, /CPU: 104°F • PSU: 95°F • Io: 100°F • Voltage: 120\.4V • Fan: 46%/); assert.match(md, /Amps: Amp1: Active, Amp2: Ready/); }); + +// ───────────────────────────────────────────────────────────── +// DECT follow-up "loading" hint (main /phonestatus output) +// ───────────────────────────────────────────────────────────── + +test('phone renderer: dectFollowUpBaseCount > 0 emits a loading hint inside the DECT section', () => { + const data = { + dectBasestations: [ + { mac: 'aa:bb:cc:dd:ee:01', meraki: { status: 'Online' } }, + { mac: 'aa:bb:cc:dd:ee:02', meraki: { status: 'Online' } }, + ], + dectHandsets: [], + }; + const md = renderPhoneStatusMarkdown(data, { + storeNum: '782', footer: false, dectFollowUpBaseCount: 2, + }); + assert.match(md, /Base-station diagnostics loading for 2 bases/); +}); + +test('phone renderer: dectFollowUpBaseCount === 1 uses singular "base"', () => { + const data = { + dectBasestations: [{ mac: 'aa:bb:cc:dd:ee:01', meraki: { status: 'Online' } }], + dectHandsets: [], + }; + const md = renderPhoneStatusMarkdown(data, { + storeNum: '782', footer: false, dectFollowUpBaseCount: 1, + }); + assert.match(md, /loading for 1 base —/); +}); + +test('phone renderer: dectFollowUpBaseCount === 0 emits no loading hint (default state)', () => { + const data = { + dectBasestations: [{ mac: 'aa:bb:cc:dd:ee:01', meraki: { status: 'Online' } }], + dectHandsets: [], + }; + const md = renderPhoneStatusMarkdown(data, { storeNum: '782', footer: false }); + assert.doesNotMatch(md, /diagnostics loading/); +}); + +// ───────────────────────────────────────────────────────────── +// DECT follow-up message (renderDectDiagnosticsMarkdown) +// ───────────────────────────────────────────────────────────── + +const okResult = (overrides = {}) => ({ + base: { mac: '6c:ab:05:f6:28:19', ip: '10.4.11.87', name: 'Basestation A' }, + ok: true, + data: { + time: { operatingTime: '02:15:00 (H:M:S)' }, + firmware: { version: '05-01-03-0101-09' }, + multiCell: { role: 'primary' }, + conflictInfo: 'No Conflict', + rebootLog: [], + rtp: { current: 0 }, + }, + verdict: { healthy: true, warnings: [], info: [] }, + elapsedMs: 812, + ...overrides, +}); + +test('dect diagnostics renderer: empty input returns empty string (caller should not send)', () => { + assert.equal(renderDectDiagnosticsMarkdown([], { storeNum: '782' }), ''); + assert.equal(renderDectDiagnosticsMarkdown(null, { storeNum: '782' }), ''); +}); + +test('dect diagnostics renderer: healthy base renders check + uptime + firmware', () => { + const md = renderDectDiagnosticsMarkdown([okResult()], { storeNum: '782', footer: false }); + assert.match(md, /\*\*DECT Base Station Diagnostics — Store 782\*\*/); + assert.match(md, /✅ \*\*Basestation A\*\* \(10\.4\.11\.87\)/); + assert.match(md, /uptime 02:15:00/); + assert.match(md, /fw 05-01-03-0101-09/); + assert.match(md, /role: primary/); +}); + +test('dect diagnostics renderer: warnings from verdict are surfaced under the header', () => { + const md = renderDectDiagnosticsMarkdown([ + okResult({ + verdict: { + healthy: false, + warnings: ['Rx errors: 42 since last boot'], + info: [], + }, + }), + ], { storeNum: '782', footer: false }); + assert.match(md, /⚠️ \*\*Basestation A\*\*/); + assert.match(md, /⚠️ Rx errors: 42 since last boot/); +}); + +test('dect diagnostics renderer: recent Power Loss reboot gets its own bolt line + suppresses duplicate warning', () => { + const md = renderDectDiagnosticsMarkdown([ + okResult({ + data: { + time: { operatingTime: '02:15:00' }, + firmware: { version: '05-01-03-0101-09' }, + multiCell: { role: 'primary' }, + conflictInfo: 'No Conflict', + rebootLog: [ + { sequence: 164, at: '2026-07-02T12:54:12', reasonName: 'Power Loss', reasonCode: 80 }, + ], + rtp: { current: 0 }, + }, + verdict: { + healthy: false, + warnings: ['1 recent power-loss reboot(s); most recent at 2026-07-02T12:54:12'], + info: [], + }, + }), + ], { storeNum: '782', footer: false }); + + // The structured line survives … + assert.match(md, /⚡ Recent power loss: 2026-07-02T12:54:12 \(reboot #164\)/); + // … but the summary warning about power-loss is filtered out to + // avoid duplication under the same header. + assert.doesNotMatch(md, /⚠️ 1 recent power-loss/); +}); + +test('dect diagnostics renderer: active RTP session gets a call icon', () => { + const md = renderDectDiagnosticsMarkdown([ + okResult({ + data: { + time: { operatingTime: '02:15:00' }, + firmware: { version: '05-01-03-0101-09' }, + multiCell: { role: 'primary' }, + conflictInfo: 'No Conflict', + rebootLog: [], + rtp: { current: 2 }, + }, + }), + ], { storeNum: '782', footer: false }); + assert.match(md, /📞 2 active RTP session/); +}); + +test('dect diagnostics renderer: base with error renders remediation hint', () => { + const md = renderDectDiagnosticsMarkdown([ + { + base: { mac: '6c:ab:05:f6:28:19', ip: '10.4.11.87', name: 'Basestation A' }, + ok: false, + data: null, + verdict: null, + elapsedMs: 15003, + error: { + code: 'RELAY_RPC_TIMEOUT', + message: 'timed out after 15000ms', + hint: 'Relay accepted the request but the base did not respond in time.', + }, + }, + ], { storeNum: '782', footer: false }); + assert.match(md, /⚠️ \*\*Basestation A\*\* \(10\.4\.11\.87\) — collect failed: timed out after 15000ms/); + assert.match(md, /Relay accepted the request but the base did not respond in time\./); +}); + +test('dect diagnostics renderer: footer references /dectstatus command by store', () => { + const md = renderDectDiagnosticsMarkdown([okResult()], { storeNum: '782' }); + assert.match(md, /Use `\/dectstatus 782`/); +});