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:
parent
17a8469592
commit
96b26a5aca
16 changed files with 2192 additions and 19 deletions
34
.env.example
34
.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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
48
dect-relay-agent/.env.example
Normal file
48
dect-relay-agent/.env.example
Normal 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
126
dect-relay-agent/README.md
Normal 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
325
dect-relay-agent/index.js
Normal 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 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();
|
||||
19
dect-relay-agent/package.json
Normal file
19
dect-relay-agent/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
42
index.js
42
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));
|
||||
|
|
|
|||
9
package-lock.json
generated
9
package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
162
services/dectCollectorService.js
Normal file
162
services/dectCollectorService.js
Normal 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
129
services/dectDiscovery.js
Normal 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
472
services/dectRelayHub.js
Normal 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; }
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
169
tests/dectDiscovery.test.js
Normal file
169
tests/dectDiscovery.test.js
Normal 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
351
tests/dectRelayHub.test.js
Normal 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();
|
||||
}
|
||||
});
|
||||
|
|
@ -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`/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue