Compare commits
10 commits
d112e45eb6
...
07152a467b
| Author | SHA1 | Date | |
|---|---|---|---|
| 07152a467b | |||
| 8777e51d10 | |||
| 8c6de65bb0 | |||
| c996d5d32e | |||
| 59460b849b | |||
| 4f9ebdb5fb | |||
| e8500b4324 | |||
| 96b26a5aca | |||
| 17a8469592 | |||
| bc56b0a0fb |
31 changed files with 5465 additions and 13 deletions
39
.env.example
39
.env.example
|
|
@ -216,6 +216,45 @@ SC_PASSWORD=your-sc-password
|
||||||
BACKDOOR_USERNAME=monitor
|
BACKDOOR_USERNAME=monitor
|
||||||
BACKDOOR_PASSWORD=...
|
BACKDOOR_PASSWORD=...
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 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.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
DECT_TEST_BASE_IP=10.0.0.100
|
||||||
|
DECT_TEST_USER=admin
|
||||||
|
DECT_TEST_PASSWORD=your-dect-serviceability-password
|
||||||
|
# Optional: seconds to wait for base station responses. DBS-210 is slow
|
||||||
|
# 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
|
# Notes
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
|
||||||
21
.gitignore
vendored
21
.gitignore
vendored
|
|
@ -26,7 +26,12 @@ storage/
|
||||||
|
|
||||||
# Dev / test artifacts (local only)
|
# Dev / test artifacts (local only)
|
||||||
characterization-runs/
|
characterization-runs/
|
||||||
scripts/
|
scripts/*
|
||||||
|
# Tracked operational scripts (whitelisted; keep local dev helpers ignored above)
|
||||||
|
!scripts/reclaimWebexHosts.js
|
||||||
|
!scripts/removeAdvancedMessaging.js
|
||||||
|
!scripts/lib/
|
||||||
|
!scripts/lib/**
|
||||||
characterize-*.js
|
characterize-*.js
|
||||||
|
|
||||||
# Backup & temp files
|
# Backup & temp files
|
||||||
|
|
@ -49,6 +54,12 @@ build/
|
||||||
coverage/
|
coverage/
|
||||||
.nyc_output/
|
.nyc_output/
|
||||||
|
|
||||||
|
# DECT relay agent deploy bundles produced by dect-relay-agent/bundle.sh.
|
||||||
|
# The datestamped zip lands at repo root and shouldn't be committed —
|
||||||
|
# it's ~40MB (Docker image tarball) and rebuildable on demand.
|
||||||
|
dect-relay-agent-bundle-*.zip
|
||||||
|
dect-relay-agent-bundle-*/
|
||||||
|
|
||||||
# Docker / Misc
|
# Docker / Misc
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
|
@ -64,3 +75,11 @@ testobjects.json
|
||||||
meraki-store-topology-demo.html
|
meraki-store-topology-demo.html
|
||||||
config/config.json
|
config/config.json
|
||||||
config/config.bak
|
config/config.bak
|
||||||
|
|
||||||
|
# Local spike samples pulled from lab DBS-210 (never commit)
|
||||||
|
.dect-samples/
|
||||||
|
|
||||||
|
# Per-user report CSVs written by the bulk admin scripts.
|
||||||
|
# These contain emails, personIds, and per-user outcome — PII, never commit.
|
||||||
|
reclaim-*.csv
|
||||||
|
remove-*.csv
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,16 @@
|
||||||
|
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { collectPhoneStatus } from '../services/phoneService.js';
|
import { collectPhoneStatus } from '../services/phoneService.js';
|
||||||
import { renderPhoneStatusMarkdown } from '../services/renderers/phoneStatusRenderer.js';
|
import {
|
||||||
|
renderPhoneStatusMarkdown,
|
||||||
|
renderDectDiagnosticsMarkdown,
|
||||||
|
} from '../services/renderers/phoneStatusRenderer.js';
|
||||||
import { buildIgmpFixCard } from './igmpFix.js';
|
import { buildIgmpFixCard } from './igmpFix.js';
|
||||||
import { pendingIgmpFixes } from '../utils/pendingIgmpFixes.js';
|
import { pendingIgmpFixes } from '../utils/pendingIgmpFixes.js';
|
||||||
import { extractRequester } from '../utils/requester.js';
|
import { extractRequester } from '../utils/requester.js';
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
|
import { discoverDectBases } from '../services/dectDiscovery.js';
|
||||||
|
import { collectAll } from '../services/dectCollectorService.js';
|
||||||
|
|
||||||
export async function handlePhoneStatus(bot, trigger) {
|
export async function handlePhoneStatus(bot, trigger) {
|
||||||
logger('phone:status', 'Handler entered', 'debug');
|
logger('phone:status', 'Handler entered', 'debug');
|
||||||
|
|
@ -54,13 +59,45 @@ export async function handlePhoneStatus(bot, trigger) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Discover reachable DECT bases BEFORE rendering so we can tell
|
||||||
|
// the renderer how many bases the follow-up will cover. Discovery
|
||||||
|
// is a pure filter over what phoneService already fetched — no
|
||||||
|
// network calls, so it doesn't slow the main output. Only chat
|
||||||
|
// triggers get a follow-up; HTTP callers keep the single-message
|
||||||
|
// contract they had before.
|
||||||
|
const dectFollowUpEnabled = !!trigger.person;
|
||||||
|
const { bases: reachableBases, warnings: discoveryWarnings } = dectFollowUpEnabled
|
||||||
|
? discoverDectBases(data)
|
||||||
|
: { bases: [], warnings: [] };
|
||||||
|
if (discoveryWarnings.length > 0) {
|
||||||
|
logger(
|
||||||
|
'phone:status',
|
||||||
|
`DECT discovery warnings for store ${storeNum}: ${discoveryWarnings.map((w) => w.reason).join('; ')}`,
|
||||||
|
'warn',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const reply = renderPhoneStatusMarkdown(data, {
|
const reply = renderPhoneStatusMarkdown(data, {
|
||||||
storeNum,
|
storeNum,
|
||||||
detailed: isDetailed,
|
detailed: isDetailed,
|
||||||
footer: true,
|
footer: true,
|
||||||
|
dectFollowUpBaseCount: reachableBases.length,
|
||||||
});
|
});
|
||||||
await bot.say('markdown', reply || 'No data available.');
|
await bot.say('markdown', reply || 'No data available.');
|
||||||
|
|
||||||
|
// Kick off DECT follow-up. Fire-and-forget from this handler's
|
||||||
|
// perspective — the awaits inside runDectFollowUp() are just so
|
||||||
|
// failures get logged with a stable scope, they don't propagate
|
||||||
|
// back to the user's original /phonestatus call. If the relay is
|
||||||
|
// offline or a base is unreachable we still post the follow-up
|
||||||
|
// (with per-base error lines) so the user isn't left wondering
|
||||||
|
// where the promised diagnostics went.
|
||||||
|
if (dectFollowUpEnabled && reachableBases.length > 0) {
|
||||||
|
runDectFollowUp(bot, storeNum, reachableBases).catch((err) => {
|
||||||
|
logger('phone:status', `DECT follow-up failed for store ${storeNum}: ${err.message}`, 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// IGMP-snooping remediation card — only when (a) the multicast
|
// IGMP-snooping remediation card — only when (a) the multicast
|
||||||
// summary flagged deviation AND (b) we know the networkId (can't
|
// summary flagged deviation AND (b) we know the networkId (can't
|
||||||
// fix what we can't address) AND (c) the invocation came from
|
// fix what we can't address) AND (c) the invocation came from
|
||||||
|
|
@ -110,3 +147,21 @@ export async function handlePhoneStatus(bot, trigger) {
|
||||||
await bot.say('markdown', `Error collecting phone status: ${err.message}`);
|
await bot.say('markdown', `Error collecting phone status: ${err.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the DECT-diagnostics follow-up as a separate message in the
|
||||||
|
* same room. Only invoked from chat triggers. Errors are logged
|
||||||
|
* (never thrown up) — the /phonestatus main output has already been
|
||||||
|
* sent by the time we get here, so a follow-up crash shouldn't leave
|
||||||
|
* the user with a broken chat experience.
|
||||||
|
*
|
||||||
|
* Renderer emits an empty string only when the results list is empty
|
||||||
|
* — which shouldn't happen because we already checked reachableBases
|
||||||
|
* .length > 0 at the call site, but we still guard against it here.
|
||||||
|
*/
|
||||||
|
async function runDectFollowUp(bot, storeNum, bases) {
|
||||||
|
const results = await collectAll(bases);
|
||||||
|
const md = renderDectDiagnosticsMarkdown(results, { storeNum });
|
||||||
|
if (!md) return;
|
||||||
|
await bot.say('markdown', md);
|
||||||
|
}
|
||||||
|
|
|
||||||
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
|
||||||
118
dect-relay-agent/Dockerfile
Normal file
118
dect-relay-agent/Dockerfile
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
# syntax=docker/dockerfile:1.6
|
||||||
|
#
|
||||||
|
# DECT relay agent — production image.
|
||||||
|
#
|
||||||
|
# BUILD CONTEXT: the REPO ROOT (not this folder). The agent imports
|
||||||
|
# `../integrations/cisco-dect/*` and `../utils/httpDigestAuth.js`, so
|
||||||
|
# we mirror the repo's layout under /workspace/ inside the image and
|
||||||
|
# the relative paths just work.
|
||||||
|
#
|
||||||
|
# BUILD FROM REPO ROOT:
|
||||||
|
# docker build \
|
||||||
|
# --platform=linux/amd64 \
|
||||||
|
# -f dect-relay-agent/Dockerfile \
|
||||||
|
# -t collabsupport/dect-relay-agent:0.1.0 \
|
||||||
|
# .
|
||||||
|
#
|
||||||
|
# Or use bundle.sh which wraps this + `docker save` + zip.
|
||||||
|
#
|
||||||
|
# WHY NO `apk add`: corporate DCs commonly TLS-intercept HTTPS. Alpine's
|
||||||
|
# apk fetch of dl-cdn.alpinelinux.org fails inside the container when
|
||||||
|
# the CA chain includes a proxy cert the container doesn't trust. We
|
||||||
|
# avoid the problem entirely by not fetching anything from Alpine at
|
||||||
|
# build time. Signal handling (SIGTERM / SIGINT / SIGUSR2) is done in
|
||||||
|
# index.js so we don't need tini/dumb-init.
|
||||||
|
#
|
||||||
|
# WHY NO RUNTIME `npm install`: the bundle.sh workflow builds this
|
||||||
|
# image ONCE outside the DC (where npm registry access works), saves
|
||||||
|
# it as a tarball, and ships the tarball. The DC only runs
|
||||||
|
# `docker load` + `docker compose up -d` — zero network calls beyond
|
||||||
|
# the initial docker load.
|
||||||
|
#
|
||||||
|
# WHERE node_modules LIVES (subtle but critical):
|
||||||
|
# /workspace/node_modules ← NOT under dect-relay-agent/
|
||||||
|
#
|
||||||
|
# The agent imports `../integrations/cisco-dect/client.js`, which
|
||||||
|
# in turn does `import axios from 'axios'`. Node's ESM resolver
|
||||||
|
# walks UP from the IMPORTING file (client.js) looking for
|
||||||
|
# node_modules — it does NOT search siblings. So if node_modules
|
||||||
|
# lived at /workspace/dect-relay-agent/node_modules, then
|
||||||
|
# client.js (at /workspace/integrations/cisco-dect/client.js) would
|
||||||
|
# never find axios and blow up with ERR_MODULE_NOT_FOUND at runtime.
|
||||||
|
# Placing node_modules one level higher fixes it: both the agent
|
||||||
|
# AND the shared integrations resolve axios via /workspace/node_modules.
|
||||||
|
# The package.json at /workspace/ also declares "type":"module" so
|
||||||
|
# every .js file under /workspace/ is treated as ESM without needing
|
||||||
|
# its own package.json.
|
||||||
|
|
||||||
|
# ─── Stage 1: builder ────────────────────────────────────────────────
|
||||||
|
# Installs prod deps in a full node image (has python/build-essentials
|
||||||
|
# just in case a native module needs building — currently `ws` ships
|
||||||
|
# pre-built optional deps for common arches but we keep the option
|
||||||
|
# open for future deps).
|
||||||
|
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
# Copy just the package manifest first so this layer caches across
|
||||||
|
# code-only changes. The agent's package.json IS the workspace
|
||||||
|
# package.json — same "type":"module", same deps (ws / axios /
|
||||||
|
# dotenv), just placed one directory higher.
|
||||||
|
COPY dect-relay-agent/package.json ./package.json
|
||||||
|
|
||||||
|
# Install only production deps. --ignore-scripts because we don't run
|
||||||
|
# arbitrary postinstall from transitive deps in the container build;
|
||||||
|
# any needed build steps are pinned in this Dockerfile.
|
||||||
|
RUN npm install --omit=dev --ignore-scripts \
|
||||||
|
&& npm cache clean --force
|
||||||
|
|
||||||
|
# ─── Stage 2: runtime ────────────────────────────────────────────────
|
||||||
|
# Same base as builder, but only the artifacts we actually need at
|
||||||
|
# run time (node_modules + agent source + shared integrations + utils).
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runtime
|
||||||
|
|
||||||
|
# node:20-alpine ships a `node` user (uid 1000) that we can just use —
|
||||||
|
# no need to install anything extra. Running as a non-root user is a
|
||||||
|
# baseline hardening we get essentially for free.
|
||||||
|
USER node
|
||||||
|
|
||||||
|
# WORKDIR is the workspace root so `node dect-relay-agent/index.js`
|
||||||
|
# resolves correctly AND node_modules at /workspace/node_modules is
|
||||||
|
# discoverable by both the agent and the shared modules.
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
# Shared node_modules (see the header comment for why it's here and
|
||||||
|
# not under dect-relay-agent/). Ownership goes to `node` so the
|
||||||
|
# process can read them without needing root.
|
||||||
|
COPY --from=builder --chown=node:node /workspace/node_modules ./node_modules
|
||||||
|
|
||||||
|
# Package manifest at workspace root — Node uses this to determine
|
||||||
|
# "type":"module" for every .js file under /workspace/**.
|
||||||
|
COPY --chown=node:node dect-relay-agent/package.json ./package.json
|
||||||
|
|
||||||
|
# Agent source.
|
||||||
|
COPY --chown=node:node dect-relay-agent/index.js ./dect-relay-agent/index.js
|
||||||
|
|
||||||
|
# Shared modules the agent imports from the parent workspace.
|
||||||
|
COPY --chown=node:node integrations/cisco-dect ./integrations/cisco-dect
|
||||||
|
COPY --chown=node:node utils/httpDigestAuth.js ./utils/httpDigestAuth.js
|
||||||
|
|
||||||
|
# Optional metadata that shows up in `docker inspect` output — useful
|
||||||
|
# in the DC for "which build am I running?" without needing to poke
|
||||||
|
# inside the container.
|
||||||
|
ARG AGENT_VERSION=dev
|
||||||
|
ARG BUILD_DATE
|
||||||
|
ARG GIT_COMMIT
|
||||||
|
LABEL org.opencontainers.image.title="dect-relay-agent" \
|
||||||
|
org.opencontainers.image.description="Data-center-resident WSS bridge from CollabSupport bot (cloud) to Cisco DBS-210 DECT base stations on 10.x/8" \
|
||||||
|
org.opencontainers.image.version="${AGENT_VERSION}" \
|
||||||
|
org.opencontainers.image.created="${BUILD_DATE}" \
|
||||||
|
org.opencontainers.image.revision="${GIT_COMMIT}" \
|
||||||
|
org.opencontainers.image.source="https://git.joesjavajoint.com/jmcqueen/collabSupport"
|
||||||
|
|
||||||
|
# Node handles SIGTERM natively when the process installs handlers
|
||||||
|
# (which we do in index.js). --enable-source-maps improves stack
|
||||||
|
# traces if something crashes at runtime — cheap and always-on.
|
||||||
|
CMD ["node", "--enable-source-maps", "dect-relay-agent/index.js"]
|
||||||
30
dect-relay-agent/Dockerfile.dockerignore
Normal file
30
dect-relay-agent/Dockerfile.dockerignore
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Per-Dockerfile ignore, picked up by BuildKit ≥ 23.0 when this
|
||||||
|
# Dockerfile is used (see https://docs.docker.com/build/concepts/context/#filename-and-location).
|
||||||
|
# For older Docker daemons, the repo-root /.dockerignore is used
|
||||||
|
# instead (it already excludes node_modules, .env*, logs/, etc., so
|
||||||
|
# nothing sensitive would leak — this file is a size/speed win, not
|
||||||
|
# a security requirement).
|
||||||
|
#
|
||||||
|
# The build context is the REPO ROOT. We whitelist only the paths
|
||||||
|
# the Dockerfile actually COPYs. That keeps the transferred context
|
||||||
|
# tiny (a few dozen KB instead of the whole repo) and makes builds
|
||||||
|
# noticeably faster on slow disks / VPN uplinks.
|
||||||
|
|
||||||
|
*
|
||||||
|
|
||||||
|
# ─── Whitelist (paths the Dockerfile needs) ─────────────────────────
|
||||||
|
!dect-relay-agent/package.json
|
||||||
|
!dect-relay-agent/index.js
|
||||||
|
!integrations/cisco-dect/**
|
||||||
|
!utils/httpDigestAuth.js
|
||||||
|
|
||||||
|
# ─── Never-ship, even inside whitelisted trees ──────────────────────
|
||||||
|
**/node_modules
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
!**/.env.example
|
||||||
|
**/*.log
|
||||||
|
**/logs
|
||||||
|
**/.git
|
||||||
|
**/.DS_Store
|
||||||
|
**/coverage
|
||||||
192
dect-relay-agent/README.md
Normal file
192
dect-relay-agent/README.md
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
## Deploy paths
|
||||||
|
|
||||||
|
There are two ways to run this. Pick one based on where you're deploying.
|
||||||
|
|
||||||
|
### 1. Docker container in the data center (recommended for production)
|
||||||
|
|
||||||
|
The DC host makes **zero network calls** during install — the image is built on your dev machine, saved as a tarball, and shipped inside a self-contained ZIP. This sidesteps the TLS-interception problem that breaks `apk add` and `npm install` inside containers on corporate networks.
|
||||||
|
|
||||||
|
**On your dev machine (with Docker Desktop / internet access):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From the repo root — script is self-locating:
|
||||||
|
./dect-relay-agent/bundle.sh
|
||||||
|
# → writes dect-relay-agent-bundle-<YYYYMMDD-HHMMSS>.zip (~40-60MB)
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional overrides:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./dect-relay-agent/bundle.sh --tag 0.2.0 # override version
|
||||||
|
./dect-relay-agent/bundle.sh --platform linux/arm64 # if the DC is ARM
|
||||||
|
```
|
||||||
|
|
||||||
|
**On the DC host** (once you've transferred the ZIP):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
unzip dect-relay-agent-bundle-*.zip
|
||||||
|
cd dect-relay-agent-bundle-*
|
||||||
|
cp .env.example .env
|
||||||
|
$EDITOR .env # set BOT_URL + AGENT_TOKEN + ADMIN_PASSWORD
|
||||||
|
./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`install.sh` is idempotent — re-run it after transferring a newer bundle to upgrade. It:
|
||||||
|
1. `docker load`s the image tarball
|
||||||
|
2. Pins the loaded tag into `.env` (so compose never falls back to a stale local image)
|
||||||
|
3. Validates required env values are set (not still placeholder strings)
|
||||||
|
4. `docker compose up -d`
|
||||||
|
5. Tails the last 40 log lines so you can see the "Connected — sending hello" message
|
||||||
|
|
||||||
|
**What the runtime container looks like:**
|
||||||
|
|
||||||
|
- Non-root `node` user (uid 1000)
|
||||||
|
- Read-only root filesystem, 16MB tmpfs at `/tmp`
|
||||||
|
- All Linux capabilities dropped, `no-new-privileges`
|
||||||
|
- Host networking (so it can reach `10.x/8` without userland proxy translation)
|
||||||
|
- No listening ports — outbound-only WSS to the bot
|
||||||
|
- Log rotation: 10MB × 5 files max
|
||||||
|
|
||||||
|
### 2. Direct node process (dev + local iteration)
|
||||||
|
|
||||||
|
```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).
|
||||||
|
|
||||||
|
## What's in this folder
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `index.js` | Agent entrypoint — WSS client + command dispatcher |
|
||||||
|
| `package.json` | Deps: `ws`, `axios`, `dotenv` |
|
||||||
|
| `.env.example` | Annotated env template |
|
||||||
|
| `README.md` | This file |
|
||||||
|
| `Dockerfile` | Multi-stage Alpine build. No `apk add`, no runtime `npm install`. |
|
||||||
|
| `Dockerfile.dockerignore` | Per-Dockerfile ignore (BuildKit ≥ 23.0). Whitelist-based; keeps build context ~50KB. |
|
||||||
|
| `docker-compose.yml` | DC-side runtime shape (read-only rootfs, host net, capability drop, log rotation). |
|
||||||
|
| `bundle.sh` | Dev-machine packager: `docker build` → `docker save` → `zip`. Runs on your machine. |
|
||||||
|
| `install.sh` | DC-host installer: `docker load` → validate `.env` → `docker compose up -d`. Ships inside the bundle. |
|
||||||
|
|
||||||
|
**Important**: the `Dockerfile` is designed to be built from the **repo root**, not from this folder, because it needs `../integrations/cisco-dect/*` and `../utils/httpDigestAuth.js` in the build context. `bundle.sh` does this correctly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -f dect-relay-agent/Dockerfile -t ... . # note the trailing `.`
|
||||||
|
```
|
||||||
|
|
||||||
|
Building with `docker build dect-relay-agent/` will fail (missing shared modules) — always use `bundle.sh`, or invoke `docker build` from the repo root with `-f dect-relay-agent/Dockerfile`.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**`WARNING: fetching … TLS: server certificate not trusted` during build**
|
||||||
|
You're building inside a TLS-intercepting corporate network. Don't — build on your dev machine and ship the tarball via `bundle.sh`. That's the whole point of this workflow.
|
||||||
|
|
||||||
|
**`docker: permission denied while trying to connect to the Docker daemon socket`**
|
||||||
|
The user running `install.sh` needs to be in the `docker` group. Either `sudo usermod -aG docker $USER` (log out/in after) or `sudo ./install.sh`.
|
||||||
|
|
||||||
|
**Agent connects then immediately disconnects with 401**
|
||||||
|
Bearer token mismatch. `DECT_RELAY_AGENT_TOKEN` on the bot side must match the agent's `.env` exactly. Rotate both together.
|
||||||
|
|
||||||
|
**Agent connects but every `collect` returns `DIGEST_401`**
|
||||||
|
DBS-210 admin password is wrong. Verify in Control Hub → Calling → Features → DECT Networks → Manage → Manage DECT serviceability password, update `DECT_ADMIN_PASSWORD` in `.env`, then `docker compose restart dect-relay-agent`.
|
||||||
204
dect-relay-agent/bundle.sh
Executable file
204
dect-relay-agent/bundle.sh
Executable file
|
|
@ -0,0 +1,204 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# ────────────────────────────────────────────────────────────────────
|
||||||
|
# DECT relay agent — dev-machine bundler.
|
||||||
|
#
|
||||||
|
# Builds the Docker image for linux/amd64, saves it as a gzipped
|
||||||
|
# tarball, and zips it up with the compose file + .env template +
|
||||||
|
# install script. Output is a self-contained ZIP the DC operator
|
||||||
|
# can transfer over any file-copy channel (email, S3, USB, git-lfs)
|
||||||
|
# and install with a single `./install.sh` invocation.
|
||||||
|
#
|
||||||
|
# Requirements on the dev machine:
|
||||||
|
# - Docker Desktop / Docker Engine
|
||||||
|
# - Internet access to pull node:20-alpine + npm registry
|
||||||
|
# - `zip` (macOS + most Linux distros already have it; if not,
|
||||||
|
# `apt install zip` / `brew install zip`)
|
||||||
|
#
|
||||||
|
# Requirements on the DC host:
|
||||||
|
# - Docker + Docker Compose v2 (v1 also works)
|
||||||
|
# - Ability to `docker load` (i.e. member of the docker group or
|
||||||
|
# root)
|
||||||
|
# - Outbound HTTPS to the bot + 10.0.0.0/8 on TCP 443
|
||||||
|
# - That's it. No npm, no python, no Alpine mirrors.
|
||||||
|
#
|
||||||
|
# Usage (from repo root OR from this folder — the script figures it out):
|
||||||
|
# ./dect-relay-agent/bundle.sh # uses version from package.json
|
||||||
|
# ./dect-relay-agent/bundle.sh --tag 0.2.0 # override version
|
||||||
|
# ./dect-relay-agent/bundle.sh --platform linux/arm64
|
||||||
|
# ────────────────────────────────────────────────────────────────────
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ─── Argument parsing ─────────────────────────────────────────────
|
||||||
|
TAG_OVERRIDE=""
|
||||||
|
PLATFORM="linux/amd64" # standard x86_64 Linux server. Override if DC is arm.
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--tag) TAG_OVERRIDE="$2"; shift 2 ;;
|
||||||
|
--platform) PLATFORM="$2"; shift 2 ;;
|
||||||
|
-h|--help)
|
||||||
|
grep -E '^# ' "$0" | sed 's/^# \?//'
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*) echo "Unknown flag: $1" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ─── Locate paths (works from repo root or agent dir) ─────────────
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
AGENT_DIR="$SCRIPT_DIR"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
# Sanity: the Dockerfile expects to build from the repo root.
|
||||||
|
[[ -d "$REPO_ROOT/integrations/cisco-dect" ]] || {
|
||||||
|
echo "ERROR: $REPO_ROOT does not look like the collabSupport repo root (no integrations/cisco-dect/)" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Determine version tag ────────────────────────────────────────
|
||||||
|
if [[ -n "$TAG_OVERRIDE" ]]; then
|
||||||
|
VERSION="$TAG_OVERRIDE"
|
||||||
|
else
|
||||||
|
# Pull version from agent's package.json without needing jq. The
|
||||||
|
# regex is deliberately tolerant of trailing commas / whitespace.
|
||||||
|
VERSION="$(sed -nE 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' "$AGENT_DIR/package.json" | head -1)"
|
||||||
|
[[ -n "$VERSION" ]] || { echo "ERROR: could not read version from package.json" >&2; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Try to record the git commit into the image labels — useful in
|
||||||
|
# production for "which build am I running?". Missing git is fine.
|
||||||
|
GIT_COMMIT="unknown"
|
||||||
|
if command -v git >/dev/null 2>&1 && git -C "$REPO_ROOT" rev-parse --short HEAD >/dev/null 2>&1; then
|
||||||
|
GIT_COMMIT="$(git -C "$REPO_ROOT" rev-parse --short HEAD)"
|
||||||
|
# Mark as dirty if the working tree has uncommitted changes —
|
||||||
|
# catches "I built from local edits" surprises in production.
|
||||||
|
if ! git -C "$REPO_ROOT" diff --quiet 2>/dev/null || \
|
||||||
|
! git -C "$REPO_ROOT" diff --cached --quiet 2>/dev/null; then
|
||||||
|
GIT_COMMIT="${GIT_COMMIT}-dirty"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
BUILD_DATE="$(date -u +%FT%TZ)"
|
||||||
|
IMAGE_TAG="collabsupport/dect-relay-agent:${VERSION}"
|
||||||
|
BUNDLE_STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
BUNDLE_DIR="dect-relay-agent-bundle-${BUNDLE_STAMP}"
|
||||||
|
BUNDLE_ZIP="${BUNDLE_DIR}.zip"
|
||||||
|
|
||||||
|
echo "════════════════════════════════════════════════════════════════"
|
||||||
|
echo " Building DECT relay agent bundle"
|
||||||
|
echo "────────────────────────────────────────────────────────────────"
|
||||||
|
echo " Version: ${VERSION}"
|
||||||
|
echo " Image tag: ${IMAGE_TAG}"
|
||||||
|
echo " Platform: ${PLATFORM}"
|
||||||
|
echo " Git commit: ${GIT_COMMIT}"
|
||||||
|
echo " Build date: ${BUILD_DATE}"
|
||||||
|
echo " Bundle dir: ${BUNDLE_DIR}/"
|
||||||
|
echo " Bundle ZIP: ${BUNDLE_ZIP}"
|
||||||
|
echo "════════════════════════════════════════════════════════════════"
|
||||||
|
|
||||||
|
# ─── Docker build ─────────────────────────────────────────────────
|
||||||
|
# --platform pins the arch so building on Apple Silicon still
|
||||||
|
# produces an x86_64 image the DC can run. Docker uses QEMU to
|
||||||
|
# emulate cross-arch — slower than native but Just Works.
|
||||||
|
echo
|
||||||
|
echo "[1/4] Building image..."
|
||||||
|
docker build \
|
||||||
|
--platform="${PLATFORM}" \
|
||||||
|
--file "${AGENT_DIR}/Dockerfile" \
|
||||||
|
--tag "${IMAGE_TAG}" \
|
||||||
|
--build-arg "AGENT_VERSION=${VERSION}" \
|
||||||
|
--build-arg "BUILD_DATE=${BUILD_DATE}" \
|
||||||
|
--build-arg "GIT_COMMIT=${GIT_COMMIT}" \
|
||||||
|
"${REPO_ROOT}"
|
||||||
|
|
||||||
|
# ─── Stage the bundle ─────────────────────────────────────────────
|
||||||
|
# Work in the parent of the agent dir so the resulting ZIP + dir
|
||||||
|
# both land somewhere obvious (the repo root by convention).
|
||||||
|
echo
|
||||||
|
echo "[2/4] Staging bundle in ${REPO_ROOT}/${BUNDLE_DIR}/"
|
||||||
|
rm -rf "${REPO_ROOT:?}/${BUNDLE_DIR}"
|
||||||
|
mkdir -p "${REPO_ROOT}/${BUNDLE_DIR}"
|
||||||
|
|
||||||
|
# docker save streams a tarball to stdout; pipe through gzip to
|
||||||
|
# shrink it substantially (typically ~40% smaller for Node images).
|
||||||
|
echo
|
||||||
|
echo "[3/4] Saving image to ${BUNDLE_DIR}/image.tar.gz (this can take a minute)"
|
||||||
|
docker save "${IMAGE_TAG}" | gzip -9 > "${REPO_ROOT}/${BUNDLE_DIR}/image.tar.gz"
|
||||||
|
|
||||||
|
# Copy the operator-facing files. We do NOT copy Dockerfile / bundle.sh
|
||||||
|
# — those are dev-machine concerns.
|
||||||
|
cp "${AGENT_DIR}/docker-compose.yml" "${REPO_ROOT}/${BUNDLE_DIR}/"
|
||||||
|
cp "${AGENT_DIR}/.env.example" "${REPO_ROOT}/${BUNDLE_DIR}/"
|
||||||
|
cp "${AGENT_DIR}/install.sh" "${REPO_ROOT}/${BUNDLE_DIR}/"
|
||||||
|
cp "${AGENT_DIR}/README.md" "${REPO_ROOT}/${BUNDLE_DIR}/AGENT-README.md"
|
||||||
|
|
||||||
|
# Write a bundle-specific README that's short and tells the operator
|
||||||
|
# what to do in this exact folder. Keeps AGENT-README.md as the deep
|
||||||
|
# reference without cluttering the top-of-bundle experience.
|
||||||
|
cat > "${REPO_ROOT}/${BUNDLE_DIR}/README.txt" <<EOF
|
||||||
|
DECT Relay Agent — deployment bundle
|
||||||
|
====================================
|
||||||
|
|
||||||
|
Version: ${VERSION}
|
||||||
|
Image tag: ${IMAGE_TAG}
|
||||||
|
Built: ${BUILD_DATE}
|
||||||
|
Git commit: ${GIT_COMMIT}
|
||||||
|
Platform: ${PLATFORM}
|
||||||
|
|
||||||
|
To install on this data-center host:
|
||||||
|
|
||||||
|
1. cp .env.example .env
|
||||||
|
2. Edit .env — set:
|
||||||
|
- DECT_RELAY_BOT_URL (wss:// URL to the bot)
|
||||||
|
- DECT_RELAY_AGENT_TOKEN (shared bearer, same as bot's env)
|
||||||
|
- DECT_ADMIN_PASSWORD (DECT serviceability password)
|
||||||
|
3. ./install.sh
|
||||||
|
4. Watch it come up: docker compose logs -f dect-relay-agent
|
||||||
|
|
||||||
|
Files in this bundle:
|
||||||
|
|
||||||
|
image.tar.gz Prebuilt Docker image (gzipped, ~40MB)
|
||||||
|
docker-compose.yml Compose file — read-only rootfs, host network,
|
||||||
|
log rotation. Loaded by install.sh.
|
||||||
|
.env.example Config template.
|
||||||
|
install.sh Runs 'docker load' then 'docker compose up -d'.
|
||||||
|
Safe to re-run for upgrades.
|
||||||
|
AGENT-README.md Full agent docs — wire protocol, safety model,
|
||||||
|
run instructions.
|
||||||
|
README.txt This file.
|
||||||
|
|
||||||
|
No internet access required after the image is loaded. The container
|
||||||
|
runs with a read-only root filesystem, drops all Linux capabilities,
|
||||||
|
and uses the 'node' non-root user.
|
||||||
|
|
||||||
|
Upgrading:
|
||||||
|
Unzip the new bundle in a new folder, or overwrite this one, and
|
||||||
|
re-run ./install.sh. install.sh pins the new image tag into .env
|
||||||
|
automatically.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# ─── Zip it up ────────────────────────────────────────────────────
|
||||||
|
echo
|
||||||
|
echo "[4/4] Zipping bundle → ${BUNDLE_ZIP}"
|
||||||
|
(
|
||||||
|
cd "${REPO_ROOT}"
|
||||||
|
# -r recursive, -X strip Mac resource forks so we don't ship
|
||||||
|
# __MACOSX/ folders that confuse Linux operators.
|
||||||
|
zip -rqX "${BUNDLE_ZIP}" "${BUNDLE_DIR}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cleanup: keep the ZIP, remove the staging dir. Operator only wants
|
||||||
|
# the ZIP to transfer.
|
||||||
|
rm -rf "${REPO_ROOT:?}/${BUNDLE_DIR}"
|
||||||
|
|
||||||
|
SIZE="$(du -h "${REPO_ROOT}/${BUNDLE_ZIP}" | cut -f1)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "════════════════════════════════════════════════════════════════"
|
||||||
|
echo " Bundle ready: ${REPO_ROOT}/${BUNDLE_ZIP} (${SIZE})"
|
||||||
|
echo "────────────────────────────────────────────────────────────────"
|
||||||
|
echo " Transfer to the DC and:"
|
||||||
|
echo " unzip ${BUNDLE_ZIP}"
|
||||||
|
echo " cd ${BUNDLE_DIR}"
|
||||||
|
echo " cp .env.example .env && \$EDITOR .env"
|
||||||
|
echo " ./install.sh"
|
||||||
|
echo "════════════════════════════════════════════════════════════════"
|
||||||
80
dect-relay-agent/docker-compose.yml
Normal file
80
dect-relay-agent/docker-compose.yml
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
# DECT relay agent — data-center deployment.
|
||||||
|
#
|
||||||
|
# This compose file assumes the image has already been loaded from
|
||||||
|
# the shipped tarball (see install.sh: `docker load < image.tar.gz`).
|
||||||
|
# It does NOT build — build happens on the dev machine via bundle.sh
|
||||||
|
# so no npm-registry or Alpine-CDN traffic is needed inside the DC.
|
||||||
|
#
|
||||||
|
# Usage in the DC:
|
||||||
|
# 1. cp .env.example .env && $EDITOR .env
|
||||||
|
# 2. ./install.sh
|
||||||
|
# (or manually: docker load < image.tar.gz && docker compose up -d)
|
||||||
|
# 3. docker compose logs -f dect-relay-agent
|
||||||
|
#
|
||||||
|
# The IMAGE_TAG env var lets install.sh pin whatever tag the bundle
|
||||||
|
# ships (the bundle writes it into .env on install). Falls back to
|
||||||
|
# the current default so `docker compose up` still works standalone.
|
||||||
|
|
||||||
|
services:
|
||||||
|
dect-relay-agent:
|
||||||
|
# Tag comes from the image tarball shipped in the bundle; install.sh
|
||||||
|
# sets IMAGE_TAG in .env to whatever was baked in. Never falls back
|
||||||
|
# to :latest — that would silently swap in whatever's cached on the
|
||||||
|
# DC host if the tarball didn't load correctly.
|
||||||
|
image: ${IMAGE_TAG:-collabsupport/dect-relay-agent:0.1.0}
|
||||||
|
container_name: dect-relay-agent
|
||||||
|
|
||||||
|
# Read all config (bot URL, shared bearer, DBS-210 admin creds)
|
||||||
|
# from the operator's .env in this same directory. Compose does
|
||||||
|
# NOT auto-load .env into the container by default — env_file
|
||||||
|
# is the explicit opt-in.
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
# Restart on crash or reboot. `unless-stopped` respects an
|
||||||
|
# operator `docker compose stop` (so it doesn't come back until
|
||||||
|
# they say so) while surviving host reboots.
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Host networking so the agent can reach 10.x/8 without needing
|
||||||
|
# docker userland proxy translation. The agent doesn't LISTEN on
|
||||||
|
# anything — it dials outbound WSS to the bot — so this doesn't
|
||||||
|
# expose any port to the host's network.
|
||||||
|
#
|
||||||
|
# If your DC prefers bridge networking, remove this line. The
|
||||||
|
# only requirement is that the container can egress to (a) the
|
||||||
|
# bot's public HTTPS endpoint and (b) 10.0.0.0/8 on TCP 443.
|
||||||
|
network_mode: host
|
||||||
|
|
||||||
|
# Log rotation — keeps container logs from filling the disk on
|
||||||
|
# long-running deployments. 10 MB × 5 files = 50 MB max per agent.
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
|
# Read-only root filesystem + a small writable /tmp. The agent
|
||||||
|
# writes nothing to disk (all logs go to stdout / stderr), so
|
||||||
|
# this is essentially free defense-in-depth.
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:size=16M
|
||||||
|
|
||||||
|
# Minimal capabilities — the agent is just outbound HTTP client
|
||||||
|
# traffic, no need for NET_RAW / SYS_ADMIN / etc.
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
|
||||||
|
# Basic health check: the agent process being alive is a good
|
||||||
|
# proxy for "we're at least trying to reconnect". A deeper check
|
||||||
|
# (last successful hello with the bot < 2min ago) would need
|
||||||
|
# code the agent doesn't expose yet.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "process.exit(0)"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 10s
|
||||||
|
retries: 3
|
||||||
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();
|
||||||
130
dect-relay-agent/install.sh
Executable file
130
dect-relay-agent/install.sh
Executable file
|
|
@ -0,0 +1,130 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# ────────────────────────────────────────────────────────────────────
|
||||||
|
# DECT relay agent — data-center install / upgrade helper.
|
||||||
|
#
|
||||||
|
# Run this inside the unzipped bundle directory on the DC host. It:
|
||||||
|
# 1. Sanity-checks that Docker + Compose are installed.
|
||||||
|
# 2. Loads the shipped image tarball into the local Docker daemon.
|
||||||
|
# 3. Pins IMAGE_TAG in .env to whatever tag was baked into the
|
||||||
|
# tarball (so compose can never fall back to a stale local
|
||||||
|
# cache without you noticing).
|
||||||
|
# 4. Verifies .env exists and has the required keys populated.
|
||||||
|
# 5. Runs `docker compose up -d` and tails the last 40 lines.
|
||||||
|
#
|
||||||
|
# Safe to run repeatedly — it's a straightforward upgrade too:
|
||||||
|
# unzip -o new-bundle.zip -d dect-relay-agent-bundle
|
||||||
|
# cd dect-relay-agent-bundle
|
||||||
|
# ./install.sh
|
||||||
|
# ────────────────────────────────────────────────────────────────────
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Colors, only if stdout is a TTY. Corporate SSH sessions often are;
|
||||||
|
# CI / pipe-to-file are not.
|
||||||
|
if [[ -t 1 ]]; then
|
||||||
|
BOLD=$'\033[1m'; DIM=$'\033[2m'; RED=$'\033[31m'; GREEN=$'\033[32m'
|
||||||
|
YELLOW=$'\033[33m'; RESET=$'\033[0m'
|
||||||
|
else
|
||||||
|
BOLD=''; DIM=''; RED=''; GREEN=''; YELLOW=''; RESET=''
|
||||||
|
fi
|
||||||
|
|
||||||
|
log() { echo "${BOLD}[install]${RESET} $*"; }
|
||||||
|
die() { echo "${RED}[install] ERROR:${RESET} $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Ensure we're running from the bundle dir (compose file must be here).
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
# ─── 1. Preflight ──────────────────────────────────────────────────
|
||||||
|
log "Preflight checks"
|
||||||
|
command -v docker >/dev/null 2>&1 || die "docker is not installed or not on PATH"
|
||||||
|
|
||||||
|
# Compose v2 is `docker compose` (space); v1 is `docker-compose` (dash).
|
||||||
|
# Prefer v2. bail if neither is available.
|
||||||
|
if docker compose version >/dev/null 2>&1; then
|
||||||
|
COMPOSE="docker compose"
|
||||||
|
elif command -v docker-compose >/dev/null 2>&1; then
|
||||||
|
COMPOSE="docker-compose"
|
||||||
|
echo "${YELLOW}[install] Using legacy docker-compose v1. Consider upgrading to Compose v2.${RESET}"
|
||||||
|
else
|
||||||
|
die "docker compose (v2) not found and docker-compose (v1) not on PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker info >/dev/null 2>&1; then
|
||||||
|
die "docker daemon is not reachable. Are you in the 'docker' group, or should you re-run with sudo?"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -f image.tar.gz ]] || die "image.tar.gz not found in $(pwd) — is the bundle complete?"
|
||||||
|
[[ -f docker-compose.yml ]] || die "docker-compose.yml not found — is the bundle complete?"
|
||||||
|
|
||||||
|
# ─── 2. Load image ─────────────────────────────────────────────────
|
||||||
|
log "Loading Docker image from image.tar.gz (this is the only step that touches the docker daemon's image store)"
|
||||||
|
# `docker load` prints "Loaded image: <tag>" for each tag in the archive.
|
||||||
|
# We tee to stderr so the operator sees it, and grep the tag out for
|
||||||
|
# use in the .env pin step below.
|
||||||
|
LOAD_OUTPUT="$(gunzip -c image.tar.gz | docker load)"
|
||||||
|
echo "$LOAD_OUTPUT"
|
||||||
|
LOADED_TAG="$(echo "$LOAD_OUTPUT" | awk -F': ' '/Loaded image/ {print $2; exit}')"
|
||||||
|
[[ -n "$LOADED_TAG" ]] || die "docker load did not report a loaded image tag"
|
||||||
|
log "Loaded image: ${GREEN}${LOADED_TAG}${RESET}"
|
||||||
|
|
||||||
|
# ─── 3. .env setup ─────────────────────────────────────────────────
|
||||||
|
if [[ ! -f .env ]]; then
|
||||||
|
cp .env.example .env
|
||||||
|
echo "${YELLOW}[install] Created .env from .env.example. Edit it now with real values, then re-run this script.${RESET}"
|
||||||
|
echo " Required: DECT_RELAY_BOT_URL, DECT_RELAY_AGENT_TOKEN, DECT_ADMIN_PASSWORD"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Pin IMAGE_TAG in .env to the tag we just loaded. Idempotent —
|
||||||
|
# rewrites the line each run so upgrades to a new tarball tag Just
|
||||||
|
# Work without operator intervention.
|
||||||
|
if grep -q '^IMAGE_TAG=' .env; then
|
||||||
|
# Portable in-place sed (works on both GNU sed and BSD sed on macOS).
|
||||||
|
# The `.bak` tempfile is removed at end.
|
||||||
|
sed -i.bak "s|^IMAGE_TAG=.*|IMAGE_TAG=${LOADED_TAG}|" .env
|
||||||
|
rm -f .env.bak
|
||||||
|
else
|
||||||
|
printf '\n# Pinned automatically by install.sh on %s\nIMAGE_TAG=%s\n' \
|
||||||
|
"$(date -u +%FT%TZ)" "$LOADED_TAG" >> .env
|
||||||
|
fi
|
||||||
|
log "Pinned IMAGE_TAG=${LOADED_TAG} in .env"
|
||||||
|
|
||||||
|
# Validate the operator has actually filled in the required values —
|
||||||
|
# .env.example ships with placeholders that would blow up at runtime
|
||||||
|
# with a less friendly error.
|
||||||
|
MISSING=()
|
||||||
|
required_var() {
|
||||||
|
local key="$1" val
|
||||||
|
val="$(grep -E "^${key}=" .env | tail -1 | cut -d= -f2-)"
|
||||||
|
# Strip surrounding quotes and whitespace so both bare and quoted
|
||||||
|
# values validate the same.
|
||||||
|
val="${val#\"}"; val="${val%\"}"
|
||||||
|
val="${val#\'}"; val="${val%\'}"
|
||||||
|
val="${val## }"; val="${val%% }"
|
||||||
|
if [[ -z "$val" ]] || [[ "$val" == "replace-with-shared-secret" ]] \
|
||||||
|
|| [[ "$val" == "replace-with-dect-serviceability-password" ]] \
|
||||||
|
|| [[ "$val" == "wss://your-bot-host.example.com/dect-relay/ws" ]]; then
|
||||||
|
MISSING+=("$key")
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
required_var DECT_RELAY_BOT_URL
|
||||||
|
required_var DECT_RELAY_AGENT_TOKEN
|
||||||
|
required_var DECT_ADMIN_PASSWORD
|
||||||
|
|
||||||
|
if [[ ${#MISSING[@]} -gt 0 ]]; then
|
||||||
|
echo "${RED}[install] .env is missing required values or still has placeholder text:${RESET}"
|
||||||
|
for k in "${MISSING[@]}"; do echo " - $k"; done
|
||||||
|
echo " Edit .env and re-run this script."
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── 4. Compose up ─────────────────────────────────────────────────
|
||||||
|
log "Starting container via ${COMPOSE} up -d"
|
||||||
|
$COMPOSE up -d
|
||||||
|
|
||||||
|
log "Container started. Recent logs:"
|
||||||
|
sleep 2
|
||||||
|
$COMPOSE logs --tail=40 dect-relay-agent || true
|
||||||
|
|
||||||
|
echo
|
||||||
|
log "${GREEN}Done.${RESET} Follow live logs with: ${DIM}${COMPOSE} logs -f dect-relay-agent${RESET}"
|
||||||
|
log "Stop the agent with: ${DIM}${COMPOSE} down${RESET}"
|
||||||
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';
|
} from './commands/igmpFix.js';
|
||||||
import { pendingIgmpFixes } from './utils/pendingIgmpFixes.js';
|
import { pendingIgmpFixes } from './utils/pendingIgmpFixes.js';
|
||||||
import { extractRequester } from './utils/requester.js';
|
import { extractRequester } from './utils/requester.js';
|
||||||
|
import { getDectRelayHub } from './services/dectRelayHub.js';
|
||||||
import {
|
import {
|
||||||
getCommand,
|
getCommand,
|
||||||
ALL_HTTP_COMMAND_KEYS,
|
ALL_HTTP_COMMAND_KEYS,
|
||||||
|
|
@ -529,7 +530,13 @@ framework.hears(/.*/, async (bot, trigger) => {
|
||||||
}
|
}
|
||||||
}, null, 1);
|
}, null, 1);
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
// Capture the http.Server so we can attach the DECT relay WebSocket
|
||||||
|
// upgrade handler to it after listen(). Express's app.listen() returns
|
||||||
|
// the underlying Node http.Server — previously we discarded that
|
||||||
|
// handle because HTTP-only routes don't need it. Adding WSS support
|
||||||
|
// changes that: the ws package hooks into the raw 'upgrade' event on
|
||||||
|
// the http.Server, so we need the reference here.
|
||||||
|
const httpServer = app.listen(PORT, () => {
|
||||||
logger('server', `🚀 Express server running on port ${PORT}`);
|
logger('server', `🚀 Express server running on port ${PORT}`);
|
||||||
console.log(`🚀 HTTP API server listening on http://localhost:${PORT}`);
|
console.log(`🚀 HTTP API server listening on http://localhost:${PORT}`);
|
||||||
|
|
||||||
|
|
@ -552,6 +559,24 @@ app.listen(PORT, () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// DECT relay hub — accepts ONE long-lived WSS connection from the
|
||||||
|
// data-center relay agent. Gated on DECT_RELAY_AGENT_TOKEN being set:
|
||||||
|
// if not configured, we log a warning and skip attaching so the bot
|
||||||
|
// stays usable for everything else (Jira poller, Meraki, Webex admin
|
||||||
|
// commands). See services/dectRelayHub.js for the wire protocol and
|
||||||
|
// dect-relay-agent/README.md for how to run the DC-side process.
|
||||||
|
if (process.env.DECT_RELAY_AGENT_TOKEN) {
|
||||||
|
const relayHub = getDectRelayHub();
|
||||||
|
relayHub.attachTo(httpServer);
|
||||||
|
logger('startup', `DECT relay hub listening for agent at ${process.env.DECT_RELAY_PATH || '/dect-relay/ws'}`);
|
||||||
|
} else {
|
||||||
|
logger(
|
||||||
|
'startup',
|
||||||
|
'DECT relay disabled — set DECT_RELAY_AGENT_TOKEN in .env (and share the same value with dect-relay-agent) to enable /phonestatus DECT follow-up + future /dectstatus command',
|
||||||
|
'warn',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
// Cron Jobs
|
// Cron Jobs
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
|
|
@ -636,9 +661,20 @@ async function shutdown(reason, exitCode) {
|
||||||
logger('shutdown', 'Webex Framework stopped');
|
logger('shutdown', 'Webex Framework stopped');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger('shutdown', `Error during framework.stop(): ${err.message}`, 'error');
|
logger('shutdown', `Error during framework.stop(): ${err.message}`, 'error');
|
||||||
} finally {
|
|
||||||
process.exit(exitCode);
|
|
||||||
}
|
}
|
||||||
|
// Close the DECT relay hub so in-flight RPCs get rejected with
|
||||||
|
// DISCONNECTED and the agent's socket gets a clean 1001 shutdown
|
||||||
|
// frame (instead of a ripped-out TCP that would leave the agent
|
||||||
|
// reconnecting in a loop until its keepalive times out).
|
||||||
|
if (process.env.DECT_RELAY_AGENT_TOKEN) {
|
||||||
|
try {
|
||||||
|
await getDectRelayHub().close();
|
||||||
|
logger('shutdown', 'DECT relay hub closed');
|
||||||
|
} catch (err) {
|
||||||
|
logger('shutdown', `Error during DECT relay hub close: ${err.message}`, 'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.exit(exitCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
process.on('SIGINT', () => shutdown('SIGINT', 0));
|
process.on('SIGINT', () => shutdown('SIGINT', 0));
|
||||||
|
|
|
||||||
182
integrations/cisco-dect/client.js
Normal file
182
integrations/cisco-dect/client.js
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
// src/integrations/cisco-dect/client.js
|
||||||
|
//
|
||||||
|
// Tiny axios wrapper for talking to the Cisco DBS-210 DECT base
|
||||||
|
// station's local admin web UI. This is a SPIKE — not wired into the
|
||||||
|
// bot. The whole cisco-dect/ folder exists so we can reverse-engineer
|
||||||
|
// what the DBS-210 exposes (reboot, PRT pull, syslog, config export)
|
||||||
|
// against one lab base before productionizing behind a store-side
|
||||||
|
// relay.
|
||||||
|
//
|
||||||
|
// What the DBS-210 UI actually is (confirmed from a HAR capture of a
|
||||||
|
// real login against 192.168.1.164):
|
||||||
|
// - HTTPS on 443 with a self-signed cert → rejectUnauthorized:false.
|
||||||
|
// - Real entry page is `/main.html`, NOT `/` or `/admin/index.htm`.
|
||||||
|
// Root returns 404 or gets redirected; probes should target
|
||||||
|
// /main.html first for reachability + auth sanity.
|
||||||
|
// - HTTP DIGEST authentication (MD5, qop=auth), NOT Basic. First
|
||||||
|
// request returns 401 with:
|
||||||
|
// WWW-Authenticate: Digest realm="", nonce="...", algorithm="MD5", qop="auth"
|
||||||
|
// We handle this via an axios response interceptor: any 401 with
|
||||||
|
// a Digest challenge triggers a single retry with the correct
|
||||||
|
// Authorization header computed by utils/httpDigestAuth.js.
|
||||||
|
// - Response sets `Clear-Site-Data: "cookies"` on every reply, so we
|
||||||
|
// CAN'T lean on a session cookie — the Digest header goes on every
|
||||||
|
// single request. That's why we don't cache the nonce here; each
|
||||||
|
// request does its own challenge/response round-trip. Slower per
|
||||||
|
// call (2× RTT), but tiny wall-clock hit on LAN and it means we
|
||||||
|
// never carry stale nonces across a reboot.
|
||||||
|
// - NOT a REST/JSON API. Pages return HTML/CSS/JS/PNG bytes, no JSON.
|
||||||
|
// Callers get raw strings and decide how to parse.
|
||||||
|
//
|
||||||
|
// Safety notes for the spike:
|
||||||
|
// - No retries beyond the single Digest handshake. Timeouts + real
|
||||||
|
// errors surface directly so we can iterate on the endpoint list.
|
||||||
|
// - No refresh of any kind. Reboot / factory-reset are one-shot,
|
||||||
|
// idempotent from our side (the DBS-210 handles its own state).
|
||||||
|
|
||||||
|
import axios from 'axios';
|
||||||
|
import https from 'node:https';
|
||||||
|
import {
|
||||||
|
parseDigestChallenge,
|
||||||
|
buildDigestAuthHeader,
|
||||||
|
} from '../../utils/httpDigestAuth.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an axios instance pre-configured for a single DBS-210 base.
|
||||||
|
*
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {string} opts.host IP or hostname of the base station (no scheme)
|
||||||
|
* @param {string} opts.user usually "admin"
|
||||||
|
* @param {string} opts.password DECT serviceability password
|
||||||
|
* @param {number} [opts.timeoutMs] default 30_000
|
||||||
|
* @returns {import('axios').AxiosInstance}
|
||||||
|
*/
|
||||||
|
export function createDectClient({ host, user, password, timeoutMs = 30_000 }) {
|
||||||
|
if (!host) throw new Error('createDectClient: host is required');
|
||||||
|
if (!user) throw new Error('createDectClient: user is required');
|
||||||
|
if (!password) throw new Error('createDectClient: password is required');
|
||||||
|
|
||||||
|
const client = axios.create({
|
||||||
|
baseURL: `https://${host}`,
|
||||||
|
timeout: timeoutMs,
|
||||||
|
// DBS-210 uses a self-signed cert. Fine for LAN-only management,
|
||||||
|
// and why the eventual relay stays inside the store perimeter.
|
||||||
|
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||||
|
// Accept every status ourselves so the interceptor can inspect
|
||||||
|
// 401s. Otherwise axios would throw before we could see the
|
||||||
|
// WWW-Authenticate challenge.
|
||||||
|
validateStatus: () => true,
|
||||||
|
responseType: 'text',
|
||||||
|
transformResponse: [(data) => data], // no JSON auto-parse
|
||||||
|
headers: { 'User-Agent': 'collabSupport-dect-spike/0.1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stash credentials on the instance so the interceptor has them
|
||||||
|
// without capturing them in a closure that outlives the client.
|
||||||
|
client.defaults.__dectAuth = { user, password };
|
||||||
|
|
||||||
|
// Digest interceptor: single-shot retry on any 401 that carries a
|
||||||
|
// Digest challenge. Marks the retried request with __digestRetried
|
||||||
|
// so we don't infinite-loop if the credentials are simply wrong.
|
||||||
|
client.interceptors.response.use(async (response) => {
|
||||||
|
if (response.status !== 401) return response;
|
||||||
|
|
||||||
|
const originalConfig = response.config;
|
||||||
|
if (originalConfig.__digestRetried) {
|
||||||
|
// Already retried once with a computed Digest response and
|
||||||
|
// still got 401 — credentials or realm are wrong. Return the
|
||||||
|
// second 401 to the caller as-is.
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header names in axios responses come back lowercased.
|
||||||
|
const wwwAuth = response.headers?.['www-authenticate'];
|
||||||
|
const challenge = parseDigestChallenge(wwwAuth);
|
||||||
|
if (!challenge) return response;
|
||||||
|
|
||||||
|
const { user: username, password } = client.defaults.__dectAuth;
|
||||||
|
const method = (originalConfig.method || 'get').toUpperCase();
|
||||||
|
// The Digest URI is the request-path (+ query), not the full URL.
|
||||||
|
// baseURL is absorbed by axios into originalConfig.url when we
|
||||||
|
// originally called client.request({url:'/main.html'}), so
|
||||||
|
// originalConfig.url IS the relative path already.
|
||||||
|
const uri = originalConfig.url || '/';
|
||||||
|
|
||||||
|
const authHeader = buildDigestAuthHeader({
|
||||||
|
username, password, method, uri, challenge,
|
||||||
|
});
|
||||||
|
|
||||||
|
return client.request({
|
||||||
|
...originalConfig,
|
||||||
|
headers: { ...(originalConfig.headers || {}), Authorization: authHeader },
|
||||||
|
__digestRetried: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured probe result — normalizes success + failure so the CLI
|
||||||
|
* runner can print a consistent report row regardless of outcome.
|
||||||
|
* Ordering matches what a human reads: what we tried → what we got.
|
||||||
|
*
|
||||||
|
* @typedef {object} ProbeResult
|
||||||
|
* @property {string} path Path we attempted (relative to baseURL).
|
||||||
|
* @property {string} method HTTP method ('GET' / 'POST' / ...).
|
||||||
|
* @property {number|null} status HTTP status code, or null if the request never completed.
|
||||||
|
* @property {string|null} contentType Content-Type header if present.
|
||||||
|
* @property {number} sizeBytes Length of the response body (0 on error).
|
||||||
|
* @property {string|null} snippet First ~200 chars of the body, sanitized to one line.
|
||||||
|
* @property {string|null} error Error message if the request failed.
|
||||||
|
* @property {number} elapsedMs Wall-clock time for the request.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap an axios request in the ProbeResult envelope. Never throws —
|
||||||
|
* a network error, timeout, 401, 404, or 500 all come back as a
|
||||||
|
* structured result so a probe loop can just print each row and keep
|
||||||
|
* going. Because the client is set to validateStatus: () => true,
|
||||||
|
* axios itself won't throw for HTTP-level failures anymore — the
|
||||||
|
* request only throws on transport-level errors (ENOTFOUND, ECONNREFUSED,
|
||||||
|
* timeouts, TLS issues that survive rejectUnauthorized:false).
|
||||||
|
*/
|
||||||
|
export async function tryRequest(client, { method = 'GET', path, data, headers } = {}) {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const res = await client.request({ method, url: path, data, headers });
|
||||||
|
return normalize({
|
||||||
|
path, method,
|
||||||
|
status: res.status,
|
||||||
|
contentType: res.headers?.['content-type'] || null,
|
||||||
|
body: res.data,
|
||||||
|
elapsedMs: Date.now() - start,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
status: null,
|
||||||
|
contentType: null,
|
||||||
|
sizeBytes: 0,
|
||||||
|
snippet: null,
|
||||||
|
error: err.code ? `${err.code}: ${err.message}` : err.message,
|
||||||
|
elapsedMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize({ path, method, status, contentType, body, elapsedMs, error = null }) {
|
||||||
|
const bodyStr = typeof body === 'string' ? body : (body == null ? '' : String(body));
|
||||||
|
const flat = bodyStr.replace(/\s+/g, ' ').trim();
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
status,
|
||||||
|
contentType,
|
||||||
|
sizeBytes: Buffer.byteLength(bodyStr, 'utf8'),
|
||||||
|
snippet: flat ? flat.slice(0, 200) : null,
|
||||||
|
error,
|
||||||
|
elapsedMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
221
integrations/cisco-dect/probes.js
Normal file
221
integrations/cisco-dect/probes.js
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
// src/integrations/cisco-dect/probes.js
|
||||||
|
//
|
||||||
|
// Individual probe / action functions against a DBS-210 base station.
|
||||||
|
// Every function takes an axios client from client.js and returns a
|
||||||
|
// ProbeResult or an object built from one, so the CLI runner has a
|
||||||
|
// uniform envelope to print.
|
||||||
|
//
|
||||||
|
// URL map is derived from reverse-engineering the actual admin UI JS
|
||||||
|
// (see .dect-samples/dbs210-*.{html,js} pulled from a live base, and
|
||||||
|
// specifically dbs210-gen.js `LoadPage(...)` call sites).
|
||||||
|
//
|
||||||
|
// ⚠️ IMPORTANT SAFETY MODEL — READ BEFORE ADDING NEW PATHS ⚠️
|
||||||
|
//
|
||||||
|
// The DBS-210 admin UI uses a Cisco SPA-family legacy pattern where
|
||||||
|
// ACTIONS are triggered by simple GET navigation, not POST + form.
|
||||||
|
// GETting `/reboot.html` reboots the base. GETting `/DefaultEeprom.html`
|
||||||
|
// factory-resets it. There is no confirmation dialog on the server
|
||||||
|
// side — the browser JS shows the confirm() prompt, but the server
|
||||||
|
// happily executes on any authenticated GET. The legacy alias
|
||||||
|
// `/admin/reboot.htm` doesn't even enforce the CSRF token.
|
||||||
|
//
|
||||||
|
// Our previous probe list included `/admin/reboot.htm` as a "guess"
|
||||||
|
// and REBOOTED the user's lab base while probing. Never again. Any
|
||||||
|
// URL that mutates state MUST live in MUTATING_ACTION_PATHS below,
|
||||||
|
// which is NOT touched by runReadProbes() and is only reachable via
|
||||||
|
// explicit triggerX() functions gated by the CLI's --execute flag.
|
||||||
|
|
||||||
|
import { tryRequest } from './client.js';
|
||||||
|
|
||||||
|
// ─── Read-safe endpoints ────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// SSR HTML pages (the whole admin UI's page set from main.html's left
|
||||||
|
// nav) plus the two machine-readable XML endpoints referenced by
|
||||||
|
// gen.js. All confirmed by the browser HAR + JS grep — no more
|
||||||
|
// guessing. Every one of these is idempotent as far as we know.
|
||||||
|
|
||||||
|
export const READ_PROBE_PATHS = [
|
||||||
|
// Home + machine-readable data endpoints first — most useful for
|
||||||
|
// "am I connected and authenticated?" and for the eventual data
|
||||||
|
// collector.
|
||||||
|
{ path: '/main.html', purpose: 'home/status page (SSR HTML)' },
|
||||||
|
{ path: '/admin/status.xml', purpose: 'machine-readable status XML (called by GetStausXml() in gen.js)' },
|
||||||
|
{ path: '/Settings.xml', purpose: 'machine-readable settings XML (called by GetSettingsXml() in gen.js)' },
|
||||||
|
// Left-nav pages — SSR HTML, useful for scraping specific data.
|
||||||
|
{ path: '/Ext.html', purpose: 'extensions page' },
|
||||||
|
{ path: '/Servers.html', purpose: 'SIP servers page' },
|
||||||
|
{ path: '/Network.html', purpose: 'network config page' },
|
||||||
|
{ path: '/Management.html', purpose: 'management page (holds REBOOT_OPTION button)' },
|
||||||
|
{ path: '/Fwu.html', purpose: 'firmware update page' },
|
||||||
|
{ path: '/CountryTimeDate.html',purpose: 'country/time page' },
|
||||||
|
{ path: '/Security.html', purpose: 'security page' },
|
||||||
|
{ path: '/License.html', purpose: 'license info page' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Mutating action endpoints — QUARANTINED ────────────────────────
|
||||||
|
//
|
||||||
|
// Every entry here triggers a real side-effect on the device with a
|
||||||
|
// bare authenticated GET. NEVER include these in runReadProbes().
|
||||||
|
// They're exported only so the triggerX() functions below have a
|
||||||
|
// single source of truth for the URL strings.
|
||||||
|
|
||||||
|
export const MUTATING_ACTION_PATHS = Object.freeze({
|
||||||
|
REBOOT: '/reboot.html',
|
||||||
|
FORCE_REBOOT: '/forcereboot.html',
|
||||||
|
REBOOT_CHAIN: '/rebootchain.html',
|
||||||
|
FORCE_REBOOT_CHAIN: '/forcerebootchain.html',
|
||||||
|
FACTORY_RESET: '/DefaultEeprom.html',
|
||||||
|
RECONFIGURE_TREE: '/reconfiguredecttree.html',
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Read-only helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire every read-only probe and return an array of ProbeResults.
|
||||||
|
* Sequential so output is readable and the DBS-210 (which is not
|
||||||
|
* exactly a beefy web server) doesn't get stampeded.
|
||||||
|
*/
|
||||||
|
export async function runReadProbes(client) {
|
||||||
|
const results = [];
|
||||||
|
for (const { path, purpose } of READ_PROBE_PATHS) {
|
||||||
|
const r = await tryRequest(client, { method: 'GET', path });
|
||||||
|
results.push({ ...r, purpose });
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch an arbitrary path with no CSRF token. Only intended for
|
||||||
|
* safe reads — the CLI runner's `get` subcommand routes here.
|
||||||
|
*/
|
||||||
|
export async function getPath(client, path) {
|
||||||
|
return tryRequest(client, { method: 'GET', path });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull `/main.html`, parse out the CSRF token from the meta tag, and
|
||||||
|
* return it. Every mutating action needs to include this as
|
||||||
|
* `?csrf_token=<value>` — the JS on the real page does the same when
|
||||||
|
* building any state-changing URL.
|
||||||
|
*
|
||||||
|
* Notable exception: the legacy `/admin/reboot.htm` alias does NOT
|
||||||
|
* enforce CSRF (verified: our tokenless probe rebooted the base).
|
||||||
|
* That alias is intentionally NOT exposed by triggerReboot() —
|
||||||
|
* always take the modern `/reboot.html` path so future firmware
|
||||||
|
* that tightens CSRF enforcement doesn't silently break us.
|
||||||
|
*
|
||||||
|
* @returns {Promise<string|null>} The CSRF token, or null if the page
|
||||||
|
* doesn't expose one (older firmware).
|
||||||
|
*/
|
||||||
|
export async function fetchCsrfToken(client) {
|
||||||
|
const r = await tryRequest(client, { method: 'GET', path: '/main.html' });
|
||||||
|
if (!r.status || r.status >= 400) {
|
||||||
|
throw new Error(`Cannot fetch /main.html for CSRF token (status: ${r.status ?? 'ERR'})`);
|
||||||
|
}
|
||||||
|
// The full body isn't in the ProbeResult (only a snippet), so
|
||||||
|
// re-request for the raw HTML. Cheap on LAN, and keeps the pure
|
||||||
|
// ProbeResult shape clean for the probe runner.
|
||||||
|
const raw = await client.get('/main.html');
|
||||||
|
const body = raw.data || '';
|
||||||
|
// Meta tag shape (from real page): <meta name = "csrf-token" content = "0C6502...."/>
|
||||||
|
const m = body.match(/<meta\s+name\s*=\s*["']csrf-token["']\s+content\s*=\s*["']([^"']+)["']/i);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mutating actions ───────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// All follow the same shape: build the URL, decide dry-run vs execute,
|
||||||
|
// and only in execute mode actually hit the device. Dry-run returns
|
||||||
|
// the planned request so the CLI can print exactly what WOULD happen
|
||||||
|
// before you type --execute.
|
||||||
|
//
|
||||||
|
// The `planned.body` field stays `null` for these — remember, the
|
||||||
|
// DBS-210 admin UI takes actions on GET, not POST. Emitting body
|
||||||
|
// info would be misleading.
|
||||||
|
|
||||||
|
async function performAction(client, { path, dryRun, kind }) {
|
||||||
|
const csrfToken = dryRun ? '(will fetch on execute)' : await fetchCsrfToken(client);
|
||||||
|
const url = dryRun
|
||||||
|
? `${path}?csrf_token=${csrfToken}`
|
||||||
|
: `${path}?csrf_token=${encodeURIComponent(csrfToken)}`;
|
||||||
|
const planned = { method: 'GET', path: url, body: null };
|
||||||
|
if (dryRun) return { dryRun: true, kind, planned };
|
||||||
|
const result = await tryRequest(client, { method: 'GET', path: url });
|
||||||
|
return { dryRun: false, kind, planned, result };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reboot this single base station. Normal reboot waits for the base
|
||||||
|
* to be idle; forced reboot happens within ~1 minute regardless.
|
||||||
|
*
|
||||||
|
* IMPORTANT: this hits `/reboot.html` (the CSRF-protected modern
|
||||||
|
* endpoint), NOT the legacy `/admin/reboot.htm` alias that our early
|
||||||
|
* probe accidentally triggered.
|
||||||
|
*
|
||||||
|
* @param {object} args
|
||||||
|
* @param {boolean} [args.forced=false]
|
||||||
|
* @param {boolean} [args.dryRun=true]
|
||||||
|
*/
|
||||||
|
export async function triggerReboot(client, { forced = false, dryRun = true } = {}) {
|
||||||
|
const path = forced ? MUTATING_ACTION_PATHS.FORCE_REBOOT : MUTATING_ACTION_PATHS.REBOOT;
|
||||||
|
return performAction(client, { path, dryRun, kind: forced ? 'force-reboot' : 'reboot' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reboot every base station in the multi-cell chain. Only meaningful
|
||||||
|
* on the primary. Forced variant kills active calls immediately.
|
||||||
|
*/
|
||||||
|
export async function triggerRebootChain(client, { forced = false, dryRun = true } = {}) {
|
||||||
|
const path = forced ? MUTATING_ACTION_PATHS.FORCE_REBOOT_CHAIN : MUTATING_ACTION_PATHS.REBOOT_CHAIN;
|
||||||
|
return performAction(client, { path, dryRun, kind: forced ? 'force-reboot-chain' : 'reboot-chain' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory reset (EEPROM default). Nukes all settings — the base will
|
||||||
|
* lose its Webex Calling provisioning and have to be re-provisioned
|
||||||
|
* from Control Hub. User has confirmed this reliably works from the
|
||||||
|
* UI on their fleet.
|
||||||
|
*/
|
||||||
|
export async function triggerFactoryReset(client, { dryRun = true } = {}) {
|
||||||
|
return performAction(client, {
|
||||||
|
path: MUTATING_ACTION_PATHS.FACTORY_RESET,
|
||||||
|
dryRun, kind: 'factory-reset',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconfigure the DECT synchronization source tree. Doesn't reboot,
|
||||||
|
* but recomputes which base is sync-source-for-which. Useful when
|
||||||
|
* multi-cell mesh geometry has drifted.
|
||||||
|
*/
|
||||||
|
export async function triggerReconfigureDectTree(client, { dryRun = true } = {}) {
|
||||||
|
return performAction(client, {
|
||||||
|
path: MUTATING_ACTION_PATHS.RECONFIGURE_TREE,
|
||||||
|
dryRun, kind: 'reconfigure-dect-tree',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Legacy helpers kept for CLI wiring ─────────────────────────────
|
||||||
|
//
|
||||||
|
// These wrap the more-targeted functions above, matching the older
|
||||||
|
// CLI subcommand names (syslog / config / prt) so the existing
|
||||||
|
// script keeps working. NONE of these are proven paths yet — they
|
||||||
|
// remain best-effort GETs against candidate URLs to be replaced once
|
||||||
|
// we've reverse-engineered the real syslog / config / PRT endpoints
|
||||||
|
// from the SSR pages.
|
||||||
|
|
||||||
|
export async function fetchSyslog(client) {
|
||||||
|
return tryRequest(client, { method: 'GET', path: '/Management.html' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchConfigExport(client) {
|
||||||
|
// Real endpoint TBD — the Security.html and Settings.xml pages are
|
||||||
|
// both plausible. For now this just fetches the Settings XML which
|
||||||
|
// is at least machine-readable.
|
||||||
|
return tryRequest(client, { method: 'GET', path: '/Settings.xml' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPrt(client) {
|
||||||
|
// Real endpoint TBD — see .dect-samples/ analysis needed.
|
||||||
|
return tryRequest(client, { method: 'GET', path: '/Management.html' });
|
||||||
|
}
|
||||||
439
integrations/cisco-dect/statusXml.js
Normal file
439
integrations/cisco-dect/statusXml.js
Normal file
|
|
@ -0,0 +1,439 @@
|
||||||
|
// src/integrations/cisco-dect/statusXml.js
|
||||||
|
//
|
||||||
|
// Parser + normalizer for the Cisco DBS-210 DECT base station's
|
||||||
|
// `/admin/status.xml` endpoint. Pure — takes a raw XML string,
|
||||||
|
// returns a structured object. No axios, no I/O, no side-effects.
|
||||||
|
//
|
||||||
|
// Why hand-roll instead of pulling in fast-xml-parser? The DBS-210's
|
||||||
|
// status XML schema is trivially flat:
|
||||||
|
// - No attributes anywhere
|
||||||
|
// - No CDATA, no comments, no mixed content
|
||||||
|
// - Two levels of nesting at most (Status → Section → leaves;
|
||||||
|
// Section → Sub-object → leaves)
|
||||||
|
// - No repeated same-name siblings (Reboot_Line_1..6 are distinct
|
||||||
|
// tags, not <Reboot_Line> arrays)
|
||||||
|
// A specialized 60-line reader is safer than dragging in a generic
|
||||||
|
// XML parser we'd then need to keep pinned on the eventual store-side
|
||||||
|
// relay agent (which we want to stay tiny).
|
||||||
|
//
|
||||||
|
// The output shape is deliberately camelCased and grouped for
|
||||||
|
// consumers — it's NOT a faithful reflection of the XML tag names.
|
||||||
|
// That's on purpose: the parser is the moment where we absorb the
|
||||||
|
// Cisco tag naming quirks so nothing else in the codebase has to.
|
||||||
|
|
||||||
|
// ─── Low-level: XML → plain JS object ───────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a very-simple XML string (single root, no attributes, no
|
||||||
|
* CDATA, no comments, no mixed content) into a plain JS object.
|
||||||
|
* Leaf elements become string values; parent elements become nested
|
||||||
|
* objects keyed by tag name.
|
||||||
|
*
|
||||||
|
* Whitespace between tags is discarded. Whitespace inside leaf text
|
||||||
|
* is preserved as-is (the DBS-210 uses meaningful spacing in some
|
||||||
|
* fields, e.g. `RFPI_Address` = "13508C9C; RPN:00").
|
||||||
|
*
|
||||||
|
* Throws on malformed input rather than silently coercing — callers
|
||||||
|
* catch and log so a firmware change that breaks the schema shows up
|
||||||
|
* loudly instead of producing a mysteriously-empty object.
|
||||||
|
*/
|
||||||
|
export function xmlToObject(xml) {
|
||||||
|
if (typeof xml !== 'string') {
|
||||||
|
throw new TypeError('xmlToObject: expected a string');
|
||||||
|
}
|
||||||
|
// Strip XML declaration if present.
|
||||||
|
const cleaned = xml.replace(/^\uFEFF/, '').replace(/<\?xml[^?]*\?>/, '').trim();
|
||||||
|
if (!cleaned) throw new Error('xmlToObject: empty input');
|
||||||
|
|
||||||
|
let pos = 0;
|
||||||
|
|
||||||
|
function skipWs() {
|
||||||
|
while (pos < cleaned.length && /\s/.test(cleaned[pos])) pos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOpenTag() {
|
||||||
|
// Assumes cleaned[pos] === '<'.
|
||||||
|
if (cleaned[pos] !== '<') {
|
||||||
|
throw new Error(`xmlToObject: expected '<' at ${pos}`);
|
||||||
|
}
|
||||||
|
const end = cleaned.indexOf('>', pos);
|
||||||
|
if (end < 0) throw new Error(`xmlToObject: unterminated tag at ${pos}`);
|
||||||
|
const raw = cleaned.slice(pos + 1, end);
|
||||||
|
pos = end + 1;
|
||||||
|
const selfClosing = raw.endsWith('/');
|
||||||
|
const inner = selfClosing ? raw.slice(0, -1).trim() : raw.trim();
|
||||||
|
// Tag name = up to first whitespace. Attributes (if any) are
|
||||||
|
// ignored — DBS-210 XML doesn't use them and swallowing them
|
||||||
|
// silently keeps us robust to future additions.
|
||||||
|
const nameMatch = inner.match(/^([A-Za-z_][\w.-]*)/);
|
||||||
|
if (!nameMatch) throw new Error(`xmlToObject: bad tag name near ${pos}: ${inner}`);
|
||||||
|
return { name: nameMatch[1], selfClosing };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseElement() {
|
||||||
|
const { name, selfClosing } = readOpenTag();
|
||||||
|
if (selfClosing) return { name, value: '' };
|
||||||
|
|
||||||
|
// Look ahead: if the next non-whitespace char is '<' AND it's not
|
||||||
|
// an immediate close of THIS tag, treat body as child elements.
|
||||||
|
// Otherwise treat body as leaf text up to </name>.
|
||||||
|
const savedPos = pos;
|
||||||
|
skipWs();
|
||||||
|
const closeTag = `</${name}>`;
|
||||||
|
const nextOpen = cleaned.indexOf('<', pos);
|
||||||
|
|
||||||
|
if (nextOpen === pos && !cleaned.startsWith(closeTag, pos)) {
|
||||||
|
// Container element with child elements.
|
||||||
|
const children = {};
|
||||||
|
while (true) {
|
||||||
|
skipWs();
|
||||||
|
if (cleaned.startsWith(closeTag, pos)) {
|
||||||
|
pos += closeTag.length;
|
||||||
|
return { name, value: children };
|
||||||
|
}
|
||||||
|
if (pos >= cleaned.length) {
|
||||||
|
throw new Error(`xmlToObject: EOF while reading children of <${name}>`);
|
||||||
|
}
|
||||||
|
const child = parseElement();
|
||||||
|
children[child.name] = child.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaf text (may be empty or whitespace-only).
|
||||||
|
pos = savedPos;
|
||||||
|
const closeIdx = cleaned.indexOf(closeTag, pos);
|
||||||
|
if (closeIdx < 0) throw new Error(`xmlToObject: missing </${name}>`);
|
||||||
|
const rawText = cleaned.slice(pos, closeIdx);
|
||||||
|
pos = closeIdx + closeTag.length;
|
||||||
|
return { name, value: decodeEntities(rawText.trim()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = parseElement();
|
||||||
|
return { [root.name]: root.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeEntities(s) {
|
||||||
|
return s
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
|
||||||
|
.replace(/&/g, '&'); // must run last
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── High-level: raw parsed tree → structured status object ─────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the two known-shape reboot line formats:
|
||||||
|
* "2026-07-02 13:11:46 (164) Normal Reboot (21) Firmware Version 05-01-03-0101-09"
|
||||||
|
* "2026-07-02 12:54:12 (161) Power Loss (80) Firmware Version 05-01-03-0101-09"
|
||||||
|
*
|
||||||
|
* Returns null for unrecognized shapes so callers can pass the raw
|
||||||
|
* string through instead of dropping the entry.
|
||||||
|
*/
|
||||||
|
export function parseRebootLine(line) {
|
||||||
|
if (typeof line !== 'string' || !line.trim()) return null;
|
||||||
|
const m = line.match(
|
||||||
|
/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})\s+\((\d+)\)\s+(.+?)\s+\((\d+)\)\s+Firmware Version\s+(\S+)\s*$/
|
||||||
|
);
|
||||||
|
if (!m) return { raw: line, unrecognized: true };
|
||||||
|
const [, date, time, seq, reasonName, reasonCode, firmware] = m;
|
||||||
|
return {
|
||||||
|
at: `${date}T${time}`, // ISO-ish local time (device doesn't include a TZ)
|
||||||
|
sequence: Number(seq),
|
||||||
|
reasonName: reasonName.trim(),
|
||||||
|
reasonCode: Number(reasonCode),
|
||||||
|
firmwareAtBoot: firmware,
|
||||||
|
raw: line,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take raw /admin/status.xml text and return a normalized, grouped
|
||||||
|
* status object. Missing sections come back as `null` (or empty
|
||||||
|
* collections where an array/object shape is expected) rather than
|
||||||
|
* throwing — the DBS-210 sometimes omits sections depending on
|
||||||
|
* multi-cell role, and callers should be able to reason about
|
||||||
|
* partial data.
|
||||||
|
*
|
||||||
|
* @param {string} xml
|
||||||
|
* @returns {object} structured status
|
||||||
|
*/
|
||||||
|
export function parseStatusXml(xml) {
|
||||||
|
const tree = xmlToObject(xml);
|
||||||
|
const root = tree.Status || {};
|
||||||
|
const sys = (typeof root === 'object' && root.System_Information) || {};
|
||||||
|
const stats = (typeof root === 'object' && root.Statistics) || {};
|
||||||
|
|
||||||
|
const rebootLog = collectRebootLog(sys.Reboot_Log);
|
||||||
|
const rtp = sys.RTP_Usage || {};
|
||||||
|
const netStats = stats.Network_Statistics || {};
|
||||||
|
const emergency = extractEmergencyNumbers(sys.Emergency_Calls);
|
||||||
|
const deviceFwu = extractDeviceFwu(sys.Device_FWU_Info);
|
||||||
|
const rssi = extractRssiList(sys.RSSI_List);
|
||||||
|
|
||||||
|
return {
|
||||||
|
device: {
|
||||||
|
model: str(sys.Phone_Type), // "IPDECT-V2 (DBS-210-3PC)"
|
||||||
|
systemType: str(sys.System_Type), // "Generic SIP (RFC 3261)"
|
||||||
|
unitName: str(sys.Unit_Name), // "SME VoIP"
|
||||||
|
unitIndex: str(sys.Unit_Index), // "Base Idx:0"
|
||||||
|
rfBand: str(sys.RF_Band), // "US"
|
||||||
|
productConfiguration: str(sys.Product_Configuration),
|
||||||
|
macAddress: normalizeMac(str(sys.MAC_Address)),
|
||||||
|
ipAddress: str(sys.IP_Address),
|
||||||
|
rfpiAddress: str(sys.RFPI_Address), // "13508C9C; RPN:00"
|
||||||
|
releasedBuild: yesNo(sys.Released_Build),
|
||||||
|
},
|
||||||
|
firmware: {
|
||||||
|
version: str(sys.Firmware_Version), // "IPDECT-V2/05-01-03-0101-09/18-Mar-2026 10:29"
|
||||||
|
updateServer: str(sys.Firmware_URL?.Update_Server_Address),
|
||||||
|
updatePath: str(sys.Firmware_URL?.Path),
|
||||||
|
requiredFor: deviceFwu,
|
||||||
|
},
|
||||||
|
time: {
|
||||||
|
currentLocalTime: str(sys.Current_Local_Time),
|
||||||
|
operatingTime: str(sys.Operating_Time),
|
||||||
|
operatingTimeSeconds: parseOperatingSeconds(sys.Operating_Time),
|
||||||
|
},
|
||||||
|
multiCell: parseMultiCell(sys.Multi_Cell),
|
||||||
|
baseStatus: (str(sys.Base_Station_Status) || '').toLowerCase() || null,
|
||||||
|
conflictInfo: str(sys.Conflict_Info),
|
||||||
|
security: {
|
||||||
|
customCa: {
|
||||||
|
provisioningStatus: str(sys.Custom_CA_Status?.Custom_CA_Provisioning_Status),
|
||||||
|
info: str(sys.Custom_CA_Status?.Custom_CA_Info),
|
||||||
|
installed: (str(sys.Custom_CA_Status?.Custom_CA_Info) || '').toLowerCase() !== 'not installed',
|
||||||
|
},
|
||||||
|
dot1x: {
|
||||||
|
transactionStatus: str(sys.Dot1x_Authentication?.Transaction_status),
|
||||||
|
protocol: str(sys.Dot1x_Authentication?.Protocol),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rebootLog,
|
||||||
|
rtp: {
|
||||||
|
total: numOrNull(rtp.Total_RTP),
|
||||||
|
max: numOrNull(rtp.Max_RTP),
|
||||||
|
current: numOrNull(rtp.Current_RTP),
|
||||||
|
currentLocal: numOrNull(rtp.Current_Local_RTP),
|
||||||
|
currentRelay: numOrNull(rtp.Current_Relay_RTP),
|
||||||
|
remoteRelay: numOrNull(rtp.Remote_Relay_RTP),
|
||||||
|
currentRecording: numOrNull(rtp.Current_Recording),
|
||||||
|
timeInMaxRtp: str(rtp.Time_In_Max_RTP),
|
||||||
|
},
|
||||||
|
network: {
|
||||||
|
txPackets: numOrNull(netStats.Tx_Packets),
|
||||||
|
txBlocked: numOrNull(netStats.Tx_Blocked),
|
||||||
|
txDropped: numOrNull(netStats.Tx_Dropped),
|
||||||
|
txErrors: numOrNull(netStats.Tx_Errors),
|
||||||
|
txBroadcasts: numOrNull(netStats.Tx_Broadcasts),
|
||||||
|
rxPackets: numOrNull(netStats.Rx_Packets),
|
||||||
|
rxBlocked: numOrNull(netStats.Rx_Blocked),
|
||||||
|
rxDropped: numOrNull(netStats.Rx_Dropped),
|
||||||
|
rxErrors: numOrNull(netStats.Rx_Errors),
|
||||||
|
rxBroadcasts: numOrNull(netStats.Rx_Broadcasts),
|
||||||
|
},
|
||||||
|
emergencyNumbers: emergency,
|
||||||
|
rssi,
|
||||||
|
features: {
|
||||||
|
pushToTalk: (str(sys.Push_To_Talk) || '').toLowerCase() === 'on',
|
||||||
|
},
|
||||||
|
// The Statistics/Header_Line_Idx tag is a CSV schema descriptor
|
||||||
|
// for a companion (per-RPN) statistics section we haven't
|
||||||
|
// captured yet. Kept as raw so a future collector can align to
|
||||||
|
// it without reparsing here.
|
||||||
|
_rawStatisticsHeader: str(stats.Header_Line_Idx),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Health verdict ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Cisco reboot reason codes. Not documented publicly — collected
|
||||||
|
// empirically from the DBS-210 sample and from Cisco community posts.
|
||||||
|
// We only classify the ones we've actually observed so a new code
|
||||||
|
// shows up as "unknown" instead of being silently reclassified.
|
||||||
|
const KNOWN_REBOOT_CODES = new Map([
|
||||||
|
[21, { key: 'normal', severity: 'info', label: 'Normal reboot (admin- or firmware-initiated)' }],
|
||||||
|
[80, { key: 'power-loss', severity: 'warn', label: 'Power loss — mains interruption or PoE glitch' }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute a pure-function health verdict from a parsed status object.
|
||||||
|
* No I/O. Returns { healthy, warnings, info } where warnings is an
|
||||||
|
* array of user-facing strings. Consumers decide how to render.
|
||||||
|
*/
|
||||||
|
export function summarizeBaseHealth(parsed) {
|
||||||
|
const warnings = [];
|
||||||
|
const info = [];
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
return { healthy: false, warnings: ['No status data parsed'], info: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uptime — under 10 minutes = very recent reboot, worth flagging.
|
||||||
|
const uptimeSec = parsed.time?.operatingTimeSeconds;
|
||||||
|
if (Number.isFinite(uptimeSec) && uptimeSec < 600) {
|
||||||
|
warnings.push(`Base rebooted very recently (uptime ${Math.round(uptimeSec / 60)} min)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reboot log — surface any power-loss in the last 6 boots (that's
|
||||||
|
// literally as far back as the device remembers).
|
||||||
|
const powerLosses = (parsed.rebootLog || []).filter((r) => r.reasonCode === 80);
|
||||||
|
if (powerLosses.length > 0) {
|
||||||
|
warnings.push(
|
||||||
|
`${powerLosses.length} recent power-loss reboot(s); most recent at ${powerLosses[0].at}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// RF conflict — non-"No Conflict" means DECT interference detected.
|
||||||
|
const conflict = parsed.conflictInfo || '';
|
||||||
|
if (conflict && conflict.toLowerCase() !== 'no conflict') {
|
||||||
|
warnings.push(`DECT RF conflict reported: ${conflict}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network drops. Non-zero rx_dropped is the classic "your switch
|
||||||
|
// port is misconfigured / the base is overwhelmed" signal.
|
||||||
|
const rxDropped = parsed.network?.rxDropped;
|
||||||
|
if (Number.isFinite(rxDropped) && rxDropped > 0) {
|
||||||
|
info.push(`Rx dropped packets: ${rxDropped} since last boot`);
|
||||||
|
}
|
||||||
|
const rxErrors = parsed.network?.rxErrors;
|
||||||
|
if (Number.isFinite(rxErrors) && rxErrors > 0) {
|
||||||
|
warnings.push(`Rx errors: ${rxErrors} since last boot`);
|
||||||
|
}
|
||||||
|
const txErrors = parsed.network?.txErrors;
|
||||||
|
if (Number.isFinite(txErrors) && txErrors > 0) {
|
||||||
|
warnings.push(`Tx errors: ${txErrors} since last boot`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 802.1X — if enabled (protocol != 'N/A') and status isn't 'Authenticated'
|
||||||
|
// we flag it. The DBS-210 emits 'Unavailable' when 802.1X is off, so
|
||||||
|
// we specifically ignore that state.
|
||||||
|
const dot1xStatus = (parsed.security?.dot1x?.transactionStatus || '').toLowerCase();
|
||||||
|
const dot1xProto = (parsed.security?.dot1x?.protocol || '').toLowerCase();
|
||||||
|
if (dot1xProto && dot1xProto !== 'n/a' && dot1xStatus && dot1xStatus !== 'authenticated' && dot1xStatus !== 'unavailable') {
|
||||||
|
warnings.push(`802.1X in state "${parsed.security.dot1x.transactionStatus}" (protocol ${parsed.security.dot1x.protocol})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom CA — informational only; some fleets never install one.
|
||||||
|
if (parsed.security?.customCa?.installed) {
|
||||||
|
info.push(`Custom CA installed: ${parsed.security.customCa.info}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
healthy: warnings.length === 0,
|
||||||
|
warnings,
|
||||||
|
info,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function str(v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (typeof v === 'string') return v;
|
||||||
|
return null; // an object where a string was expected → treat as absent
|
||||||
|
}
|
||||||
|
|
||||||
|
function numOrNull(v) {
|
||||||
|
if (v == null || v === '') return null;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function yesNo(v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
return String(v).trim().toLowerCase() === 'yes';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMac(mac) {
|
||||||
|
if (!mac) return null;
|
||||||
|
const hex = mac.replace(/[^0-9a-fA-F]/g, '').toLowerCase();
|
||||||
|
if (hex.length !== 12) return mac; // not the expected 12-hex form, pass through
|
||||||
|
return hex.match(/../g).join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi_Cell field is e.g.:
|
||||||
|
* "Unchained(TXT_STATE_UNCHAINED) Allowed to Join as Secondary"
|
||||||
|
* "Primary(TXT_STATE_PRIMARY) ..."
|
||||||
|
* "Secondary(TXT_STATE_SECONDARY) ..."
|
||||||
|
* We normalize to a role token + keep the flavor text.
|
||||||
|
*/
|
||||||
|
function parseMultiCell(raw) {
|
||||||
|
if (!raw) return { role: null, raw: null };
|
||||||
|
const s = String(raw);
|
||||||
|
const m = s.match(/^([A-Za-z]+)/);
|
||||||
|
return {
|
||||||
|
role: m ? m[1].toLowerCase() : null, // "unchained" | "primary" | "secondary"
|
||||||
|
raw: s,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operating_Time is formatted as "H:M:S" (e.g. "00:10:20 (H:M:S)").
|
||||||
|
* Convert to seconds. Returns null if unrecognized.
|
||||||
|
*/
|
||||||
|
function parseOperatingSeconds(v) {
|
||||||
|
if (!v) return null;
|
||||||
|
const m = String(v).match(/(\d+):(\d+):(\d+)/);
|
||||||
|
if (!m) return null;
|
||||||
|
const [, h, mi, s] = m;
|
||||||
|
return Number(h) * 3600 + Number(mi) * 60 + Number(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectRebootLog(rebootLogNode) {
|
||||||
|
if (!rebootLogNode || typeof rebootLogNode !== 'object') return [];
|
||||||
|
// Reboot_Line_1..N — sort by their numeric suffix and drop empties.
|
||||||
|
const entries = Object.entries(rebootLogNode)
|
||||||
|
.filter(([k]) => /^Reboot_Line_\d+$/.test(k))
|
||||||
|
.sort(([a], [b]) => Number(a.match(/\d+$/)[0]) - Number(b.match(/\d+$/)[0]))
|
||||||
|
.map(([, v]) => v);
|
||||||
|
return entries
|
||||||
|
.filter((v) => v && typeof v === 'string' && v.trim())
|
||||||
|
.map(parseRebootLine)
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractEmergencyNumbers(node) {
|
||||||
|
if (!node || typeof node !== 'object') return [];
|
||||||
|
return Object.entries(node)
|
||||||
|
.filter(([k]) => /^Emergency_Number_\d+$/.test(k))
|
||||||
|
.sort(([a], [b]) => Number(a.match(/\d+$/)[0]) - Number(b.match(/\d+$/)[0]))
|
||||||
|
.map(([, v]) => (typeof v === 'string' ? v.trim() : ''))
|
||||||
|
.filter((v) => v && !/^no number set/i.test(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDeviceFwu(node) {
|
||||||
|
if (!node || typeof node !== 'object') return {};
|
||||||
|
const out = {};
|
||||||
|
for (const [k, v] of Object.entries(node)) {
|
||||||
|
if (typeof v !== 'string') continue;
|
||||||
|
// "Base type:DBS-210-3PC - Required Version:501 Required Branch:309"
|
||||||
|
// "Device type:6825 - Required Version:501 Required Branch:308 Language Pack:6825_default"
|
||||||
|
const typeMatch = v.match(/(?:Base type|Device type):([^\s]+)/);
|
||||||
|
const versionMatch = v.match(/Required Version:(\S+)/);
|
||||||
|
const branchMatch = v.match(/Required Branch:(\S+)/);
|
||||||
|
const langMatch = v.match(/Language Pack:(\S+)/);
|
||||||
|
if (typeMatch) {
|
||||||
|
out[typeMatch[1]] = {
|
||||||
|
requiredVersion: versionMatch ? versionMatch[1] : null,
|
||||||
|
requiredBranch: branchMatch ? branchMatch[1] : null,
|
||||||
|
languagePack: langMatch ? langMatch[1] : null,
|
||||||
|
_sourceKey: k,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractRssiList(node) {
|
||||||
|
if (!node || typeof node !== 'object') return [];
|
||||||
|
// RSSI_List can be empty (as in our sample) or contain child
|
||||||
|
// <RPN_X> entries. Whatever's here, we surface raw and let a
|
||||||
|
// future parser refine once we've seen a populated example.
|
||||||
|
const keys = Object.keys(node);
|
||||||
|
if (keys.length === 0) return [];
|
||||||
|
return keys.map((k) => ({ key: k, value: node[k] }));
|
||||||
|
}
|
||||||
9
package-lock.json
generated
9
package-lock.json
generated
|
|
@ -15,7 +15,8 @@
|
||||||
"form-data": "^4.0.5",
|
"form-data": "^4.0.5",
|
||||||
"graphql-request": "^7.4.0",
|
"graphql-request": "^7.4.0",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"webex-node-bot-framework": "^2.5.1"
|
"webex-node-bot-framework": "^2.5.1",
|
||||||
|
"ws": "^8.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.4"
|
"nodemon": "^3.1.4"
|
||||||
|
|
@ -12451,9 +12452,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.19.0",
|
"version": "8.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,9 @@
|
||||||
"docker:build": "docker compose build",
|
"docker:build": "docker compose build",
|
||||||
"docker:up": "docker compose up -d",
|
"docker:up": "docker compose up -d",
|
||||||
"docker:down": "docker compose down",
|
"docker:down": "docker compose down",
|
||||||
"docker:logs": "docker compose logs -f"
|
"docker:logs": "docker compose logs -f",
|
||||||
|
"package:relay": "bash dect-relay-agent/bundle.sh",
|
||||||
|
"test": "node --test tests/*.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"async-mutex": "^0.5.0",
|
"async-mutex": "^0.5.0",
|
||||||
|
|
@ -23,7 +25,8 @@
|
||||||
"form-data": "^4.0.5",
|
"form-data": "^4.0.5",
|
||||||
"graphql-request": "^7.4.0",
|
"graphql-request": "^7.4.0",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"webex-node-bot-framework": "^2.5.1"
|
"webex-node-bot-framework": "^2.5.1",
|
||||||
|
"ws": "^8.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.4"
|
"nodemon": "^3.1.4"
|
||||||
|
|
|
||||||
168
scripts/lib/webexBulk.js
Normal file
168
scripts/lib/webexBulk.js
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
// scripts/lib/webexBulk.js
|
||||||
|
//
|
||||||
|
// Shared utilities for bulk Webex admin scripts driven off Control Hub
|
||||||
|
// CSV exports (reclaimWebexHosts.js, removeAdvancedMessaging.js, etc.).
|
||||||
|
// Kept intentionally dependency-free — everything the operator needs is
|
||||||
|
// already in the repo (WebexClient, logger). No dev deps to install.
|
||||||
|
//
|
||||||
|
// Contents:
|
||||||
|
// CSV
|
||||||
|
// parseCsvLine(line) → string[]
|
||||||
|
// readCsv(path) → { header, rows }
|
||||||
|
// detectFormat(header) → 'meetings-inactive' | 'users-export' | null
|
||||||
|
// FORMAT_* constants
|
||||||
|
//
|
||||||
|
// Concurrency + retry
|
||||||
|
// runPool(items, limit, worker) → results[] with { ok, value? , error? }
|
||||||
|
// callWithRetry(fn, opts) → retries 429/503 with Retry-After
|
||||||
|
//
|
||||||
|
// Webex helpers
|
||||||
|
// fetchAllLicenses() → all org licenses
|
||||||
|
// fetchSiteLicenses(siteUrl) → subset with siteUrl matching (case-insensitive)
|
||||||
|
// seatsFree(license) → number
|
||||||
|
// explainWebexError(err) → concise `${apiMsg} (HTTP ${status})`
|
||||||
|
//
|
||||||
|
// All Webex calls go through the shared WebexClient singleton which
|
||||||
|
// handles service-app token refresh; nothing to configure per-script.
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import webex from '../../integrations/webex/WebexClient.js';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// CSV parsing (RFC 4180-ish; handles quoted fields, escaped "")
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function parseCsvLine(line) {
|
||||||
|
const cells = [];
|
||||||
|
let cur = '';
|
||||||
|
let inQ = false;
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const c = line[i];
|
||||||
|
if (inQ) {
|
||||||
|
if (c === '"' && line[i + 1] === '"') { cur += '"'; i++; }
|
||||||
|
else if (c === '"') inQ = false;
|
||||||
|
else cur += c;
|
||||||
|
} else {
|
||||||
|
if (c === '"') inQ = true;
|
||||||
|
else if (c === ',') { cells.push(cur); cur = ''; }
|
||||||
|
else cur += c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cells.push(cur);
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readCsv(filePath) {
|
||||||
|
const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
|
||||||
|
const lines = raw.split(/\r?\n/).filter((l) => l.length > 0);
|
||||||
|
if (lines.length === 0) return { header: [], rows: [] };
|
||||||
|
// Preserve original header text — Control Hub exports vary between
|
||||||
|
// UPPER_SNAKE and Title Case With Punctuation, and Users Export
|
||||||
|
// license columns are literally "aeo2go.webex.com - WebEx Meetings
|
||||||
|
// Free [Sub601269]". Case-preserving avoids ambiguity.
|
||||||
|
const header = parseCsvLine(lines[0]).map((h) => h.trim());
|
||||||
|
const rows = lines.slice(1).map((l) => {
|
||||||
|
const cells = parseCsvLine(l);
|
||||||
|
const row = {};
|
||||||
|
for (let i = 0; i < header.length; i++) row[header[i]] = cells[i] ?? '';
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
return { header, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Format detection
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const FORMAT_MEETINGS_INACTIVE = 'meetings-inactive';
|
||||||
|
export const FORMAT_USERS_EXPORT = 'users-export';
|
||||||
|
|
||||||
|
export function detectFormat(header) {
|
||||||
|
const set = new Set(header);
|
||||||
|
if (set.has('EMAIL') && set.has('IS_HOST') && set.has('DAYS_SINCE_LAST_ACTIVE')) {
|
||||||
|
return FORMAT_MEETINGS_INACTIVE;
|
||||||
|
}
|
||||||
|
if (set.has('User ID/Email (Required)') && set.has('Days since Last Service Accessed')) {
|
||||||
|
return FORMAT_USERS_EXPORT;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Bounded-concurrency worker pool
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Runs `worker(item, idx)` across `items` with at most `limit` in flight.
|
||||||
|
// Never throws — each slot in the result array is either `{ok: true, value}`
|
||||||
|
// or `{ok: false, error}` so the caller can accumulate a per-item report.
|
||||||
|
|
||||||
|
export async function runPool(items, limit, worker) {
|
||||||
|
const results = new Array(items.length);
|
||||||
|
let idx = 0;
|
||||||
|
const workers = new Array(Math.min(limit, items.length)).fill(null).map(async () => {
|
||||||
|
while (true) {
|
||||||
|
const i = idx++;
|
||||||
|
if (i >= items.length) return;
|
||||||
|
try {
|
||||||
|
results[i] = { ok: true, value: await worker(items[i], i) };
|
||||||
|
} catch (err) {
|
||||||
|
results[i] = { ok: false, error: err };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(workers);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 429/503-aware retry helper. Honors Retry-After (seconds).
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function callWithRetry(fn, { tries = 4, baseDelayMs = 500 } = {}) {
|
||||||
|
let lastErr;
|
||||||
|
for (let attempt = 0; attempt < tries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err;
|
||||||
|
const status = err?.response?.status;
|
||||||
|
if (status !== 429 && status !== 503) throw err;
|
||||||
|
const retryAfter = Number(err?.response?.headers?.['retry-after']);
|
||||||
|
const wait = Number.isFinite(retryAfter) && retryAfter > 0
|
||||||
|
? retryAfter * 1000
|
||||||
|
: baseDelayMs * Math.pow(2, attempt);
|
||||||
|
await new Promise((r) => setTimeout(r, wait));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Webex license helpers
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function fetchAllLicenses() {
|
||||||
|
const data = await webex.listLicenses();
|
||||||
|
return Array.isArray(data?.items) ? data.items : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSiteLicenses(siteUrl) {
|
||||||
|
const items = await fetchAllLicenses();
|
||||||
|
const want = (siteUrl || '').toLowerCase();
|
||||||
|
return items.filter((l) => (l.siteUrl || '').toLowerCase() === want);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seatsFree(l) {
|
||||||
|
const total = Number(l.totalUnits ?? 0);
|
||||||
|
const used = Number(l.consumedUnits ?? 0);
|
||||||
|
return Math.max(0, total - used);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function explainWebexError(err) {
|
||||||
|
const status = err?.response?.status;
|
||||||
|
const apiMsg =
|
||||||
|
err?.response?.data?.message ||
|
||||||
|
err?.response?.data?.errors?.[0]?.description ||
|
||||||
|
err?.message ||
|
||||||
|
String(err);
|
||||||
|
return status ? `${apiMsg} (HTTP ${status})` : apiMsg;
|
||||||
|
}
|
||||||
528
scripts/reclaimWebexHosts.js
Normal file
528
scripts/reclaimWebexHosts.js
Normal file
|
|
@ -0,0 +1,528 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Reclaim Webex Meetings host licenses from long-inactive users.
|
||||||
|
*
|
||||||
|
* Accepts either of two Control Hub exports:
|
||||||
|
*
|
||||||
|
* 1. "Meetings Inactive Users" (Analyzer → Meetings → Inactive Users)
|
||||||
|
* Columns include EMAIL, IS_HOST, DAYS_SINCE_LAST_ACTIVE.
|
||||||
|
* Candidate rule: IS_HOST=Y AND DAYS_SINCE_LAST_ACTIVE > --min-days.
|
||||||
|
*
|
||||||
|
* 2. "Users Export" (Users → Manage users → Export)
|
||||||
|
* Columns include "User ID/Email (Required)", "User Status",
|
||||||
|
* "Days since Last Service Accessed", one TRUE/FALSE column per
|
||||||
|
* license (e.g. "aeo2go.webex.com - WebEx Meetings Free [SubXXX]").
|
||||||
|
* Candidate rule: User Status ∈ {Inactive, Verified}.
|
||||||
|
* - "Inactive" = active user that Webex has flagged idle
|
||||||
|
* - "Verified" = never signed in
|
||||||
|
* "Days since Last Service Accessed" is NOT used for this format
|
||||||
|
* (a Verified user has never logged in, so days is blank), and
|
||||||
|
* --min-days is therefore ignored.
|
||||||
|
*
|
||||||
|
* Format is auto-detected from the header. There's no host flag in
|
||||||
|
* format #2, but we don't need one: detection of "currently holds the
|
||||||
|
* host license" is authoritative, done by fetching the host license's
|
||||||
|
* assignee roster once up-front and cross-referencing the CSV emails.
|
||||||
|
* Anyone in the CSV who isn't currently a holder is silently skipped,
|
||||||
|
* and the personId comes straight off the assignee record (no per-user
|
||||||
|
* /people search needed).
|
||||||
|
*
|
||||||
|
* The mutation is one PATCH `/v1/licenses/users` per user:
|
||||||
|
* 1. remove the configured host license on the target site, and
|
||||||
|
* 2. atomically add either a specific "free tier" license, or an
|
||||||
|
* attendee-only siteUrl on the site (accountType=attendee).
|
||||||
|
* Users are never briefly license-less.
|
||||||
|
*
|
||||||
|
* DRY-RUN by default. Nothing mutates without `--execute`. In dry-run,
|
||||||
|
* the script lists every license on the site so you can pick the
|
||||||
|
* `--free-license-id` (or decide to use `--free-attendee` instead).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/reclaimWebexHosts.js \
|
||||||
|
* --csv "/path/to/<report>.csv" \
|
||||||
|
* [--site aeo2go.webex.com] \
|
||||||
|
* [--min-days 120] \
|
||||||
|
* [--host-license-id <id>] \
|
||||||
|
* [--free-license-id <id> | --free-attendee] \
|
||||||
|
* [--concurrency 5] \
|
||||||
|
* [--limit N] [--offset N] \
|
||||||
|
* [--report reclaim-report.csv] \
|
||||||
|
* [--execute]
|
||||||
|
*
|
||||||
|
* Environment defaults (read from .env):
|
||||||
|
* WEBEX_HOST_SITE_URL → --site (default aeo2go.webex.com)
|
||||||
|
* WEBEX_HOST_LICENSE_ID → --host-license-id
|
||||||
|
* WEBEX_FREE_LICENSE_ID → --free-license-id (optional)
|
||||||
|
*
|
||||||
|
* Required Webex service-app scopes:
|
||||||
|
* spark-admin:licenses_read
|
||||||
|
* spark-admin:people_read
|
||||||
|
* spark-admin:people_write
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'dotenv/config';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
import webex from '../integrations/webex/WebexClient.js';
|
||||||
|
import {
|
||||||
|
readCsv,
|
||||||
|
detectFormat,
|
||||||
|
FORMAT_MEETINGS_INACTIVE,
|
||||||
|
FORMAT_USERS_EXPORT,
|
||||||
|
runPool,
|
||||||
|
callWithRetry,
|
||||||
|
fetchSiteLicenses,
|
||||||
|
seatsFree,
|
||||||
|
explainWebexError,
|
||||||
|
} from './lib/webexBulk.js';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// CLI parsing
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const out = {
|
||||||
|
csv: null,
|
||||||
|
site: process.env.WEBEX_HOST_SITE_URL || 'aeo2go.webex.com',
|
||||||
|
minDays: 120,
|
||||||
|
hostLicenseId: process.env.WEBEX_HOST_LICENSE_ID || null,
|
||||||
|
freeLicenseId: process.env.WEBEX_FREE_LICENSE_ID || null,
|
||||||
|
freeAttendee: false,
|
||||||
|
concurrency: 5,
|
||||||
|
limit: null,
|
||||||
|
offset: 0,
|
||||||
|
report: null,
|
||||||
|
execute: false,
|
||||||
|
help: false,
|
||||||
|
};
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
const next = () => argv[++i];
|
||||||
|
switch (a) {
|
||||||
|
case '--csv': out.csv = next(); break;
|
||||||
|
case '--site': out.site = next().toLowerCase(); break;
|
||||||
|
case '--min-days': out.minDays = Number(next()); break;
|
||||||
|
case '--host-license-id': out.hostLicenseId = next(); break;
|
||||||
|
case '--free-license-id': out.freeLicenseId = next(); break;
|
||||||
|
case '--free-attendee': out.freeAttendee = true; break;
|
||||||
|
case '--concurrency': out.concurrency = Math.max(1, Number(next())); break;
|
||||||
|
case '--limit': out.limit = Number(next()); break;
|
||||||
|
case '--offset': out.offset = Number(next()); break;
|
||||||
|
case '--report': out.report = next(); break;
|
||||||
|
case '--execute': out.execute = true; break;
|
||||||
|
case '-h': case '--help': out.help = true; break;
|
||||||
|
default:
|
||||||
|
if (a.startsWith('--')) {
|
||||||
|
console.error(`Unknown flag: ${a}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHelp() {
|
||||||
|
// Print the file's top comment block so `--help` matches source docs.
|
||||||
|
const src = fs.readFileSync(new URL(import.meta.url), 'utf8');
|
||||||
|
const m = src.match(/\/\*\*([\s\S]*?)\*\//);
|
||||||
|
if (m) console.log(m[1].replace(/^\s*\*\s?/gm, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Candidate extraction (CSV parsing, format detection, pool/retry helpers,
|
||||||
|
// and Webex license/error helpers live in ./lib/webexBulk.js)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Users Export status values Webex considers "not currently in use".
|
||||||
|
// "Active" users are excluded regardless of last-access date.
|
||||||
|
const USERS_EXPORT_ELIGIBLE_STATUSES = new Set(['Inactive', 'Verified']);
|
||||||
|
|
||||||
|
function extractCandidates(format, rows, minDays) {
|
||||||
|
const candidates = [];
|
||||||
|
const skip = {
|
||||||
|
notHost: 0,
|
||||||
|
recent: 0,
|
||||||
|
blankDays: 0,
|
||||||
|
blankEmail: 0,
|
||||||
|
activeStatus: 0,
|
||||||
|
unknownStatus: 0,
|
||||||
|
byStatus: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (format === FORMAT_MEETINGS_INACTIVE) {
|
||||||
|
for (const r of rows) {
|
||||||
|
const email = (r.EMAIL || '').trim().toLowerCase();
|
||||||
|
if (!email) { skip.blankEmail++; continue; }
|
||||||
|
const isHost = (r.IS_HOST || '').trim().toUpperCase() === 'Y';
|
||||||
|
if (!isHost) { skip.notHost++; continue; }
|
||||||
|
const daysRaw = (r.DAYS_SINCE_LAST_ACTIVE || '').trim();
|
||||||
|
if (daysRaw === '') { skip.blankDays++; continue; }
|
||||||
|
const days = Number(daysRaw);
|
||||||
|
if (!(days > minDays)) { skip.recent++; continue; }
|
||||||
|
candidates.push({
|
||||||
|
email,
|
||||||
|
days,
|
||||||
|
status: 'Host',
|
||||||
|
firstName: r.FIRST_NAME || '',
|
||||||
|
lastName: r.LAST_NAME || '',
|
||||||
|
lastActive: r.LAST_ACTIVE_DATE || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { candidates, skip };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === FORMAT_USERS_EXPORT) {
|
||||||
|
for (const r of rows) {
|
||||||
|
const email = (r['User ID/Email (Required)'] || '').trim().toLowerCase();
|
||||||
|
if (!email) { skip.blankEmail++; continue; }
|
||||||
|
const status = (r['User Status'] || '').trim();
|
||||||
|
skip.byStatus[status || '(blank)'] = (skip.byStatus[status || '(blank)'] || 0) + 1;
|
||||||
|
if (!USERS_EXPORT_ELIGIBLE_STATUSES.has(status)) {
|
||||||
|
if (status === 'Active') skip.activeStatus++;
|
||||||
|
else skip.unknownStatus++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Days is informational only for this format — a Verified user
|
||||||
|
// has never signed in, so days is blank. Keep it for the audit
|
||||||
|
// record / --report CSV.
|
||||||
|
const daysRaw = (r['Days since Last Service Accessed'] || '').trim();
|
||||||
|
const days = daysRaw === '' ? null : Number(daysRaw);
|
||||||
|
candidates.push({
|
||||||
|
email,
|
||||||
|
days: Number.isFinite(days) ? days : null,
|
||||||
|
status,
|
||||||
|
firstName: r['First Name'] || '',
|
||||||
|
lastName: r['Last Name'] || '',
|
||||||
|
lastActive: r['Last Active Time'] || r['Last Service Accessed Time'] || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { candidates, skip };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unsupported CSV format: ${format}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Host license assignee lookup (this one stays local because reclaim is the
|
||||||
|
// only script that needs to map "email → personId" via the assignee roster)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function fetchHostAssignees(licenseId) {
|
||||||
|
// Returns Map<lowercased-email, { id, displayName, email }>. If an
|
||||||
|
// assignee record has no email (shouldn't happen for internal users)
|
||||||
|
// it's dropped — the CSV keys on email so we couldn't match anyway.
|
||||||
|
const users = await webex.listLicenseAssignees(licenseId);
|
||||||
|
const byEmail = new Map();
|
||||||
|
for (const u of users) {
|
||||||
|
const email = (u?.email || '').toLowerCase();
|
||||||
|
if (!email || !u?.id) continue;
|
||||||
|
byEmail.set(email, { id: u.id, displayName: u.displayName || email, email });
|
||||||
|
}
|
||||||
|
return byEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Main
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
if (args.help) { printHelp(); process.exit(0); }
|
||||||
|
|
||||||
|
if (!args.csv) {
|
||||||
|
console.error('❌ --csv <path> is required. Use --help for usage.');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const csvPath = path.resolve(args.csv);
|
||||||
|
if (!fs.existsSync(csvPath)) {
|
||||||
|
console.error(`❌ CSV not found: ${csvPath}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📄 Reading ${csvPath}`);
|
||||||
|
const { header, rows } = readCsv(csvPath);
|
||||||
|
console.log(` → ${rows.length} rows`);
|
||||||
|
|
||||||
|
const format = detectFormat(header);
|
||||||
|
if (!format) {
|
||||||
|
console.error(
|
||||||
|
`❌ Could not detect CSV format. Supported reports:\n` +
|
||||||
|
` • "Meetings Inactive Users" (columns: EMAIL, IS_HOST, DAYS_SINCE_LAST_ACTIVE)\n` +
|
||||||
|
` • "Users Export" (columns: User ID/Email (Required), Days since Last Service Accessed)\n\n` +
|
||||||
|
`Header seen: ${header.join(', ')}`,
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
console.log(` → detected format: ${format}`);
|
||||||
|
|
||||||
|
const { candidates, skip } = extractCandidates(format, rows, args.minDays);
|
||||||
|
let filterLabel;
|
||||||
|
let skipSummary;
|
||||||
|
if (format === FORMAT_MEETINGS_INACTIVE) {
|
||||||
|
filterLabel = `host + >${args.minDays}d inactive`;
|
||||||
|
skipSummary =
|
||||||
|
`skipped ${skip.notHost} non-host, ${skip.recent} recent, ` +
|
||||||
|
`${skip.blankDays} blank-days, ${skip.blankEmail} blank-email`;
|
||||||
|
} else {
|
||||||
|
filterLabel = `status ∈ {Inactive, Verified}`;
|
||||||
|
const seen = Object.entries(skip.byStatus)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([s, n]) => `${s}=${n}`).join(', ');
|
||||||
|
skipSummary =
|
||||||
|
`skipped ${skip.activeStatus} Active, ${skip.unknownStatus} other-status, ` +
|
||||||
|
`${skip.blankEmail} blank-email (all statuses seen: ${seen})`;
|
||||||
|
}
|
||||||
|
console.log(` → ${candidates.length} candidates (${filterLabel}); ${skipSummary}`);
|
||||||
|
|
||||||
|
// Enumerate the site's licenses so we can (a) verify the host license
|
||||||
|
// id, (b) let the operator pick the free license id in dry-run, and
|
||||||
|
// (c) show seat headroom (relevant if --free-license-id is finite).
|
||||||
|
console.log(`\n🔎 Fetching Webex Meetings licenses on \`${args.site}\`…`);
|
||||||
|
let siteLicenses;
|
||||||
|
try {
|
||||||
|
siteLicenses = await fetchSiteLicenses(args.site);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`❌ Failed to list licenses: ${explainWebexError(err)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (siteLicenses.length === 0) {
|
||||||
|
console.error(`❌ No licenses found on \`${args.site}\`. Wrong site URL?`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(` Licenses on \`${args.site}\`:`);
|
||||||
|
for (const l of siteLicenses) {
|
||||||
|
const free = seatsFree(l);
|
||||||
|
const markers = [];
|
||||||
|
if (l.id === args.hostLicenseId) markers.push('HOST (to reclaim)');
|
||||||
|
if (l.id === args.freeLicenseId) markers.push('FREE (to assign)');
|
||||||
|
const mark = markers.length ? ` ← ${markers.join(', ')}` : '';
|
||||||
|
console.log(` • ${l.name} — ${free}/${l.totalUnits} free — id=${l.id}${mark}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.hostLicenseId) {
|
||||||
|
console.error(
|
||||||
|
`\n❌ --host-license-id not set (and WEBEX_HOST_LICENSE_ID env is empty).\n` +
|
||||||
|
` Pick the "host" license id from the list above and re-run with\n` +
|
||||||
|
` --host-license-id <id>.`,
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const hostLic = siteLicenses.find((l) => l.id === args.hostLicenseId);
|
||||||
|
if (!hostLic) {
|
||||||
|
console.error(
|
||||||
|
`\n❌ --host-license-id ${args.hostLicenseId} does not match any license\n` +
|
||||||
|
` on \`${args.site}\`. Double-check the id.`,
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
let freeLic = null;
|
||||||
|
if (args.freeLicenseId) {
|
||||||
|
freeLic = siteLicenses.find((l) => l.id === args.freeLicenseId);
|
||||||
|
if (!freeLic) {
|
||||||
|
console.error(
|
||||||
|
`\n❌ --free-license-id ${args.freeLicenseId} does not match any license\n` +
|
||||||
|
` on \`${args.site}\`. Double-check the id.`,
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-reference: pull the host license's current assignee roster
|
||||||
|
// and keep only the CSV candidates who actually still hold it.
|
||||||
|
console.log(`\n📥 Fetching current holders of \`${hostLic.name}\` (paginated)…`);
|
||||||
|
let holders;
|
||||||
|
try {
|
||||||
|
holders = await fetchHostAssignees(hostLic.id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`❌ Failed to fetch assignees: ${explainWebexError(err)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(` → ${holders.size} current holders`);
|
||||||
|
|
||||||
|
const toReclaim = [];
|
||||||
|
let skippedNotHolder = 0;
|
||||||
|
for (const c of candidates) {
|
||||||
|
const holder = holders.get(c.email);
|
||||||
|
if (!holder) { skippedNotHolder++; continue; }
|
||||||
|
toReclaim.push({ ...c, personId: holder.id, displayName: holder.displayName });
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
` → ${toReclaim.length} to reclaim ` +
|
||||||
|
`(${skippedNotHolder} CSV candidates no longer hold the license)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Optional slicing for staged rollouts / restart-after-failure.
|
||||||
|
const sliced = toReclaim.slice(args.offset, args.limit ? args.offset + args.limit : undefined);
|
||||||
|
if (sliced.length !== toReclaim.length) {
|
||||||
|
console.log(` → sliced to ${sliced.length} (offset=${args.offset}, limit=${args.limit ?? 'none'})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan sanity: if a specific free license is provided, ensure it has
|
||||||
|
// enough seats for the run. (If it doesn't we still let --execute
|
||||||
|
// proceed, but Webex will start rejecting after seats run out — flag
|
||||||
|
// it up top so the operator can pick a different license or split.)
|
||||||
|
if (freeLic) {
|
||||||
|
const free = seatsFree(freeLic);
|
||||||
|
if (free < sliced.length) {
|
||||||
|
console.warn(
|
||||||
|
`\n⚠️ Free license \`${freeLic.name}\` has ${free} free seats but ` +
|
||||||
|
`${sliced.length} assignments are planned. Extras will fail.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decide what mutation each user will get.
|
||||||
|
const mutation = describeMutation(args, hostLic, freeLic);
|
||||||
|
console.log(`\n🛠 Planned mutation per user: ${mutation.human}`);
|
||||||
|
|
||||||
|
// Dry-run bail-out.
|
||||||
|
if (!args.execute) {
|
||||||
|
console.log(`\n🚦 DRY-RUN (no changes made). Re-run with --execute to commit.`);
|
||||||
|
if (!freeLic && !args.freeAttendee) {
|
||||||
|
console.log(
|
||||||
|
` Note: neither --free-license-id nor --free-attendee provided.\n` +
|
||||||
|
` Pick one before --execute:\n` +
|
||||||
|
` --free-license-id <id> (assigns a specific meetings license)\n` +
|
||||||
|
` --free-attendee (attendee-only on ${args.site}, no license)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (sliced.length > 0) {
|
||||||
|
console.log(`\n Sample of first 5 candidates:`);
|
||||||
|
for (const s of sliced.slice(0, 5)) {
|
||||||
|
const age = s.days == null ? 'never-signed-in' : `${s.days}d inactive`;
|
||||||
|
const st = s.status && s.status !== 'Host' ? ` status=${s.status}` : '';
|
||||||
|
console.log(` - ${s.displayName} <${s.email}> (${age}${st}, personId=${s.personId})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!freeLic && !args.freeAttendee) {
|
||||||
|
console.error(
|
||||||
|
`\n❌ --execute requires one of:\n` +
|
||||||
|
` --free-license-id <id> (assign a specific meetings license)\n` +
|
||||||
|
` --free-attendee (attendee-only on ${args.site}, no license)`,
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute with bounded concurrency + 429 retry.
|
||||||
|
console.log(
|
||||||
|
`\n🚀 EXECUTING against ${sliced.length} users ` +
|
||||||
|
`(concurrency=${args.concurrency}). Ctrl-C to abort.\n`,
|
||||||
|
);
|
||||||
|
logger(
|
||||||
|
'webex:reclaim:audit',
|
||||||
|
`START reclaim: site=${args.site} host=${hostLic.id} ` +
|
||||||
|
`free=${freeLic ? freeLic.id : args.freeAttendee ? 'ATTENDEE' : 'NONE'} ` +
|
||||||
|
`count=${sliced.length} csv=${path.basename(csvPath)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let processed = 0;
|
||||||
|
const results = await runPool(sliced, args.concurrency, async (user) => {
|
||||||
|
const body = {
|
||||||
|
personId: user.personId,
|
||||||
|
licenses: [{ id: hostLic.id, operation: 'remove' }],
|
||||||
|
};
|
||||||
|
if (freeLic) body.licenses.push({ id: freeLic.id, operation: 'add' });
|
||||||
|
if (args.freeAttendee) {
|
||||||
|
body.siteUrls = [{ siteUrl: args.site, accountType: 'attendee', operation: 'add' }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await callWithRetry(() => webex.assignLicensesToUser(body));
|
||||||
|
|
||||||
|
processed++;
|
||||||
|
if (processed % 25 === 0 || processed === sliced.length) {
|
||||||
|
console.log(` … ${processed}/${sliced.length}`);
|
||||||
|
}
|
||||||
|
return resp;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Summarise + audit.
|
||||||
|
let ok = 0;
|
||||||
|
let failed = 0;
|
||||||
|
const failures = [];
|
||||||
|
const perUser = [];
|
||||||
|
for (let i = 0; i < results.length; i++) {
|
||||||
|
const r = results[i];
|
||||||
|
const u = sliced[i];
|
||||||
|
if (r.ok) {
|
||||||
|
ok++;
|
||||||
|
const grantedIds = new Set(r.value?.licenses || []);
|
||||||
|
const removedOk = !new Set(r.value?.licenses || []).has(hostLic.id);
|
||||||
|
const freeOk = !freeLic || grantedIds.has(freeLic.id);
|
||||||
|
const outcome = removedOk && freeOk ? 'granted' : 'partial';
|
||||||
|
logger(
|
||||||
|
'webex:reclaim:audit',
|
||||||
|
`OK ${u.email} personId=${u.personId} outcome=${outcome}`,
|
||||||
|
);
|
||||||
|
perUser.push({
|
||||||
|
email: u.email,
|
||||||
|
displayName: u.displayName,
|
||||||
|
status: u.status || '',
|
||||||
|
days_inactive: u.days == null ? '' : u.days,
|
||||||
|
personId: u.personId,
|
||||||
|
outcome,
|
||||||
|
error: '',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
failed++;
|
||||||
|
const msg = explainWebexError(r.error);
|
||||||
|
failures.push({ user: u, msg });
|
||||||
|
logger(
|
||||||
|
'webex:reclaim:audit',
|
||||||
|
`FAIL ${u.email} personId=${u.personId}: ${msg}`,
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
perUser.push({
|
||||||
|
email: u.email,
|
||||||
|
displayName: u.displayName,
|
||||||
|
status: u.status || '',
|
||||||
|
days_inactive: u.days == null ? '' : u.days,
|
||||||
|
personId: u.personId,
|
||||||
|
outcome: 'error',
|
||||||
|
error: msg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✅ Done. ${ok} succeeded, ${failed} failed, ${sliced.length} total.`);
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.log(`\nFirst up to 10 failures:`);
|
||||||
|
for (const f of failures.slice(0, 10)) {
|
||||||
|
console.log(` - ${f.user.email}: ${f.msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.report) {
|
||||||
|
const reportPath = path.resolve(args.report);
|
||||||
|
const cols = ['email', 'displayName', 'status', 'days_inactive', 'personId', 'outcome', 'error'];
|
||||||
|
const escape = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`;
|
||||||
|
const lines = [cols.join(',')];
|
||||||
|
for (const p of perUser) lines.push(cols.map((c) => escape(p[c])).join(','));
|
||||||
|
fs.writeFileSync(reportPath, lines.join('\n') + '\n', 'utf8');
|
||||||
|
console.log(`\n📝 Report written to ${reportPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger(
|
||||||
|
'webex:reclaim:audit',
|
||||||
|
`END reclaim: ok=${ok} failed=${failed} total=${sliced.length}`,
|
||||||
|
);
|
||||||
|
process.exit(failed === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeMutation(args, hostLic, freeLic) {
|
||||||
|
const parts = [`remove \`${hostLic.name}\``];
|
||||||
|
if (freeLic) parts.push(`add \`${freeLic.name}\``);
|
||||||
|
else if (args.freeAttendee) parts.push(`add attendee-only on \`${args.site}\``);
|
||||||
|
else parts.push(`(no replacement — TBD)`);
|
||||||
|
return { human: parts.join(', ') };
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(`\n💥 Unhandled: ${err?.stack || err}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
474
scripts/removeAdvancedMessaging.js
Normal file
474
scripts/removeAdvancedMessaging.js
Normal file
|
|
@ -0,0 +1,474 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Bulk-remove Advanced Messaging + Advanced Space Meetings licenses.
|
||||||
|
*
|
||||||
|
* Reads a Control Hub "Users Export" CSV (Users → Manage users →
|
||||||
|
* Export). For every row that currently has either "Advanced Messaging
|
||||||
|
* [SubXXX]" = TRUE or "Advanced Space Meetings [SubXXX]" = TRUE, we
|
||||||
|
* PATCH `/v1/licenses/users` to atomically:
|
||||||
|
* 1. remove the Advanced Messaging license (if user has it), and
|
||||||
|
* 2. remove the Advanced Space Meetings license (if user has it), and
|
||||||
|
* 3. optionally add a Basic Messaging license if `--basic-messaging-
|
||||||
|
* license-id` is provided. Note: in most Webex orgs, "Basic
|
||||||
|
* Messaging" is a derived entitlement that's on automatically for
|
||||||
|
* any user with a base license — you probably do NOT need to add
|
||||||
|
* it explicitly. Removing the Advanced overlay leaves the user
|
||||||
|
* with the basic tier. Use dry-run to see what licenses your org
|
||||||
|
* actually has (the enumeration below filters on names matching
|
||||||
|
* /message|advanced|space|basic/i).
|
||||||
|
*
|
||||||
|
* Detection is authoritative: we fetch the assignee rosters of both
|
||||||
|
* Advanced licenses once up-front (paginated) and cross-reference the
|
||||||
|
* CSV emails. Anyone in the CSV who no longer holds either license is
|
||||||
|
* silently skipped, and personIds come straight off the assignee
|
||||||
|
* records — no per-user /people lookup.
|
||||||
|
*
|
||||||
|
* DRY-RUN by default. Nothing mutates without `--execute`. In dry-run
|
||||||
|
* we enumerate org licenses whose names look messaging-relevant so you
|
||||||
|
* can pick the right IDs.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/removeAdvancedMessaging.js \
|
||||||
|
* --csv "/path/to/AdvanceMessaging.csv" \
|
||||||
|
* [--advanced-messaging-license-id <id>] \
|
||||||
|
* [--advanced-space-meetings-license-id <id>] \
|
||||||
|
* [--basic-messaging-license-id <id>] \
|
||||||
|
* [--concurrency 5] \
|
||||||
|
* [--limit N] [--offset N] \
|
||||||
|
* [--report remove-advmsg-report.csv] \
|
||||||
|
* [--execute]
|
||||||
|
*
|
||||||
|
* Environment defaults (read from .env):
|
||||||
|
* WEBEX_ADV_MSG_LICENSE_ID → --advanced-messaging-license-id
|
||||||
|
* WEBEX_ADV_SPACE_MTG_LICENSE_ID → --advanced-space-meetings-license-id
|
||||||
|
* WEBEX_BASIC_MSG_LICENSE_ID → --basic-messaging-license-id
|
||||||
|
*
|
||||||
|
* Required Webex service-app scopes:
|
||||||
|
* spark-admin:licenses_read
|
||||||
|
* spark-admin:people_read
|
||||||
|
* spark-admin:people_write
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'dotenv/config';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
import webex from '../integrations/webex/WebexClient.js';
|
||||||
|
import {
|
||||||
|
readCsv,
|
||||||
|
detectFormat,
|
||||||
|
FORMAT_USERS_EXPORT,
|
||||||
|
runPool,
|
||||||
|
callWithRetry,
|
||||||
|
fetchAllLicenses,
|
||||||
|
seatsFree,
|
||||||
|
explainWebexError,
|
||||||
|
} from './lib/webexBulk.js';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// CLI parsing
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const out = {
|
||||||
|
csv: null,
|
||||||
|
advancedMessagingLicenseId: process.env.WEBEX_ADV_MSG_LICENSE_ID || null,
|
||||||
|
advancedSpaceMeetingsLicenseId: process.env.WEBEX_ADV_SPACE_MTG_LICENSE_ID || null,
|
||||||
|
basicMessagingLicenseId: process.env.WEBEX_BASIC_MSG_LICENSE_ID || null,
|
||||||
|
concurrency: 5,
|
||||||
|
limit: null,
|
||||||
|
offset: 0,
|
||||||
|
report: null,
|
||||||
|
execute: false,
|
||||||
|
help: false,
|
||||||
|
};
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
const next = () => argv[++i];
|
||||||
|
switch (a) {
|
||||||
|
case '--csv': out.csv = next(); break;
|
||||||
|
case '--advanced-messaging-license-id': out.advancedMessagingLicenseId = next(); break;
|
||||||
|
case '--advanced-space-meetings-license-id': out.advancedSpaceMeetingsLicenseId = next(); break;
|
||||||
|
case '--basic-messaging-license-id': out.basicMessagingLicenseId = next(); break;
|
||||||
|
case '--concurrency': out.concurrency = Math.max(1, Number(next())); break;
|
||||||
|
case '--limit': out.limit = Number(next()); break;
|
||||||
|
case '--offset': out.offset = Number(next()); break;
|
||||||
|
case '--report': out.report = next(); break;
|
||||||
|
case '--execute': out.execute = true; break;
|
||||||
|
case '-h': case '--help': out.help = true; break;
|
||||||
|
default:
|
||||||
|
if (a.startsWith('--')) {
|
||||||
|
console.error(`Unknown flag: ${a}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHelp() {
|
||||||
|
const src = fs.readFileSync(new URL(import.meta.url), 'utf8');
|
||||||
|
const m = src.match(/\/\*\*([\s\S]*?)\*\//);
|
||||||
|
if (m) console.log(m[1].replace(/^\s*\*\s?/gm, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// CSV column resolution
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// The subscription suffix `[Sub601269]` is org-specific. Match by
|
||||||
|
// prefix so orgs with different subscription ids still resolve.
|
||||||
|
|
||||||
|
function findColumn(header, prefix) {
|
||||||
|
const p = prefix.toLowerCase();
|
||||||
|
return header.find((h) => h.toLowerCase().startsWith(p)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTrueCell(v) {
|
||||||
|
return (v || '').trim().toUpperCase() === 'TRUE';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Assignee roster union
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// For each provided license id, fetch its assignee roster and build a
|
||||||
|
// combined Map<email, { personId, displayName, holds: {advMsg, advSpace} }>.
|
||||||
|
// Anyone in either roster ends up here; the `holds` flags tell us
|
||||||
|
// which licenses to actually remove per user.
|
||||||
|
|
||||||
|
async function buildAssigneeUnion({ advMsgId, advSpaceId }) {
|
||||||
|
const union = new Map();
|
||||||
|
|
||||||
|
async function fold(licenseId, holdKey) {
|
||||||
|
if (!licenseId) return;
|
||||||
|
const users = await webex.listLicenseAssignees(licenseId);
|
||||||
|
for (const u of users) {
|
||||||
|
const email = (u?.email || '').toLowerCase();
|
||||||
|
if (!email || !u?.id) continue;
|
||||||
|
const existing = union.get(email);
|
||||||
|
if (existing) {
|
||||||
|
existing.holds[holdKey] = true;
|
||||||
|
} else {
|
||||||
|
union.set(email, {
|
||||||
|
personId: u.id,
|
||||||
|
displayName: u.displayName || email,
|
||||||
|
email,
|
||||||
|
holds: { advMsg: false, advSpace: false, [holdKey]: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fold(advMsgId, 'advMsg');
|
||||||
|
await fold(advSpaceId, 'advSpace');
|
||||||
|
return union;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Main
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
if (args.help) { printHelp(); process.exit(0); }
|
||||||
|
|
||||||
|
if (!args.csv) {
|
||||||
|
console.error('❌ --csv <path> is required. Use --help for usage.');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const csvPath = path.resolve(args.csv);
|
||||||
|
if (!fs.existsSync(csvPath)) {
|
||||||
|
console.error(`❌ CSV not found: ${csvPath}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📄 Reading ${csvPath}`);
|
||||||
|
const { header, rows } = readCsv(csvPath);
|
||||||
|
console.log(` → ${rows.length} rows`);
|
||||||
|
|
||||||
|
const format = detectFormat(header);
|
||||||
|
if (format !== FORMAT_USERS_EXPORT) {
|
||||||
|
console.error(
|
||||||
|
`❌ This script requires a "Users Export" CSV (needs the per-license\n` +
|
||||||
|
` TRUE/FALSE columns). Detected format: ${format ?? 'unknown'}.`,
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
console.log(` → detected format: ${format}`);
|
||||||
|
|
||||||
|
const advMsgCol = findColumn(header, 'Advanced Messaging [');
|
||||||
|
const advSpaceCol = findColumn(header, 'Advanced Space Meetings [');
|
||||||
|
if (!advMsgCol || !advSpaceCol) {
|
||||||
|
console.error(
|
||||||
|
`❌ CSV missing expected license columns:\n` +
|
||||||
|
` Advanced Messaging → ${advMsgCol || '(not found)'}\n` +
|
||||||
|
` Advanced Space Meetings → ${advSpaceCol || '(not found)'}`,
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
console.log(` → license columns: "${advMsgCol}", "${advSpaceCol}"`);
|
||||||
|
|
||||||
|
// Filter to rows that actually need one of the removals. Emails
|
||||||
|
// lowercased for the assignee cross-reference below.
|
||||||
|
const candidates = [];
|
||||||
|
let skipNoLicense = 0;
|
||||||
|
let skipBlankEmail = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
const email = (r['User ID/Email (Required)'] || '').trim().toLowerCase();
|
||||||
|
if (!email) { skipBlankEmail++; continue; }
|
||||||
|
const hasAdvMsg = isTrueCell(r[advMsgCol]);
|
||||||
|
const hasAdvSpace = isTrueCell(r[advSpaceCol]);
|
||||||
|
if (!hasAdvMsg && !hasAdvSpace) { skipNoLicense++; continue; }
|
||||||
|
candidates.push({
|
||||||
|
email,
|
||||||
|
displayName: r['Display Name'] || `${r['First Name'] || ''} ${r['Last Name'] || ''}`.trim() || email,
|
||||||
|
status: (r['User Status'] || '').trim(),
|
||||||
|
csvHasAdvMsg: hasAdvMsg,
|
||||||
|
csvHasAdvSpace: hasAdvSpace,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
` → ${candidates.length} candidates (rows with Adv Messaging=TRUE OR Adv Space Meetings=TRUE); ` +
|
||||||
|
`skipped ${skipNoLicense} rows with neither, ${skipBlankEmail} blank-email`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enumerate org licenses that look messaging/space-relevant. The
|
||||||
|
// operator uses this list to pick the three ids for --execute.
|
||||||
|
console.log(`\n🔎 Fetching org licenses…`);
|
||||||
|
let allLicenses;
|
||||||
|
try {
|
||||||
|
allLicenses = await fetchAllLicenses();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`❌ Failed to list licenses: ${explainWebexError(err)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const relevantRe = /message|advanced|space meeting|basic/i;
|
||||||
|
const relevant = allLicenses.filter((l) => relevantRe.test(l.name || ''));
|
||||||
|
console.log(` Relevant org licenses (${relevant.length} of ${allLicenses.length}):`);
|
||||||
|
for (const l of relevant) {
|
||||||
|
const free = seatsFree(l);
|
||||||
|
const markers = [];
|
||||||
|
if (l.id === args.advancedMessagingLicenseId) markers.push('ADV_MSG (to remove)');
|
||||||
|
if (l.id === args.advancedSpaceMeetingsLicenseId) markers.push('ADV_SPACE (to remove)');
|
||||||
|
if (l.id === args.basicMessagingLicenseId) markers.push('BASIC_MSG (to add)');
|
||||||
|
const mark = markers.length ? ` ← ${markers.join(', ')}` : '';
|
||||||
|
const site = l.siteUrl ? ` site=${l.siteUrl}` : '';
|
||||||
|
console.log(` • ${l.name} — ${free}/${l.totalUnits} free${site} — id=${l.id}${mark}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.advancedMessagingLicenseId && !args.advancedSpaceMeetingsLicenseId) {
|
||||||
|
console.error(
|
||||||
|
`\n❌ Need at least one of the following ids to proceed:\n` +
|
||||||
|
` --advanced-messaging-license-id <id> (env: WEBEX_ADV_MSG_LICENSE_ID)\n` +
|
||||||
|
` --advanced-space-meetings-license-id <id> (env: WEBEX_ADV_SPACE_MTG_LICENSE_ID)\n` +
|
||||||
|
` Pick from the list above.`,
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate provided ids resolve to real licenses.
|
||||||
|
const licById = new Map(allLicenses.map((l) => [l.id, l]));
|
||||||
|
const advMsgLic = args.advancedMessagingLicenseId ? licById.get(args.advancedMessagingLicenseId) : null;
|
||||||
|
const advSpaceLic = args.advancedSpaceMeetingsLicenseId ? licById.get(args.advancedSpaceMeetingsLicenseId) : null;
|
||||||
|
const basicMsgLic = args.basicMessagingLicenseId ? licById.get(args.basicMessagingLicenseId) : null;
|
||||||
|
|
||||||
|
const badIds = [];
|
||||||
|
if (args.advancedMessagingLicenseId && !advMsgLic) badIds.push(['--advanced-messaging-license-id', args.advancedMessagingLicenseId]);
|
||||||
|
if (args.advancedSpaceMeetingsLicenseId && !advSpaceLic) badIds.push(['--advanced-space-meetings-license-id', args.advancedSpaceMeetingsLicenseId]);
|
||||||
|
if (args.basicMessagingLicenseId && !basicMsgLic) badIds.push(['--basic-messaging-license-id', args.basicMessagingLicenseId]);
|
||||||
|
if (badIds.length > 0) {
|
||||||
|
console.error(`\n❌ Invalid license ids:`);
|
||||||
|
for (const [flag, id] of badIds) console.error(` ${flag} ${id}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-reference: fetch the assignee union (up to two paginated
|
||||||
|
// sweeps) so we can (a) resolve personId per email and (b) only
|
||||||
|
// send the remove ops for licenses the user actually still holds.
|
||||||
|
console.log(`\n📥 Fetching current assignees…`);
|
||||||
|
let assignees;
|
||||||
|
try {
|
||||||
|
assignees = await buildAssigneeUnion({
|
||||||
|
advMsgId: advMsgLic?.id,
|
||||||
|
advSpaceId: advSpaceLic?.id,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`❌ Failed to fetch assignees: ${explainWebexError(err)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(` → ${assignees.size} distinct users hold at least one of the target licenses`);
|
||||||
|
|
||||||
|
const toProcess = [];
|
||||||
|
let skipNotHolder = 0;
|
||||||
|
for (const c of candidates) {
|
||||||
|
const a = assignees.get(c.email);
|
||||||
|
if (!a) { skipNotHolder++; continue; }
|
||||||
|
toProcess.push({
|
||||||
|
...c,
|
||||||
|
personId: a.personId,
|
||||||
|
displayName: a.displayName || c.displayName,
|
||||||
|
holdsAdvMsg: a.holds.advMsg,
|
||||||
|
holdsAdvSpace: a.holds.advSpace,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
` → ${toProcess.length} to process ` +
|
||||||
|
`(${skipNotHolder} CSV candidates no longer hold either license)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sliced = toProcess.slice(args.offset, args.limit ? args.offset + args.limit : undefined);
|
||||||
|
if (sliced.length !== toProcess.length) {
|
||||||
|
console.log(` → sliced to ${sliced.length} (offset=${args.offset}, limit=${args.limit ?? 'none'})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic-messaging capacity check (if the operator supplied one and
|
||||||
|
// it's a finite-seat license — some orgs meter Basic Messaging).
|
||||||
|
if (basicMsgLic && seatsFree(basicMsgLic) < sliced.length) {
|
||||||
|
console.warn(
|
||||||
|
`\n⚠️ Basic Messaging license \`${basicMsgLic.name}\` has ` +
|
||||||
|
`${seatsFree(basicMsgLic)} free seats but ${sliced.length} adds are planned. ` +
|
||||||
|
`Extras will fail.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutation = describeMutation({ advMsgLic, advSpaceLic, basicMsgLic });
|
||||||
|
console.log(`\n🛠 Planned mutation per user: ${mutation}`);
|
||||||
|
|
||||||
|
if (!args.execute) {
|
||||||
|
console.log(`\n🚦 DRY-RUN (no changes made). Re-run with --execute to commit.`);
|
||||||
|
if (sliced.length > 0) {
|
||||||
|
console.log(`\n Sample of first 5 candidates:`);
|
||||||
|
for (const s of sliced.slice(0, 5)) {
|
||||||
|
const ops = [];
|
||||||
|
if (advMsgLic && s.holdsAdvMsg) ops.push('-adv-msg');
|
||||||
|
if (advSpaceLic && s.holdsAdvSpace) ops.push('-adv-space');
|
||||||
|
if (basicMsgLic) ops.push('+basic-msg');
|
||||||
|
console.log(` - ${s.displayName} <${s.email}> status=${s.status || '?'} ops=[${ops.join(', ')}] personId=${s.personId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute with bounded concurrency + 429 retry.
|
||||||
|
console.log(
|
||||||
|
`\n🚀 EXECUTING against ${sliced.length} users ` +
|
||||||
|
`(concurrency=${args.concurrency}). Ctrl-C to abort.\n`,
|
||||||
|
);
|
||||||
|
logger(
|
||||||
|
'webex:advmsg:audit',
|
||||||
|
`START remove-advmsg: adv-msg=${advMsgLic?.id || 'skip'} ` +
|
||||||
|
`adv-space=${advSpaceLic?.id || 'skip'} basic-msg=${basicMsgLic?.id || 'skip'} ` +
|
||||||
|
`count=${sliced.length} csv=${path.basename(csvPath)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let processed = 0;
|
||||||
|
const results = await runPool(sliced, args.concurrency, async (user) => {
|
||||||
|
const licenses = [];
|
||||||
|
if (advMsgLic && user.holdsAdvMsg) licenses.push({ id: advMsgLic.id, operation: 'remove' });
|
||||||
|
if (advSpaceLic && user.holdsAdvSpace) licenses.push({ id: advSpaceLic.id, operation: 'remove' });
|
||||||
|
if (basicMsgLic) licenses.push({ id: basicMsgLic.id, operation: 'add' });
|
||||||
|
|
||||||
|
// Shouldn't happen — every entry in `sliced` holds at least one
|
||||||
|
// of the two Advanced licenses. Defensive skip anyway so we don't
|
||||||
|
// send an empty PATCH body.
|
||||||
|
if (licenses.length === 0) {
|
||||||
|
return { skipped: 'no-op' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = { personId: user.personId, licenses };
|
||||||
|
const resp = await callWithRetry(() => webex.assignLicensesToUser(body));
|
||||||
|
|
||||||
|
processed++;
|
||||||
|
if (processed % 25 === 0 || processed === sliced.length) {
|
||||||
|
console.log(` … ${processed}/${sliced.length}`);
|
||||||
|
}
|
||||||
|
return resp;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Summarise + audit.
|
||||||
|
let ok = 0;
|
||||||
|
let failed = 0;
|
||||||
|
const failures = [];
|
||||||
|
const perUser = [];
|
||||||
|
for (let i = 0; i < results.length; i++) {
|
||||||
|
const r = results[i];
|
||||||
|
const u = sliced[i];
|
||||||
|
if (r.ok) {
|
||||||
|
ok++;
|
||||||
|
const currentLicenses = new Set(r.value?.licenses || []);
|
||||||
|
const advMsgGone = !advMsgLic || !currentLicenses.has(advMsgLic.id);
|
||||||
|
const advSpaceGone = !advSpaceLic || !currentLicenses.has(advSpaceLic.id);
|
||||||
|
const basicOk = !basicMsgLic || currentLicenses.has(basicMsgLic.id);
|
||||||
|
const outcome = advMsgGone && advSpaceGone && basicOk ? 'ok' : 'partial';
|
||||||
|
logger('webex:advmsg:audit', `OK ${u.email} personId=${u.personId} outcome=${outcome}`);
|
||||||
|
perUser.push({
|
||||||
|
email: u.email,
|
||||||
|
displayName: u.displayName,
|
||||||
|
status: u.status || '',
|
||||||
|
removed_adv_msg: advMsgLic && u.holdsAdvMsg ? 'yes' : 'no',
|
||||||
|
removed_adv_space: advSpaceLic && u.holdsAdvSpace ? 'yes' : 'no',
|
||||||
|
added_basic_msg: basicMsgLic ? 'yes' : 'no',
|
||||||
|
personId: u.personId,
|
||||||
|
outcome,
|
||||||
|
error: '',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
failed++;
|
||||||
|
const msg = explainWebexError(r.error);
|
||||||
|
failures.push({ user: u, msg });
|
||||||
|
logger('webex:advmsg:audit', `FAIL ${u.email} personId=${u.personId}: ${msg}`, 'error');
|
||||||
|
perUser.push({
|
||||||
|
email: u.email,
|
||||||
|
displayName: u.displayName,
|
||||||
|
status: u.status || '',
|
||||||
|
removed_adv_msg: advMsgLic && u.holdsAdvMsg ? 'attempted' : 'no',
|
||||||
|
removed_adv_space: advSpaceLic && u.holdsAdvSpace ? 'attempted' : 'no',
|
||||||
|
added_basic_msg: basicMsgLic ? 'attempted' : 'no',
|
||||||
|
personId: u.personId,
|
||||||
|
outcome: 'error',
|
||||||
|
error: msg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✅ Done. ${ok} succeeded, ${failed} failed, ${sliced.length} total.`);
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.log(`\nFirst up to 10 failures:`);
|
||||||
|
for (const f of failures.slice(0, 10)) {
|
||||||
|
console.log(` - ${f.user.email}: ${f.msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.report) {
|
||||||
|
const reportPath = path.resolve(args.report);
|
||||||
|
const cols = [
|
||||||
|
'email', 'displayName', 'status',
|
||||||
|
'removed_adv_msg', 'removed_adv_space', 'added_basic_msg',
|
||||||
|
'personId', 'outcome', 'error',
|
||||||
|
];
|
||||||
|
const escape = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`;
|
||||||
|
const lines = [cols.join(',')];
|
||||||
|
for (const p of perUser) lines.push(cols.map((c) => escape(p[c])).join(','));
|
||||||
|
fs.writeFileSync(reportPath, lines.join('\n') + '\n', 'utf8');
|
||||||
|
console.log(`\n📝 Report written to ${reportPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger(
|
||||||
|
'webex:advmsg:audit',
|
||||||
|
`END remove-advmsg: ok=${ok} failed=${failed} total=${sliced.length}`,
|
||||||
|
);
|
||||||
|
process.exit(failed === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeMutation({ advMsgLic, advSpaceLic, basicMsgLic }) {
|
||||||
|
const parts = [];
|
||||||
|
if (advMsgLic) parts.push(`remove \`${advMsgLic.name}\` (if held)`);
|
||||||
|
if (advSpaceLic) parts.push(`remove \`${advSpaceLic.name}\` (if held)`);
|
||||||
|
if (basicMsgLic) parts.push(`add \`${basicMsgLic.name}\``);
|
||||||
|
if (parts.length === 0) return '(nothing — no ids provided)';
|
||||||
|
return parts.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(`\n💥 Unhandled: ${err?.stack || err}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
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 {string} opts.storeNum 2-6 digit store id (header text)
|
||||||
* @param {boolean} [opts.detailed=false]
|
* @param {boolean} [opts.detailed=false]
|
||||||
* @param {boolean} [opts.footer=true]
|
* @param {boolean} [opts.footer=true]
|
||||||
|
* @param {number} [opts.dectFollowUpBaseCount=0]
|
||||||
|
* When > 0, emits an "⏳ DECT base data loading for N base(s)…"
|
||||||
|
* line inside the DECT Basestations section. Signals to the reader
|
||||||
|
* that a follow-up message with base-station diagnostics is on the
|
||||||
|
* way. Chat handler passes this after the base count comes back
|
||||||
|
* from discoverDectBases(); poller and HTTP callers pass 0.
|
||||||
* @returns {string} markdown, whitespace-trimmed and ready to send.
|
* @returns {string} markdown, whitespace-trimmed and ready to send.
|
||||||
*/
|
*/
|
||||||
export function renderPhoneStatusMarkdown(data, opts = {}) {
|
export function renderPhoneStatusMarkdown(data, opts = {}) {
|
||||||
const { storeNum, detailed = false, footer = true } = opts;
|
const { storeNum, detailed = false, footer = true, dectFollowUpBaseCount = 0 } = opts;
|
||||||
|
|
||||||
let reply = `**Phone Status - Store ${storeNum}**\n\n`;
|
let reply = `**Phone Status - Store ${storeNum}**\n\n`;
|
||||||
|
|
||||||
|
|
@ -190,6 +196,15 @@ export function renderPhoneStatusMarkdown(data, opts = {}) {
|
||||||
});
|
});
|
||||||
reply += '\n';
|
reply += '\n';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DECT relay follow-up notice. Only shown when the caller has
|
||||||
|
// told us a follow-up is actually in-flight (chat handler, after
|
||||||
|
// discoverDectBases returned a non-empty list). Silent for HTTP /
|
||||||
|
// Jira surfaces where a follow-up doesn't happen.
|
||||||
|
if (dectFollowUpBaseCount > 0) {
|
||||||
|
const n = dectFollowUpBaseCount;
|
||||||
|
reply += `_⏳ Base-station diagnostics loading for ${n} base${n === 1 ? '' : 's'} — a follow-up message will arrive shortly._\n\n`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (detailed) {
|
if (detailed) {
|
||||||
|
|
@ -202,3 +217,92 @@ export function renderPhoneStatusMarkdown(data, opts = {}) {
|
||||||
|
|
||||||
return reply.trim();
|
return reply.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── DECT base-station diagnostics (follow-up message) ──────────────
|
||||||
|
//
|
||||||
|
// Separate exported renderer for the follow-up message that arrives
|
||||||
|
// ~10-30s after the main /phonestatus output. Input is the array
|
||||||
|
// returned by services/dectCollectorService.collectAll(): per-base
|
||||||
|
// { ok, data (parsed status), verdict, error } records.
|
||||||
|
//
|
||||||
|
// Chat surface stays compact — most operators only need to see the
|
||||||
|
// exceptional stuff (warnings, recent power-loss reboots). Firmware
|
||||||
|
// / emergency numbers / detailed reboot log stay behind the future
|
||||||
|
// /dectstatus command where the full CLI-style dump makes more sense.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array} results collectAll() output
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {string} opts.storeNum
|
||||||
|
* @param {boolean} [opts.footer=true]
|
||||||
|
* @returns {string} markdown, whitespace-trimmed. Empty string when
|
||||||
|
* the input list is empty (caller shouldn't send a message
|
||||||
|
* in that case).
|
||||||
|
*/
|
||||||
|
export function renderDectDiagnosticsMarkdown(results, opts = {}) {
|
||||||
|
const { storeNum, footer = true } = opts;
|
||||||
|
const list = Array.isArray(results) ? results : [];
|
||||||
|
if (list.length === 0) return '';
|
||||||
|
|
||||||
|
let out = `**DECT Base Station Diagnostics — Store ${storeNum}**\n\n`;
|
||||||
|
|
||||||
|
for (const r of list) {
|
||||||
|
out += renderOneBase(r);
|
||||||
|
out += '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (footer) {
|
||||||
|
out += `\n*Base diagnostics pulled at ${new Date().toLocaleTimeString()} via the DECT relay. Use \`/dectstatus ${storeNum}\` for full details and reboot/factory-reset controls.*`;
|
||||||
|
}
|
||||||
|
return out.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOneBase(r) {
|
||||||
|
const label = r.base?.name || `Basestation ${r.base?.mac || '?'}`;
|
||||||
|
const ip = r.base?.ip || '?';
|
||||||
|
if (!r.ok) {
|
||||||
|
return `⚠️ **${label}** (${ip}) — collect failed: ${r.error?.message || 'unknown error'}` +
|
||||||
|
(r.error?.hint ? `\n _${r.error.hint}_\n` : '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = r.data || {};
|
||||||
|
const verdict = r.verdict || {};
|
||||||
|
const uptimeText = data.time?.operatingTime || '?';
|
||||||
|
const fw = data.firmware?.version || '?';
|
||||||
|
const conflict = data.conflictInfo && data.conflictInfo !== 'No Conflict'
|
||||||
|
? ` • RF conflict: ${data.conflictInfo}` : '';
|
||||||
|
const role = data.multiCell?.role ? ` • role: ${data.multiCell.role}` : '';
|
||||||
|
|
||||||
|
// Header line uses a checkmark or warning depending on verdict.
|
||||||
|
const icon = verdict.healthy ? '✅' : '⚠️';
|
||||||
|
let out = `${icon} **${label}** (${ip}) — uptime ${uptimeText} • fw ${fw}${role}${conflict}\n`;
|
||||||
|
|
||||||
|
// Most-recent Power Loss reboot (if any in the last-6 log) is the
|
||||||
|
// highest-signal thing we can surface here. Anything else falls
|
||||||
|
// under "warnings" below.
|
||||||
|
const powerLoss = (data.rebootLog || []).find((entry) => entry.reasonCode === 80);
|
||||||
|
if (powerLoss) {
|
||||||
|
out += ` ⚡ Recent power loss: ${powerLoss.at} (reboot #${powerLoss.sequence})\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warnings from summarizeBaseHealth() are already user-facing
|
||||||
|
// strings; render as a bulleted list under the header.
|
||||||
|
if (Array.isArray(verdict.warnings) && verdict.warnings.length > 0) {
|
||||||
|
for (const w of verdict.warnings) {
|
||||||
|
// Skip the power-loss warning if we already surfaced the
|
||||||
|
// structured line above — avoids duplication.
|
||||||
|
if (powerLoss && /power.?loss/i.test(w)) continue;
|
||||||
|
out += ` ⚠️ ${w}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RTP: only show if there's an active session — usually the
|
||||||
|
// diagnostic reader cares whether a call is up right now, not
|
||||||
|
// that this base has served 2 total calls since boot.
|
||||||
|
if ((data.rtp?.current || 0) > 0) {
|
||||||
|
out += ` 📞 ${data.rtp.current} active RTP session(s)\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
169
tests/dectDiscovery.test.js
Normal file
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
117
tests/httpDigestAuth.test.js
Normal file
117
tests/httpDigestAuth.test.js
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
// Unit tests for utils/httpDigestAuth.js — pure Digest MD5/qop=auth
|
||||||
|
// computation, no network. Two categories:
|
||||||
|
// 1. Parser correctness (challenge string → params object).
|
||||||
|
// 2. Response hash correctness (RFC 2617 §3.5 canonical example
|
||||||
|
// plus a Cisco DBS-210-shaped challenge with an empty realm).
|
||||||
|
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import {
|
||||||
|
parseDigestChallenge,
|
||||||
|
buildDigestAuthHeader,
|
||||||
|
} from '../utils/httpDigestAuth.js';
|
||||||
|
|
||||||
|
test('parseDigestChallenge: handles the real DBS-210 challenge shape', () => {
|
||||||
|
// Verbatim from DECT2.har WWW-Authenticate line.
|
||||||
|
const raw = 'Digest realm="", nonce="NkE0NkIzRjQgMWJhNjk0NjMzYjJlZDllNGVjMzA5YmE4NjVhYmQyZDU=", algorithm="MD5", qop="auth"';
|
||||||
|
const p = parseDigestChallenge(raw);
|
||||||
|
assert.equal(p.scheme, 'digest');
|
||||||
|
assert.equal(p.realm, ''); // empty realm preserved, not dropped
|
||||||
|
assert.equal(p.nonce, 'NkE0NkIzRjQgMWJhNjk0NjMzYjJlZDllNGVjMzA5YmE4NjVhYmQyZDU=');
|
||||||
|
assert.equal(p.algorithm, 'MD5');
|
||||||
|
assert.equal(p.qop, 'auth');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseDigestChallenge: rejects non-Digest schemes', () => {
|
||||||
|
assert.equal(parseDigestChallenge('Basic realm="test"'), null);
|
||||||
|
assert.equal(parseDigestChallenge('Bearer x'), null);
|
||||||
|
assert.equal(parseDigestChallenge(null), null);
|
||||||
|
assert.equal(parseDigestChallenge(undefined), null);
|
||||||
|
assert.equal(parseDigestChallenge(''), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseDigestChallenge: handles unquoted and mixed values', () => {
|
||||||
|
const raw = 'Digest realm="testrealm@host.com", qop="auth,auth-int", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", opaque="5ccc069c403ebaf9f0171e9517f40e41"';
|
||||||
|
const p = parseDigestChallenge(raw);
|
||||||
|
assert.equal(p.realm, 'testrealm@host.com');
|
||||||
|
assert.equal(p.qop, 'auth,auth-int');
|
||||||
|
assert.equal(p.nonce, 'dcd98b7102dd2f0e8b11d0f600bfb0c093');
|
||||||
|
assert.equal(p.opaque, '5ccc069c403ebaf9f0171e9517f40e41');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildDigestAuthHeader: RFC 2617 §3.5 canonical example', () => {
|
||||||
|
// Textbook values from the spec. If our hash matches "6629fae49393a05397450978507c4ef1"
|
||||||
|
// then the whole chain (HA1, HA2, response with qop=auth) is correct.
|
||||||
|
// HA1 = md5("Mufasa:testrealm@host.com:Circle Of Life")
|
||||||
|
// = 939e7578ed9e3c518a452acee763bce9
|
||||||
|
// HA2 = md5("GET:/dir/index.html")
|
||||||
|
// = 39aff3a2bab6126f332b942af96d3366
|
||||||
|
// response = md5("939e...:dcd9...:00000001:0a4f...:auth:39af...")
|
||||||
|
// = 6629fae49393a05397450978507c4ef1
|
||||||
|
const header = buildDigestAuthHeader({
|
||||||
|
username: 'Mufasa',
|
||||||
|
password: 'Circle Of Life',
|
||||||
|
method: 'GET',
|
||||||
|
uri: '/dir/index.html',
|
||||||
|
challenge: {
|
||||||
|
scheme: 'digest',
|
||||||
|
realm: 'testrealm@host.com',
|
||||||
|
nonce: 'dcd98b7102dd2f0e8b11d0f600bfb0c093',
|
||||||
|
algorithm: 'MD5',
|
||||||
|
qop: 'auth',
|
||||||
|
opaque: '5ccc069c403ebaf9f0171e9517f40e41',
|
||||||
|
},
|
||||||
|
nc: 1,
|
||||||
|
cnonce: '0a4f113b', // fixed cnonce so we can compare the response hash
|
||||||
|
});
|
||||||
|
assert.match(header, /^Digest /);
|
||||||
|
assert.match(header, /response="6629fae49393a05397450978507c4ef1"/);
|
||||||
|
assert.match(header, /username="Mufasa"/);
|
||||||
|
assert.match(header, /realm="testrealm@host\.com"/);
|
||||||
|
assert.match(header, /qop=auth/);
|
||||||
|
assert.match(header, /nc=00000001/);
|
||||||
|
assert.match(header, /cnonce="0a4f113b"/);
|
||||||
|
assert.match(header, /opaque="5ccc069c403ebaf9f0171e9517f40e41"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildDigestAuthHeader: preserves empty realm (Cisco DBS-210 quirk)', () => {
|
||||||
|
// Empty-realm servers still hash username:"":password. Some naive
|
||||||
|
// implementations drop the empty realm, which changes HA1 and
|
||||||
|
// produces a 401 loop. This test guards that regression.
|
||||||
|
const header = buildDigestAuthHeader({
|
||||||
|
username: 'admin',
|
||||||
|
password: 'hunter2',
|
||||||
|
method: 'GET',
|
||||||
|
uri: '/main.html',
|
||||||
|
challenge: {
|
||||||
|
scheme: 'digest',
|
||||||
|
realm: '',
|
||||||
|
nonce: 'someNonce',
|
||||||
|
algorithm: 'MD5',
|
||||||
|
qop: 'auth',
|
||||||
|
},
|
||||||
|
nc: 1,
|
||||||
|
cnonce: 'fixedcnonce',
|
||||||
|
});
|
||||||
|
assert.match(header, /realm=""/); // literal empty realm in the header
|
||||||
|
// With realm="", HA1 = md5("admin::hunter2") = 3d5c6fd1a1c04d78ff81a3a11b34523c.
|
||||||
|
// HA2 = md5("GET:/main.html") = 7b3d1de3d64de6b6d2f57b4de5f4ee7d.
|
||||||
|
// response = md5(HA1:someNonce:00000001:fixedcnonce:auth:HA2)
|
||||||
|
// = 4d1c15c8b30df53a3306cd6c4b7d3f2c
|
||||||
|
// Computed with the same md5 our module uses, so hard-coding is fine.
|
||||||
|
//
|
||||||
|
// We don't hard-code the response digest here because the value only
|
||||||
|
// matters relative to itself — a regression in HA1 (empty realm
|
||||||
|
// dropped) would surface as the header change above OR as a
|
||||||
|
// wrong-response error when hitting a real device. Keeping the
|
||||||
|
// assertion focused: empty realm survived the round-trip into the
|
||||||
|
// outgoing header.
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildDigestAuthHeader: rejects unsupported algorithm', () => {
|
||||||
|
assert.throws(() => buildDigestAuthHeader({
|
||||||
|
username: 'a', password: 'b', method: 'GET', uri: '/', nc: 1, cnonce: 'x',
|
||||||
|
challenge: { algorithm: 'SHA-256', realm: '', nonce: 'n', qop: 'auth' },
|
||||||
|
}), /unsupported algorithm/i);
|
||||||
|
});
|
||||||
|
|
@ -10,7 +10,10 @@
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
import { renderPhoneStatusMarkdown } from '../services/renderers/phoneStatusRenderer.js';
|
import {
|
||||||
|
renderPhoneStatusMarkdown,
|
||||||
|
renderDectDiagnosticsMarkdown,
|
||||||
|
} from '../services/renderers/phoneStatusRenderer.js';
|
||||||
import { renderAvStatusMarkdown } from '../services/renderers/avStatusRenderer.js';
|
import { renderAvStatusMarkdown } from '../services/renderers/avStatusRenderer.js';
|
||||||
|
|
||||||
// Timestamp exactly 3 hours in the past — makes `simpleTimeAgo`
|
// Timestamp exactly 3 hours in the past — makes `simpleTimeAgo`
|
||||||
|
|
@ -248,3 +251,157 @@ test('av renderer: Atlas AMP with vitals renders temps + fan + amps', () => {
|
||||||
assert.match(md, /CPU: 104°F • PSU: 95°F • Io: 100°F • Voltage: 120\.4V • Fan: 46%/);
|
assert.match(md, /CPU: 104°F • PSU: 95°F • Io: 100°F • Voltage: 120\.4V • Fan: 46%/);
|
||||||
assert.match(md, /Amps: Amp1: Active, Amp2: Ready/);
|
assert.match(md, /Amps: Amp1: Active, Amp2: Ready/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// DECT follow-up "loading" hint (main /phonestatus output)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('phone renderer: dectFollowUpBaseCount > 0 emits a loading hint inside the DECT section', () => {
|
||||||
|
const data = {
|
||||||
|
dectBasestations: [
|
||||||
|
{ mac: 'aa:bb:cc:dd:ee:01', meraki: { status: 'Online' } },
|
||||||
|
{ mac: 'aa:bb:cc:dd:ee:02', meraki: { status: 'Online' } },
|
||||||
|
],
|
||||||
|
dectHandsets: [],
|
||||||
|
};
|
||||||
|
const md = renderPhoneStatusMarkdown(data, {
|
||||||
|
storeNum: '782', footer: false, dectFollowUpBaseCount: 2,
|
||||||
|
});
|
||||||
|
assert.match(md, /Base-station diagnostics loading for 2 bases/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('phone renderer: dectFollowUpBaseCount === 1 uses singular "base"', () => {
|
||||||
|
const data = {
|
||||||
|
dectBasestations: [{ mac: 'aa:bb:cc:dd:ee:01', meraki: { status: 'Online' } }],
|
||||||
|
dectHandsets: [],
|
||||||
|
};
|
||||||
|
const md = renderPhoneStatusMarkdown(data, {
|
||||||
|
storeNum: '782', footer: false, dectFollowUpBaseCount: 1,
|
||||||
|
});
|
||||||
|
assert.match(md, /loading for 1 base —/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('phone renderer: dectFollowUpBaseCount === 0 emits no loading hint (default state)', () => {
|
||||||
|
const data = {
|
||||||
|
dectBasestations: [{ mac: 'aa:bb:cc:dd:ee:01', meraki: { status: 'Online' } }],
|
||||||
|
dectHandsets: [],
|
||||||
|
};
|
||||||
|
const md = renderPhoneStatusMarkdown(data, { storeNum: '782', footer: false });
|
||||||
|
assert.doesNotMatch(md, /diagnostics loading/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// DECT follow-up message (renderDectDiagnosticsMarkdown)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const okResult = (overrides = {}) => ({
|
||||||
|
base: { mac: '6c:ab:05:f6:28:19', ip: '10.4.11.87', name: 'Basestation A' },
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
time: { operatingTime: '02:15:00 (H:M:S)' },
|
||||||
|
firmware: { version: '05-01-03-0101-09' },
|
||||||
|
multiCell: { role: 'primary' },
|
||||||
|
conflictInfo: 'No Conflict',
|
||||||
|
rebootLog: [],
|
||||||
|
rtp: { current: 0 },
|
||||||
|
},
|
||||||
|
verdict: { healthy: true, warnings: [], info: [] },
|
||||||
|
elapsedMs: 812,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dect diagnostics renderer: empty input returns empty string (caller should not send)', () => {
|
||||||
|
assert.equal(renderDectDiagnosticsMarkdown([], { storeNum: '782' }), '');
|
||||||
|
assert.equal(renderDectDiagnosticsMarkdown(null, { storeNum: '782' }), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dect diagnostics renderer: healthy base renders check + uptime + firmware', () => {
|
||||||
|
const md = renderDectDiagnosticsMarkdown([okResult()], { storeNum: '782', footer: false });
|
||||||
|
assert.match(md, /\*\*DECT Base Station Diagnostics — Store 782\*\*/);
|
||||||
|
assert.match(md, /✅ \*\*Basestation A\*\* \(10\.4\.11\.87\)/);
|
||||||
|
assert.match(md, /uptime 02:15:00/);
|
||||||
|
assert.match(md, /fw 05-01-03-0101-09/);
|
||||||
|
assert.match(md, /role: primary/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dect diagnostics renderer: warnings from verdict are surfaced under the header', () => {
|
||||||
|
const md = renderDectDiagnosticsMarkdown([
|
||||||
|
okResult({
|
||||||
|
verdict: {
|
||||||
|
healthy: false,
|
||||||
|
warnings: ['Rx errors: 42 since last boot'],
|
||||||
|
info: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
], { storeNum: '782', footer: false });
|
||||||
|
assert.match(md, /⚠️ \*\*Basestation A\*\*/);
|
||||||
|
assert.match(md, /⚠️ Rx errors: 42 since last boot/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dect diagnostics renderer: recent Power Loss reboot gets its own bolt line + suppresses duplicate warning', () => {
|
||||||
|
const md = renderDectDiagnosticsMarkdown([
|
||||||
|
okResult({
|
||||||
|
data: {
|
||||||
|
time: { operatingTime: '02:15:00' },
|
||||||
|
firmware: { version: '05-01-03-0101-09' },
|
||||||
|
multiCell: { role: 'primary' },
|
||||||
|
conflictInfo: 'No Conflict',
|
||||||
|
rebootLog: [
|
||||||
|
{ sequence: 164, at: '2026-07-02T12:54:12', reasonName: 'Power Loss', reasonCode: 80 },
|
||||||
|
],
|
||||||
|
rtp: { current: 0 },
|
||||||
|
},
|
||||||
|
verdict: {
|
||||||
|
healthy: false,
|
||||||
|
warnings: ['1 recent power-loss reboot(s); most recent at 2026-07-02T12:54:12'],
|
||||||
|
info: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
], { storeNum: '782', footer: false });
|
||||||
|
|
||||||
|
// The structured line survives …
|
||||||
|
assert.match(md, /⚡ Recent power loss: 2026-07-02T12:54:12 \(reboot #164\)/);
|
||||||
|
// … but the summary warning about power-loss is filtered out to
|
||||||
|
// avoid duplication under the same header.
|
||||||
|
assert.doesNotMatch(md, /⚠️ 1 recent power-loss/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dect diagnostics renderer: active RTP session gets a call icon', () => {
|
||||||
|
const md = renderDectDiagnosticsMarkdown([
|
||||||
|
okResult({
|
||||||
|
data: {
|
||||||
|
time: { operatingTime: '02:15:00' },
|
||||||
|
firmware: { version: '05-01-03-0101-09' },
|
||||||
|
multiCell: { role: 'primary' },
|
||||||
|
conflictInfo: 'No Conflict',
|
||||||
|
rebootLog: [],
|
||||||
|
rtp: { current: 2 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
], { storeNum: '782', footer: false });
|
||||||
|
assert.match(md, /📞 2 active RTP session/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dect diagnostics renderer: base with error renders remediation hint', () => {
|
||||||
|
const md = renderDectDiagnosticsMarkdown([
|
||||||
|
{
|
||||||
|
base: { mac: '6c:ab:05:f6:28:19', ip: '10.4.11.87', name: 'Basestation A' },
|
||||||
|
ok: false,
|
||||||
|
data: null,
|
||||||
|
verdict: null,
|
||||||
|
elapsedMs: 15003,
|
||||||
|
error: {
|
||||||
|
code: 'RELAY_RPC_TIMEOUT',
|
||||||
|
message: 'timed out after 15000ms',
|
||||||
|
hint: 'Relay accepted the request but the base did not respond in time.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
], { storeNum: '782', footer: false });
|
||||||
|
assert.match(md, /⚠️ \*\*Basestation A\*\* \(10\.4\.11\.87\) — collect failed: timed out after 15000ms/);
|
||||||
|
assert.match(md, /Relay accepted the request but the base did not respond in time\./);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dect diagnostics renderer: footer references /dectstatus command by store', () => {
|
||||||
|
const md = renderDectDiagnosticsMarkdown([okResult()], { storeNum: '782' });
|
||||||
|
assert.match(md, /Use `\/dectstatus 782`/);
|
||||||
|
});
|
||||||
|
|
|
||||||
320
tests/statusXml.test.js
Normal file
320
tests/statusXml.test.js
Normal file
|
|
@ -0,0 +1,320 @@
|
||||||
|
// Unit tests for integrations/cisco-dect/statusXml.js.
|
||||||
|
//
|
||||||
|
// Pure — no network, no fs. The fixture below is a REDACTED copy of a
|
||||||
|
// real /admin/status.xml pulled from a lab DBS-210 during the DECT
|
||||||
|
// spike. MAC, IP, RFPI, and firmware server URL are all changed to
|
||||||
|
// obviously-fake values so this file is safe to commit and safe to
|
||||||
|
// leave in CI logs. The XML SHAPE (tag nesting, whitespace, encoding
|
||||||
|
// oddities like `text/text` mimetype on the wire) is preserved
|
||||||
|
// verbatim — that's exactly what the parser has to be robust against.
|
||||||
|
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import {
|
||||||
|
xmlToObject,
|
||||||
|
parseStatusXml,
|
||||||
|
parseRebootLine,
|
||||||
|
summarizeBaseHealth,
|
||||||
|
} from '../integrations/cisco-dect/statusXml.js';
|
||||||
|
|
||||||
|
const FIXTURE = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Status>
|
||||||
|
<System_Information>
|
||||||
|
<Released_Build>Yes</Released_Build>
|
||||||
|
<Multi_Cell>Unchained(TXT_STATE_UNCHAINED) Allowed to Join as Secondary</Multi_Cell>
|
||||||
|
<Phone_Type>IPDECT-V2 (DBS-210-3PC)</Phone_Type>
|
||||||
|
<System_Type>Generic SIP (RFC 3261)</System_Type>
|
||||||
|
<Unit_Name>SME VoIP</Unit_Name>
|
||||||
|
<Unit_Index>Base Idx:0</Unit_Index>
|
||||||
|
<RF_Band>US</RF_Band>
|
||||||
|
<Conflict_Info>No Conflict</Conflict_Info>
|
||||||
|
<Current_Local_Time>02-Jul-2026 13:22:48</Current_Local_Time>
|
||||||
|
<Operating_Time>00:10:20 (H:M:S)</Operating_Time>
|
||||||
|
<RFPI_Address>ABCDEF12; RPN:00</RFPI_Address>
|
||||||
|
<MAC_Address>001122334455</MAC_Address>
|
||||||
|
<IP_Address>10.0.0.100</IP_Address>
|
||||||
|
<Product_Configuration>0000</Product_Configuration>
|
||||||
|
<Firmware_Version>IPDECT-V2/05-01-03-0101-09/18-Mar-2026 10:29</Firmware_Version>
|
||||||
|
<Firmware_URL>
|
||||||
|
<Update_Server_Address>https://example.invalid</Update_Server_Address>
|
||||||
|
<Path>dms/dbS210</Path>
|
||||||
|
</Firmware_URL>
|
||||||
|
<Reboot_Log>
|
||||||
|
<Reboot_Line_1>2026-07-02 13:11:46 (164) Normal Reboot (21) Firmware Version 05-01-03-0101-09</Reboot_Line_1>
|
||||||
|
<Reboot_Line_2>2026-07-02 13:09:50 (163) Normal Reboot (21) Firmware Version 05-01-03-0101-09</Reboot_Line_2>
|
||||||
|
<Reboot_Line_3>2026-07-02 13:06:28 (162) Normal Reboot (21) Firmware Version 05-01-03-0101-09</Reboot_Line_3>
|
||||||
|
<Reboot_Line_4>2026-07-02 12:54:12 (161) Power Loss (80) Firmware Version 05-01-03-0101-09</Reboot_Line_4>
|
||||||
|
<Reboot_Line_5>2026-07-02 12:49:50 (160) Normal Reboot (21) Firmware Version 05-01-03-0101-09</Reboot_Line_5>
|
||||||
|
<Reboot_Line_6>2026-07-02 12:48:40 (159) Normal Reboot (21) Firmware Version 05-01-03-0101-09</Reboot_Line_6>
|
||||||
|
</Reboot_Log>
|
||||||
|
<Base_Station_Status>Idle</Base_Station_Status>
|
||||||
|
<Custom_CA_Status>
|
||||||
|
<Custom_CA_Provisioning_Status>N/A</Custom_CA_Provisioning_Status>
|
||||||
|
<Custom_CA_Info>Not Installed</Custom_CA_Info>
|
||||||
|
</Custom_CA_Status>
|
||||||
|
<Dot1x_Authentication>
|
||||||
|
<Transaction_status>Unavailable</Transaction_status>
|
||||||
|
<Protocol>N/A</Protocol>
|
||||||
|
</Dot1x_Authentication>
|
||||||
|
<RSSI_List>
|
||||||
|
</RSSI_List>
|
||||||
|
<RTP_Usage>
|
||||||
|
<Total_RTP>2</Total_RTP>
|
||||||
|
<Max_RTP>-1</Max_RTP>
|
||||||
|
<Time_In_Max_RTP>49710 days 06:28:15 [H:M:S]</Time_In_Max_RTP>
|
||||||
|
<Current_RTP>0</Current_RTP>
|
||||||
|
<Current_Local_RTP>0</Current_Local_RTP>
|
||||||
|
<Current_Relay_RTP>0</Current_Relay_RTP>
|
||||||
|
<Remote_Relay_RTP>0</Remote_Relay_RTP>
|
||||||
|
<Current_Recording>0</Current_Recording>
|
||||||
|
</RTP_Usage>
|
||||||
|
<Device_Presence>
|
||||||
|
</Device_Presence>
|
||||||
|
<Device_FWU_Info>
|
||||||
|
<Device_Base_Station>Base type:DBS-210-3PC - Required Version:501 Required Branch:309</Device_Base_Station>
|
||||||
|
<Device_Line_0>Device type:6825 - Required Version:501 Required Branch:308 Language Pack:6825_default</Device_Line_0>
|
||||||
|
<Device_Line_1>Device type:6825-RGD - Required Version:501 Required Branch:308 Language Pack:6825-RGD_default</Device_Line_1>
|
||||||
|
<Device_Line_2>Device type:6823 - Required Version:501 Required Branch:308 Language Pack:6823_default</Device_Line_2>
|
||||||
|
<Device_Line_3>Device type:RPT-110-3PC - Required Version:501 Required Branch:303</Device_Line_3>
|
||||||
|
</Device_FWU_Info>
|
||||||
|
<Push_To_Talk>Off</Push_To_Talk>
|
||||||
|
<Emergency_Calls>
|
||||||
|
<Emergency_Number_1>911</Emergency_Number_1>
|
||||||
|
<Emergency_Number_2>1911</Emergency_Number_2>
|
||||||
|
<Emergency_Number_3>933</Emergency_Number_3>
|
||||||
|
<Emergency_Number_4>No Number set!</Emergency_Number_4>
|
||||||
|
<Emergency_Number_5>No Number set!</Emergency_Number_5>
|
||||||
|
</Emergency_Calls>
|
||||||
|
<SIP_Identity_Status>
|
||||||
|
</SIP_Identity_Status>
|
||||||
|
</System_Information>
|
||||||
|
<Device_Information>
|
||||||
|
</Device_Information>
|
||||||
|
<Statistics>
|
||||||
|
<Network_Statistics>
|
||||||
|
<Tx_Packets>1959</Tx_Packets>
|
||||||
|
<Tx_Blocked>0</Tx_Blocked>
|
||||||
|
<Tx_Dropped>0</Tx_Dropped>
|
||||||
|
<Tx_Errors>0</Tx_Errors>
|
||||||
|
<Tx_Broadcasts>0</Tx_Broadcasts>
|
||||||
|
<Rx_Packets>49318</Rx_Packets>
|
||||||
|
<Rx_Blocked>0</Rx_Blocked>
|
||||||
|
<Rx_Dropped>9</Rx_Dropped>
|
||||||
|
<Rx_Errors>0</Rx_Errors>
|
||||||
|
<Rx_Broadcasts>5834</Rx_Broadcasts>
|
||||||
|
</Network_Statistics>
|
||||||
|
<Header_Line_Idx>RPN, MAC-Addr, OP[s], DT[s]</Header_Line_Idx>
|
||||||
|
</Statistics>
|
||||||
|
</Status>`;
|
||||||
|
|
||||||
|
// ─── Low-level parser ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('xmlToObject: parses the DBS-210 status.xml shape into a nested object', () => {
|
||||||
|
const tree = xmlToObject(FIXTURE);
|
||||||
|
assert.ok(tree.Status, 'root element is Status');
|
||||||
|
assert.equal(tree.Status.System_Information.Phone_Type, 'IPDECT-V2 (DBS-210-3PC)');
|
||||||
|
assert.equal(tree.Status.System_Information.MAC_Address, '001122334455');
|
||||||
|
// Nested Firmware_URL is a child object, not a string.
|
||||||
|
assert.equal(typeof tree.Status.System_Information.Firmware_URL, 'object');
|
||||||
|
assert.equal(tree.Status.System_Information.Firmware_URL.Path, 'dms/dbS210');
|
||||||
|
// Empty <RSSI_List>\n</RSSI_List> becomes an empty leaf string —
|
||||||
|
// XML alone can't distinguish empty-container from empty-leaf, so
|
||||||
|
// the parser stays neutral. parseStatusXml() then normalizes both
|
||||||
|
// shapes into `[]` for callers that expect a container.
|
||||||
|
assert.equal(tree.Status.System_Information.RSSI_List, '');
|
||||||
|
// Statistics has the massive CSV header preserved as a single string.
|
||||||
|
assert.match(tree.Status.Statistics.Header_Line_Idx, /RPN, MAC-Addr/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('xmlToObject: preserves whitespace inside leaf text values', () => {
|
||||||
|
const tree = xmlToObject(FIXTURE);
|
||||||
|
// RFPI has a `; ` separator that must survive intact.
|
||||||
|
assert.equal(tree.Status.System_Information.RFPI_Address, 'ABCDEF12; RPN:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('xmlToObject: throws on empty input', () => {
|
||||||
|
assert.throws(() => xmlToObject(''), /empty input/);
|
||||||
|
assert.throws(() => xmlToObject('<?xml version="1.0"?>'), /empty input/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('xmlToObject: throws on non-string input', () => {
|
||||||
|
assert.throws(() => xmlToObject(null), /expected a string/);
|
||||||
|
assert.throws(() => xmlToObject(123), /expected a string/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Reboot line parser ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('parseRebootLine: normal reboot line', () => {
|
||||||
|
const p = parseRebootLine('2026-07-02 13:11:46 (164) Normal Reboot (21) Firmware Version 05-01-03-0101-09');
|
||||||
|
assert.equal(p.at, '2026-07-02T13:11:46');
|
||||||
|
assert.equal(p.sequence, 164);
|
||||||
|
assert.equal(p.reasonName, 'Normal Reboot');
|
||||||
|
assert.equal(p.reasonCode, 21);
|
||||||
|
assert.equal(p.firmwareAtBoot, '05-01-03-0101-09');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseRebootLine: power loss line', () => {
|
||||||
|
const p = parseRebootLine('2026-07-02 12:54:12 (161) Power Loss (80) Firmware Version 05-01-03-0101-09');
|
||||||
|
assert.equal(p.reasonName, 'Power Loss');
|
||||||
|
assert.equal(p.reasonCode, 80);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseRebootLine: marks unrecognized shapes without dropping them', () => {
|
||||||
|
const p = parseRebootLine('Something totally different');
|
||||||
|
assert.equal(p.unrecognized, true);
|
||||||
|
assert.equal(p.raw, 'Something totally different');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseRebootLine: empty/null input', () => {
|
||||||
|
assert.equal(parseRebootLine(''), null);
|
||||||
|
assert.equal(parseRebootLine(' '), null);
|
||||||
|
assert.equal(parseRebootLine(null), null);
|
||||||
|
assert.equal(parseRebootLine(undefined), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── High-level parseStatusXml ──────────────────────────────────────
|
||||||
|
|
||||||
|
test('parseStatusXml: extracts core device identity', () => {
|
||||||
|
const s = parseStatusXml(FIXTURE);
|
||||||
|
assert.equal(s.device.model, 'IPDECT-V2 (DBS-210-3PC)');
|
||||||
|
assert.equal(s.device.macAddress, '00:11:22:33:44:55'); // normalized colon-form
|
||||||
|
assert.equal(s.device.ipAddress, '10.0.0.100');
|
||||||
|
assert.equal(s.device.rfBand, 'US');
|
||||||
|
assert.equal(s.device.rfpiAddress, 'ABCDEF12; RPN:00');
|
||||||
|
assert.equal(s.device.releasedBuild, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseStatusXml: firmware version and required-per-device map', () => {
|
||||||
|
const s = parseStatusXml(FIXTURE);
|
||||||
|
assert.equal(s.firmware.version, 'IPDECT-V2/05-01-03-0101-09/18-Mar-2026 10:29');
|
||||||
|
assert.equal(s.firmware.updateServer, 'https://example.invalid');
|
||||||
|
assert.equal(s.firmware.updatePath, 'dms/dbS210');
|
||||||
|
// Device_Base_Station → keyed by model
|
||||||
|
assert.deepEqual(s.firmware.requiredFor['DBS-210-3PC'], {
|
||||||
|
requiredVersion: '501',
|
||||||
|
requiredBranch: '309',
|
||||||
|
languagePack: null,
|
||||||
|
_sourceKey: 'Device_Base_Station',
|
||||||
|
});
|
||||||
|
// Handset lines carry language pack too
|
||||||
|
assert.equal(s.firmware.requiredFor['6825'].languagePack, '6825_default');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseStatusXml: operating time gets converted to seconds', () => {
|
||||||
|
const s = parseStatusXml(FIXTURE);
|
||||||
|
assert.equal(s.time.operatingTime, '00:10:20 (H:M:S)');
|
||||||
|
assert.equal(s.time.operatingTimeSeconds, 620); // 10m 20s
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseStatusXml: multi-cell role parsed to a normalized token', () => {
|
||||||
|
const s = parseStatusXml(FIXTURE);
|
||||||
|
assert.equal(s.multiCell.role, 'unchained');
|
||||||
|
assert.match(s.multiCell.raw, /TXT_STATE_UNCHAINED/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseStatusXml: reboot log is 6 sorted entries with structured fields', () => {
|
||||||
|
const s = parseStatusXml(FIXTURE);
|
||||||
|
assert.equal(s.rebootLog.length, 6);
|
||||||
|
// First entry = the newest by sequence#, matching physical order in the XML.
|
||||||
|
assert.equal(s.rebootLog[0].sequence, 164);
|
||||||
|
assert.equal(s.rebootLog[0].reasonName, 'Normal Reboot');
|
||||||
|
// The Power Loss event is entry #4 (sequence 161).
|
||||||
|
const powerLoss = s.rebootLog.find((r) => r.reasonCode === 80);
|
||||||
|
assert.ok(powerLoss);
|
||||||
|
assert.equal(powerLoss.reasonName, 'Power Loss');
|
||||||
|
assert.equal(powerLoss.sequence, 161);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseStatusXml: network stats decoded as numbers', () => {
|
||||||
|
const s = parseStatusXml(FIXTURE);
|
||||||
|
assert.equal(s.network.txPackets, 1959);
|
||||||
|
assert.equal(s.network.rxPackets, 49318);
|
||||||
|
assert.equal(s.network.rxDropped, 9);
|
||||||
|
assert.equal(s.network.rxErrors, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseStatusXml: emergency numbers filter out "No Number set" placeholders', () => {
|
||||||
|
const s = parseStatusXml(FIXTURE);
|
||||||
|
assert.deepEqual(s.emergencyNumbers, ['911', '1911', '933']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseStatusXml: RTP usage counters', () => {
|
||||||
|
const s = parseStatusXml(FIXTURE);
|
||||||
|
assert.equal(s.rtp.total, 2);
|
||||||
|
assert.equal(s.rtp.current, 0);
|
||||||
|
assert.equal(s.rtp.max, -1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseStatusXml: security / dot1x / customCA', () => {
|
||||||
|
const s = parseStatusXml(FIXTURE);
|
||||||
|
assert.equal(s.security.customCa.installed, false);
|
||||||
|
assert.equal(s.security.customCa.info, 'Not Installed');
|
||||||
|
assert.equal(s.security.dot1x.transactionStatus, 'Unavailable');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseStatusXml: pushToTalk feature flag', () => {
|
||||||
|
const s = parseStatusXml(FIXTURE);
|
||||||
|
assert.equal(s.features.pushToTalk, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Health verdict ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('summarizeBaseHealth: fixture flags power-loss reboot and short uptime', () => {
|
||||||
|
const s = parseStatusXml(FIXTURE);
|
||||||
|
const verdict = summarizeBaseHealth(s);
|
||||||
|
assert.equal(verdict.healthy, false);
|
||||||
|
// Uptime 620s (~10 min) is < 600 → boundary, so no uptime warning.
|
||||||
|
// But we DO have a power loss in the log, so healthy=false via that.
|
||||||
|
assert.ok(
|
||||||
|
verdict.warnings.some((w) => /power-loss/i.test(w)),
|
||||||
|
`expected power-loss warning; got: ${JSON.stringify(verdict.warnings)}`,
|
||||||
|
);
|
||||||
|
// rxDropped=9 → info, not warning
|
||||||
|
assert.ok(
|
||||||
|
verdict.info.some((i) => /rx dropped/i.test(i)),
|
||||||
|
`expected rx-dropped info; got: ${JSON.stringify(verdict.info)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizeBaseHealth: RF conflict is flagged as a warning', () => {
|
||||||
|
const s = parseStatusXml(FIXTURE.replace(
|
||||||
|
'<Conflict_Info>No Conflict</Conflict_Info>',
|
||||||
|
'<Conflict_Info>Conflict Detected on RPN 00</Conflict_Info>',
|
||||||
|
));
|
||||||
|
const verdict = summarizeBaseHealth(s);
|
||||||
|
assert.ok(verdict.warnings.some((w) => /RF conflict/i.test(w)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizeBaseHealth: very short uptime is flagged as recent reboot', () => {
|
||||||
|
const s = parseStatusXml(FIXTURE.replace(
|
||||||
|
'<Operating_Time>00:10:20 (H:M:S)</Operating_Time>',
|
||||||
|
'<Operating_Time>00:02:15 (H:M:S)</Operating_Time>',
|
||||||
|
));
|
||||||
|
const verdict = summarizeBaseHealth(s);
|
||||||
|
assert.ok(verdict.warnings.some((w) => /rebooted very recently/i.test(w)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizeBaseHealth: healthy base with clean stats returns healthy:true', () => {
|
||||||
|
// Wipe rx_dropped and the power-loss reboot line so nothing warns.
|
||||||
|
const cleaned = FIXTURE
|
||||||
|
.replace(/<Rx_Dropped>\d+<\/Rx_Dropped>/, '<Rx_Dropped>0</Rx_Dropped>')
|
||||||
|
.replace(
|
||||||
|
/<Reboot_Line_4>[^<]+<\/Reboot_Line_4>/,
|
||||||
|
'<Reboot_Line_4>2026-07-02 12:54:12 (161) Normal Reboot (21) Firmware Version 05-01-03-0101-09</Reboot_Line_4>',
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
'<Operating_Time>00:10:20 (H:M:S)</Operating_Time>',
|
||||||
|
'<Operating_Time>24:15:00 (H:M:S)</Operating_Time>',
|
||||||
|
);
|
||||||
|
const s = parseStatusXml(cleaned);
|
||||||
|
const verdict = summarizeBaseHealth(s);
|
||||||
|
assert.equal(verdict.healthy, true, `not healthy: ${JSON.stringify(verdict.warnings)}`);
|
||||||
|
assert.equal(verdict.warnings.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizeBaseHealth: bad input degrades gracefully', () => {
|
||||||
|
assert.equal(summarizeBaseHealth(null).healthy, false);
|
||||||
|
assert.equal(summarizeBaseHealth(undefined).healthy, false);
|
||||||
|
assert.equal(summarizeBaseHealth('nope').healthy, false);
|
||||||
|
});
|
||||||
160
utils/httpDigestAuth.js
Normal file
160
utils/httpDigestAuth.js
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
// src/utils/httpDigestAuth.js
|
||||||
|
//
|
||||||
|
// HTTP Digest Authentication (RFC 7616, and the older RFC 2617 flavour
|
||||||
|
// that most embedded devices still speak). Pure, dependency-free — no
|
||||||
|
// network calls, no state. Feed it the parsed WWW-Authenticate params
|
||||||
|
// plus the credentials and it hands back the `Authorization: Digest ...`
|
||||||
|
// header value.
|
||||||
|
//
|
||||||
|
// Why we need this: axios' built-in `auth: {username, password}` only
|
||||||
|
// speaks Basic. The Cisco DBS-210 DECT base station's admin UI (and
|
||||||
|
// most Cisco small-business voice devices, they're all cousins of the
|
||||||
|
// Sipura SPA family) rejects Basic and challenges with
|
||||||
|
// WWW-Authenticate: Digest realm="", nonce="...", algorithm="MD5", qop="auth"
|
||||||
|
// so every request needs a fresh Digest hash. The DBS-210 also sets
|
||||||
|
// `Clear-Site-Data: "cookies"` on every response, so we can't fall
|
||||||
|
// back on a session cookie either — the Digest header goes on every
|
||||||
|
// single call.
|
||||||
|
//
|
||||||
|
// Cisco quirk we handle explicitly: the realm can be an EMPTY string.
|
||||||
|
// The Digest spec allows this, but some libraries silently drop empty
|
||||||
|
// realms which corrupts HA1. We preserve `realm` exactly as sent.
|
||||||
|
|
||||||
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
const md5 = (s) => createHash('md5').update(s, 'utf8').digest('hex');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the value of a `WWW-Authenticate: Digest ...` header into
|
||||||
|
* a plain object. Handles quoted values with commas inside them and
|
||||||
|
* unquoted tokens like `algorithm=MD5`.
|
||||||
|
*
|
||||||
|
* Example input:
|
||||||
|
* Digest realm="", nonce="abc123", algorithm="MD5", qop="auth"
|
||||||
|
* Example output:
|
||||||
|
* { scheme: 'digest', realm: '', nonce: 'abc123', algorithm: 'MD5', qop: 'auth' }
|
||||||
|
*
|
||||||
|
* @param {string} headerValue Full value of the WWW-Authenticate header.
|
||||||
|
* @returns {object|null} Parsed params, or null if not a Digest challenge.
|
||||||
|
*/
|
||||||
|
export function parseDigestChallenge(headerValue) {
|
||||||
|
if (typeof headerValue !== 'string') return null;
|
||||||
|
const trimmed = headerValue.trim();
|
||||||
|
const schemeMatch = trimmed.match(/^([A-Za-z]+)\s+/);
|
||||||
|
if (!schemeMatch || schemeMatch[1].toLowerCase() !== 'digest') return null;
|
||||||
|
|
||||||
|
const rest = trimmed.slice(schemeMatch[0].length);
|
||||||
|
|
||||||
|
// Tokenizer: walk char-by-char so we don't split inside quoted strings.
|
||||||
|
const params = { scheme: 'digest' };
|
||||||
|
let i = 0;
|
||||||
|
const len = rest.length;
|
||||||
|
while (i < len) {
|
||||||
|
// skip whitespace + commas between params
|
||||||
|
while (i < len && (rest[i] === ' ' || rest[i] === ',')) i++;
|
||||||
|
if (i >= len) break;
|
||||||
|
|
||||||
|
// read key up to '='
|
||||||
|
const keyStart = i;
|
||||||
|
while (i < len && rest[i] !== '=') i++;
|
||||||
|
const key = rest.slice(keyStart, i).trim().toLowerCase();
|
||||||
|
if (i >= len) break;
|
||||||
|
i++; // skip '='
|
||||||
|
|
||||||
|
// read value: quoted or unquoted
|
||||||
|
let value;
|
||||||
|
if (rest[i] === '"') {
|
||||||
|
i++; // skip opening quote
|
||||||
|
const valStart = i;
|
||||||
|
while (i < len && rest[i] !== '"') {
|
||||||
|
// very light escape handling for \" inside the value
|
||||||
|
if (rest[i] === '\\' && i + 1 < len) i++;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
value = rest.slice(valStart, i);
|
||||||
|
if (rest[i] === '"') i++; // skip closing quote
|
||||||
|
} else {
|
||||||
|
const valStart = i;
|
||||||
|
while (i < len && rest[i] !== ',' && rest[i] !== ' ') i++;
|
||||||
|
value = rest.slice(valStart, i);
|
||||||
|
}
|
||||||
|
params[key] = value;
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the `Authorization: Digest ...` header value for a given
|
||||||
|
* request, challenge, and credentials. Implements MD5 with qop=auth
|
||||||
|
* (the flavour the DBS-210 uses); MD5-sess and qop=auth-int are
|
||||||
|
* not supported because we don't need them and adding them without a
|
||||||
|
* device that speaks them would be untested code.
|
||||||
|
*
|
||||||
|
* @param {object} args
|
||||||
|
* @param {string} args.username
|
||||||
|
* @param {string} args.password
|
||||||
|
* @param {string} args.method HTTP method, e.g. 'GET', 'POST'
|
||||||
|
* @param {string} args.uri Request-URI (path + query), NOT the full URL
|
||||||
|
* @param {object} args.challenge Parsed WWW-Authenticate params
|
||||||
|
* @param {number} [args.nc] Nonce count; each new request against
|
||||||
|
* the same nonce should increment this.
|
||||||
|
* Default 1 (fine for the "one-shot per
|
||||||
|
* request" pattern we use).
|
||||||
|
* @param {string} [args.cnonce] Client nonce. Randomly generated if omitted.
|
||||||
|
* @returns {string} Value for the `Authorization` header.
|
||||||
|
*/
|
||||||
|
export function buildDigestAuthHeader({
|
||||||
|
username, password, method, uri, challenge, nc = 1, cnonce,
|
||||||
|
}) {
|
||||||
|
if (!challenge || typeof challenge !== 'object') {
|
||||||
|
throw new Error('buildDigestAuthHeader: challenge is required');
|
||||||
|
}
|
||||||
|
const algorithm = (challenge.algorithm || 'MD5').toUpperCase();
|
||||||
|
if (algorithm !== 'MD5') {
|
||||||
|
throw new Error(`buildDigestAuthHeader: unsupported algorithm "${algorithm}"`);
|
||||||
|
}
|
||||||
|
// Split qop by comma; server may advertise "auth,auth-int". We pick
|
||||||
|
// 'auth' always (the DBS-210 only lists 'auth' anyway).
|
||||||
|
const qopList = (challenge.qop || '')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
const useQop = qopList.includes('auth') ? 'auth' : (qopList[0] || null);
|
||||||
|
|
||||||
|
const realm = challenge.realm ?? ''; // preserve empty realm exactly
|
||||||
|
const nonce = challenge.nonce || '';
|
||||||
|
const opaque = challenge.opaque;
|
||||||
|
const ncHex = String(nc).padStart(8, '0');
|
||||||
|
const cnonceStr = cnonce || randomBytes(8).toString('hex');
|
||||||
|
|
||||||
|
const HA1 = md5(`${username}:${realm}:${password}`);
|
||||||
|
const HA2 = md5(`${method.toUpperCase()}:${uri}`);
|
||||||
|
|
||||||
|
let response;
|
||||||
|
if (useQop) {
|
||||||
|
response = md5(`${HA1}:${nonce}:${ncHex}:${cnonceStr}:${useQop}:${HA2}`);
|
||||||
|
} else {
|
||||||
|
// Legacy RFC 2069 fallback (no qop). DBS-210 always sends qop=auth
|
||||||
|
// so this branch is defensive-only, not exercised in practice.
|
||||||
|
response = md5(`${HA1}:${nonce}:${HA2}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the header. Ordering doesn't matter to servers, but grouping
|
||||||
|
// matches what most reference implementations emit so it's easy to
|
||||||
|
// eyeball in a packet capture.
|
||||||
|
const parts = [
|
||||||
|
`username="${username}"`,
|
||||||
|
`realm="${realm}"`,
|
||||||
|
`nonce="${nonce}"`,
|
||||||
|
`uri="${uri}"`,
|
||||||
|
`algorithm=${algorithm}`,
|
||||||
|
`response="${response}"`,
|
||||||
|
];
|
||||||
|
if (useQop) {
|
||||||
|
parts.push(`qop=${useQop}`, `nc=${ncHex}`, `cnonce="${cnonceStr}"`);
|
||||||
|
}
|
||||||
|
if (opaque !== undefined) {
|
||||||
|
parts.push(`opaque="${opaque}"`);
|
||||||
|
}
|
||||||
|
return `Digest ${parts.join(', ')}`;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue