Compare commits

...

10 commits

Author SHA1 Message Date
07152a467b Add scripts/removeAdvancedMessaging.js + extract shared bulk lib
New scripts/removeAdvancedMessaging.js reads a Users Export CSV
and bulk-removes the Advanced Messaging and Advanced Space Meetings
licenses from every listed user (with optional add of a Basic
Messaging license, though in most Webex orgs Basic Messaging is a
derived entitlement and no explicit add is required).

Detection is authoritative like the reclaim script: the assignee
rosters of the two Advanced licenses are fetched once up-front,
unioned by email, and the CSV is cross-referenced. PersonIds come
straight off the roster (no per-user /people lookup). Only the
remove ops the user actually still needs are emitted — the PATCH
body is trimmed per user based on which licenses they hold.

Dry-run enumerates every org license whose name matches
/message|advanced|space|basic/i so the operator can discover the
three ids without prior knowledge. --advanced-messaging-license-id,
--advanced-space-meetings-license-id, and --basic-messaging-license-id
also read WEBEX_ADV_MSG_LICENSE_ID / WEBEX_ADV_SPACE_MTG_LICENSE_ID
/ WEBEX_BASIC_MSG_LICENSE_ID from .env if set.

Also extracted the CSV parsing, format detection, pool/retry
helpers, and Webex license helpers from reclaimWebexHosts.js into
a shared scripts/lib/webexBulk.js module. reclaimWebexHosts.js now
imports from it — no behavior change (verified against both CSV
formats: 1028 candidates on the Meetings Inactive Users report,
665 on the Users Export report). Net -106 lines from the reclaim
script.

.gitignore updates:
  - whitelist scripts/lib/ and the new removeAdvancedMessaging.js
    file so they get tracked
  - exclude reclaim-*.csv and remove-*.csv (per-user report CSVs
    generated by --report contain PII and must never be committed)
2026-07-07 15:54:37 -04:00
8777e51d10 Filter users-export by User Status (Inactive or Verified), not days
For the "Users Export" CSV, the target population is any account
Webex has flagged as not currently in use — that's status=Inactive
(previously active, now idle) or status=Verified (never signed in).
"Days since Last Service Accessed" is dropped as a filter criterion
because a Verified user has never signed in and therefore has a
blank days value. --min-days is documented as ignored for this
format.

The candidate record still carries days (nullable) so the sample
line and --report CSV can show it as informational context. Added
a "status" column to the report and to the audit-friendly console
sample.

Also prints every distinct User Status seen with counts, so the
operator can spot surprise values (e.g. the one "FALSE" row in the
current export) before hitting --execute.
2026-07-07 15:37:08 -04:00
8c6de65bb0 Support Control Hub "Users Export" CSV in reclaimWebexHosts
Auto-detect CSV format from the header:
  • meetings-inactive: EMAIL / IS_HOST / DAYS_SINCE_LAST_ACTIVE
    (Analyzer → Meetings → Inactive Users)
  • users-export: "User ID/Email (Required)" /
    "Days since Last Service Accessed"
    (Users → Manage users → Export)

The users-export report has no host flag, but we don't need one —
the authoritative host-holder set comes from the live assignee
roster fetched from the Webex API. Format-B rows with blank
"Days since Last Service Accessed" (never-signed-in accounts,
often generic mailroom/store logins) are intentionally skipped so
they aren't silently reclaimed.

Also stopped upper-casing the header so we can preserve the
punctuation-rich column names Users Export uses verbatim.
2026-07-07 15:34:11 -04:00
c996d5d32e Add scripts/reclaimWebexHosts.js — bulk host license reclaim
Reads a Control Hub "Meetings Inactive Users" CSV, filters to
IS_HOST=Y AND DAYS_SINCE_LAST_ACTIVE > --min-days (default 120),
cross-references against the current holders of --host-license-id
(so no per-user /people lookup), then PATCHes /v1/licenses/users to
atomically remove the host license and either (a) add a specific
free-tier license (--free-license-id) or (b) add attendee-only
siteUrl on --site (--free-attendee).

Dry-run by default; enumerates every license on the site so the
operator can pick the free tier. Bounded concurrency with 429/503
retry, optional --offset/--limit for staged rollouts, per-user
outcome CSV via --report, and full audit trail via the existing
webex:reclaim:audit log scope.

Whitelisted in .gitignore so it stays version-controlled alongside
the other tracked operational scripts.
2026-07-07 15:02:04 -04:00
59460b849b Fix DECT relay container: install node_modules at /workspace, not under agent
The runtime container failed with ERR_MODULE_NOT_FOUND: axios when
integrations/cisco-dect/client.js tried to load. Root cause is
Node's ESM resolver: it walks UP from the IMPORTING file looking
for node_modules, never sideways into siblings.

Container filesystem before:
  /workspace/dect-relay-agent/node_modules/       <- axios lives here
  /workspace/dect-relay-agent/index.js            <- ok, finds it by walking up
  /workspace/integrations/cisco-dect/client.js    <- walks up to /, never sees axios

Node 20.20 has --experimental-detect-module ON by default, so
client.js is still treated as ESM (starts with `import`), and the
resolver correctly reports "cannot find package 'axios'" rather
than syntax-erroring on the import keyword. But it still can't find
the package — the location is wrong.

Fix: install node_modules at /workspace/ so BOTH the agent AND the
shared modules can find it by walking up.

  /workspace/node_modules/                        <- axios here now
  /workspace/package.json                         <- also here, "type":"module" for all descendants
  /workspace/dect-relay-agent/index.js            <- walks up to /workspace/node_modules ✓
  /workspace/integrations/cisco-dect/client.js    <- walks up to /workspace/node_modules ✓
  /workspace/utils/httpDigestAuth.js              <- same ✓

WORKDIR moves from /workspace/dect-relay-agent to /workspace, and
CMD changes accordingly:
  node --enable-source-maps dect-relay-agent/index.js

Rebuild + reship the bundle with `./dect-relay-agent/bundle.sh` and
`./install.sh` on the DC host — it's an idempotent upgrade.

Verified locally (agent modules import cleanly using the same
directory shape as the container).
2026-07-03 10:15:57 -04:00
4f9ebdb5fb Rework DECT relay bundle to ship a pre-built Docker image
The previous packager (scripts/packageDectRelayAgent.js) shipped a
source-only bundle and expected the DC host to build the image with
`docker compose up --build`. That fails hard in corporate DCs with
TLS-intercepted egress: Alpine's apk fetch of dl-cdn.alpinelinux.org
can't verify the intercepted certificate ("apk: TLS: server
certificate not trusted"), and npm install would fail the same way
if apk had succeeded.

New approach: build the image ONCE on the dev machine (where TLS
works), save it as a gzipped tarball, and ship a ZIP whose install
step is `docker load` + `docker compose up -d`. Zero network calls
inside the DC container, ever.

Bundling (dev-machine):
- dect-relay-agent/bundle.sh: build → docker save → gzip → zip.
  Auto-derives version from package.json, records git sha + dirty
  flag + build date into image labels. Cross-arch friendly
  (--platform=linux/amd64 by default; --platform linux/arm64 for
  ARM DCs). Output: dect-relay-agent-bundle-<YYYYMMDD-HHMMSS>.zip
  at repo root (typically 40-60MB).
- dect-relay-agent/Dockerfile: multi-stage node:20-alpine build.
  No apk add. No runtime npm install. Non-root `node` user (uid
  1000). Node handles SIGTERM natively via index.js handlers, so
  no tini/dumb-init needed. Designed to build from the REPO ROOT
  (not the agent folder) because the agent imports shared modules
  from ../integrations/cisco-dect and ../utils.
- dect-relay-agent/Dockerfile.dockerignore: per-Dockerfile ignore
  (BuildKit ≥ 23.0) with a whitelist that keeps the build context
  to ~50KB. Older Docker daemons fall through to the repo-root
  .dockerignore, which already excludes secrets — nothing sensitive
  can leak either way.
- package.json: `npm run package:relay` now invokes bundle.sh.

