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).
This commit is contained in:
Joseph McQueen 2026-07-02 17:03:32 -04:00
parent 17a8469592
commit 96b26a5aca
16 changed files with 2192 additions and 19 deletions

View file

@ -217,18 +217,16 @@ BACKDOOR_USERNAME=monitor
BACKDOOR_PASSWORD=... BACKDOOR_PASSWORD=...
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Cisco DBS-210 DECT base station — LOCAL TEST HARNESS # Cisco DBS-210 DECT — LOCAL DEV TEST HARNESS
# Used only by scripts/testDectBase.js during the spike phase (see # Used only by scripts/testDectBase.js when iterating locally against a
# integrations/cisco-dect/). Not wired into the bot yet — this is for # lab base. NOT read by the bot at runtime — the bot never talks to a
# reverse-engineering the DBS-210 web UI (reboot, PRT pull, syslog, # DBS-210 directly. Actual production DBS-210 access lives in the
# config export) against a single lab base before scaling to a # DECT_RELAY_* env below and is executed by dect-relay-agent/.
# per-store relay agent.
# #
# Cisco's guidance: use the DECT serviceability password (Control Hub # Cisco's guidance: use the DECT serviceability password (Control Hub
# → Calling → Features → DECT Networks → Manage → Manage DECT # → Calling → Features → DECT Networks → Manage → Manage DECT
# serviceability password). Our tenant is configured to share a # serviceability password). Our tenant is configured to share a
# single password across all bases in the fleet, so this one credential # single password across all bases in the fleet.
# will work fleet-wide once we add the relay layer.
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
DECT_TEST_BASE_IP=10.0.0.100 DECT_TEST_BASE_IP=10.0.0.100
DECT_TEST_USER=admin 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. # on syslog/PRT downloads; 30s is a good starting point.
DECT_TEST_TIMEOUT_MS=30000 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 # Notes
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------

View file

@ -8,11 +8,16 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { collectPhoneStatus } from '../services/phoneService.js'; 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 { buildIgmpFixCard } from './igmpFix.js';
import { pendingIgmpFixes } from '../utils/pendingIgmpFixes.js'; import { pendingIgmpFixes } from '../utils/pendingIgmpFixes.js';
import { extractRequester } from '../utils/requester.js'; import { extractRequester } from '../utils/requester.js';
import { logger } from '../utils/logger.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) { export async function handlePhoneStatus(bot, trigger) {
logger('phone:status', 'Handler entered', 'debug'); logger('phone:status', 'Handler entered', 'debug');
@ -54,13 +59,45 @@ export async function handlePhoneStatus(bot, trigger) {
return; 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, { const reply = renderPhoneStatusMarkdown(data, {
storeNum, storeNum,
detailed: isDetailed, detailed: isDetailed,
footer: true, footer: true,
dectFollowUpBaseCount: reachableBases.length,
}); });
await bot.say('markdown', reply || 'No data available.'); 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 // IGMP-snooping remediation card — only when (a) the multicast
// summary flagged deviation AND (b) we know the networkId (can't // summary flagged deviation AND (b) we know the networkId (can't
// fix what we can't address) AND (c) the invocation came from // 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}`); 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);
}

View file

@ -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

126
dect-relay-agent/README.md Normal file
View file

@ -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_<uuid>", "type": "collect", "baseIp": "10.4.11.87" }
{ "id": "cmd_<uuid>", "type": "reboot", "baseIp": "10.4.11.87" }
```
**Agent → Bot (reply):**
```json
{ "id": "cmd_<uuid>", "ok": true, "elapsedMs": 812,
"result": { "parsed": { ... }, "verdict": { "healthy": true, ... } } }
{ "id": "cmd_<uuid>", "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.

325
dect-relay-agent/index.js Normal file
View file

@ -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://<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();

View file

@ -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"
}
}

View file