Runtime (DC-host):
- dect-relay-agent/docker-compose.yml: pins IMAGE_TAG from .env
  (install.sh writes it there — never falls back to :latest), reads
  the rest of the config via env_file, restart: unless-stopped,
  host networking (needed to reach 10.x/8 without userland proxy
  translation, and the agent doesn't listen on anything). Hardened:
  read_only: true rootfs with a 16MB /tmp tmpfs, cap_drop: ALL,
  no-new-privileges, log rotation at 10MB × 5 files.
- dect-relay-agent/install.sh: preflight (docker + compose present,
  daemon reachable, bundle files intact), docker load, pin loaded
  tag into .env, validate .env has the three required values not
  still set to placeholder strings, docker compose up -d, tail last
  40 log lines. Idempotent — safe to re-run on upgrades.

Cleanup:
- scripts/packageDectRelayAgent.js: deleted (superseded).
- .gitignore: drops the scripts/* + !packageDectRelayAgent.js dance
  since we no longer need to whitelist that one file; add pattern
  for the datestamped bundle zips + staging dirs at repo root.
- dect-relay-agent/README.md: replaces the deploy section with the
  new dev-machine-build → DC-host-load workflow, plus a
  troubleshooting section keyed on the exact error messages seen
  during the failed in-DC build (TLS cert not trusted, docker perm
  denied, DIGEST_401).

Verified: all 113 existing tests still pass. Docker build itself
requires a Docker daemon (dev machine) so can't be exercised in
this sandbox — the bash scripts pass `bash -n` syntax checks.
2026-07-03 10:05:05 -04:00
e8500b4324 Package dect-relay-agent as a Docker deploy bundle
Adds a one-command packager (`npm run package:relay`) that produces a
self-contained zip ready to transfer into the data center and start
with `docker compose up -d --build`. Three commands on the DC host:
unzip, edit .env, docker compose up.

Why a packager instead of `docker build` in the repo:
The agent's index.js imports the shared cisco-dect + httpDigestAuth
modules via `../integrations/...` paths, so a naive
`docker build dect-relay-agent/` would fail because those files live
outside the build context. The packager copies them into a
`workspace/` tree inside the bundle so the Dockerfile sees them as
local paths without any source rewriting.

Docker artifacts (in dect-relay-agent/):
- Dockerfile: multi-stage node:20-alpine build (~55MB final image),
  non-root `dect` user (UID/GID 1500), tini as PID 1 for clean
  SIGTERM propagation to node's graceful-shutdown path,
  `npm install --omit=dev --ignore-scripts` in the deps stage.
- docker-compose.yml: restart:unless-stopped, JSON log rotation
  (10MB × 5 files), pgrep-based health check. No `ports:` block
  because the agent is outbound-only (dials the bot).
- .dockerignore: defensive — the bundle already excludes cruft, but
  this hardens against a stray manual build.

Packager (scripts/packageDectRelayAgent.js):
- Assembles agent code + shared modules + deploy artifacts into a
  timestamped staging dir (.package-relay-tmp/, git-ignored).
- Generates a bundle README with three-command deploy instructions,
  ongoing-ops table, no-internet-DC fallback (docker save/load), and
  troubleshooting for the most common failure modes.
- Generates BUNDLE_INFO.txt with build metadata (git sha + dirty
  flag + timestamp + size) so the DC operator can trace deployed
  bundles back to source.
- Emits `dist/dect-relay-agent-bundle-<YYYYMMDD-HHMMSS>.zip` (30KB).
- Cleans staging in a finally block so failed runs don't leak.

Bundle layout (matches Dockerfile expectations):
  dect-relay-agent-bundle-<version>/
    Dockerfile, docker-compose.yml, .dockerignore
    .env.example, README.md, BUNDLE_INFO.txt
    workspace/dect-relay-agent/{package.json, index.js}
    workspace/integrations/cisco-dect/{client,probes,statusXml}.js
    workspace/utils/httpDigestAuth.js

Wiring:
- package.json: new `package:relay` and `test` npm scripts.
- .gitignore: `scripts/` changed to `scripts/*` so `!scripts/
  packageDectRelayAgent.js` can re-include just the packager
  (git forbids re-including files under a fully-excluded directory,
  hence the glob form).
- dect-relay-agent/README.md: rewrites deployment section to show
  the Docker path as the recommended production route, with the
  node-directly path kept for local dev.

Verified end-to-end: `npm run package:relay` produces a valid zip
that unpacks to the expected layout in <2s. All 113 existing tests
still pass.
2026-07-03 09:32:26 -04:00
96b26a5aca DECT relay Phase 1: WSS hub + agent + /phonestatus follow-up
The bot runs in the public cloud and can't reach the 10.x/8 network
where DBS-210 bases live. This phase adds a data-center-resident relay
agent that dials outbound over WSS to the bot, and lets /phonestatus
post a follow-up message with per-base health after its main output
has already shipped.

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

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

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

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

Tests (99 -> 113):
- tests/dectDiscovery.test.js: 13 cases covering the 10.x guardrail,
  MAC normalization, IP source preference, dedup, and warning shape.
- tests/dectRelayHub.test.js: 14 integration cases using a real
  ws pair on an ephemeral 127.0.0.1 port — auth (missing / wrong /
  correct via header / correct via protocol fallback), hello frame,
  RPC round-trip with correlation, agent error surfacing, concurrent
  out-of-order replies, timeout, mid-flight disconnect, replacement
  of a stale socket, and execAction routing.
- tests/renderers.test.js: 8 new cases for the DECT-follow-up loading
  hint (plural / singular / off) and the diagnostics renderer (empty,
  healthy, warning, power-loss dedup, active RTP, error hint, footer).
2026-07-02 17:03:32 -04:00
17a8469592 Add DBS-210 status.xml parser + health verdict
Second half of the DECT spike: the read-side "collector" that turns
a raw /admin/status.xml body into a normalized JS object plus a
pure health verdict. This is what will feed the /phonestatus base-
station diagnostics section once we wire it in.

- integrations/cisco-dect/statusXml.js:
  - xmlToObject(): 60-line hand-rolled parser targeted at the
    DBS-210's flat XML shape. No attributes, no CDATA, no comments
    — so we avoid pulling in a generic XML lib. Throws loudly on
    malformed input.
  - parseRebootLine(): decodes the reboot-log entries the device
    keeps in Reboot_Line_1..6, extracting timestamp + sequence #
    + reason name/code + firmware version. Unrecognized shapes come
    back marked `unrecognized:true` instead of being dropped.
  - parseStatusXml(): grouped, camelCased view of the device state
    (device / firmware / time / multiCell / rebootLog / rtp /
    network / security / emergencyNumbers / features). Every field
    is null-safe.
  - summarizeBaseHealth(): pure-function verdict. Flags recent
    reboots (uptime < 10 min), power-loss events in the log,
    DECT RF conflicts, non-zero rx/tx errors. Splits into
    warnings vs info so consumers can render at the right severity.
- tests/statusXml.test.js: 23 tests covering the parser, the
  reboot-line decoder, the higher-level normalizer, and the health
  verdict — using a REDACTED inline copy of a real status.xml
  captured from a lab base. MAC/IP/RFPI/firmware-server URL are
  all obviously-fake so the fixture is safe to commit.
2026-07-02 15:35:34 -04:00
bc56b0a0fb Add Cisco DBS-210 DECT base spike (HTTP Digest client + safe probes)
Spike scaffolding for reverse-engineering the local admin UI on a
Cisco DBS-210 DECT base station. Not wired into the bot yet -- the
plan is a status.xml data-collector next, then a per-store relay
that fronts these calls over a websocket back to the bot.

- utils/httpDigestAuth.js: dependency-free HTTP Digest MD5/qop=auth
  header builder + WWW-Authenticate parser. Preserves empty realm,
  which the DBS-210 sends and which most libs silently drop.
- integrations/cisco-dect/client.js: axios wrapper with self-signed
  TLS bypass and a single-shot Digest challenge/response interceptor.
- integrations/cisco-dect/probes.js: verified-safe read paths only in
  READ_PROBE_PATHS. Every mutating path is quarantined in the
  MUTATING_ACTION_PATHS map and exposed only via explicit trigger
  helpers (reboot/force-reboot/reboot-chain/factory-reset/reconfigure-
  tree) that fetch and attach the CSRF token from /main.html. The
  legacy /admin/reboot.htm alias -- which triggered a real reboot
  during our first blind probe -- is intentionally NOT reachable.
- tests/httpDigestAuth.test.js: 6 unit tests, including the RFC 2617
  canonical example and the DBS-210 empty-realm quirk.
- .env.example: adds DECT_TEST_BASE_IP / _USER / _PASSWORD /
  _TIMEOUT_MS for the local test harness (script itself lives under
  scripts/, which stays gitignored).
- .gitignore: adds .dect-samples/ so lab captures don't leak.
2026-07-02 15:30:42 -04:00
31 changed files with 5465 additions and 13 deletions

View file

@ -216,6 +216,45 @@ SC_PASSWORD=your-sc-password
BACKDOOR_USERNAME=monitor
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
# -----------------------------------------------------------------------------

21
.gitignore vendored
View file

@ -26,7 +26,12 @@ storage/
# Dev / test artifacts (local only)
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
# Backup & temp files
@ -49,6 +54,12 @@ build/
coverage/
.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-compose.override.yml
@ -64,3 +75,11 @@ testobjects.json
meraki-store-topology-demo.html
config/config.json
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

View file

@ -8,11 +8,16 @@
import { randomUUID } from 'node:crypto';
import { collectPhoneStatus } from '../services/phoneService.js';
import { renderPhoneStatusMarkdown } from '../services/renderers/phoneStatusRenderer.js';
import {
renderPhoneStatusMarkdown,
renderDectDiagnosticsMarkdown,
} from '../services/renderers/phoneStatusRenderer.js';
import { buildIgmpFixCard } from './igmpFix.js';
import { pendingIgmpFixes } from '../utils/pendingIgmpFixes.js';
import { extractRequester } from '../utils/requester.js';
import { logger } from '../utils/logger.js';
import { discoverDectBases } from '../services/dectDiscovery.js';
import { collectAll } from '../services/dectCollectorService.js';
export async function handlePhoneStatus(bot, trigger) {
logger('phone:status', 'Handler entered', 'debug');
@ -54,13 +59,45 @@ export async function handlePhoneStatus(bot, trigger) {
return;
}
// Discover reachable DECT bases BEFORE rendering so we can tell
// the renderer how many bases the follow-up will cover. Discovery
// is a pure filter over what phoneService already fetched — no
// network calls, so it doesn't slow the main output. Only chat
// triggers get a follow-up; HTTP callers keep the single-message
// contract they had before.
const dectFollowUpEnabled = !!trigger.person;
const { bases: reachableBases, warnings: discoveryWarnings } = dectFollowUpEnabled
? discoverDectBases(data)
: { bases: [], warnings: [] };
if (discoveryWarnings.length > 0) {
logger(
'phone:status',
`DECT discovery warnings for store ${storeNum}: ${discoveryWarnings.map((w) => w.reason).join('; ')}`,
'warn',
);
}
const reply = renderPhoneStatusMarkdown(data, {
storeNum,
detailed: isDetailed,
footer: true,
dectFollowUpBaseCount: reachableBases.length,
});
await bot.say('markdown', reply || 'No data available.');
// Kick off DECT follow-up. Fire-and-forget from this handler's
// perspective — the awaits inside runDectFollowUp() are just so
// failures get logged with a stable scope, they don't propagate
// back to the user's original /phonestatus call. If the relay is
// offline or a base is unreachable we still post the follow-up
// (with per-base error lines) so the user isn't left wondering
// where the promised diagnostics went.
if (dectFollowUpEnabled && reachableBases.length > 0) {
runDectFollowUp(bot, storeNum, reachableBases).catch((err) => {
logger('phone:status', `DECT follow-up failed for store ${storeNum}: ${err.message}`, 'error');
});
}
// IGMP-snooping remediation card — only when (a) the multicast
// summary flagged deviation AND (b) we know the networkId (can't
// fix what we can't address) AND (c) the invocation came from
@ -110,3 +147,21 @@ export async function handlePhoneStatus(bot, trigger) {
await bot.say('markdown', `Error collecting phone status: ${err.message}`);
}
}
/**
* Run the DECT-diagnostics follow-up as a separate message in the
* same room. Only invoked from chat triggers. Errors are logged
* (never thrown up) the /phonestatus main output has already been
* sent by the time we get here, so a follow-up crash shouldn't leave
* the user with a broken chat experience.
*
* Renderer emits an empty string only when the results list is empty
* which shouldn't happen because we already checked reachableBases
* .length > 0 at the call site, but we still guard against it here.
*/
async function runDectFollowUp(bot, storeNum, bases) {
const results = await collectAll(bases);
const md = renderDectDiagnosticsMarkdown(results, { storeNum });
if (!md) return;
await bot.say('markdown', md);
}

View file

@ -0,0 +1,48 @@
# =============================================================================
# DECT Relay Agent — data-center-resident bridge to Cisco DBS-210 bases
# =============================================================================
#
# This agent runs INSIDE the corporate network (has route to 10.x/8)
# and dials outbound over WSS to the CollabSupport bot. The bot
# process itself runs in the public cloud and can't reach 10.x
# directly; this agent is the only thing that can talk to a DBS-210.
#
# See README.md in this folder for run instructions.
# ─── Where to dial the bot ───────────────────────────────────────────
#
# Full WSS URL to the bot's DECT relay endpoint. Must be wss:// (never
# ws:// — the bearer token would be visible in cleartext). The path
# defaults to /dect-relay/ws to match the bot's DECT_RELAY_PATH env
# on the other side; only change here if you've changed it there too.
DECT_RELAY_BOT_URL=wss://your-bot-host.example.com/dect-relay/ws
# Shared bearer token — MUST match the bot's DECT_RELAY_AGENT_TOKEN
# exactly. Rotate both sides at once to avoid a lockout window.
# Suggested generation: `openssl rand -hex 32`
DECT_RELAY_AGENT_TOKEN=replace-with-shared-secret
# Optional friendly identifier reported to the bot on hello.
# Shows up in the bot's logs and eventually /dectstatus admin views.
# Defaults to os.hostname() if unset.
# DECT_RELAY_AGENT_HOSTNAME=dc-dect-relay-01
# ─── DBS-210 admin credentials ───────────────────────────────────────
#
# Cisco tenants share ONE serviceability password across all bases
# in the fleet (configured in Control Hub → Calling → Features →
# DECT Networks → Manage → Manage DECT serviceability password), so
# a single credential works for every 10.x base this agent can reach.
DECT_ADMIN_USER=admin
DECT_ADMIN_PASSWORD=replace-with-dect-serviceability-password
# Per-request HTTPS timeout when talking to a DBS-210. Bases going
# through a corporate proxy can be slow — 30s is comfortable, 15s
# is aggressive.
DECT_ADMIN_TIMEOUT_MS=30000
# ─── Optional tuning ─────────────────────────────────────────────────
#
# How long to wait between reconnect attempts when the bot socket
# drops. Uses exponential backoff up to this cap.
# DECT_RELAY_RECONNECT_MAX_MS=30000

118
dect-relay-agent/Dockerfile Normal file
View 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"]

View 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
View 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
View 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 "════════════════════════════════════════════════════════════════"

View 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
View file

@ -0,0 +1,325 @@
#!/usr/bin/env node
// dect-relay-agent/index.js
//
// Data-center-resident WebSocket client that bridges the CollabSupport
// bot (running in the public cloud) to Cisco DBS-210 DECT base
// stations on the private 10.x/8 corporate network.
//
// Runtime shape:
// 1. On startup, dial `wss://<bot>/dect-relay/ws` with the shared
// bearer token from DECT_RELAY_AGENT_TOKEN.
// 2. Send a `hello` frame declaring version + hostname + supported
// command types.
// 3. Loop waiting for command frames from the bot. Dispatch each
// into the (already-tested) integrations/cisco-dect/ modules
// shared with the bot's own spike CLI (scripts/testDectBase.js).
// 4. Reply with `{id, ok, result|error, elapsedMs}` per command.
// 5. On disconnect, reconnect with exponential backoff. Restart the
// cycle from step 2 (a fresh hello) so the bot's registry is
// always in sync with the agent's actual capabilities.
//
// This agent does NOT store DECT credentials in transit — the bearer
// token is per-agent. DBS-210 admin creds live only in THIS process's
// .env and never leave the DC.
import 'dotenv/config';
import WebSocket from 'ws';
import os from 'node:os';
import { createDectClient } from '../integrations/cisco-dect/client.js';
import {
triggerReboot,
triggerRebootChain,
triggerFactoryReset,
triggerReconfigureDectTree,
} from '../integrations/cisco-dect/probes.js';
import {
parseStatusXml,
summarizeBaseHealth,
} from '../integrations/cisco-dect/statusXml.js';
// ─── Config ─────────────────────────────────────────────────────────
const CFG = {
botUrl: process.env.DECT_RELAY_BOT_URL,
token: process.env.DECT_RELAY_AGENT_TOKEN,
hostname: process.env.DECT_RELAY_AGENT_HOSTNAME || os.hostname(),
dectUser: process.env.DECT_ADMIN_USER || 'admin',
dectPass: process.env.DECT_ADMIN_PASSWORD,
dectTimeout: Number(process.env.DECT_ADMIN_TIMEOUT_MS) || 30_000,
reconnectMaxMs: Number(process.env.DECT_RELAY_RECONNECT_MAX_MS) || 30_000,
};
const AGENT_VERSION = '0.1.0';
const CAPABILITIES = [
'collect',
'reboot',
'force-reboot',
'reboot-chain',
'force-reboot-chain',
'factory-reset',
'reconfigure-tree',
];
const HEARTBEAT_INTERVAL_MS = 30_000;
// ─── Logging (dependency-free; agent runs standalone) ───────────────
function log(scope, msg, level = 'info') {
const ts = new Date().toISOString();
const line = `[${ts}] [${level.toUpperCase()}] [${scope}] ${msg}`;
if (level === 'error' || level === 'warn') console.error(line);
else console.log(line);
}
// ─── Startup validation ─────────────────────────────────────────────
function assertConfig() {
const missing = [];
if (!CFG.botUrl) missing.push('DECT_RELAY_BOT_URL');
if (!CFG.token) missing.push('DECT_RELAY_AGENT_TOKEN');
if (!CFG.dectPass) missing.push('DECT_ADMIN_PASSWORD');
if (missing.length > 0) {
console.error(`Missing required env: ${missing.join(', ')}. See .env.example.`);
process.exit(1);
}
if (!CFG.botUrl.startsWith('wss://') && !CFG.botUrl.startsWith('ws://')) {
console.error(`DECT_RELAY_BOT_URL must start with wss:// (or ws:// for local dev). Got: ${CFG.botUrl}`);
process.exit(1);
}
if (CFG.botUrl.startsWith('ws://') && !/(^|\.)localhost/.test(CFG.botUrl) && !/127\.0\.0\.1/.test(CFG.botUrl)) {
log('startup', `⚠️ DECT_RELAY_BOT_URL is plain ws:// against a non-local host — bearer token would be sent in cleartext`, 'warn');
}
}
// ─── Reconnect loop ─────────────────────────────────────────────────
let currentWs = null;
let heartbeatTimer = null;
let reconnectAttempt = 0;
let shuttingDown = false;
function scheduleReconnect() {
if (shuttingDown) return;
reconnectAttempt += 1;
// Exponential backoff with jitter: 1s, 2s, 4s, 8s… capped at
// reconnectMaxMs, plus 01000ms jitter to de-sync herds when
// multiple agents restart at once (future-proofing — today there's
// only one).
const base = Math.min(1000 * 2 ** (reconnectAttempt - 1), CFG.reconnectMaxMs);
const jitter = Math.floor(Math.random() * 1000);
const delay = base + jitter;
log('reconnect', `Attempt #${reconnectAttempt} in ${delay}ms`);
setTimeout(connect, delay);
}
function stopHeartbeat() {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
}
function connect() {
if (shuttingDown) return;
log('connect', `Dialing ${CFG.botUrl}`);
const ws = new WebSocket(CFG.botUrl, {
// Preferred auth path: standard Authorization header. Some
// reverse proxies strip it on WS upgrades; the bot accepts the
// Sec-WebSocket-Protocol fallback too, but header is cleaner.
headers: { Authorization: `Bearer ${CFG.token}` },
// Handshake grace period. Bot's WSS layer should accept
// instantly, but corporate proxies can be slow.
handshakeTimeout: 15_000,
});
currentWs = ws;
ws.on('open', () => {
reconnectAttempt = 0;
log('connect', 'Connected — sending hello');
send({
type: 'hello',
agentVersion: AGENT_VERSION,
hostname: CFG.hostname,
capabilities: CAPABILITIES,
});
startHeartbeat();
});
ws.on('message', (raw) => handleMessage(raw));
ws.on('close', (code, reason) => {
log('connect', `Socket closed (code=${code} reason="${reason.toString()}")`);
stopHeartbeat();
currentWs = null;
scheduleReconnect();
});
ws.on('error', (err) => {
// 'error' can fire BEFORE 'close' on handshake failures (401,
// TLS problems, DNS). Log it and let 'close' handle reconnection.
log('connect', `Socket error: ${err.message}`, 'warn');
});
}
function startHeartbeat() {
stopHeartbeat();
heartbeatTimer = setInterval(() => {
if (currentWs && currentWs.readyState === WebSocket.OPEN) {
// JSON-level ping — bot replies with `{type:'pong', at:...}`.
// We also let the underlying ws library exchange its own
// ping/pong frames; belt-and-suspenders because some proxies
// strip WS control frames.
send({ type: 'ping', at: Date.now() });
try { currentWs.ping(); } catch { /* ignore */ }
}
}, HEARTBEAT_INTERVAL_MS);
if (heartbeatTimer.unref) heartbeatTimer.unref();
}
function send(obj) {
if (!currentWs || currentWs.readyState !== WebSocket.OPEN) return false;
try {
currentWs.send(JSON.stringify(obj));
return true;
} catch (err) {
log('send', `Failed to send frame: ${err.message}`, 'warn');
return false;
}
}
// ─── Command dispatch ───────────────────────────────────────────────
async function handleMessage(raw) {
let msg;
try {
msg = JSON.parse(raw.toString('utf8'));
} catch {
log('dispatch', `Ignoring non-JSON frame (${raw.length} bytes)`, 'warn');
return;
}
if (!msg || typeof msg !== 'object') return;
// Server-initiated JSON ping — reply with pong (also refreshes the
// bot-side lastPongAt timestamp).
if (msg.type === 'ping') {
send({ type: 'pong', at: Date.now() });
return;
}
if (msg.type === 'pong') return; // no-op; we just want to see it come back
// Everything else must have an id and a command type.
if (!msg.id) {
log('dispatch', `Frame missing id: ${JSON.stringify(msg).slice(0, 120)}`, 'warn');
return;
}
if (!msg.type) {
replyError(msg.id, 'MALFORMED', 'command frame missing `type`');
return;
}
if (!msg.baseIp) {
replyError(msg.id, 'MALFORMED', 'command frame missing `baseIp`');
return;
}
const started = Date.now();
try {
const result = await dispatch(msg);
replyOk(msg.id, result, Date.now() - started);
} catch (err) {
const code = err?.code || 'AGENT_EXCEPTION';
log('dispatch', `Command ${msg.type} for ${msg.baseIp} failed: ${err.message}`, 'warn');
replyError(msg.id, code, err.message, { stack: err.stack?.split('\n')[0] });
}
}
/**
* Route one command to the right helper. Every branch returns a
* plain JS object that will be JSON-serialized as the `result`
* field of the reply frame.
*/
async function dispatch(cmd) {
const client = createDectClient({
host: cmd.baseIp,
user: CFG.dectUser,
password: CFG.dectPass,
timeoutMs: CFG.dectTimeout,
});
switch (cmd.type) {
case 'collect': {
const started = Date.now();
const resp = await client.get('/admin/status.xml');
const elapsedMs = Date.now() - started;
if (resp.status !== 200 || typeof resp.data !== 'string' || !resp.data.trim()) {
const err = new Error(`base returned status=${resp.status} (${resp.data?.length || 0} bytes)`);
err.code = 'BASE_BAD_STATUS';
throw err;
}
const parsed = parseStatusXml(resp.data);
const verdict = summarizeBaseHealth(parsed);
return { parsed, verdict, fetchedInMs: elapsedMs };
}
// All mutating actions are one-shot GETs (see integrations/cisco-
// dect/probes.js). They come back as either `{dryRun:true,...}`
// (which we never pass here — dryRun is always false from the
// bot) or `{dryRun:false, kind, planned, result}`. We surface
// `planned` + `result` so the bot can log the CSRF'd URL and
// whether the base responded 200.
case 'reboot': return await triggerReboot(client, { forced: false, dryRun: false });
case 'force-reboot': return await triggerReboot(client, { forced: true, dryRun: false });
case 'reboot-chain': return await triggerRebootChain(client, { forced: false, dryRun: false });
case 'force-reboot-chain': return await triggerRebootChain(client, { forced: true, dryRun: false });
case 'factory-reset': return await triggerFactoryReset(client, { dryRun: false });
case 'reconfigure-tree': return await triggerReconfigureDectTree(client, { dryRun: false });
default: {
const err = new Error(`unknown command type: ${cmd.type}`);
err.code = 'UNKNOWN_COMMAND';
throw err;
}
}
}
function replyOk(id, result, elapsedMs) {
send({ id, ok: true, result, elapsedMs });
}
function replyError(id, code, message, detail = null) {
send({ id, ok: false, error: { code, message, ...(detail || {}) } });
}
// ─── Signal handling ────────────────────────────────────────────────
async function shutdown(reason) {
if (shuttingDown) return;
shuttingDown = true;
log('shutdown', `Shutting down — ${reason}`);
stopHeartbeat();
if (currentWs) {
try { currentWs.close(1000, 'agent shutdown'); } catch { /* ignore */ }
}
// Give the close frame a moment to flush before exit. 500ms is
// enough for local + LAN cases and doesn't meaningfully delay
// container restarts.
setTimeout(() => process.exit(0), 500);
}
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('uncaughtException', (err) => {
log('uncaught', `${err.message}\n${err.stack}`, 'error');
shutdown('uncaughtException');
});
process.on('unhandledRejection', (reason) => {
const msg = reason instanceof Error ? `${reason.message}\n${reason.stack}` : String(reason);
log('uncaught', msg, 'error');
shutdown('unhandledRejection');
});
// ─── Start ──────────────────────────────────────────────────────────
assertConfig();
log('startup', `dect-relay-agent v${AGENT_VERSION} — hostname=${CFG.hostname}, bot=${CFG.botUrl}`);
connect();

130
dect-relay-agent/install.sh Executable file
View 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}"

View file

@ -0,0 +1,19 @@
{
"name": "dect-relay-agent",
"version": "0.1.0",
"description": "Data-center-resident agent that bridges the CollabSupport bot (cloud) to Cisco DBS-210 DECT base stations on the private 10.x network.",
"type": "module",
"private": true,
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"axios": "^1.13.6",
"dotenv": "^17.3.1",
"ws": "^8.21.0"
},
"engines": {
"node": ">=20"
}
}

View file

@ -35,6 +35,7 @@ import {
} from './commands/igmpFix.js';
import { pendingIgmpFixes } from './utils/pendingIgmpFixes.js';
import { extractRequester } from './utils/requester.js';
import { getDectRelayHub } from './services/dectRelayHub.js';
import {
getCommand,
ALL_HTTP_COMMAND_KEYS,
@ -529,7 +530,13 @@ framework.hears(/.*/, async (bot, trigger) => {
}
}, null, 1);
app.listen(PORT, () => {
// Capture the http.Server so we can attach the DECT relay WebSocket
// upgrade handler to it after listen(). Express's app.listen() returns
// the underlying Node http.Server — previously we discarded that
// handle because HTTP-only routes don't need it. Adding WSS support
// changes that: the ws package hooks into the raw 'upgrade' event on
// the http.Server, so we need the reference here.
const httpServer = app.listen(PORT, () => {
logger('server', `🚀 Express server running on port ${PORT}`);
console.log(`🚀 HTTP API server listening on http://localhost:${PORT}`);
@ -552,6 +559,24 @@ app.listen(PORT, () => {
}
});
// DECT relay hub — accepts ONE long-lived WSS connection from the
// data-center relay agent. Gated on DECT_RELAY_AGENT_TOKEN being set:
// if not configured, we log a warning and skip attaching so the bot
// stays usable for everything else (Jira poller, Meraki, Webex admin
// commands). See services/dectRelayHub.js for the wire protocol and
// dect-relay-agent/README.md for how to run the DC-side process.
if (process.env.DECT_RELAY_AGENT_TOKEN) {
const relayHub = getDectRelayHub();
relayHub.attachTo(httpServer);
logger('startup', `DECT relay hub listening for agent at ${process.env.DECT_RELAY_PATH || '/dect-relay/ws'}`);
} else {
logger(
'startup',
'DECT relay disabled — set DECT_RELAY_AGENT_TOKEN in .env (and share the same value with dect-relay-agent) to enable /phonestatus DECT follow-up + future /dectstatus command',
'warn',
);
}
// ──────────────────────────────────────────────
// Cron Jobs
// ──────────────────────────────────────────────
@ -636,9 +661,20 @@ async function shutdown(reason, exitCode) {
logger('shutdown', 'Webex Framework stopped');
} catch (err) {
logger('shutdown', `Error during framework.stop(): ${err.message}`, 'error');
} finally {
process.exit(exitCode);
}
// Close the DECT relay hub so in-flight RPCs get rejected with
// DISCONNECTED and the agent's socket gets a clean 1001 shutdown
// frame (instead of a ripped-out TCP that would leave the agent
// reconnecting in a loop until its keepalive times out).
if (process.env.DECT_RELAY_AGENT_TOKEN) {
try {
await getDectRelayHub().close();
logger('shutdown', 'DECT relay hub closed');
} catch (err) {
logger('shutdown', `Error during DECT relay hub close: ${err.message}`, 'warn');
}
}
process.exit(exitCode);
}
process.on('SIGINT', () => shutdown('SIGINT', 0));

View 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,
};
}