@ -35,6 +35,7 @@ import {
} from './commands/igmpFix.js'; } from './commands/igmpFix.js';
import { pendingIgmpFixes } from './utils/pendingIgmpFixes.js'; import { pendingIgmpFixes } from './utils/pendingIgmpFixes.js';
import { extractRequester } from './utils/requester.js'; import { extractRequester } from './utils/requester.js';
import { getDectRelayHub } from './services/dectRelayHub.js';
import { import {
getCommand, getCommand,
ALL_HTTP_COMMAND_KEYS, ALL_HTTP_COMMAND_KEYS,
@ -529,7 +530,13 @@ framework.hears(/.*/, async (bot, trigger) => {
} }
}, null, 1); }, 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}`); logger('server', `🚀 Express server running on port ${PORT}`);
console.log(`🚀 HTTP API server listening on http://localhost:${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 // Cron Jobs
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
@ -636,9 +661,20 @@ async function shutdown(reason, exitCode) {
logger('shutdown', 'Webex Framework stopped'); logger('shutdown', 'Webex Framework stopped');
} catch (err) { } catch (err) {
logger('shutdown', `Error during framework.stop(): ${err.message}`, 'error'); 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)); process.on('SIGINT', () => shutdown('SIGINT', 0));

9
package-lock.json generated
View file

@ -15,7 +15,8 @@
"form-data": "^4.0.5", "form-data": "^4.0.5",
"graphql-request": "^7.4.0", "graphql-request": "^7.4.0",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"webex-node-bot-framework": "^2.5.1" "webex-node-bot-framework": "^2.5.1",
"ws": "^8.21.0"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.1.4" "nodemon": "^3.1.4"
@ -12451,9 +12452,9 @@
} }
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "8.19.0", "version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"

View file

@ -23,7 +23,8 @@
"form-data": "^4.0.5", "form-data": "^4.0.5",
"graphql-request": "^7.4.0", "graphql-request": "^7.4.0",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"webex-node-bot-framework": "^2.5.1" "webex-node-bot-framework": "^2.5.1",
"ws": "^8.21.0"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.1.4" "nodemon": "^3.1.4"

View file

@ -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<BaseCollectResult[]>}
*/
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;
}
}

129
services/dectDiscovery.js Normal file
View file

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

472
services/dectRelayHub.js Normal file
View file

@ -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 <token>` on the WSS upgrade request.
* We also accept `Sec-WebSocket-Protocol: bearer.<token>` 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 <token>` header (canonical).
* 2. `Sec-WebSocket-Protocol: bearer.<token>` (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; }

View file

@ -29,10 +29,16 @@ import { simpleTimeAgo, formatBytes } from '../../utils/time.js';
* @param {string} opts.storeNum 2-6 digit store id (header text) * @param {string} opts.storeNum 2-6 digit store id (header text)
* @param {boolean} [opts.detailed=false] * @param {boolean} [opts.detailed=false]
* @param {boolean} [opts.footer=true] * @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. * @returns {string} markdown, whitespace-trimmed and ready to send.
*/ */
export function renderPhoneStatusMarkdown(data, opts = {}) { 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`; let reply = `**Phone Status - Store ${storeNum}**\n\n`;
@ -190,6 +196,15 @@ export function renderPhoneStatusMarkdown(data, opts = {}) {
}); });
reply += '\n'; 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) { if (detailed) {
@ -202,3 +217,92 @@ export function renderPhoneStatusMarkdown(data, opts = {}) {
return reply.trim(); 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;
}

169
tests/dectDiscovery.test.js Normal file
View file

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

351
tests/dectRelayHub.test.js Normal file
View file

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

View file

@ -10,7 +10,10 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; 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'; import { renderAvStatusMarkdown } from '../services/renderers/avStatusRenderer.js';
// Timestamp exactly 3 hours in the past — makes `simpleTimeAgo` // 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, /CPU: 104°F • PSU: 95°F • Io: 100°F • Voltage: 120\.4V • Fan: 46%/);
assert.match(md, /Amps: Amp1: Active, Amp2: Ready/); 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`/);
});