View 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' });
}

View 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(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
.replace(/&amp;/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
View file

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

View file

@ -13,7 +13,9 @@
"docker:build": "docker compose build",
"docker:up": "docker compose up -d",
"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": {
"async-mutex": "^0.5.0",
@ -23,7 +25,8 @@
"form-data": "^4.0.5",
"graphql-request": "^7.4.0",
"node-cron": "^4.2.1",
"webex-node-bot-framework": "^2.5.1"
"webex-node-bot-framework": "^2.5.1",
"ws": "^8.21.0"
},
"devDependencies": {
"nodemon": "^3.1.4"

168
scripts/lib/webexBulk.js Normal file
View 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;
}

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

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

View file

@ -0,0 +1,162 @@
// src/services/dectCollectorService.js
//
// Fan-out layer over the DECT relay hub. Callers hand it a list of
// bases (from services/dectDiscovery.js), it dispatches one RPC per
// base in parallel and returns a normalized per-base result array.
//
// Kept intentionally thin: it doesn't render, it doesn't decide what
// to do with warnings, it doesn't touch Meraki. Whoever calls this
// (the /phonestatus follow-up, the /dectstatus command in Phase 2,
// the Jira poller in Phase 3) owns presentation.
import { getDectRelayHub, RelayErrorCodes } from './dectRelayHub.js';
import { logger } from '../utils/logger.js';
const LOG_SCOPE = 'dect:collector';
const DEFAULT_TIMEOUT_MS = Number(process.env.DECT_COLLECT_TIMEOUT_MS) || 15_000;
/**
* @typedef {object} BaseTarget
* @property {string} mac
* @property {string} ip
* @property {string} name
*/
/**
* @typedef {object} BaseCollectResult
* @property {BaseTarget} base
* @property {boolean} ok
* @property {object|null} data parsed status object (when ok)
* @property {object|null} verdict { healthy, warnings, info } (when ok)
* @property {number|null} elapsedMs
* @property {object|null} error { code, message } (when !ok)
*/
/**
* Run `collect` against every base in the list, in parallel. One base
* failing (timeout, offline, bad creds) does NOT fail the batch
* that base's entry just has ok:false. Ordering of returned entries
* matches the input.
*
* @param {BaseTarget[]} bases
* @param {object} [opts]
* @param {number} [opts.timeoutMs] per-base RPC timeout override
* @param {object} [opts.hub] inject a hub for tests
* @returns {Promise<BaseCollectResult[]>}
*/
export async function collectAll(bases, opts = {}) {
const list = Array.isArray(bases) ? bases : [];
if (list.length === 0) return [];
const hub = opts.hub || getDectRelayHub();
const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
logger(LOG_SCOPE, `Fanning out collect() to ${list.length} base(s)`, 'debug');
const results = await Promise.all(list.map((base) => collectOne(hub, base, timeoutMs)));
const okCount = results.filter((r) => r.ok).length;
logger(LOG_SCOPE, `Collect finished: ${okCount}/${list.length} succeeded`, 'debug');
return results;
}
/**
* Single-base variant. Mostly here for the eventual /dectstatus
* command's individual "refresh this base" flow collectAll uses it
* internally.
*/
export async function collectOne(hub, base, timeoutMs = DEFAULT_TIMEOUT_MS) {
if (!base?.ip) {
return {
base, ok: false, data: null, verdict: null, elapsedMs: null,
error: { code: 'NO_IP', message: 'base has no IP address' },
};
}
const started = Date.now();
try {
const { result, elapsedMs } = await hub.collect(base.ip, { timeoutMs });
return {
base,
ok: true,
data: result?.parsed || result || null,
verdict: result?.verdict || null,
elapsedMs: elapsedMs ?? (Date.now() - started),
error: null,
};
} catch (err) {
// We keep the code+message split so renderers can decide whether
// to show a hint ("relay is offline" vs "wrong password" are very
// different remediations).
const code = err?.code || 'UNKNOWN';
return {
base,
ok: false,
data: null,
verdict: null,
elapsedMs: Date.now() - started,
error: {
code,
message: err?.message || String(err),
// For NOT_CONNECTED there's no per-base fix — surface a hint.
hint: hintFor(code),
},
};
}
}
/**
* Run one of the mutating actions against a base. Same envelope shape
* as collectOne (ok / error / elapsedMs) so callers can log it
* uniformly. Actions handled here mirror the CLI script's subcommands.
*
* @param {BaseTarget} base
* @param {string} action 'reboot' | 'force-reboot' | 'reboot-chain' |
* 'force-reboot-chain' | 'factory-reset' |
* 'reconfigure-tree'
* @param {object} [opts]
* @param {number} [opts.timeoutMs]
* @param {object} [opts.hub]
*/
export async function execAction(base, action, opts = {}) {
if (!base?.ip) {
return {
base, ok: false, elapsedMs: null,
error: { code: 'NO_IP', message: 'base has no IP address' },
};
}
const hub = opts.hub || getDectRelayHub();
const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
const started = Date.now();
try {
const { result, elapsedMs } = await hub.execAction(base.ip, action, {}, { timeoutMs });
return {
base, ok: true, action,
elapsedMs: elapsedMs ?? (Date.now() - started),
result: result || null,
error: null,
};
} catch (err) {
const code = err?.code || 'UNKNOWN';
return {
base, ok: false, action,
elapsedMs: Date.now() - started,
error: {
code, message: err?.message || String(err),
hint: hintFor(code),
},
};
}
}
function hintFor(code) {
switch (code) {
case RelayErrorCodes.NOT_CONNECTED:
return 'DECT relay agent is not connected. Check that dect-relay-agent is running in the data center.';
case RelayErrorCodes.TIMEOUT:
return 'Relay accepted the request but the base did not respond in time. The base may be offline, rebooting, or unreachable.';
case RelayErrorCodes.DISCONNECTED:
return 'Relay agent disconnected while this command was in flight. Try again in a moment.';
default:
return null;
}
}

129
services/dectDiscovery.js Normal file
View file

@ -0,0 +1,129 @@
// src/services/dectDiscovery.js
//
// Turn "the list of DECT basestations we already know about for a
// store" into "the list of bases the DECT relay should actually try
// to talk to". Pure, no I/O — the input comes straight from
// collectPhoneStatus() output (or /phone/devices/build), so this
// module just filters and normalizes.
//
// The single hard rule enforced here is the 10.x network guard: every
// production DECT base at AE lives on the 10.0.0.0/8 corporate
// network. Anything with a different first octet is either a
// leftover, a mis-inventoried device, or the base has been swapped
// out and not yet re-Merakied — either way the relay should NOT try
// to talk to it (a random 192.168.x.x on some client's laptop is not
// something we want to Digest-auth into). We flag those cases as
// warnings so the caller can surface them.
// Cisco DECT MAC OUI prefixes (first three octets of the MAC).
// Not enforced hard — some fleets have odd MACs — but used as a
// tie-breaker when the Webex API's baseStation entries are noisy.
// Kept exported so tests + future callers can extend.
export const CISCO_DECT_MAC_OUI_PREFIXES = new Set([
'6cab05', // observed on lab DBS-210-3PC
'00040f', // classic Cisco DECT range
]);
/**
* Discover reachable DBS-210 bases for a store from a collectPhoneStatus
* result.
*
* @param {object} phoneStatus collectPhoneStatus() output
* @returns {object} discovery { bases: [...], warnings: [...] }
* - bases: [{ mac, ip, name, source }] ready to hand to the relay
* - warnings: [{ mac, ip, reason }] bases we deliberately excluded
*/
export function discoverDectBases(phoneStatus) {
const bases = [];
const warnings = [];
const seenIps = new Set();
const seenMacs = new Set();
const raw = Array.isArray(phoneStatus?.dectBasestations)
? phoneStatus.dectBasestations
: [];
for (const base of raw) {
// Pick the best IP source. Meraki's live client scan is more
// trustworthy than the Webex API record (which lags device DHCP
// renewals), so we prefer it. Webex's ipAddress is the fallback.
const ip = pickIp(base);
const mac = normalizeMac(base.mac);
const name = base.name || base.displayName || `Basestation ${mac || '?'}`;
if (!mac) {
warnings.push({ mac: null, ip, reason: 'base has no MAC address in inventory' });
continue;
}
if (!ip) {
warnings.push({ mac, ip: null, reason: 'no IP address available (base may be unreachable)' });
continue;
}
if (!isTenDotIp(ip)) {
warnings.push({
mac,
ip,
reason: `base IP ${ip} is not on the corporate 10.0.0.0/8 network; skipping (production bases should always be 10.x)`,
});
continue;
}
if (seenIps.has(ip)) {
warnings.push({ mac, ip, reason: `duplicate IP ${ip} in discovery result — keeping the first entry` });
continue;
}
if (seenMacs.has(mac)) {
warnings.push({ mac, ip, reason: `duplicate MAC ${mac} in discovery result — keeping the first entry` });
continue;
}
seenIps.add(ip);
seenMacs.add(mac);
bases.push({
mac,
ip,
name,
// Track where the IP came from — useful in logs if a base is
// reachable via one source but not the other.
source: base.meraki?.ip ? 'meraki' : 'webex',
});
}
return { bases, warnings };
}
// ─── Helpers ────────────────────────────────────────────────────────
/**
* Test whether an IP string is on 10.0.0.0/8 (i.e. first octet is 10).
* Accepts plain IPv4 dotted strings; anything else returns false
* (we're intentionally conservative here CIDRs, IPv6, hostnames all
* fall through to "not on 10.x" so the relay never touches them).
*/
export function isTenDotIp(value) {
if (typeof value !== 'string') return false;
const m = value.trim().match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (!m) return false;
const octets = [m[1], m[2], m[3], m[4]].map(Number);
if (octets.some((o) => o < 0 || o > 255)) return false;
return octets[0] === 10;
}
/**
* Normalize a MAC address to lowercase-colon-separated form
* (`aa:bb:cc:dd:ee:ff`). Returns null if the input doesn't look like
* a 12-hex-nibble MAC.
*/
export function normalizeMac(mac) {
if (!mac || typeof mac !== 'string') return null;
const hex = mac.replace(/[^0-9a-fA-F]/g, '').toLowerCase();
if (hex.length !== 12) return null;
return hex.match(/../g).join(':');
}
function pickIp(base) {
const merakiIp = base?.meraki?.ip;
if (typeof merakiIp === 'string' && merakiIp.trim()) return merakiIp.trim();
const webexIp = base?.ipAddress;
if (typeof webexIp === 'string' && webexIp.trim() && webexIp !== '—') return webexIp.trim();
return null;
}

472
services/dectRelayHub.js Normal file
View file

@ -0,0 +1,472 @@
// src/services/dectRelayHub.js
//
// Bot-side of the DECT relay: a WebSocket server that accepts ONE
// long-lived connection from a data-center-resident relay agent, plus
// a promise-based RPC API for the rest of the bot to call ("collect
// this base's status", "reboot this base"). The agent — which lives
// in `dect-relay-agent/` in this repo — makes the actual HTTPS Digest
// calls to DBS-210 base stations on the private 10.x network.
//
// Why a WebSocket at all: the bot runs in the public cloud and can't
// reach 10.x. The agent runs in the DC and can, but the DC can't
// accept unsolicited inbound connections. WSS solves both sides: the
// agent dials outbound to the bot (traversing NAT / proxy just like
// any HTTPS request), and once the socket is up the bot can push
// commands whenever it wants.
//
// Only ONE agent is expected to connect. If a second agent dials in,
// we assume it's a legitimate restart (agent redeployed, network
// blip, etc.), close the old socket, and replace it with the new one.
// This is safe because the RPC pending-map is drained + rejected on
// disconnect — any in-flight command reports back "relay disconnected"
// rather than silently hanging.
//
// Auth is a static bearer token shared between bot .env and agent
// .env. That's fine for a single trusted agent — WSS gives us
// transport-level confidentiality, and rotating the token is a
// two-line env change. If we ever need multiple agents we'd swap
// this for per-agent tokens plus an agent-id → base-list registry.
import { WebSocketServer } from 'ws';
import { randomUUID, timingSafeEqual } from 'node:crypto';
import { logger } from '../utils/logger.js';
const LOG_SCOPE = 'dect:relay-hub';
const DEFAULTS = {
path: '/dect-relay/ws',
rpcTimeoutMs: 15_000, // per-command default; callers can override
heartbeatIntervalMs: 30_000,
heartbeatIdleTimeoutMs: 90_000, // treat socket as dead if no pong in this long
};
// Error codes surfaced back to callers via rejected RPC promises.
// Keeping them string-typed (not numeric) so log lines stay readable.
export const RelayErrorCodes = Object.freeze({
NOT_CONNECTED: 'RELAY_NOT_CONNECTED',
DISCONNECTED: 'RELAY_DISCONNECTED_MID_RPC',
TIMEOUT: 'RELAY_RPC_TIMEOUT',
AGENT_ERROR: 'RELAY_AGENT_ERROR', // agent returned {ok:false, error:{...}}
MALFORMED: 'RELAY_MALFORMED_REPLY',
});
/**
* Structured error thrown by RPC calls. Carrying a code + optional
* detail lets callers branch on it (e.g. render "relay offline"
* differently from "base returned 401") without regex-matching on
* .message strings.
*/
export class DectRelayError extends Error {
constructor(code, message, detail = null) {
super(message);
this.name = 'DectRelayError';
this.code = code;
this.detail = detail;
}
}
/**
* The hub itself. Not a singleton class the module exports one
* default instance below and that's what the bot uses. Keeping it
* class-shaped anyway so tests can spin up an isolated hub with an
* ephemeral port and its own token.
*/
export class DectRelayHub {
constructor({ token, path = DEFAULTS.path } = {}) {
if (!token || typeof token !== 'string') {
throw new Error('DectRelayHub: token is required');
}
this._token = Buffer.from(token, 'utf8');
this._path = path;
this._socket = null;
this._hello = null; // last hello frame from the agent
this._pending = new Map(); // cmdId → { resolve, reject, timer }
this._lastPongAt = 0;
this._heartbeatTimer = null;
this._wss = null;
}
/** True when there's a live agent socket we can command. */
isConnected() {
return !!(this._socket && this._socket.readyState === 1 /* OPEN */);
}
/**
* Snapshot of the current connection state. Safe to expose over a
* health endpoint or /dectstatus admin page no secrets in here.
*/
status() {
return {
connected: this.isConnected(),
agent: this._hello ? { ...this._hello } : null,
inFlight: this._pending.size,
lastPongMsAgo: this._lastPongAt ? Date.now() - this._lastPongAt : null,
};
}
/**
* Attach the WebSocket upgrade handler to a Node http.Server. Must
* be called during startup, AFTER app.listen() returns the http
* server. Express doesn't upgrade sockets itself, so we hook the
* 'upgrade' event manually and route just our path anything else
* (e.g. a future webhook that needs its own upgrade) can add its
* own listener without conflict.
*
* @param {import('node:http').Server} httpServer
*/
attachTo(httpServer) {
if (this._wss) throw new Error('DectRelayHub: already attached');
// noServer:true → we do the upgrade dance manually so we can
// enforce auth BEFORE ws does its handshake. Otherwise ws would
// 101 first and then we'd have to close, which is uglier + wastes
// a round-trip on every unauthorized probe.
this._wss = new WebSocketServer({ noServer: true });
httpServer.on('upgrade', (req, socket, head) => {
// Only handle our path; leave others alone so future upgrades
// don't collide.
const url = req.url || '';
// Match with or without a trailing slash / query string.
const cleanPath = url.split('?')[0].replace(/\/$/, '');
if (cleanPath !== this._path.replace(/\/$/, '')) return;
if (!this._checkAuth(req)) {
logger(LOG_SCOPE, `Unauthorized upgrade attempt from ${req.socket.remoteAddress}`, 'warn');
socket.write('HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n');
socket.destroy();
return;
}
this._wss.handleUpgrade(req, socket, head, (ws) => this._adoptAgent(ws, req));
});
logger(LOG_SCOPE, `WebSocket upgrade handler attached at ${this._path}`);
}
/**
* Constant-time bearer-token comparison. The bearer must be sent
* as `Authorization: Bearer <token>` on the WSS upgrade request.
* We also accept `Sec-WebSocket-Protocol: bearer.<token>` as a
* fallback because some proxies strip Authorization headers on
* upgrade requests this hides the token in a spec-compliant
* subprotocol string that isn't logged by most access logs.
*/
_checkAuth(req) {
const presented = extractBearer(req);
if (!presented) return false;
const buf = Buffer.from(presented, 'utf8');
if (buf.length !== this._token.length) return false;
try {
return timingSafeEqual(buf, this._token);
} catch {
return false;
}
}
_adoptAgent(ws, req) {
const from = req.socket.remoteAddress || 'unknown';
// Replace any existing socket: we only want ONE agent at a time.
if (this._socket) {
logger(LOG_SCOPE, `Replacing existing agent socket with new connection from ${from}`, 'warn');
try { this._socket.close(1000, 'replaced by newer agent'); } catch { /* ignore */ }
this._socket = null;
this._hello = null;
}
this._socket = ws;
this._lastPongAt = Date.now();
logger(LOG_SCOPE, `Agent connected from ${from}`);
// Every handler closes over `ws` so we can distinguish the socket
// that fired the event from `this._socket` — critical when a
// second agent replaces the first: the replaced socket's async
// 'close' event will fire AFTER we've swapped in the new socket,
// and without this guard it would wipe out the replacement.
ws.on('message', (raw) => this._onMessage(ws, raw));
ws.on('close', (code, reasonBuf) => this._onSocketClose(ws, code, reasonBuf?.toString?.() || ''));
ws.on('error', (err) => logger(LOG_SCOPE, `Agent socket error: ${err.message}`, 'error'));
ws.on('pong', () => { if (ws === this._socket) this._lastPongAt = Date.now(); });
this._startHeartbeat();
}
_startHeartbeat() {
this._stopHeartbeat();
this._heartbeatTimer = setInterval(() => {
if (!this.isConnected()) return;
// If we haven't seen a pong in too long, assume dead. Node's ws
// client won't detect a wedged TCP for many minutes; this
// heartbeat is how we recover in under 90s.
if (Date.now() - this._lastPongAt > DEFAULTS.heartbeatIdleTimeoutMs) {
logger(LOG_SCOPE, 'No pong in idle timeout — terminating agent socket', 'warn');
try { this._socket.terminate(); } catch { /* ignore */ }
return;
}
try { this._socket.ping(); } catch { /* ignore, will surface via 'error' */ }
}, DEFAULTS.heartbeatIntervalMs);
// Don't hold the event loop open on process exit.
if (this._heartbeatTimer.unref) this._heartbeatTimer.unref();
}
_stopHeartbeat() {
if (this._heartbeatTimer) {
clearInterval(this._heartbeatTimer);
this._heartbeatTimer = null;
}
}
_onSocketClose(sourceSocket, code, reason) {
// If this event is for a socket that's already been replaced by
// a newer connection, log at debug and skip the state reset —
// otherwise we'd wipe out the replacement socket we just adopted.
if (this._socket && this._socket !== sourceSocket) {
logger(LOG_SCOPE, `Ignoring close from replaced socket (code=${code})`, 'debug');
return;
}
logger(LOG_SCOPE, `Agent disconnected (code=${code}, reason="${reason}")`);
this._socket = null;
this._hello = null;
this._stopHeartbeat();
// Reject any in-flight RPCs so callers don't hang forever.
for (const [, entry] of this._pending) {
clearTimeout(entry.timer);
entry.reject(new DectRelayError(
RelayErrorCodes.DISCONNECTED,
'DECT relay disconnected while command was in flight',
));
}
this._pending.clear();
}
_onMessage(sourceSocket, raw) {
// Drop late messages from a replaced socket (see _onSocketClose).
if (sourceSocket !== this._socket) return;
let msg;
try {
msg = JSON.parse(raw.toString('utf8'));
} catch {
logger(LOG_SCOPE, `Ignoring non-JSON frame from agent (${raw.length} bytes)`, 'warn');
return;
}
if (!msg || typeof msg !== 'object') {
logger(LOG_SCOPE, 'Ignoring non-object frame from agent', 'warn');
return;
}
if (msg.type === 'hello') {
this._hello = {
agentVersion: msg.agentVersion || 'unknown',
hostname: msg.hostname || null,
capabilities: Array.isArray(msg.capabilities) ? msg.capabilities.slice() : [],
receivedAt: new Date().toISOString(),
};
logger(LOG_SCOPE, `Agent hello: version=${this._hello.agentVersion} host=${this._hello.hostname} caps=${this._hello.capabilities.join(',')}`);
return;
}
if (msg.type === 'ping') {
// Explicit JSON-level ping (in addition to the ws-level ping
// frames). Reply with an equivalent pong so a symmetric agent
// can verify liveness without relying on the ws framing.
this._sendRaw({ type: 'pong', at: Date.now() });
return;
}
if (msg.type === 'pong') {
this._lastPongAt = Date.now();
return;
}
// Otherwise it's an RPC reply for a pending command.
if (!msg.id) {
logger(LOG_SCOPE, `Ignoring frame with no id and unknown type ${msg.type}`, 'warn');
return;
}
const entry = this._pending.get(msg.id);
if (!entry) {
// Late reply after timeout — safe to drop.
logger(LOG_SCOPE, `Reply for unknown/expired cmd id ${msg.id} — dropping`, 'debug');
return;
}
this._pending.delete(msg.id);
clearTimeout(entry.timer);
if (msg.ok === true) {
entry.resolve({ result: msg.result, elapsedMs: msg.elapsedMs || null });
} else if (msg.ok === false) {
const err = msg.error || {};
entry.reject(new DectRelayError(
err.code || RelayErrorCodes.AGENT_ERROR,
err.message || 'Agent reported an error',
err,
));
} else {
entry.reject(new DectRelayError(
RelayErrorCodes.MALFORMED,
`Agent reply missing ok field for id ${msg.id}`,
msg,
));
}
}
_sendRaw(obj) {
if (!this.isConnected()) return false;
try {
this._socket.send(JSON.stringify(obj));
return true;
} catch (err) {
logger(LOG_SCOPE, `Failed to send frame: ${err.message}`, 'warn');
return false;
}
}
/**
* Send an RPC command to the agent. Returns { result, elapsedMs }
* on success, throws DectRelayError on failure. Timeouts and
* disconnects are surfaced as rejections callers should always
* try/catch or use .catch().
*
* @param {object} payload command frame WITHOUT id (added here)
* @param {object} [opts]
* @param {number} [opts.timeoutMs] per-call override; default 15s
*/
rpc(payload, { timeoutMs = DEFAULTS.rpcTimeoutMs } = {}) {
return new Promise((resolve, reject) => {
if (!this.isConnected()) {
reject(new DectRelayError(
RelayErrorCodes.NOT_CONNECTED,
'DECT relay is not connected — data-center agent may be offline',
));
return;
}
const id = `cmd_${randomUUID()}`;
const timer = setTimeout(() => {
this._pending.delete(id);
reject(new DectRelayError(
RelayErrorCodes.TIMEOUT,
`DECT relay RPC ${payload.type || '(no type)'} timed out after ${timeoutMs}ms`,
));
}, timeoutMs);
// Do NOT unref this timer — we want the process to stay alive
// until every in-flight RPC has resolved or timed out.
this._pending.set(id, { resolve, reject, timer });
const ok = this._sendRaw({ id, ...payload });
if (!ok) {
this._pending.delete(id);
clearTimeout(timer);
reject(new DectRelayError(
RelayErrorCodes.NOT_CONNECTED,
'Failed to send frame (socket may have just closed)',
));
}
});
}
/** Convenience: fetch parsed status.xml for a given base IP. */
collect(baseIp, opts) {
return this.rpc({ type: 'collect', baseIp }, opts);
}
/**
* Convenience: execute one of the mutating actions the agent
* exposes (reboot / force-reboot / reboot-chain / force-reboot-chain
* / factory-reset / reconfigure-tree). The agent is the audit
* boundary for these the bot's own audit sink STILL records the
* intent (see commands/dectStatus.js in Phase 2), but the agent
* logs the actual HTTP call.
*/
execAction(baseIp, action, extra = {}, opts) {
return this.rpc({ type: action, baseIp, ...extra }, opts);
}
/**
* Shut everything down. Called from graceful-shutdown paths.
* Safe to call when nothing is attached.
*
* Terminates every socket the WSS layer is still tracking rather
* than relying on graceful close a straggling client (e.g. an
* agent whose TCP is wedged after a NAT reboot) would otherwise
* block `wss.close()`'s callback and hang the shutdown path.
*/
async close() {
this._stopHeartbeat();
if (this._socket) {
try { this._socket.close(1001, 'bot shutting down'); } catch { /* ignore */ }
this._socket = null;
}
if (this._wss) {
// Force-close any client the wss is still tracking. Without
// this, wss.close() waits indefinitely for all clients to
// disconnect on their own — fine in the happy path, but tests
// and NAT wedges both cause hangs.
for (const client of this._wss.clients) {
try { client.terminate(); } catch { /* ignore */ }
}
await new Promise((res) => this._wss.close(() => res()));
this._wss = null;
}
for (const [, entry] of this._pending) {
clearTimeout(entry.timer);
entry.reject(new DectRelayError(
RelayErrorCodes.DISCONNECTED,
'Bot shutting down',
));
}
this._pending.clear();
}
}
// ─── Helpers ────────────────────────────────────────────────────────
/**
* Pull the bearer token out of the upgrade request. Order tried:
* 1. `Authorization: Bearer <token>` header (canonical).
* 2. `Sec-WebSocket-Protocol: bearer.<token>` (proxy-friendly).
* Returns null if neither is present or well-formed.
*/
function extractBearer(req) {
const auth = req.headers['authorization'];
if (typeof auth === 'string') {
const m = auth.match(/^Bearer\s+(\S+)\s*$/i);
if (m) return m[1];
}
const proto = req.headers['sec-websocket-protocol'];
if (typeof proto === 'string') {
for (const part of proto.split(',')) {
const trimmed = part.trim();
if (trimmed.startsWith('bearer.')) return trimmed.slice('bearer.'.length);
}
}
return null;
}
// ─── Default singleton for the bot to use ───────────────────────────
let _defaultHub = null;
/**
* Return the process-wide DectRelayHub, constructing it on first
* access using env config. Throws if DECT_RELAY_AGENT_TOKEN is not
* set surfaces the missing config at startup rather than silently
* being non-functional.
*/
export function getDectRelayHub() {
if (_defaultHub) return _defaultHub;
const token = process.env.DECT_RELAY_AGENT_TOKEN;
if (!token) {
throw new Error(
'DECT_RELAY_AGENT_TOKEN is not set. Add it to your .env and share ' +
'the same value with the dect-relay-agent. Until then, DECT commands ' +
'will fail with RELAY_NOT_CONNECTED.',
);
}
_defaultHub = new DectRelayHub({ token, path: process.env.DECT_RELAY_PATH || DEFAULTS.path });
return _defaultHub;
}
// Test-only: reset the singleton. Not exported from an index barrel;
// only imported by unit tests that need isolation.
export function _resetDectRelayHubForTests() { _defaultHub = null; }

View file

@ -29,10 +29,16 @@ import { simpleTimeAgo, formatBytes } from '../../utils/time.js';
* @param {string} opts.storeNum 2-6 digit store id (header text)
* @param {boolean} [opts.detailed=false]
* @param {boolean} [opts.footer=true]
* @param {number} [opts.dectFollowUpBaseCount=0]
* When > 0, emits an "⏳ DECT base data loading for N base(s)…"
* line inside the DECT Basestations section. Signals to the reader
* that a follow-up message with base-station diagnostics is on the
* way. Chat handler passes this after the base count comes back
* from discoverDectBases(); poller and HTTP callers pass 0.
* @returns {string} markdown, whitespace-trimmed and ready to send.
*/
export function renderPhoneStatusMarkdown(data, opts = {}) {
const { storeNum, detailed = false, footer = true } = opts;
const { storeNum, detailed = false, footer = true, dectFollowUpBaseCount = 0 } = opts;
let reply = `**Phone Status - Store ${storeNum}**\n\n`;
@ -190,6 +196,15 @@ export function renderPhoneStatusMarkdown(data, opts = {}) {
});
reply += '\n';
}
// DECT relay follow-up notice. Only shown when the caller has
// told us a follow-up is actually in-flight (chat handler, after
// discoverDectBases returned a non-empty list). Silent for HTTP /
// Jira surfaces where a follow-up doesn't happen.
if (dectFollowUpBaseCount > 0) {
const n = dectFollowUpBaseCount;
reply += `_⏳ Base-station diagnostics loading for ${n} base${n === 1 ? '' : 's'} — a follow-up message will arrive shortly._\n\n`;
}
}
if (detailed) {
@ -202,3 +217,92 @@ export function renderPhoneStatusMarkdown(data, opts = {}) {
return reply.trim();
}
// ─── DECT base-station diagnostics (follow-up message) ──────────────
//
// Separate exported renderer for the follow-up message that arrives
// ~10-30s after the main /phonestatus output. Input is the array
// returned by services/dectCollectorService.collectAll(): per-base
// { ok, data (parsed status), verdict, error } records.
//
// Chat surface stays compact — most operators only need to see the
// exceptional stuff (warnings, recent power-loss reboots). Firmware
// / emergency numbers / detailed reboot log stay behind the future
// /dectstatus command where the full CLI-style dump makes more sense.
/**
* @param {Array} results collectAll() output
* @param {object} opts
* @param {string} opts.storeNum
* @param {boolean} [opts.footer=true]
* @returns {string} markdown, whitespace-trimmed. Empty string when
* the input list is empty (caller shouldn't send a message
* in that case).
*/
export function renderDectDiagnosticsMarkdown(results, opts = {}) {
const { storeNum, footer = true } = opts;
const list = Array.isArray(results) ? results : [];
if (list.length === 0) return '';
let out = `**DECT Base Station Diagnostics — Store ${storeNum}**\n\n`;
for (const r of list) {
out += renderOneBase(r);
out += '\n';
}
if (footer) {
out += `\n*Base diagnostics pulled at ${new Date().toLocaleTimeString()} via the DECT relay. Use \`/dectstatus ${storeNum}\` for full details and reboot/factory-reset controls.*`;
}
return out.trim();
}
function renderOneBase(r) {
const label = r.base?.name || `Basestation ${r.base?.mac || '?'}`;
const ip = r.base?.ip || '?';
if (!r.ok) {
return `⚠️ **${label}** (${ip}) — collect failed: ${r.error?.message || 'unknown error'}` +
(r.error?.hint ? `\n _${r.error.hint}_\n` : '\n');
}
const data = r.data || {};
const verdict = r.verdict || {};
const uptimeText = data.time?.operatingTime || '?';
const fw = data.firmware?.version || '?';
const conflict = data.conflictInfo && data.conflictInfo !== 'No Conflict'
? ` • RF conflict: ${data.conflictInfo}` : '';
const role = data.multiCell?.role ? ` • role: ${data.multiCell.role}` : '';
// Header line uses a checkmark or warning depending on verdict.
const icon = verdict.healthy ? '✅' : '⚠️';
let out = `${icon} **${label}** (${ip}) — uptime ${uptimeText} • fw ${fw}${role}${conflict}\n`;
// Most-recent Power Loss reboot (if any in the last-6 log) is the
// highest-signal thing we can surface here. Anything else falls
// under "warnings" below.
const powerLoss = (data.rebootLog || []).find((entry) => entry.reasonCode === 80);
if (powerLoss) {
out += ` ⚡ Recent power loss: ${powerLoss.at} (reboot #${powerLoss.sequence})\n`;
}
// Warnings from summarizeBaseHealth() are already user-facing
// strings; render as a bulleted list under the header.
if (Array.isArray(verdict.warnings) && verdict.warnings.length > 0) {
for (const w of verdict.warnings) {
// Skip the power-loss warning if we already surfaced the
// structured line above — avoids duplication.
if (powerLoss && /power.?loss/i.test(w)) continue;
out += ` ⚠️ ${w}\n`;
}
}
// RTP: only show if there's an active session — usually the
// diagnostic reader cares whether a call is up right now, not
// that this base has served 2 total calls since boot.
if ((data.rtp?.current || 0) > 0) {
out += ` 📞 ${data.rtp.current} active RTP session(s)\n`;
}
return out;
}

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

@ -0,0 +1,169 @@
// Unit tests for services/dectDiscovery.js. Pure — no network, no fs.
// The interesting cases are all around the 10.x guardrail and the
// dedup/priority logic when Meraki and Webex report different IPs
// for the same base.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
discoverDectBases,
isTenDotIp,
normalizeMac,
} from '../services/dectDiscovery.js';
// ─── Helpers ────────────────────────────────────────────────────────
const fixture = (overrides = {}) => ({
dectBasestations: [],
...overrides,
});
const base = (attrs = {}) => ({
mac: '6c:ab:05:f6:28:19',
name: 'Basestation A',
ipAddress: '—',
meraki: {},
...attrs,
});
// ─── isTenDotIp ─────────────────────────────────────────────────────
test('isTenDotIp: accepts 10.x/8 addresses', () => {
assert.equal(isTenDotIp('10.0.0.1'), true);
assert.equal(isTenDotIp('10.255.255.254'), true);
assert.equal(isTenDotIp('10.4.11.87'), true);
});
test('isTenDotIp: rejects non-10.x addresses', () => {
assert.equal(isTenDotIp('192.168.1.164'), false);
assert.equal(isTenDotIp('172.16.0.1'), false);
assert.equal(isTenDotIp('11.0.0.1'), false);
assert.equal(isTenDotIp('100.0.0.1'), false);
});
test('isTenDotIp: rejects malformed input', () => {
assert.equal(isTenDotIp(null), false);
assert.equal(isTenDotIp(''), false);
assert.equal(isTenDotIp('10.'), false);
assert.equal(isTenDotIp('10.0.0'), false);
assert.equal(isTenDotIp('10.0.0.256'), false);
assert.equal(isTenDotIp('10.0.0.1.5'), false);
assert.equal(isTenDotIp('not-an-ip'), false);
assert.equal(isTenDotIp(10), false);
});
// ─── normalizeMac ───────────────────────────────────────────────────
test('normalizeMac: handles various input formats', () => {
assert.equal(normalizeMac('6cab05f62819'), '6c:ab:05:f6:28:19');
assert.equal(normalizeMac('6C:AB:05:F6:28:19'), '6c:ab:05:f6:28:19');
assert.equal(normalizeMac('6c-ab-05-f6-28-19'), '6c:ab:05:f6:28:19');
assert.equal(normalizeMac('6cab.05f6.2819'), '6c:ab:05:f6:28:19');
});
test('normalizeMac: rejects bad input', () => {
assert.equal(normalizeMac(null), null);
assert.equal(normalizeMac(''), null);
assert.equal(normalizeMac('not-a-mac'), null);
assert.equal(normalizeMac('6cab05f62819aa'), null); // 14 hex chars
});
// ─── discoverDectBases ──────────────────────────────────────────────
test('discoverDectBases: empty input returns empty result (never throws)', () => {
assert.deepEqual(discoverDectBases({}), { bases: [], warnings: [] });
assert.deepEqual(discoverDectBases(null), { bases: [], warnings: [] });
assert.deepEqual(discoverDectBases({ dectBasestations: null }), { bases: [], warnings: [] });
});
test('discoverDectBases: happy path — one Meraki-enriched base on 10.x', () => {
const result = discoverDectBases(fixture({
dectBasestations: [
base({ meraki: { ip: '10.4.11.87' } }),
],
}));
assert.equal(result.bases.length, 1);
assert.equal(result.warnings.length, 0);
assert.deepEqual(result.bases[0], {
mac: '6c:ab:05:f6:28:19',
ip: '10.4.11.87',
name: 'Basestation A',
source: 'meraki',
});
});
test('discoverDectBases: prefers Meraki IP over Webex IP', () => {
const result = discoverDectBases(fixture({
dectBasestations: [
base({
ipAddress: '10.4.11.100', // Webex-reported (potentially stale)
meraki: { ip: '10.4.11.87' }, // Meraki-reported (live)
}),
],
}));
assert.equal(result.bases[0].ip, '10.4.11.87');
assert.equal(result.bases[0].source, 'meraki');
});
test('discoverDectBases: falls back to Webex IP when no Meraki data', () => {
const result = discoverDectBases(fixture({
dectBasestations: [
base({ ipAddress: '10.4.11.87', meraki: {} }),
],
}));
assert.equal(result.bases[0].ip, '10.4.11.87');
assert.equal(result.bases[0].source, 'webex');
});
test('discoverDectBases: 10.x guardrail rejects non-corporate IPs with a warning', () => {
const result = discoverDectBases(fixture({
dectBasestations: [
base({ mac: 'aa:bb:cc:dd:ee:01', meraki: { ip: '192.168.1.164' } }),
base({ mac: 'aa:bb:cc:dd:ee:02', meraki: { ip: '172.16.0.100' } }),
base({ mac: 'aa:bb:cc:dd:ee:03', meraki: { ip: '10.4.11.87' } }),
],
}));
assert.equal(result.bases.length, 1);
assert.equal(result.bases[0].ip, '10.4.11.87');
assert.equal(result.warnings.length, 2);
// Both warnings should reference the offending IPs and the 10.x rule.
assert.match(result.warnings[0].reason, /192\.168\.1\.164/);
assert.match(result.warnings[0].reason, /10\.0\.0\.0\/8/);
assert.match(result.warnings[1].reason, /172\.16\.0\.100/);
});
test('discoverDectBases: rejects bases with no MAC (inventory bug)', () => {
const result = discoverDectBases(fixture({
dectBasestations: [
base({ mac: null, meraki: { ip: '10.4.11.87' } }),
],
}));
assert.equal(result.bases.length, 0);
assert.equal(result.warnings.length, 1);
assert.match(result.warnings[0].reason, /no MAC/i);
});
test('discoverDectBases: rejects bases with no IP anywhere', () => {
const result = discoverDectBases(fixture({
dectBasestations: [
base({ ipAddress: '—', meraki: {} }),
],
}));
assert.equal(result.bases.length, 0);
assert.equal(result.warnings.length, 1);
assert.match(result.warnings[0].reason, /no IP address/i);
});
test('discoverDectBases: dedups by IP and MAC (keeps first)', () => {
const result = discoverDectBases(fixture({
dectBasestations: [
base({ mac: 'aa:bb:cc:dd:ee:01', meraki: { ip: '10.4.11.87' }, name: 'first' }),
base({ mac: 'aa:bb:cc:dd:ee:02', meraki: { ip: '10.4.11.87' }, name: 'duplicate-ip' }),
base({ mac: 'aa:bb:cc:dd:ee:01', meraki: { ip: '10.4.11.88' }, name: 'duplicate-mac' }),
],
}));
assert.equal(result.bases.length, 1);
assert.equal(result.bases[0].name, 'first');
assert.equal(result.warnings.length, 2);
});

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

@ -0,0 +1,351 @@
// Integration tests for services/dectRelayHub.js.
//
// These spin up a real HTTP server on an ephemeral port, attach the
// hub, and connect a real `ws` client that plays the role of the
// dect-relay-agent. This gives us end-to-end coverage of the auth
// path, the wire protocol, RPC correlation, timeouts, and clean
// disconnect handling — none of which we can meaningfully test with
// pure mocks.
//
// Every test creates its own hub + server so they can run in parallel
// without port conflicts. All servers are torn down in the test's
// finally block so a failing test can't leak file descriptors.
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import WebSocket from 'ws';
import { DectRelayHub, RelayErrorCodes, DectRelayError } from '../services/dectRelayHub.js';
const TEST_TOKEN = 'super-secret-token-for-tests';
// ─── Test harness ───────────────────────────────────────────────────
/**
* Spin up an HTTP server with the hub attached on an ephemeral port.
* Returns { hub, port, closeAll }. Caller MUST call closeAll() (in a
* try/finally) to release the port + socket handles.
*/
async function makeHub(token = TEST_TOKEN) {
const server = http.createServer((req, res) => {
res.writeHead(404); res.end();
});
const hub = new DectRelayHub({ token });
hub.attachTo(server);
await new Promise((res) => server.listen(0, '127.0.0.1', res));
const { port } = server.address();
return {
hub,
port,
async closeAll() {
await hub.close();
await new Promise((res) => server.close(() => res()));
},
};
}
/**
* Open a WebSocket client to the hub. Optional bearer overrides the
* default token useful for the "wrong token" test.
*/
function connectAgent(port, { bearer = TEST_TOKEN, useProtocol = false } = {}) {
const url = `ws://127.0.0.1:${port}/dect-relay/ws`;
const opts = useProtocol
? { headers: {}, protocol: `bearer.${bearer}` }
: { headers: { Authorization: `Bearer ${bearer}` } };
return new WebSocket(url, opts.protocol ? opts.protocol : undefined, {
headers: opts.headers,
handshakeTimeout: 3000,
});
}
function waitOpen(ws) {
return new Promise((resolve, reject) => {
ws.once('open', resolve);
ws.once('error', reject);
});
}
function waitClose(ws) {
return new Promise((resolve) => ws.once('close', (code, reason) => resolve({ code, reason: reason?.toString() || '' })));
}
// A tiny agent that immediately replies to every command with the
// given handler. Handler receives the parsed inbound message and
// returns either { ok:true, result:{...} } or throws.
function attachAutoAgent(ws, handler) {
ws.on('message', async (raw) => {
const msg = JSON.parse(raw.toString('utf8'));
if (msg.type === 'ping') { ws.send(JSON.stringify({ type: 'pong', at: Date.now() })); return; }
if (!msg.id) return;
try {
const result = await handler(msg);
ws.send(JSON.stringify({ id: msg.id, ok: true, result, elapsedMs: 1 }));
} catch (err) {
ws.send(JSON.stringify({
id: msg.id, ok: false,
error: { code: err.code || 'AUTO_AGENT_ERR', message: err.message },
}));
}
});
}
// ─── isConnected / status ───────────────────────────────────────────
test('hub: isConnected is false with no agent', async () => {
const { hub, closeAll } = await makeHub();
try {
assert.equal(hub.isConnected(), false);
assert.equal(hub.status().connected, false);
assert.equal(hub.status().agent, null);
assert.equal(hub.status().inFlight, 0);
} finally {
await closeAll();
}
});
test('hub: rpc without connection rejects with NOT_CONNECTED', async () => {
const { hub, closeAll } = await makeHub();
try {
await assert.rejects(
hub.collect('10.0.0.100'),
(err) => err instanceof DectRelayError && err.code === RelayErrorCodes.NOT_CONNECTED,
);
} finally {
await closeAll();
}
});
// ─── Auth ──────────────────────────────────────────────────────────
test('hub: rejects upgrade with no bearer', async () => {
const { port, closeAll } = await makeHub();
try {
const ws = new WebSocket(`ws://127.0.0.1:${port}/dect-relay/ws`, {
handshakeTimeout: 3000,
});
// Server writes a raw 401 before the WS handshake completes.
// ws throws 'Unexpected server response: 401' as an error.
await assert.rejects(waitOpen(ws), /401/);
} finally {
await closeAll();
}
});
test('hub: rejects upgrade with wrong bearer', async () => {
const { port, closeAll } = await makeHub();
try {
const ws = connectAgent(port, { bearer: 'wrong-token' });
await assert.rejects(waitOpen(ws), /401/);
} finally {
await closeAll();
}
});
test('hub: accepts upgrade with correct bearer via Authorization header', async () => {
const { hub, port, closeAll } = await makeHub();
try {
const ws = connectAgent(port);
await waitOpen(ws);
// Give the hub a tick to record the adoption.
await new Promise((r) => setImmediate(r));
assert.equal(hub.isConnected(), true);
ws.close();
await waitClose(ws);
} finally {
await closeAll();
}
});
test('hub: accepts upgrade via Sec-WebSocket-Protocol fallback', async () => {
const { hub, port, closeAll } = await makeHub();
try {
const ws = connectAgent(port, { useProtocol: true });
await waitOpen(ws);
await new Promise((r) => setImmediate(r));
assert.equal(hub.isConnected(), true);
ws.close();
await waitClose(ws);
} finally {
await closeAll();
}
});
// ─── Hello frame ────────────────────────────────────────────────────
test('hub: records agent hello frame into status()', async () => {
const { hub, port, closeAll } = await makeHub();
try {
const ws = connectAgent(port);
await waitOpen(ws);
ws.send(JSON.stringify({
type: 'hello', agentVersion: '9.9.9', hostname: 'test-host',
capabilities: ['collect', 'reboot'],
}));
// Wait until hub processes it (message events are queued).
await new Promise((r) => setTimeout(r, 20));
const s = hub.status();
assert.equal(s.connected, true);
assert.equal(s.agent.agentVersion, '9.9.9');
assert.equal(s.agent.hostname, 'test-host');
assert.deepEqual(s.agent.capabilities, ['collect', 'reboot']);
ws.close();
await waitClose(ws);
} finally {
await closeAll();
}
});
// ─── RPC correlation ────────────────────────────────────────────────
test('hub: RPC round-trip resolves with agent result', async () => {
const { hub, port, closeAll } = await makeHub();
try {
const ws = connectAgent(port);
await waitOpen(ws);
attachAutoAgent(ws, async (msg) => {
assert.equal(msg.type, 'collect');
assert.equal(msg.baseIp, '10.4.11.87');
return { parsed: { device: { macAddress: 'aa:bb:cc:dd:ee:ff' } }, verdict: { healthy: true } };
});
const { result } = await hub.collect('10.4.11.87');
assert.equal(result.parsed.device.macAddress, 'aa:bb:cc:dd:ee:ff');
assert.equal(result.verdict.healthy, true);
ws.close();
await waitClose(ws);
} finally {
await closeAll();
}
});
test('hub: RPC error from agent surfaces as DectRelayError with code', async () => {
const { hub, port, closeAll } = await makeHub();
try {
const ws = connectAgent(port);
await waitOpen(ws);
attachAutoAgent(ws, async () => {
const err = new Error('base rejected credentials');
err.code = 'DIGEST_401';
throw err;
});
await assert.rejects(
hub.collect('10.4.11.87'),
(err) => err instanceof DectRelayError && err.code === 'DIGEST_401'
&& /base rejected credentials/i.test(err.message),
);
} finally {
await closeAll();
}
});
test('hub: multiple concurrent RPCs correlate by id, not order', async () => {
const { hub, port, closeAll } = await makeHub();
try {
const ws = connectAgent(port);
await waitOpen(ws);
// Delay short IPs less than long IPs, deliberately reversing
// response order relative to send order.
ws.on('message', (raw) => {
const msg = JSON.parse(raw.toString('utf8'));
if (!msg.id) return;
const delay = msg.baseIp === '10.0.0.1' ? 40 : 5;
setTimeout(() => {
ws.send(JSON.stringify({
id: msg.id, ok: true, result: { echo: msg.baseIp }, elapsedMs: delay,
}));
}, delay);
});
const [a, b] = await Promise.all([
hub.collect('10.0.0.1'), // slower
hub.collect('10.0.0.2'), // faster
]);
assert.equal(a.result.echo, '10.0.0.1');
assert.equal(b.result.echo, '10.0.0.2');
} finally {
await closeAll();
}
});
// ─── Timeout ────────────────────────────────────────────────────────
test('hub: RPC that never gets a reply times out with TIMEOUT code', async () => {
const { hub, port, closeAll } = await makeHub();
try {
const ws = connectAgent(port);
await waitOpen(ws);
// Silent agent: acknowledge nothing.
ws.on('message', () => { /* intentionally do nothing */ });
await assert.rejects(
hub.collect('10.0.0.1', { timeoutMs: 50 }),
(err) => err instanceof DectRelayError && err.code === RelayErrorCodes.TIMEOUT,
);
} finally {
await closeAll();
}
});
// ─── Mid-flight disconnect ──────────────────────────────────────────
test('hub: agent disconnect mid-RPC rejects the pending promise', async () => {
const { hub, port, closeAll } = await makeHub();
try {
const ws = connectAgent(port);
await waitOpen(ws);
// Close the socket the moment we receive a command.
ws.on('message', () => ws.close(1000, 'test'));
await assert.rejects(
hub.collect('10.0.0.1', { timeoutMs: 2000 }),
(err) => err instanceof DectRelayError && err.code === RelayErrorCodes.DISCONNECTED,
);
} finally {
await closeAll();
}
});
// ─── Second agent replaces first ────────────────────────────────────
test('hub: second agent connection replaces the first (with clean close)', async () => {
const { hub, port, closeAll } = await makeHub();
try {
const wsA = connectAgent(port);
await waitOpen(wsA);
const closedA = waitClose(wsA);
const wsB = connectAgent(port);
await waitOpen(wsB);
// wsA should have been closed by the hub with reason "replaced".
const closeInfo = await closedA;
assert.equal(closeInfo.code, 1000);
assert.match(closeInfo.reason, /replaced/i);
// The hub is still connected — to wsB now.
assert.equal(hub.isConnected(), true);
wsB.close();
await waitClose(wsB);
} finally {
await closeAll();
}
});
// ─── execAction routing ────────────────────────────────────────────
test('hub: execAction routes action name into type field', async () => {
const { hub, port, closeAll } = await makeHub();
try {
const ws = connectAgent(port);
await waitOpen(ws);
let observed = null;
attachAutoAgent(ws, async (msg) => {
observed = msg;
return { ok: 'done' };
});
await hub.execAction('10.0.0.1', 'reboot', { forced: false });
assert.equal(observed.type, 'reboot');
assert.equal(observed.baseIp, '10.0.0.1');
assert.equal(observed.forced, false);
} finally {
await closeAll();
}
});

View file

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

View file

@ -10,7 +10,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { renderPhoneStatusMarkdown } from '../services/renderers/phoneStatusRenderer.js';
import {
renderPhoneStatusMarkdown,
renderDectDiagnosticsMarkdown,
} from '../services/renderers/phoneStatusRenderer.js';
import { renderAvStatusMarkdown } from '../services/renderers/avStatusRenderer.js';
// Timestamp exactly 3 hours in the past — makes `simpleTimeAgo`
@ -248,3 +251,157 @@ test('av renderer: Atlas AMP with vitals renders temps + fan + amps', () => {
assert.match(md, /CPU: 104°F • PSU: 95°F • Io: 100°F • Voltage: 120\.4V • Fan: 46%/);
assert.match(md, /Amps: Amp1: Active, Amp2: Ready/);
});
// ─────────────────────────────────────────────────────────────
// DECT follow-up "loading" hint (main /phonestatus output)
// ─────────────────────────────────────────────────────────────
test('phone renderer: dectFollowUpBaseCount > 0 emits a loading hint inside the DECT section', () => {
const data = {
dectBasestations: [
{ mac: 'aa:bb:cc:dd:ee:01', meraki: { status: 'Online' } },
{ mac: 'aa:bb:cc:dd:ee:02', meraki: { status: 'Online' } },
],
dectHandsets: [],
};
const md = renderPhoneStatusMarkdown(data, {
storeNum: '782', footer: false, dectFollowUpBaseCount: 2,
});
assert.match(md, /Base-station diagnostics loading for 2 bases/);
});
test('phone renderer: dectFollowUpBaseCount === 1 uses singular "base"', () => {
const data = {
dectBasestations: [{ mac: 'aa:bb:cc:dd:ee:01', meraki: { status: 'Online' } }],
dectHandsets: [],
};
const md = renderPhoneStatusMarkdown(data, {
storeNum: '782', footer: false, dectFollowUpBaseCount: 1,
});
assert.match(md, /loading for 1 base —/);
});
test('phone renderer: dectFollowUpBaseCount === 0 emits no loading hint (default state)', () => {
const data = {
dectBasestations: [{ mac: 'aa:bb:cc:dd:ee:01', meraki: { status: 'Online' } }],
dectHandsets: [],
};
const md = renderPhoneStatusMarkdown(data, { storeNum: '782', footer: false });
assert.doesNotMatch(md, /diagnostics loading/);
});
// ─────────────────────────────────────────────────────────────
// DECT follow-up message (renderDectDiagnosticsMarkdown)
// ─────────────────────────────────────────────────────────────
const okResult = (overrides = {}) => ({
base: { mac: '6c:ab:05:f6:28:19', ip: '10.4.11.87', name: 'Basestation A' },
ok: true,
data: {
time: { operatingTime: '02:15:00 (H:M:S)' },
firmware: { version: '05-01-03-0101-09' },
multiCell: { role: 'primary' },
conflictInfo: 'No Conflict',
rebootLog: [],
rtp: { current: 0 },
},
verdict: { healthy: true, warnings: [], info: [] },
elapsedMs: 812,
...overrides,
});
test('dect diagnostics renderer: empty input returns empty string (caller should not send)', () => {
assert.equal(renderDectDiagnosticsMarkdown([], { storeNum: '782' }), '');
assert.equal(renderDectDiagnosticsMarkdown(null, { storeNum: '782' }), '');
});
test('dect diagnostics renderer: healthy base renders check + uptime + firmware', () => {
const md = renderDectDiagnosticsMarkdown([okResult()], { storeNum: '782', footer: false });
assert.match(md, /\*\*DECT Base Station Diagnostics — Store 782\*\*/);
assert.match(md, /✅ \*\*Basestation A\*\* \(10\.4\.11\.87\)/);
assert.match(md, /uptime 02:15:00/);
assert.match(md, /fw 05-01-03-0101-09/);
assert.match(md, /role: primary/);
});
test('dect diagnostics renderer: warnings from verdict are surfaced under the header', () => {
const md = renderDectDiagnosticsMarkdown([
okResult({
verdict: {
healthy: false,
warnings: ['Rx errors: 42 since last boot'],
info: [],
},
}),
], { storeNum: '782', footer: false });
assert.match(md, /⚠️ \*\*Basestation A\*\*/);
assert.match(md, /⚠️ Rx errors: 42 since last boot/);
});
test('dect diagnostics renderer: recent Power Loss reboot gets its own bolt line + suppresses duplicate warning', () => {
const md = renderDectDiagnosticsMarkdown([
okResult({
data: {
time: { operatingTime: '02:15:00' },
firmware: { version: '05-01-03-0101-09' },
multiCell: { role: 'primary' },
conflictInfo: 'No Conflict',
rebootLog: [
{ sequence: 164, at: '2026-07-02T12:54:12', reasonName: 'Power Loss', reasonCode: 80 },
],
rtp: { current: 0 },
},
verdict: {
healthy: false,
warnings: ['1 recent power-loss reboot(s); most recent at 2026-07-02T12:54:12'],
info: [],
},
}),
], { storeNum: '782', footer: false });
// The structured line survives …
assert.match(md, /⚡ Recent power loss: 2026-07-02T12:54:12 \(reboot #164\)/);
// … but the summary warning about power-loss is filtered out to
// avoid duplication under the same header.
assert.doesNotMatch(md, /⚠️ 1 recent power-loss/);
});
test('dect diagnostics renderer: active RTP session gets a call icon', () => {
const md = renderDectDiagnosticsMarkdown([
okResult({
data: {
time: { operatingTime: '02:15:00' },
firmware: { version: '05-01-03-0101-09' },
multiCell: { role: 'primary' },
conflictInfo: 'No Conflict',
rebootLog: [],
rtp: { current: 2 },
},
}),
], { storeNum: '782', footer: false });
assert.match(md, /📞 2 active RTP session/);
});
test('dect diagnostics renderer: base with error renders remediation hint', () => {
const md = renderDectDiagnosticsMarkdown([
{
base: { mac: '6c:ab:05:f6:28:19', ip: '10.4.11.87', name: 'Basestation A' },
ok: false,
data: null,
verdict: null,
elapsedMs: 15003,
error: {
code: 'RELAY_RPC_TIMEOUT',
message: 'timed out after 15000ms',
hint: 'Relay accepted the request but the base did not respond in time.',
},
},
], { storeNum: '782', footer: false });
assert.match(md, /⚠️ \*\*Basestation A\*\* \(10\.4\.11\.87\) — collect failed: timed out after 15000ms/);
assert.match(md, /Relay accepted the request but the base did not respond in time\./);
});
test('dect diagnostics renderer: footer references /dectstatus command by store', () => {
const md = renderDectDiagnosticsMarkdown([okResult()], { storeNum: '782' });
assert.match(md, /Use `\/dectstatus 782`/);
});

320
tests/statusXml.test.js Normal file
View 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
View 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(', ')}`;
}