From f7953b8eb55f7535f35a48aacf307cbc0fb4a53d Mon Sep 17 00:00:00 2001 From: jmcqueen Date: Tue, 28 Jul 2026 18:01:01 -0400 Subject: [PATCH] Add MPP desk phone diagnostics follow-up to /phonestatus via relay. Wire CP-78xx probe discovery, relay phone-probe commands, and a chat follow-up message so store desk phones get registration, switch, and provisioning detail alongside DECT and WAN diagnostics. Co-authored-by: Cursor --- .env.example | 19 + commands/help.js | 17 +- commands/phoneStatus.js | 51 +- dect-relay-agent/.env.example | 12 + dect-relay-agent/Dockerfile | 4 +- dect-relay-agent/Dockerfile.dockerignore | 1 + dect-relay-agent/README.md | 34 + dect-relay-agent/bundle.sh | 4 + dect-relay-agent/index.js | 53 +- docs/mpp-phone-data-inventory.md | 281 +++++++ index.js | 44 ++ .../cisco-mpp-phone/aggregateProbe.js | 87 ++ integrations/cisco-mpp-phone/client.js | 123 +++ .../cisco-mpp-phone/downloadStatusJson.js | 90 +++ integrations/cisco-mpp-phone/jsonRows.js | 35 + integrations/cisco-mpp-phone/nsJson.js | 54 ++ integrations/cisco-mpp-phone/probes.js | 183 +++++ integrations/cisco-mpp-phone/statusJson.js | 184 +++++ integrations/cisco-mpp-phone/statusXml.js | 265 +++++++ integrations/cisco-mpp-phone/systemJson.js | 24 + scripts/lib/botPhoneApi.js | 75 ++ services/dectRelayHub.js | 10 + services/phoneCollectorService.js | 180 +++++ services/phoneDiscovery.js | 185 +++++ services/phoneStatus/capturePhoneProbe.js | 137 ++++ .../renderers/mppPhoneDiagnosticsRenderer.js | 204 +++++ services/renderers/phoneStatusRenderer.js | 8 + tests/ciscoMppPhone.downloadStatus.test.js | 25 + tests/ciscoMppPhone.nsJson.test.js | 23 + tests/ciscoMppPhone.statusJson.test.js | 31 + tests/ciscoMppPhone.systemJson.test.js | 17 + tests/ciscoMppPhone.test.js | 165 ++++ tests/dectRelayHub.test.js | 24 + tests/fixtures/mpp/download-status-782.json | 34 + tests/fixtures/mpp/ns-782.json | 35 + tests/fixtures/mpp/status-782-live.json | 745 ++++++++++++++++++ tests/fixtures/mpp/status-782-sample.xml | 26 + tests/fixtures/mpp/system-782.json | 29 + tests/mppPhoneDiagnosticsRenderer.test.js | 73 ++ tests/phoneDiscovery.test.js | 103 +++ tests/renderers.test.js | 12 + 41 files changed, 3696 insertions(+), 10 deletions(-) create mode 100644 docs/mpp-phone-data-inventory.md create mode 100644 integrations/cisco-mpp-phone/aggregateProbe.js create mode 100644 integrations/cisco-mpp-phone/client.js create mode 100644 integrations/cisco-mpp-phone/downloadStatusJson.js create mode 100644 integrations/cisco-mpp-phone/jsonRows.js create mode 100644 integrations/cisco-mpp-phone/nsJson.js create mode 100644 integrations/cisco-mpp-phone/probes.js create mode 100644 integrations/cisco-mpp-phone/statusJson.js create mode 100644 integrations/cisco-mpp-phone/statusXml.js create mode 100644 integrations/cisco-mpp-phone/systemJson.js create mode 100644 scripts/lib/botPhoneApi.js create mode 100644 services/phoneCollectorService.js create mode 100644 services/phoneDiscovery.js create mode 100644 services/phoneStatus/capturePhoneProbe.js create mode 100644 services/renderers/mppPhoneDiagnosticsRenderer.js create mode 100644 tests/ciscoMppPhone.downloadStatus.test.js create mode 100644 tests/ciscoMppPhone.nsJson.test.js create mode 100644 tests/ciscoMppPhone.statusJson.test.js create mode 100644 tests/ciscoMppPhone.systemJson.test.js create mode 100644 tests/ciscoMppPhone.test.js create mode 100644 tests/fixtures/mpp/download-status-782.json create mode 100644 tests/fixtures/mpp/ns-782.json create mode 100644 tests/fixtures/mpp/status-782-live.json create mode 100644 tests/fixtures/mpp/status-782-sample.xml create mode 100644 tests/fixtures/mpp/system-782.json create mode 100644 tests/mppPhoneDiagnosticsRenderer.test.js create mode 100644 tests/phoneDiscovery.test.js diff --git a/.env.example b/.env.example index ebceba0..53fb363 100644 --- a/.env.example +++ b/.env.example @@ -499,6 +499,25 @@ DECT_RELAY_AGENT_TOKEN= # node scripts/fetchDectStatusXml.js 782 --save-dir tests/fixtures/dect/ # Uses GET /api/dect/raw-xml/:storeNumber (collect-raw via relay). +# ----------------------------------------------------------------------------- +# Cisco MPP desk phone — LOCAL DEV TEST HARNESS +# Used by scripts/testMppPhone.js when iterating locally against a lab +# CP-78xx on 10.x. NOT read by the bot at runtime — production phone +# access lives in DECT_RELAY_* + dect-relay-agent/ (phone-probe commands). +# +# Prerequisite: enable Web Server on the phone (Settings → Security → +# Web Access). Phones use HTTPS with a self-signed cert. Password is +# optional — /Status.json is usually readable without admin credentials. +# ----------------------------------------------------------------------------- +PHONE_TEST_IP=10.0.0.100 +# PHONE_TEST_USER=admin +# PHONE_TEST_PASSWORD= +PHONE_TEST_TIMEOUT_MS=15000 + +# Capture MPP probe data from a live store (bot + relay must be running): +# node scripts/probePhone.js raw 782 --save-dir tests/fixtures/mpp/ +# Uses GET /api/phone/probe/:storeNumber (phone-probe-raw via relay). + # ----------------------------------------------------------------------------- # Twilio /calltest — outbound voice path testing # ----------------------------------------------------------------------------- diff --git a/commands/help.js b/commands/help.js index bc8f66a..5ae5b6b 100644 --- a/commands/help.js +++ b/commands/help.js @@ -55,11 +55,22 @@ const LONG_HELP = { }, phonestatus: { title: '/phonestatus', - usage: ['/phonestatus ', '/phonestatus detailed'], - examples: ['/phonestatus 782', '/phonestatus 782 detailed'], + usage: [ + '/phonestatus ', + '/phonestatus detailed', + '/phonestatus verbose', + '/phonestatus detailed verbose', + ], + examples: [ + '/phonestatus 782', + '/phonestatus 782 detailed', + '/phonestatus 782 verbose', + '/phonestatus 782 debug', + ], notes: [ 'Shows DECT basestations + IP phones with Meraki links.', - 'Detailed mode adds firmware, serial, SIP details and errors.', + 'Detailed mode adds firmware, serial, SIP details and errors (main message only).', + 'When MPP desk phones are discovered on 10.x, a follow-up **MPP Desk Phone Diagnostics** message arrives via the relay (registration, switch port, provisioning history). Pass `verbose` or `debug` for extension/debug counters.', 'When the store is Prisma SD-WAN managed (site name `CG` padded to 5 digits), a follow-up **WAN Diagnostics** message arrives with per-path latency/jitter/loss/MOS, site healthscore, and any alarms — averaged over the last 7 days by default (widened from 24h so sporadic Webex Calling stores get enough call samples; override via `WAN_STANDARD_WINDOW_MINUTES` or pass `--window 24h` on /voicediag).', 'If `PRISMA_APP_ID_VOICE` is configured (e.g. pointing at `Webex_Calling_RTP` for Webex Calling shops), the follow-up also includes a **Voice Traffic Quality** section with real DPI-measured MOS / packet loss / jitter for that app — surfaces transient degradation the 7d link-probe averages smooth away.', 'For the full DECT base dump + reboot/factory-reset controls, use `/dectstatus `.', diff --git a/commands/phoneStatus.js b/commands/phoneStatus.js index f4b5986..a71348d 100644 --- a/commands/phoneStatus.js +++ b/commands/phoneStatus.js @@ -13,12 +13,15 @@ import { renderDectDiagnosticsMarkdown, } from '../services/renderers/phoneStatusRenderer.js'; import { renderWanDiagnosticsMarkdown } from '../services/renderers/wanDiagnosticsRenderer.js'; +import { renderMppPhoneDiagnosticsMarkdown } from '../services/renderers/mppPhoneDiagnosticsRenderer.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 { discoverDeskPhones } from '../services/phoneDiscovery.js'; import { collectAll } from '../services/dectCollectorService.js'; +import { probeAll } from '../services/phoneCollectorService.js'; import { siteNameForStore, findSdwanSiteForStore } from '../integrations/paloalto/sites.js'; import { collectSdwanForStore } from '../services/enrichment/sdwanEnrichment.js'; @@ -31,9 +34,13 @@ export async function handlePhoneStatus(bot, trigger) { let storeNum = args[0]?.trim() || query.storeNum || query.store || query.s; - const isDetailed = (args[1]?.toLowerCase() === 'detailed') || - (query.mode === 'detailed') || - (query.detailed === 'true' || query.detailed === true); + const isDetailed = argIncludes(args, 'detailed') + || (query.mode === 'detailed') + || (query.detailed === 'true' || query.detailed === true); + + const isVerbose = argIncludes(args, 'verbose') || argIncludes(args, 'debug') + || query.verbose === 'true' || query.verbose === true + || query.debug === 'true' || query.debug === true; if (!storeNum || !/^\d{2,4}$/.test(storeNum)) { const errorMsg = 'Please provide a 2–4 digit store number.\n' + @@ -72,6 +79,9 @@ export async function handlePhoneStatus(bot, trigger) { const { bases: reachableBases, warnings: discoveryWarnings } = dectFollowUpEnabled ? discoverDectBases(data) : { bases: [], warnings: [] }; + const { phones: mppPhones, warnings: mppDiscoveryWarnings } = dectFollowUpEnabled + ? discoverDeskPhones(data) + : { phones: [], warnings: [] }; if (discoveryWarnings.length > 0) { logger( 'phone:status', @@ -79,6 +89,22 @@ export async function handlePhoneStatus(bot, trigger) { 'warn', ); } + if (mppDiscoveryWarnings.length > 0) { + logger( + 'phone:status', + `MPP discovery warnings for store ${storeNum}: ${mppDiscoveryWarnings.map((w) => w.reason).join('; ')}`, + 'warn', + ); + } + if (dectFollowUpEnabled) { + logger( + 'phone:status', + `MPP discovery for store ${storeNum}: ` + + `${mppPhones.length > 0 + ? `${mppPhones.length} desk phone(s) selected — follow-up scheduled (${mppPhones.map((p) => p.ip).join(', ')})` + : 'no MPP desk phones selected — no follow-up'}`, + ); + } // WAN follow-up discovery. Cheap Prisma-side check: hits the // 4-hour-cached sites list to confirm this store is Prisma- @@ -116,6 +142,7 @@ export async function handlePhoneStatus(bot, trigger) { detailed: isDetailed, footer: true, dectFollowUpBaseCount: reachableBases.length, + mppFollowUpPhoneCount: mppPhones.length, wanFollowUpEnabled, }); await bot.say('markdown', reply || 'No data available.'); @@ -133,6 +160,12 @@ export async function handlePhoneStatus(bot, trigger) { }); } + if (dectFollowUpEnabled && mppPhones.length > 0) { + runMppPhoneFollowUp(bot, storeNum, mppPhones, { verbose: isVerbose }).catch((err) => { + logger('phone:status', `MPP phone follow-up failed for store ${storeNum}: ${err.message}`, 'error'); + }); + } + // WAN follow-up (mirrors the DECT pattern). Only kicked when // discovery above already confirmed we have a Prisma site for // this store. Fire-and-forget with a stable log scope. @@ -210,6 +243,13 @@ async function runDectFollowUp(bot, storeNum, bases) { await bot.say('markdown', md); } +async function runMppPhoneFollowUp(bot, storeNum, phones, { verbose = false } = {}) { + const results = await probeAll(phones); + const md = renderMppPhoneDiagnosticsMarkdown(results, { storeNum, verbose }); + if (!md) return; + await bot.say('markdown', md); +} + /** * Prisma SD-WAN follow-up. Runs the enrichment composer (which * hydrates site + elements + healthscore + per-path LQM + alarms in @@ -242,3 +282,8 @@ function safeSiteName(storeNum) { return ''; } } + +function argIncludes(args, token) { + const needle = String(token).toLowerCase(); + return (args || []).some((a) => String(a).toLowerCase() === needle); +} diff --git a/dect-relay-agent/.env.example b/dect-relay-agent/.env.example index fdaa6d0..5f426c8 100644 --- a/dect-relay-agent/.env.example +++ b/dect-relay-agent/.env.example @@ -41,6 +41,18 @@ DECT_ADMIN_PASSWORD=replace-with-dect-serviceability-password # is aggressive. DECT_ADMIN_TIMEOUT_MS=30000 +# ─── MPP desk phone admin credentials (CP-78xx, Webex Calling) ─────── +# +# Used for phone-probe / phone-probe-raw relay commands. Phones use +# HTTPS on 443 with a self-signed cert (agent accepts untrusted TLS). +# Enable Web Server on the phone (Settings → Security → Web Access). +# PHONE_ADMIN_PASSWORD is OPTIONAL — Webex MPP serves /Status.json +# without credentials when the user web UI is enabled. Set only if +# your site requires auth for admin/legacy paths. +# PHONE_ADMIN_USER=admin +# PHONE_ADMIN_PASSWORD= +PHONE_ADMIN_TIMEOUT_MS=15000 + # ─── Optional tuning ───────────────────────────────────────────────── # # How long to wait between reconnect attempts when the bot socket diff --git a/dect-relay-agent/Dockerfile b/dect-relay-agent/Dockerfile index 6c23c50..ea904f3 100644 --- a/dect-relay-agent/Dockerfile +++ b/dect-relay-agent/Dockerfile @@ -40,7 +40,8 @@ # 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. +# AND the shared integrations (cisco-dect + cisco-mpp-phone) 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. @@ -97,6 +98,7 @@ 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 integrations/cisco-mpp-phone ./integrations/cisco-mpp-phone COPY --chown=node:node utils/httpDigestAuth.js ./utils/httpDigestAuth.js # Optional metadata that shows up in `docker inspect` output — useful diff --git a/dect-relay-agent/Dockerfile.dockerignore b/dect-relay-agent/Dockerfile.dockerignore index 137d873..e877cd2 100644 --- a/dect-relay-agent/Dockerfile.dockerignore +++ b/dect-relay-agent/Dockerfile.dockerignore @@ -16,6 +16,7 @@ !dect-relay-agent/package.json !dect-relay-agent/index.js !integrations/cisco-dect/** +!integrations/cisco-mpp-phone/** !utils/httpDigestAuth.js # ─── Never-ship, even inside whitelisted trees ────────────────────── diff --git a/dect-relay-agent/README.md b/dect-relay-agent/README.md index 9695a4a..6fc2b6e 100644 --- a/dect-relay-agent/README.md +++ b/dect-relay-agent/README.md @@ -168,6 +168,40 @@ node scripts/fetchDectStatusXml.js 782 --save-dir tests/fixtures/dect/ 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). +## MPP desk phones (CP-78xx, Webex Calling) + +The same relay agent can probe Cisco MPP desk phones registered directly with Webex. Phones use **HTTPS on 443** with a self-signed certificate (the agent accepts untrusted TLS, same as DBS-210). + +### Manual prerequisite (per phone) + +Before `phone-probe` can return data: + +1. On the phone: **Settings → Security → Web Access** — enable **Web Server** (Admin Access optional for read-only probes). +2. From a host on `10.x`, verify JSON is reachable without credentials (typical for Webex-provisioned MPP): + ```bash + curl -k https:///Status.json + ``` +3. **No admin password is required** for read-only status on Webex Calling MPP phones — `/Status.json` is served by the user web UI. Set `PHONE_ADMIN_PASSWORD` only if your site locks down admin paths (`/admin/*`) and you have a local password. + +Lab store for fixture capture: **782**. + +### Relay commands + +**Bot → Agent (phone probe):** +```json +{ "id": "cmd_", "type": "phone-probe", "targetIp": "10.4.11.50" } +{ "id": "cmd_", "type": "phone-probe-raw", "targetIp": "10.4.11.50" } +``` + +`phone-probe-raw` includes full response bodies for fixture capture. `baseIp` is accepted as an alias for `targetIp`. + +**Capture script (from repo root, bot + relay running):** +```bash +node scripts/probePhone.js raw 782 --save-dir tests/fixtures/mpp/ +``` + +Or via HTTP API: `GET /api/phone/probe/782?ip=10.x.x.x` + ## Safety guarantees - DECT admin credentials NEVER leave this agent. The bot only knows the WSS bearer token. diff --git a/dect-relay-agent/bundle.sh b/dect-relay-agent/bundle.sh index 6877459..5bcd2c0 100755 --- a/dect-relay-agent/bundle.sh +++ b/dect-relay-agent/bundle.sh @@ -53,6 +53,10 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" echo "ERROR: $REPO_ROOT does not look like the collabSupport repo root (no integrations/cisco-dect/)" >&2 exit 1 } +[[ -d "$REPO_ROOT/integrations/cisco-mpp-phone" ]] || { + echo "ERROR: $REPO_ROOT is missing integrations/cisco-mpp-phone/ (required for MPP phone-probe)" >&2 + exit 1 +} # ─── Determine version tag ──────────────────────────────────────── if [[ -n "$TAG_OVERRIDE" ]]; then diff --git a/dect-relay-agent/index.js b/dect-relay-agent/index.js index 112ad6c..0b264f0 100644 --- a/dect-relay-agent/index.js +++ b/dect-relay-agent/index.js @@ -37,6 +37,10 @@ import { summarizeBaseHealth, } from '../integrations/cisco-dect/statusXml.js'; import { inventoryStatusXml } from '../integrations/cisco-dect/statusXmlInventory.js'; +import { createMppPhoneClient } from '../integrations/cisco-mpp-phone/client.js'; +import { runPhoneProbe } from '../integrations/cisco-mpp-phone/probes.js'; +import { parseStatusXml as parsePhoneStatusXml, summarizePhoneHealth } from '../integrations/cisco-mpp-phone/statusXml.js'; +import { parseStatusJson, summarizePhoneHealthFromJson } from '../integrations/cisco-mpp-phone/statusJson.js'; // ─── Config ───────────────────────────────────────────────────────── @@ -47,10 +51,13 @@ const CFG = { dectUser: process.env.DECT_ADMIN_USER || 'admin', dectPass: process.env.DECT_ADMIN_PASSWORD, dectTimeout: Number(process.env.DECT_ADMIN_TIMEOUT_MS) || 30_000, + phoneUser: process.env.PHONE_ADMIN_USER || 'admin', + phonePass: process.env.PHONE_ADMIN_PASSWORD, + phoneTimeout: Number(process.env.PHONE_ADMIN_TIMEOUT_MS) || 15_000, reconnectMaxMs: Number(process.env.DECT_RELAY_RECONNECT_MAX_MS) || 30_000, }; -const AGENT_VERSION = '0.2.0'; +const AGENT_VERSION = '0.3.0'; const CAPABILITIES = [ 'collect', 'collect-raw', @@ -60,6 +67,8 @@ const CAPABILITIES = [ 'force-reboot-chain', 'factory-reset', 'reconfigure-tree', + 'phone-probe', + 'phone-probe-raw', ]; const HEARTBEAT_INTERVAL_MS = 30_000; @@ -219,7 +228,15 @@ async function handleMessage(raw) { replyError(msg.id, 'MALFORMED', 'command frame missing `type`'); return; } - if (!msg.baseIp) { + + const phoneCmd = msg.type === 'phone-probe' || msg.type === 'phone-probe-raw'; + const targetIp = msg.targetIp || msg.baseIp; + if (phoneCmd) { + if (!targetIp) { + replyError(msg.id, 'MALFORMED', 'phone command frame missing `targetIp`'); + return; + } + } else if (!msg.baseIp) { replyError(msg.id, 'MALFORMED', 'command frame missing `baseIp`'); return; } @@ -230,7 +247,8 @@ async function handleMessage(raw) { 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'); + const label = phoneCmd ? targetIp : msg.baseIp; + log('dispatch', `Command ${msg.type} for ${label} failed: ${err.message}`, 'warn'); replyError(msg.id, code, err.message, { stack: err.stack?.split('\n')[0] }); } } @@ -241,6 +259,35 @@ async function handleMessage(raw) { * field of the reply frame. */ async function dispatch(cmd) { + if (cmd.type === 'phone-probe' || cmd.type === 'phone-probe-raw') { + const host = cmd.targetIp || cmd.baseIp; + const clientOpts = { + host, + timeoutMs: CFG.phoneTimeout, + }; + if (CFG.phonePass) { + clientOpts.user = CFG.phoneUser; + clientOpts.password = CFG.phonePass; + } + const client = createMppPhoneClient(clientOpts); + const includeBodies = cmd.type === 'phone-probe-raw'; + const probeResult = await runPhoneProbe(client, { includeBodies }); + const parsed = probeResult.statusJson + ? parseStatusJson(probeResult.statusJson) + : (probeResult.statusXml ? parsePhoneStatusXml(probeResult.statusXml) : null); + const verdict = parsed + ? (probeResult.statusJson ? summarizePhoneHealthFromJson(parsed) : summarizePhoneHealth(parsed)) + : null; + return { + ...probeResult, + parsed, + verdict, + byteLength: probeResult.statusXml + ? Buffer.byteLength(probeResult.statusXml, 'utf8') + : null, + }; + } + const client = createDectClient({ host: cmd.baseIp, user: CFG.dectUser, diff --git a/docs/mpp-phone-data-inventory.md b/docs/mpp-phone-data-inventory.md new file mode 100644 index 0000000..b4e0910 --- /dev/null +++ b/docs/mpp-phone-data-inventory.md @@ -0,0 +1,281 @@ +# MPP Phone Probe — Data Inventory + +Reference catalog for Cisco CP-78xx phones on Webex Calling (WxC MPP firmware). +Validated against lab store **782** (`10.43.206.157`, CP-7841-3PCC, MAC `CC98914F6799`). + +Use this document to decide what to surface in `/phonestatus`, tickets, and diagnostics. + +--- + +## How data flows + +``` +Webex Control Hub Phone HTTPS (via DC relay agent) +───────────────── ──────────────────────────────── +collectPhoneStatus() → GET /Status.json ← primary (parsed today) + MAC, name, IP GET /Download%20Status.json ← fetched, not parsed + product, webexId GET /ns.json ← fetched, not parsed + GET /basic/System.json ← fetched, not parsed + GET /admin/* ← login HTML (no password) +``` + +**Auth:** No credentials required for JSON endpoints when **Web Server** is enabled +(Settings → Security → Web Access). Webex does not provide per-phone admin passwords. + +**Typical probe time:** ~11s for 8 sequential GETs through the relay (store 782). + +**CLI:** + +```bash +node scripts/probePhone.js probe 782 --ip 10.43.206.157 +node scripts/probePhone.js raw 782 --ip 10.43.206.157 --save-dir tests/fixtures/mpp/ +``` + +--- + +## Source A — Webex inventory (before probe) + +From `collectPhoneStatus()` / `discoverDeskPhones()`. Used to find phones and match MAC ↔ IP. + +| Field | Example (782) | Notes | +|-------|---------------|-------| +| `mac` | `CC98914F6799` | Normalized to colon format in probe target | +| `name` | `Store 00782 CP-7841` | Display name in Control Hub | +| `product` | `Cisco 7841` | Used to classify MPP vs room/DECT | +| `ip` | `10.43.206.157` | From Meraki or Webex `ipAddress` | +| `webexId` | device UUID | Cross-reference to Control Hub | +| `source` | `meraki` / `webex` | Where IP came from | +| `capabilities` | `["xapi"]` | Present on WxC phones; not a probe blocker | + +**Not available from Webex alone:** live registration state, firmware load name, switch port, +provisioning history, SIP registrar IP. + +--- + +## Source B — `/Status.json` (~12 KB) + +Phone web UI: **Info → Status**. Primary source — fully parsed into `parsed` + `verdict`. + +### Structured fields extracted today (`parseStatusJson`) + +| Group | Fields | Example (782) | +|-------|--------|---------------| +| **Device** | product, mac, firmware, serial, hostname | CP-7841-3PCC, cc:98:91:4f:67:99 | +| **Network** | ipv4, netmask, gateway, dns1, dns2, vlan, connectionType, ipStatus | 10.43.206.157, VLAN 4095, DHCP, OK | +| **Registration** | `registration` (Ext 1), `lines[]` per extension | Registered → 150.253.156.211 | +| **SIP counters** | messagesSent, messagesRecv | 82 / 81 | +| **Reboot history** | last 5 reasons + timestamps | Provisioning, Upgrade, Cloud Triggered | +| **Health verdict** | `healthy`, `warnings[]`, `info[]` | healthy: true | + +### All sections in raw `Status.json` (~93 populated fields on 782) + +| Section | Useful for | Key fields | +|---------|------------|------------| +| **System Information** | Identity | Host Name, Primary NTP (`ntp.broadcloudpbx.net`) | +| **IPv4 Information** | Network triage | IP Status, DHCP/Static, IP, mask, gateway, DNS | +| **IPv6 Information** | IPv6 rollout | Usually empty/initializing on store LAN | +| **Reboot History** | "Why did it reboot?" | 5 entries: Provisioning / Upgrade / Cloud Triggered + timestamp | +| **VPN Status** | VPN phones | VPN Connected, client address (No on 782) | +| **Product Information** | Inventory match | Product, Serial, VID, MAC, Software/Hardware version, Client Cert, **WxC** auth type | +| **Phone Status** | Uptime & health | Current time, **Elapsed Time**, SIP byte/packet counters, **Operational VLAN**, **SW/PC Port** link, Upgrade Status | +| **Dot1x Authentication** | 802.1X stores | Transaction status (Authenticated), Protocol | +| **LED Status** | Visual state | Line 1–4, headset, speaker, mute, MWI cadence/color (**Line 1 Green** = registered) | +| **Ext 1–4 Status** | **Core voice health** | Registration State, Last Registration At/IP, Next Registration In Seconds, MWI, Hoteling, Extended Function | +| **Paging Status** | Overhead paging | Multicast Rx/Tx packets | +| **XML Streaming Status** | Cisco XML apps | Streaming Rx packets | +| **TR-069 Status** | Remote mgmt | Feature disabled on WxC MPP | +| **PRT Status** | Problem report tool | Generation/upload status (usually empty) | + +### Per-extension detail (Ext 1–4) + +| Field | Ext 1 (782) | Ext 2–4 | +|-------|-------------|---------| +| Registration State | **Registered** | Not Registered | +| Last Registration At | timestamp | — | +| Last Registration IP | 150.253.156.211 | — | +| Next Registration In Seconds | ~70 | — | +| Message Waiting | No | No | +| Hoteling State | Disabled | Disabled | + +--- + +## Source C — `/Download%20Status.json` (~3 KB) + +Phone web UI: **Info → Download Status**. Fetched in probe; **not parsed** into `parsed` today. + +| Section | Fields | Example (782) | +|---------|--------|---------------| +| **Firmware Upgrade Status** | 1–3 history entries | `[date][https://binaries.webex.com/.../sip78xx....loads]Upgrade Succeeded.` | +| **Transition Authorization Status** | WxC migration | `migration-service-a.wbx2.com` — Authorization Succeeded | +| **Provisioning Status** | Resync history | `[https://cisco.sipflash.com:443/*]Resync Succeeded.` | +| **Custom CA Status** | Custom CA provisioning | Empty | +| **MIC Cert Refresh Status** | Certificate renewal | **MIC Cert Download Failed** — file not found (candidate warning) | + +**Good for:** "Did provisioning/firmware succeed recently?" without Control Hub. + +--- + +## Source D — `/ns.json` (~9 KB) + +Phone web UI: **Info → Network Statistics**. Fetched in probe; **not parsed** today. + +| Section | Useful fields | Example (782) | +|---------|---------------|---------------| +| **Ethernet Information** | Tx/Rx frames, broadcasts, multicasts, unicasts | ~2M frames each direction | +| **Network Port Information** | Error counters, frame size histogram | All zeros (clean link) | +| **LLDP/CDP neighbors** | **Switch hostname, port, mgmt IP** | **SW00782R**, Port 45, 10.229.105.251 | +| **Port speed** | Link speed/duplex | 100M Full | +| **Access Port Information** | PC port stats | All zeros (PC port disabled) | + +**Good for:** "Which switch port is this phone on?" — often more useful than Webex for physical troubleshooting. + +--- + +## Source E — `/basic/System.json` (~8 KB) + +Phone web UI: **Settings → System** (configuration, not live status). Fetched; **not parsed** today. +Would replace broken `cfgParsed` from `/admin/cfg.xml`. + +| Section | Fields | Example (782) | +|---------|--------|---------------| +| **System Configuration** | Enable Web Server, Survivability Test Mode | Web Server: **Yes** | +| **Network Settings** | IP Mode (Dual), IPv4/IPv6 DHCP vs static | DHCP | +| **HTTP Proxy** | Proxy Mode, WPAD, PAC URL, host/port, auth | **Off** | +| **802.1X Authentication** | Certificate Select | Manufacturing installed | +| **Optional Network** | Host Name, Domain (editable) | Empty | +| **VPN Settings** | Server, username, connect on boot | Empty | + +**Good for:** Confirming web server enabled, proxy misconfig, 802.1X cert source. + +--- + +## Source F — Legacy XML paths (not useful on WxC MPP) + +| Path | What you get | Auth | +|------|--------------|------| +| `/admin/status.xml` | Admin **login HTML** (HTTP 200, not XML) | Admin password | +| `/admin/cfg.xml` | Admin **login HTML** | Admin password | +| `/status.xml` | **403 Forbidden** redirect page | Admin Access required | + +`cfgParsed: {}` and `cfg: web=?` in CLI output are expected without admin credentials. + +--- + +## Source G — Explore-only (not in default probe) + +| Path | Purpose | +|------|---------| +| `/Debug%20Info.json` | Debug bundle metadata | +| `/basic/init.json` | SPA tab/layout definitions | +| `/admin/advanced` | Legacy admin HTML | + +```bash +node scripts/testMppPhone.js --explore --ip 10.x.x.x +``` + +--- + +## Probe response shape (today) + +| Output key | Content | +|------------|---------| +| `parsed` | Structured data from `/Status.json` | +| `verdict` | Health summary (`healthy`, `warnings`, `info`) | +| `summary` | HTTP probe stats (ok paths, bytes, timing) | +| `statusJson` | Raw JSON string (in raw/API responses) | +| `probes[]` | Per-path status, size, snippet/body (raw mode) | +| `cfgParsed` | Empty unless real `/admin/cfg.xml` XML | +| `downloadStatusJson` | Truncated in summary probe; full in raw mode | + +--- + +## Suggested tiers for product decisions + +### Tier 1 — Likely want in `/phonestatus` or tickets + +| Signal | Source | Why | +|--------|--------|-----| +| Registration state | Status.json Ext 1 | #1 voice issue | +| Last registration IP | Status.json | Confirms WxC registrar | +| Product + MAC + IP | Status + Webex | Identity | +| Firmware version | Status.json | Upgrade troubleshooting | +| Uptime / elapsed time | Status.json | Recent reboot? | +| Latest reboot reason | Status.json | Provisioning vs power vs cloud | +| Switch + port | **ns.json** (not parsed yet) | Physical layer | +| VLAN | Status.json | Voice VLAN verification | +| Healthy / warnings | `verdict` | Single pass/fail | + +### Tier 2 — Useful for deeper triage + +| Signal | Source | +|--------|--------| +| Last provisioning resync | Download Status.json | +| Last firmware upgrade URL + result | Download Status.json | +| WxC transition auth history | Download Status.json | +| SIP message/byte counters | Status.json | +| Link speed/duplex | Status.json + ns.json | +| 802.1X status | Status.json | +| Web server / proxy config | System.json | +| LED state (line 1 green) | Status.json | +| MWI / hoteling | Status.json Ext 1 | +| MIC cert refresh failures | Download Status.json | + +### Tier 3 — Debug / rarely needed + +| Signal | Source | +|--------|--------| +| Full Ethernet error counters | ns.json | +| IPv6 details | Status.json | +| TR-069 / XML streaming / paging pkt counts | Status.json | +| All 4 extension slots | Status.json | +| Raw probe bodies | `probePhone.js raw` | +| Debug Info.json | explore mode | + +--- + +## Gaps — not available without admin password + +- SIP proxy, registrar, line DN, display name (in `/admin/cfg.xml` or line config JSON) +- Phone reboot/resync commands (`/admin/reboot`, `/admin/resync` — mutating, intentionally separate) +- Admin-only configuration changes + +Webex Control Hub has user/number assignment; phone web UI has line config behind admin login. + +--- + +## Lab snapshot — store 782 + +| Item | Value | +|------|-------| +| Product | CP-7841-3PCC | +| MAC | CC98914F6799 | +| IP / VLAN | 10.43.206.157 / 4095 | +| Firmware | sip78xx.12-0-7MPP0501-20260317-aa82ce7433.loads | +| Registration | Registered → 150.253.156.211 | +| Uptime | ~22 days | +| Switch | SW00782R Port 45 (10.229.105.251) | +| Last reboot | Provisioning 07/28/2026 15:29 | +| Last resync | cisco.sipflash.com 07/28/2026 15:30 | +| MIC cert | Download failed (not renewed) | + +--- + +## Implementation notes + +**Highest-value unparsed additions:** + +1. `/Download%20Status.json` — provisioning/firmware history, MIC cert warnings +2. `/ns.json` — switch port / LLDP neighbor +3. `/basic/System.json` — web server / proxy / 802.1X config (replaces cfg.xml path) + +**Code references:** + +- Probe paths: `integrations/cisco-mpp-phone/probes.js` +- Status.json parser: `integrations/cisco-mpp-phone/statusJson.js` +- Discovery: `services/phoneDiscovery.js` +- Capture API: `services/phoneStatus/capturePhoneProbe.js` +- Fixture: `tests/fixtures/mpp/status-782-live.json` + +--- + +*Last validated: 2026-07-28 against live probe of 10.43.206.157 via relay.* diff --git a/index.js b/index.js index 1241a8a..2aaef41 100644 --- a/index.js +++ b/index.js @@ -193,6 +193,50 @@ app.get('/api/dect/raw-xml/:storeNumber', dataAuthGate, async (req, res) => { } }); +import { capturePhoneProbe, capturePhoneProbeDirect } from './services/phoneStatus/capturePhoneProbe.js'; +app.get('/api/phone/probe/:storeNumber', dataAuthGate, async (req, res) => { + const storeNumber = (req.params.storeNumber || '').trim(); + const phoneFilter = (req.query.phone || req.query.ip || req.query.mac || '').trim(); + + try { + const result = await capturePhoneProbe(storeNumber, { phoneFilter: phoneFilter || undefined }); + res.json(result); + } catch (err) { + const code = err?.code || 'CAPTURE_FAILED'; + const status = code === 'INVALID_STORE' ? 400 + : code === 'PHONE_NOT_FOUND' ? 404 + : code === 'RELAY_NOT_CONFIGURED' ? 503 + : 500; + logger('phone:capture', `probe failed for store ${storeNumber}: ${err.message}`, 'error'); + res.status(status).json({ + success: false, + code, + message: err.message, + knownPhones: err.knownPhones || undefined, + }); + } +}); + +app.get('/api/phone/probe-direct/:targetIp', dataAuthGate, async (req, res) => { + const targetIp = (req.params.targetIp || '').trim(); + + try { + const result = await capturePhoneProbeDirect(targetIp); + res.json(result); + } catch (err) { + const code = err?.code || 'CAPTURE_FAILED'; + const status = code === 'INVALID_IP' ? 400 + : code === 'RELAY_NOT_CONFIGURED' ? 503 + : 500; + logger('phone:capture', `probe-direct failed for ${targetIp}: ${err.message}`, 'error'); + res.status(status).json({ + success: false, + code, + message: err.message, + }); + } +}); + app.get('/api/av/device/:storeNumber/:identifier', dataAuthGate, async (req, res) => { const result = await getShapedDeviceData(req.params.storeNumber, req.params.identifier); res.json(result); diff --git a/integrations/cisco-mpp-phone/aggregateProbe.js b/integrations/cisco-mpp-phone/aggregateProbe.js new file mode 100644 index 0000000..68c370a --- /dev/null +++ b/integrations/cisco-mpp-phone/aggregateProbe.js @@ -0,0 +1,87 @@ +// integrations/cisco-mpp-phone/aggregateProbe.js +// +// Combine MPP probe JSON payloads into a single view for rendering. + +import { parseStatusJson, summarizePhoneHealthFromJson } from './statusJson.js'; +import { parseDownloadStatusJson, downloadStatusWarnings } from './downloadStatusJson.js'; +import { parseNsJson } from './nsJson.js'; +import { parseSystemJson } from './systemJson.js'; +import { parseStatusXml, summarizePhoneHealth } from './statusXml.js'; + +/** + * @param {object} input + * @param {string} [input.statusJson] + * @param {string} [input.statusXml] + * @param {string} [input.downloadStatusJson] + * @param {string} [input.nsJson] + * @param {string} [input.systemJson] + * @param {object[]} [input.probes] + * @returns {object} + */ +export function buildMppProbeView({ + statusJson, + statusXml, + downloadStatusJson, + nsJson, + systemJson, + probes, +} = {}) { + let parsed = null; + let baseVerdict = { healthy: false, warnings: ['no status payload'], info: [] }; + + if (statusJson) { + parsed = parseStatusJson(statusJson); + baseVerdict = summarizePhoneHealthFromJson(parsed); + } else if (statusXml) { + parsed = parseStatusXml(statusXml); + baseVerdict = summarizePhoneHealth(parsed); + } + + const download = downloadStatusJson ? parseDownloadStatusJson(downloadStatusJson) : null; + const networkNeighbor = nsJson ? parseNsJson(nsJson) : null; + const system = systemJson ? parseSystemJson(systemJson) : null; + + const extraWarnings = [ + ...downloadStatusWarnings(download), + ]; + + const warnings = [...(baseVerdict.warnings || [])]; + for (const w of extraWarnings) { + if (!warnings.includes(w)) warnings.push(w); + } + + const info = [...(baseVerdict.info || [])]; + if (networkNeighbor?.switchDevice) { + info.push(`switch: ${networkNeighbor.switchDevice} ${networkNeighbor.switchPort || ''}`.trim()); + } + if (download?.latestProvisioning?.url) { + const host = shortenUrl(download.latestProvisioning.url); + info.push(`last resync: ${host}`); + } + + const verdict = { + healthy: warnings.length === 0, + warnings, + info, + }; + + return { + parsed, + download, + networkNeighbor, + system, + verdict, + probes: Array.isArray(probes) ? probes : [], + }; +} + +function shortenUrl(url) { + if (!url || typeof url !== 'string') return '?'; + try { + const normalized = url.startsWith('http') ? url : `https://${url}`; + const u = new URL(normalized); + return u.hostname; + } catch { + return url.length > 40 ? `${url.slice(0, 40)}…` : url; + } +} diff --git a/integrations/cisco-mpp-phone/client.js b/integrations/cisco-mpp-phone/client.js new file mode 100644 index 0000000..a2e7771 --- /dev/null +++ b/integrations/cisco-mpp-phone/client.js @@ -0,0 +1,123 @@ +// integrations/cisco-mpp-phone/client.js +// +// Axios wrapper for Cisco MPP desk phones (CP-78xx, Webex Calling). +// HTTPS on 443 with a self-signed cert; Basic auth is typical, with +// Digest retry on 401 (same interceptor pattern as DECT). + +import axios from 'axios'; +import https from 'node:https'; +import { + parseDigestChallenge, + buildDigestAuthHeader, +} from '../../utils/httpDigestAuth.js'; + +/** + * @param {object} opts + * @param {string} opts.host + * @param {string} [opts.user] default `admin` when password is set + * @param {string} [opts.password] omit for unauthenticated probes (Webex MPP JSON) + * @param {number} [opts.timeoutMs] + * @returns {import('axios').AxiosInstance} + */ +export function createMppPhoneClient({ host, user, password, timeoutMs = 15_000 }) { + if (!host) throw new Error('createMppPhoneClient: host is required'); + + const useAuth = !!(password && String(password).length > 0); + const authUser = user || 'admin'; + + const client = axios.create({ + baseURL: `https://${host}`, + timeout: timeoutMs, + httpsAgent: new https.Agent({ rejectUnauthorized: false }), + validateStatus: () => true, + responseType: 'text', + transformResponse: [(data) => data], + ...(useAuth ? { auth: { username: authUser, password } } : {}), + headers: { 'User-Agent': 'collabSupport-mpp-phone/0.1' }, + }); + + if (useAuth) { + client.defaults.__mppAuth = { user: authUser, password }; + } + + client.interceptors.response.use(async (response) => { + if (response.status !== 401) return response; + if (!client.defaults.__mppAuth) return response; + + const originalConfig = response.config; + if (originalConfig.__digestRetried) return response; + + const wwwAuth = response.headers?.['www-authenticate']; + const challenge = parseDigestChallenge(wwwAuth); + if (!challenge) return response; + + const { user: username, password: pass } = client.defaults.__mppAuth; + const method = (originalConfig.method || 'get').toUpperCase(); + const uri = originalConfig.url || '/'; + + const authHeader = buildDigestAuthHeader({ + username, password: pass, method, uri, challenge, + }); + + return client.request({ + ...originalConfig, + auth: undefined, + headers: { ...(originalConfig.headers || {}), Authorization: authHeader }, + __digestRetried: true, + }); + }); + + return client; +} + +/** + * @typedef {object} ProbeResult + * @property {string} path + * @property {string} method + * @property {number|null} status + * @property {string|null} contentType + * @property {number} sizeBytes + * @property {string|null} snippet + * @property {string|null} error + * @property {number} elapsedMs + */ + +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, + }; +} diff --git a/integrations/cisco-mpp-phone/downloadStatusJson.js b/integrations/cisco-mpp-phone/downloadStatusJson.js new file mode 100644 index 0000000..6c6fca7 --- /dev/null +++ b/integrations/cisco-mpp-phone/downloadStatusJson.js @@ -0,0 +1,90 @@ +// integrations/cisco-mpp-phone/downloadStatusJson.js +// +// Parser for /Download%20Status.json (provisioning + firmware history). + +import { coerceRows, fieldsFromRows, extractIndexed } from './jsonRows.js'; + +/** + * @param {string|Array} raw + * @returns {object} + */ +export function parseDownloadStatusJson(raw) { + const rows = coerceRows(raw); + const fields = fieldsFromRows(rows); + + const firmwareUpgrades = extractIndexed(fields, /^Firmware Upgrade Status \d+$/i); + const provisioning = extractIndexed(fields, /^Provisioning Status \d+$/i); + const transitionAuth = extractIndexed(fields, /^Transition Authorization Status \d+$/i); + const micCertStatus = fields['MIC Cert Provisioning Status'] || null; + const micCertInfo = fields['MIC Cert Info'] || null; + + return { + fields, + latestFirmwareUpgrade: parseHistoryEntry(firmwareUpgrades[0]), + latestProvisioning: parseHistoryEntry(provisioning[0]), + latestTransitionAuth: parseHistoryEntry(transitionAuth[0]), + firmwareUpgrades: firmwareUpgrades.map(parseHistoryEntry).filter(Boolean), + provisioningHistory: provisioning.map(parseHistoryEntry).filter(Boolean), + micCert: { + provisioningStatus: micCertStatus, + info: micCertInfo, + failed: !!(micCertStatus && /fail/i.test(micCertStatus)), + }, + }; +} + +/** + * Parse "[timestamp][url]Result." or "[seq][timestamp][url]Result." entries. + * @param {string|null} raw + */ +export function parseHistoryEntry(raw) { + if (!raw || typeof raw !== 'string') return null; + const trimmed = raw.trim(); + if (!trimmed) return null; + + const triple = trimmed.match(/^\[([^\]]*)\]\[([^\]]*)\]\[([^\]]*)\](.+)$/); + if (triple && looksLikeUrl(triple[3])) { + return buildEntry(trimmed, triple[2], triple[3], triple[4]); + } + + const double = trimmed.match(/^\[([^\]]*)\]\[([^\]]*)\](.+)$/); + if (double) { + if (looksLikeUrl(double[2])) { + return buildEntry(trimmed, double[1], double[2], double[3]); + } + return buildEntry(trimmed, null, double[1], double[2]); + } + + return buildEntry(trimmed, null, null, trimmed); +} + +function looksLikeUrl(s) { + return typeof s === 'string' && (/^https?:\/\//i.test(s) || /\.[a-z]{2,}/i.test(s)); +} + +function buildEntry(raw, timestamp, url, resultPart) { + const result = (resultPart || '').trim(); + return { + raw, + timestamp: timestamp || null, + url: url || null, + result: result || null, + succeeded: /succeed/i.test(result), + failed: /fail/i.test(result), + }; +} + +export function downloadStatusWarnings(download) { + const warnings = []; + if (!download) return warnings; + if (download.micCert?.failed) { + warnings.push(`MIC cert: ${download.micCert.provisioningStatus}`); + } + if (download.latestProvisioning?.failed) { + warnings.push(`provisioning: ${download.latestProvisioning.result}`); + } + if (download.latestFirmwareUpgrade?.failed) { + warnings.push(`firmware upgrade: ${download.latestFirmwareUpgrade.result}`); + } + return warnings; +} diff --git a/integrations/cisco-mpp-phone/jsonRows.js b/integrations/cisco-mpp-phone/jsonRows.js new file mode 100644 index 0000000..52614c2 --- /dev/null +++ b/integrations/cisco-mpp-phone/jsonRows.js @@ -0,0 +1,35 @@ +// integrations/cisco-mpp-phone/jsonRows.js +// +// Shared helpers for MPP SPA JSON endpoints (array-of-rows format). + +export function coerceRows(raw) { + if (Array.isArray(raw)) return raw; + if (typeof raw === 'string') { + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + return []; +} + +/** @param {Array} rows */ +export function fieldsFromRows(rows) { + const fields = {}; + for (const row of rows) { + const name = typeof row?.name === 'string' ? row.name.trim() : ''; + if (!name) continue; + const value = row?.value == null ? '' : String(row.value).trim(); + if (value) fields[name] = value; + } + return fields; +} + +export function extractIndexed(fields, re) { + return Object.entries(fields) + .filter(([k]) => re.test(k)) + .sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true })) + .map(([, v]) => v); +} diff --git a/integrations/cisco-mpp-phone/nsJson.js b/integrations/cisco-mpp-phone/nsJson.js new file mode 100644 index 0000000..c78f43c --- /dev/null +++ b/integrations/cisco-mpp-phone/nsJson.js @@ -0,0 +1,54 @@ +// integrations/cisco-mpp-phone/nsJson.js +// +// Parser for /ns.json (network statistics + LLDP/CDP neighbors). + +import { coerceRows, fieldsFromRows } from './jsonRows.js'; + +const ERROR_COUNTER_FIELDS = [ + 'RxDropPkts', 'RxUndersizePkts', 'RxOversizePkts', 'RxFragments', 'RxJabbers', + 'RxAlignErrs', 'RxFCSErrs', 'RxSymbolErrs', 'TxDropPkts', 'TxCollisions', + 'TxLateCollisions', +]; + +/** + * @param {string|Array} raw + * @returns {object} + */ +export function parseNsJson(raw) { + const rows = coerceRows(raw); + const fields = fieldsFromRows(rows); + + const neighbor = { + lldpDevice: fields['LLDPNeighborDeviceId'] || null, + lldpPort: fields['LLDPNeighborPort'] || null, + lldpIp: fields['LLDPNeighborIP'] || null, + cdpDevice: fields['CDPNeighborDeviceId'] || null, + cdpPort: fields['CDPNeighborPort'] || null, + cdpIp: fields['CDPNeighborIP'] || null, + }; + + const switchDevice = neighbor.lldpDevice || neighbor.cdpDevice || null; + const switchPort = neighbor.lldpPort || neighbor.cdpPort || null; + const switchIp = neighbor.lldpIp || neighbor.cdpIp || null; + + const portSpeed = (fields['PortSpeed'] || '').trim() || null; + const errorCounters = {}; + for (const key of ERROR_COUNTER_FIELDS) { + const val = fields[key]; + if (val != null && val !== '' && val !== '0') { + errorCounters[key] = val; + } + } + + return { + fields, + neighbor, + switchDevice, + switchPort, + switchIp, + portSpeed, + errorCounters, + txFrames: fields['Tx Frames'] || fields['TxtotalGoodPkt'] || null, + rxFrames: fields['Rx Frames'] || fields['RxtotalPkt'] || null, + }; +} diff --git a/integrations/cisco-mpp-phone/probes.js b/integrations/cisco-mpp-phone/probes.js new file mode 100644 index 0000000..47b2af0 --- /dev/null +++ b/integrations/cisco-mpp-phone/probes.js @@ -0,0 +1,183 @@ +// integrations/cisco-mpp-phone/probes.js +// +// Read-only probes against Cisco MPP desk phones (Webex Calling). +// Current-release Webex MPP firmware uses JSON endpoints on the SPA +// web UI (not legacy /admin/status.xml). + +import { tryRequest } from './client.js'; +import { parseCfgXml } from './statusXml.js'; + +export const READ_PROBE_PATHS = [ + { path: '/', purpose: 'user web landing (SPA shell)' }, + { path: '/Status.json', purpose: 'primary status JSON (Info > Status tab)' }, + { path: '/Download%20Status.json', purpose: 'provisioning + firmware upgrade history' }, + { path: '/ns.json', purpose: 'network statistics JSON' }, + { path: '/basic/System.json', purpose: 'system configuration JSON (admin fields)' }, + // Legacy fallbacks (older MPP loads / CUCM-style) + { path: '/admin/status.xml', purpose: 'legacy admin status XML' }, + { path: '/status.xml', purpose: 'legacy user status XML' }, + { path: '/admin/cfg.xml', purpose: 'legacy admin config XML' }, +]; + +export const EXPLORE_PROBE_PATHS = [ + { path: '/Debug%20Info.json', purpose: 'debug info JSON' }, + { path: '/basic/init.json', purpose: 'UI layout metadata' }, + { path: '/admin/advanced', purpose: 'legacy admin HTML landing' }, +]; + +export const MUTATING_ACTION_PATHS = Object.freeze({ + REBOOT: '/admin/reboot', + RESYNC: '/admin/resync', + UPGRADE: '/admin/upgrade', +}); + +export async function runProbeList(client, pathList) { + const results = []; + for (const { path, purpose } of pathList) { + const r = await tryRequest(client, { method: 'GET', path }); + results.push({ ...r, purpose }); + } + return results; +} + +export async function runReadProbes(client) { + return runProbeList(client, READ_PROBE_PATHS); +} + +export async function runExploreProbes(client) { + return runProbeList(client, [...READ_PROBE_PATHS, ...EXPLORE_PROBE_PATHS]); +} + +export async function runPhoneProbe(client, { includeBodies = false } = {}) { + const started = Date.now(); + const probes = await runReadProbes(client); + + const statusJson = await fetchBodyIfOk(probes, client, '/Status.json'); + const statusXml = !statusJson + ? (await fetchBodyIfOk(probes, client, '/admin/status.xml') + || await fetchBodyIfOk(probes, client, '/status.xml')) + : null; + + const downloadStatusJson = await fetchBodyIfOk(probes, client, '/Download%20Status.json'); + const nsJson = await fetchBodyIfOk(probes, client, '/ns.json'); + const systemJson = await fetchBodyIfOk(probes, client, '/basic/System.json'); + const cfgXml = await fetchBodyIfOk(probes, client, '/admin/cfg.xml'); + const cfgParsed = cfgXml ? parseCfgXml(cfgXml) : null; + + const summary = summarizeProbes(probes, { statusJson, statusXml }); + + return { + probes: includeBodies ? await attachBodies(client, probes) : probes, + summary, + statusJson, + downloadStatusJson: downloadStatusJson || null, + nsJson: nsJson || null, + systemJson: systemJson || null, + statusXml, + cfgXml: includeBodies ? cfgXml : (cfgXml ? truncate(cfgXml, 4000) : null), + cfgParsed: cfgParsed?.highlights || null, + byteLength: statusJson + ? Buffer.byteLength(statusJson, 'utf8') + : (statusXml ? Buffer.byteLength(statusXml, 'utf8') : null), + elapsedMs: Date.now() - started, + }; +} + +export async function fetchStatusJson(client) { + const r = await tryRequest(client, { method: 'GET', path: '/Status.json' }); + if (r.status === 200 && r.sizeBytes > 0) { + const body = await fetchBody(client, '/Status.json'); + return { + rawJson: body, + rawXml: null, + byteLength: Buffer.byteLength(body || '', 'utf8'), + probe: r, + path: '/Status.json', + }; + } + const xml = await fetchStatusXml(client); + return { + rawJson: null, + rawXml: xml.rawXml, + byteLength: xml.byteLength, + probe: xml.probe, + path: xml.path, + }; +} + +export async function fetchStatusXml(client) { + for (const path of ['/admin/status.xml', '/status.xml']) { + const r = await tryRequest(client, { method: 'GET', path }); + if (r.status === 200 && r.sizeBytes > 0) { + const body = await fetchBody(client, path); + return { rawXml: body, byteLength: Buffer.byteLength(body || '', 'utf8'), probe: r, path }; + } + } + const err = new Error('phone returned no Status.json or status.xml'); + err.code = 'PHONE_BAD_STATUS'; + throw err; +} + +async function fetchBodyIfOk(probes, client, path) { + const p = probes.find((row) => row.path === path); + if (p?.status === 200 && p.sizeBytes > 0) { + return fetchBody(client, path); + } + return null; +} + +async function fetchBody(client, path) { + try { + const res = await client.get(path); + return typeof res.data === 'string' ? res.data : JSON.stringify(res.data ?? ''); + } catch { + return null; + } +} + +async function attachBodies(client, probes) { + const out = []; + for (const p of probes) { + if (p.status === 200 && p.sizeBytes > 0) { + const body = await fetchBody(client, p.path); + out.push({ ...p, body: body ?? null }); + } else { + out.push({ ...p, body: null }); + } + } + return out; +} + +function truncate(str, max) { + if (!str || str.length <= max) return str; + return `${str.slice(0, max)}\n`; +} + +export function summarizeProbes(probes, { statusJson = null, statusXml = null } = {}) { + const okCount = probes.filter((p) => p.status === 200).length; + const authOk = probes.some((p) => p.status === 200); + const statusJsonFound = probes.some( + (p) => p.path === '/Status.json' && p.status === 200 && p.sizeBytes > 0, + ) || !!statusJson; + const statusXmlFound = probes.some( + (p) => (p.path === '/admin/status.xml' || p.path === '/status.xml') + && p.status === 200 && p.sizeBytes > 0, + ) || !!statusXml; + return { + okCount, + total: probes.length, + authOk, + statusJsonFound, + statusXmlFound, + statusJsonBytes: statusJson ? Buffer.byteLength(statusJson, 'utf8') : 0, + statusXmlBytes: statusXml ? Buffer.byteLength(statusXml, 'utf8') : 0, + forbidden: probes.filter((p) => p.status === 403).map((p) => p.path), + unauthorized: probes.filter((p) => p.status === 401).map((p) => p.path), + notFound: probes.filter((p) => p.status === 404).map((p) => p.path), + okPaths: probes.filter((p) => p.status === 200).map((p) => p.path), + }; +} + +export async function getPath(client, path) { + return tryRequest(client, { method: 'GET', path }); +} diff --git a/integrations/cisco-mpp-phone/statusJson.js b/integrations/cisco-mpp-phone/statusJson.js new file mode 100644 index 0000000..f647834 --- /dev/null +++ b/integrations/cisco-mpp-phone/statusJson.js @@ -0,0 +1,184 @@ +// integrations/cisco-mpp-phone/statusJson.js +// +// Parser for MPP Web UI JSON endpoints (Status.json). + +import { coerceRows, extractIndexed } from './jsonRows.js'; + +/** + * @param {string|object|Array} raw + * @returns {object} + */ +export function parseStatusJson(raw) { + const rows = coerceRows(raw); + const fields = {}; + const lines = []; + let currentExt = null; + + for (const row of rows) { + const name = typeof row?.name === 'string' ? row.name.trim() : ''; + if (!name) continue; + const value = row?.value == null ? '' : String(row.value).trim(); + + const extMatch = name.match(/^Ext (\d+) Status$/i); + if (extMatch) { + currentExt = Number(extMatch[1]); + lines.push({ index: currentExt }); + continue; + } + + if (currentExt && isLineField(name)) { + const line = lines.find((l) => l.index === currentExt); + if (line) assignLineField(line, name, value); + fields[`Ext ${currentExt} ${name}`] = value; + continue; + } + + if (value) fields[name] = value; + } + + const device = { + product: fields['Product Name'] || null, + mac: normalizeMac(fields['MAC Address']), + firmware: fields['Software Version'] || fields['Firmware Version'] || null, + serial: fields['Serial Number'] || null, + hostname: fields['Host Name'] || null, + }; + + const network = { + ipv4: fields['Current IP'] || null, + netmask: fields['Current Netmask'] || null, + gateway: fields['Current Gateway'] || null, + dns1: fields['Primary DNS'] || null, + dns2: fields['Secondary DNS'] || fields['Secondary DNS'] || null, + vlan: fields['Operational VLAN ID'] || fields['VLAN ID'] || null, + connectionType: fields['Connection Type'] || null, + ipStatus: fields['IP Status'] || null, + }; + + const rebootHistory = extractIndexed(fields, /^Reboot Reason \d+$/i); + const normalizedLines = lines + .filter((l) => l.registrationState || l.lastRegistrationAt || l.lastRegistrationIp) + .map((l) => ({ + index: l.index, + registrationState: l.registrationState || null, + lastRegistrationAt: l.lastRegistrationAt || null, + lastRegistrationIp: l.lastRegistrationIp || null, + nextRegistrationInSeconds: l.nextRegistrationInSeconds || null, + messageWaiting: l.messageWaiting || null, + hotelingState: l.hotelingState || null, + })); + + const primary = normalizedLines.find((l) => l.index === 1) || normalizedLines[0] || null; + + const phoneStatus = { + elapsedTime: fields['Elapsed Time'] || null, + currentTime: fields['Current Time'] || null, + linkSpeed: fields['SW Port'] || null, + linkConfig: fields['SW Port Config'] || null, + upgradeStatus: fields['Upgrade Status'] || null, + externalIp: fields['External IP'] || null, + dot1xStatus: fields['Transaction status'] || null, + dot1xProtocol: fields['Protocol'] || null, + ledLine1Color: fields['Line 1 LED Color'] || null, + ledLine1Cadence: fields['Line 1 LED Cadence'] || null, + messageWaiting: fields['Ext 1 Message Waiting'] || fields['Message Waiting'] || null, + hotelingState: fields['Ext 1 Hoteling State'] || fields['Hoteling State'] || null, + sipBytesSent: fields['SIP Bytes Sent'] || null, + sipBytesRecv: fields['SIP Bytes Recv'] || null, + vpnConnected: fields['VPN Connected'] || null, + ipv6Status: fields['IP Status_IPV6'] || null, + ipv6Address: fields['Current IP_IPV6'] || null, + tr069Feature: fields['TR-069 Feature'] || null, + multicastRx: fields['Multicast Rx Pkts'] || null, + multicastTx: fields['Multicast Tx Pkts'] || null, + streamingRx: fields['Streaming Rx Pkts'] || null, + }; + + return { + fields, + device, + network, + lines: normalizedLines, + rebootHistory, + phoneStatus, + provisioning: { + latest: fields['Provisioning Status 1'] || null, + firmwareUpgrade: fields['Firmware Upgrade Status 1'] || null, + }, + sip: { + messagesSent: fields['SIP Messages Sent'] || null, + messagesRecv: fields['SIP Messages Recv'] || null, + }, + macAddress: device.mac, + firmware: device.firmware, + ipAddress: network.ipv4, + registration: primary?.registrationState || null, + }; +} + +export function summarizePhoneHealthFromJson(parsed) { + const warnings = []; + const info = []; + + if (!parsed?.fields || Object.keys(parsed.fields).length === 0) { + return { healthy: false, warnings: ['Status.json parsed empty or missing'], info }; + } + + info.push(`${Object.keys(parsed.fields).length} status field(s) from Status.json`); + + const line1 = parsed.lines?.find((l) => l.index === 1) || parsed.lines?.[0]; + if (line1?.registrationState) { + const reg = line1.registrationState.toLowerCase(); + if (reg.includes('not registered') || reg.includes('fail')) { + warnings.push(`Ext ${line1.index} registration: ${line1.registrationState}`); + } else { + info.push(`Ext ${line1.index} registration: ${line1.registrationState}`); + if (line1.lastRegistrationAt) info.push(`last reg: ${line1.lastRegistrationAt}`); + if (line1.lastRegistrationIp) info.push(`registrar IP: ${line1.lastRegistrationIp}`); + } + } else if (parsed.registration) { + info.push(`registration: ${parsed.registration}`); + } else { + warnings.push('no extension registration state in Status.json'); + } + + if (parsed.device?.firmware) info.push(`firmware: ${parsed.device.firmware}`); + if (parsed.device?.product) info.push(`product: ${parsed.device.product}`); + if (parsed.network?.ipv4) info.push(`ip: ${parsed.network.ipv4}`); + if (parsed.network?.vlan) info.push(`vlan: ${parsed.network.vlan}`); + if (parsed.rebootHistory?.length) info.push(`latest reboot: ${parsed.rebootHistory[0]}`); + + return { healthy: warnings.length === 0, warnings, info }; +} + +function isLineField(name) { + return [ + 'Registration State', + 'Last Registration At', + 'Last Registration IP', + 'Next Registration In Seconds', + 'Mapped SIP Port', + 'Extended Function Status', + 'Message Waiting', + 'Hoteling State', + ].includes(name); +} + +function assignLineField(line, name, value) { + switch (name) { + case 'Registration State': line.registrationState = value; break; + case 'Last Registration At': line.lastRegistrationAt = value; break; + case 'Last Registration IP': line.lastRegistrationIp = value; break; + case 'Next Registration In Seconds': line.nextRegistrationInSeconds = value; break; + case 'Message Waiting': line.messageWaiting = value; break; + case 'Hoteling State': line.hotelingState = value; break; + default: break; + } +} + +function normalizeMac(raw) { + if (!raw || typeof raw !== 'string') return null; + const hex = raw.replace(/[^0-9a-fA-F]/g, '').toLowerCase(); + if (hex.length !== 12) return null; + return hex.match(/../g).join(':'); +} diff --git a/integrations/cisco-mpp-phone/statusXml.js b/integrations/cisco-mpp-phone/statusXml.js new file mode 100644 index 0000000..974eb67 --- /dev/null +++ b/integrations/cisco-mpp-phone/statusXml.js @@ -0,0 +1,265 @@ +// integrations/cisco-mpp-phone/statusXml.js +// +// Parser for MPP /admin/status.xml and /admin/cfg.xml snapshots. +// Uses the same nested-xml reader as DECT (phones use a similar dump shape). + +import { xmlToObject } from '../cisco-dect/statusXml.js'; + +const MAC_RE = /^[0-9a-f]{12}$/i; + +/** + * @param {string} rawXml + * @returns {object} + */ +export function parseStatusXml(rawXml) { + if (!rawXml || typeof rawXml !== 'string') { + return { fields: {}, device: {}, lines: [], rebootHistory: [], network: {}, provisioning: {} }; + } + + let tree; + try { + tree = xmlToObject(rawXml); + } catch { + return { fields: {}, device: {}, lines: [], rebootHistory: [], network: {}, provisioning: {}, parseError: 'xml parse failed' }; + } + + const root = pickRoot(tree); + const fields = flattenLeaves(root); + + return { + fields, + device: extractDevice(fields, root), + lines: extractLines(fields, root), + rebootHistory: extractRebootHistory(fields, root), + network: extractNetwork(fields), + provisioning: extractProvisioning(fields), + sip: extractSip(fields), + // Back-compat aliases used by summarizePhoneHealth + collector + macAddress: extractDevice(fields, root).mac || null, + firmware: extractDevice(fields, root).firmware || null, + ipAddress: extractDevice(fields, root).ip || extractNetwork(fields).ipv4 || null, + registration: extractPrimaryRegistration(fields, root), + }; +} + +/** + * Parse cfg.xml — passwords are redacted by the phone; useful for SIP/provision context. + * @param {string} rawXml + * @returns {object} + */ +export function parseCfgXml(rawXml) { + if (!rawXml || typeof rawXml !== 'string') { + return { fields: {}, highlights: {} }; + } + let tree; + try { + tree = xmlToObject(rawXml); + } catch { + return { fields: {}, highlights: {}, parseError: 'xml parse failed' }; + } + const root = pickRoot(tree); + const fields = flattenLeaves(root); + return { + fields, + highlights: { + webServer: pickField(fields, ['Enable_Web_Server', 'System.Enable_Web_Server']), + webAdminAccess: pickField(fields, ['Enable_Web_Admin_Access', 'System.Enable_Web_Admin_Access']), + sipProxy: pickField(fields, ['Proxy', 'SIP_Proxy', 'Line_1.Proxy', 'Line_1.SIP_Proxy']), + registrar: pickField(fields, ['Registrar', 'Line_1.Registrar']), + userId: pickField(fields, ['User_ID', 'Line_1.User_ID', 'Line_1.Auth_User_ID']), + displayName: pickField(fields, ['Display_Name', 'Line_1.Display_Name']), + upgradeRule: pickField(fields, ['Upgrade_Rule', 'Upgrade_Enable']), + profileRule: pickField(fields, ['Profile_Rule']), + }, + }; +} + +export function summarizePhoneHealth(parsed) { + const warnings = []; + const info = []; + + if (!parsed?.fields || Object.keys(parsed.fields).length === 0) { + return { healthy: false, warnings: ['status.xml parsed empty or missing'], info }; + } + + const fieldCount = Object.keys(parsed.fields).length; + info.push(`${fieldCount} status field(s) extracted`); + + const reg = parsed.registration || parsed.lines?.[0]?.state || parsed.lines?.[0]?.registration; + if (reg) { + const regStr = String(reg).toLowerCase(); + if (regStr.includes('unreg') || regStr.includes('fail') || regStr === '0' || regStr.includes('not registered')) { + warnings.push(`registration: ${reg}`); + } else { + info.push(`registration: ${reg}`); + } + } else { + warnings.push('no line registration state found in status.xml'); + } + + if (parsed.device?.firmware) info.push(`firmware: ${parsed.device.firmware}`); + if (parsed.device?.mac) info.push(`mac: ${parsed.device.mac}`); + if (parsed.network?.ipv4 || parsed.ipAddress) { + info.push(`ip: ${parsed.network?.ipv4 || parsed.ipAddress}`); + } + if (parsed.network?.vlan) info.push(`vlan: ${parsed.network.vlan}`); + + const reboots = parsed.rebootHistory || []; + if (reboots.length > 0) { + info.push(`reboot history: ${reboots.length} entr${reboots.length === 1 ? 'y' : 'ies'} (latest: ${reboots[0]})`); + } + + return { healthy: warnings.length === 0, warnings, info }; +} + +function pickRoot(tree) { + if (!tree || typeof tree !== 'object') return {}; + return tree.Status + || tree.status + || tree['flat-profile'] + || tree['flat_profile'] + || tree.device + || tree.Device + || tree; +} + +export function flattenLeaves(node, prefix = '', out = {}) { + if (node == null) return out; + if (typeof node === 'string') { + const val = node.trim(); + if (val && prefix) out[prefix] = val; + return out; + } + if (typeof node !== 'object') return out; + for (const [k, v] of Object.entries(node)) { + const key = prefix ? `${prefix}.${k}` : k; + if (typeof v === 'string') { + const val = v.trim(); + if (val) out[key] = val; + } else if (v && typeof v === 'object') { + flattenLeaves(v, key, out); + } + } + return out; +} + +function pickField(fields, candidates) { + for (const c of candidates) { + if (fields[c]) return fields[c]; + } + const lower = Object.fromEntries(Object.entries(fields).map(([k, v]) => [k.toLowerCase(), v])); + for (const c of candidates) { + const hit = lower[c.toLowerCase()]; + if (hit) return hit; + } + return null; +} + +function normalizeMac(raw) { + if (!raw || typeof raw !== 'string') return null; + const hex = raw.replace(/[^0-9a-fA-F]/g, '').toLowerCase(); + if (hex.length !== 12) return null; + return hex.match(/../g).join(':'); +} + +function extractDevice(fields, root) { + const mac = normalizeMac(pickField(fields, [ + 'MAC_Address', 'MACAddress', 'Mac_Address', 'Network.MAC_Address', 'Product_information.MAC_Address', + ])); + return { + mac, + ip: pickField(fields, ['IP_Address', 'IPAddress', 'Network.IP_Address', 'IPv4_Address', 'Network.IPv4_Address']), + firmware: pickField(fields, [ + 'Firmware_Version', 'Software_Version', 'SoftwareVersion', 'Product_information.Software_Version', + ]), + serial: pickField(fields, ['Serial_Number', 'SerialNumber', 'Product_information.Serial_Number']), + product: pickField(fields, ['Product_Name', 'Phone_Type', 'Product_information.Product_Name']), + uptime: pickField(fields, ['Operating_Time', 'Elapsed_Time', 'Up_Time']), + }; +} + +function extractNetwork(fields) { + return { + ipv4: pickField(fields, ['IP_Address', 'IPAddress', 'Network.IP_Address', 'IPv4_Address', 'Network.IPv4_Address', 'Current_IP']), + ipv6: pickField(fields, ['IPv6_Address', 'Network.IPv6_Address']), + vlan: pickField(fields, ['VLAN_ID', 'Vlan_ID', 'Network.VLAN_ID']), + hostname: pickField(fields, ['Host_Name', 'HostName', 'Network.Host_Name']), + switchPort: pickField(fields, ['Switch_Port_Config', 'Switch_Port_Link', 'Network.Switch_Port_Config']), + dns: pickField(fields, ['DNS_1', 'DNS1', 'Network.DNS_1']), + gateway: pickField(fields, ['Default_Router', 'Default_Gateway', 'Network.Default_Router']), + }; +} + +function extractProvisioning(fields) { + return { + state: pickField(fields, [ + 'Customization', 'Customization_State', 'Provisioning_Status', 'Provision_Status', + ]), + profile: pickField(fields, ['Profile_Rule', 'Provisioning_Profile']), + lastResync: pickField(fields, ['Last_Resync', 'Last_Resync_Time']), + }; +} + +function extractSip(fields) { + return { + proxy: pickField(fields, ['Proxy', 'SIP_Proxy', 'Line_1.Proxy']), + registrar: pickField(fields, ['Registrar', 'Line_1.Registrar']), + outboundProxy: pickField(fields, ['Outbound_Proxy', 'Line_1.Outbound_Proxy']), + }; +} + +function extractRebootHistory(fields, root) { + const out = []; + const history = root?.Reboot_History || root?.RebootHistory; + if (history && typeof history === 'object') { + for (let i = 1; i <= 5; i += 1) { + const v = history[`Reboot_Reason_${i}`] || history[`RebootReason${i}`]; + if (v && String(v).trim()) out.push(String(v).trim()); + } + } + for (let i = 1; i <= 5; i += 1) { + const v = fields[`Reboot_Reason_${i}`] || fields[`Reboot_History.Reboot_Reason_${i}`]; + if (v && !out.includes(v)) out.push(v); + } + return out; +} + +function extractLines(fields, root) { + const lines = []; + for (let i = 1; i <= 8; i += 1) { + const prefix = `Line_${i}`; + const state = pickField(fields, [ + `${prefix}.Registration`, `${prefix}.Line_State`, `${prefix}.State`, + `Registration_${i}`, `Line_State_${i}`, + ]); + const user = pickField(fields, [`${prefix}.User_ID`, `${prefix}.Auth_User_ID`, `${prefix}.Display_Name`]); + const active = pickField(fields, [`${prefix}.Active`, `${prefix}.Call_State`]); + if (state || user || active) { + lines.push({ + index: i, + userId: user, + state: state || active, + registration: state, + }); + } + } + + // Fallback: scan for any registration-ish keys + if (lines.length === 0) { + for (const [k, v] of Object.entries(fields)) { + if (/line.*regist|registration/i.test(k) && v) { + lines.push({ index: lines.length + 1, state: v, registration: v, key: k }); + } + } + } + + return lines; +} + +function extractPrimaryRegistration(fields, root) { + const line = extractLines(fields, root)[0]; + if (line?.registration || line?.state) return line.registration || line.state; + return pickField(fields, [ + 'Registration', 'Line_State', 'SIP_Registration', 'Phone_State', + ]); +} diff --git a/integrations/cisco-mpp-phone/systemJson.js b/integrations/cisco-mpp-phone/systemJson.js new file mode 100644 index 0000000..aaefc0c --- /dev/null +++ b/integrations/cisco-mpp-phone/systemJson.js @@ -0,0 +1,24 @@ +// integrations/cisco-mpp-phone/systemJson.js +// +// Parser for /basic/System.json (web/proxy/network config). + +import { coerceRows, fieldsFromRows } from './jsonRows.js'; + +/** + * @param {string|Array} raw + * @returns {object} + */ +export function parseSystemJson(raw) { + const rows = coerceRows(raw); + const fields = fieldsFromRows(rows); + + return { + fields, + webServer: fields['Enable Web Server'] || null, + proxyMode: fields['Proxy Mode'] || null, + ipMode: fields['IP Mode'] || null, + connectionType: fields['Connection Type'] || fields['Connection Type_IPV6'] || null, + dot1xCertSelect: fields['Certificate Select'] || null, + autoConfig: fields['Auto Config'] || null, + }; +} diff --git a/scripts/lib/botPhoneApi.js b/scripts/lib/botPhoneApi.js new file mode 100644 index 0000000..b421b31 --- /dev/null +++ b/scripts/lib/botPhoneApi.js @@ -0,0 +1,75 @@ +// scripts/lib/botPhoneApi.js +// +// Route phone probes through the running bot's HTTP API when the CLI +// process has no attached relay WebSocket (normal for local scripts). + +export function botApiBase() { + return (process.env.PROBE_PHONE_API_BASE + || process.env.BOT_API_BASE + || `http://localhost:${process.env.SERVER_PORT || 1800}`).replace(/\/$/, ''); +} + +export function localRelayConnected(getDectRelayHub) { + try { + return getDectRelayHub().isConnected(); + } catch { + return false; + } +} + +export async function botApiFetch(path) { + const token = process.env.HTTP_API_TOKEN; + const headers = token ? { Authorization: `Bearer ${token}` } : {}; + const url = `${botApiBase()}${path}`; + let res; + try { + res = await fetch(url, { headers }); + } catch (err) { + const hint = 'Start the bot (node index.js) so the relay WebSocket is attached, or set PROBE_PHONE_API_BASE to a running instance.'; + const e = new Error(`Cannot reach bot API at ${url} — ${err.message}. ${hint}`); + e.code = 'BOT_API_UNREACHABLE'; + e.cause = err; + throw e; + } + const body = await res.json().catch(() => ({})); + if (!res.ok) { + const err = new Error(body?.message || `bot API ${res.status} ${path}`); + err.code = body?.code || 'BOT_API_ERROR'; + err.body = body; + throw err; + } + return body; +} + +export function mapApiPhoneToProbeResult(target, phoneRow) { + return { + phone: target, + ok: !!phoneRow?.ok, + probes: phoneRow?.probes || null, + statusJson: phoneRow?.statusJson || null, + statusXml: phoneRow?.statusXml || null, + parsed: phoneRow?.parsed || null, + verdict: phoneRow?.verdict || null, + summary: phoneRow?.summary || null, + cfgParsed: phoneRow?.cfgParsed || null, + byteLength: phoneRow?.byteLength || null, + elapsedMs: phoneRow?.elapsedMs || null, + error: phoneRow?.error || null, + }; +} + +export async function probeTargetsViaBotApi(targets) { + const results = []; + let relay = { connected: false }; + for (const t of targets) { + const data = await botApiFetch(`/api/phone/probe-direct/${encodeURIComponent(t.ip)}`); + relay = data.relay || relay; + results.push(mapApiPhoneToProbeResult(t, data.phones?.[0])); + } + return { results, relay }; +} + +export async function captureStoreViaBotApi(store, { ip } = {}) { + const qs = ip ? `?ip=${encodeURIComponent(ip)}` : ''; + return botApiFetch(`/api/phone/probe/${store}${qs}`); +} diff --git a/services/dectRelayHub.js b/services/dectRelayHub.js index 78eba27..88722da 100644 --- a/services/dectRelayHub.js +++ b/services/dectRelayHub.js @@ -380,6 +380,16 @@ export class DectRelayHub { return this.rpc({ type: 'collect-raw', baseIp }, opts); } + /** Read-only probe of an MPP desk phone (summary probes, no raw bodies). */ + phoneProbe(targetIp, opts) { + return this.rpc({ type: 'phone-probe', targetIp }, opts); + } + + /** Full probe including response bodies for fixture capture. */ + phoneProbeRaw(targetIp, opts) { + return this.rpc({ type: 'phone-probe-raw', targetIp }, opts); + } + /** * Convenience: execute one of the mutating actions the agent * exposes (reboot / force-reboot / reboot-chain / force-reboot-chain diff --git a/services/phoneCollectorService.js b/services/phoneCollectorService.js new file mode 100644 index 0000000..66bb824 --- /dev/null +++ b/services/phoneCollectorService.js @@ -0,0 +1,180 @@ +// services/phoneCollectorService.js +// +// Fan-out MPP phone probes over the DECT relay hub. + +import { getDectRelayHub, RelayErrorCodes } from './dectRelayHub.js'; +import { buildMppProbeView } from '../integrations/cisco-mpp-phone/aggregateProbe.js'; +import { logger } from '../utils/logger.js'; + +const LOG_SCOPE = 'phone:collector'; + +const DEFAULT_TIMEOUT_MS = Number(process.env.PHONE_COLLECT_TIMEOUT_MS) || 15_000; + +/** + * @param {object[]} phones from discoverDeskPhones() + * @param {object} [opts] + * @returns {Promise} + */ +export async function probeAll(phones, opts = {}) { + const list = Array.isArray(phones) ? phones : []; + if (list.length === 0) return []; + const hub = opts.hub || getDectRelayHub(); + const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS; + + logger(LOG_SCOPE, `Fanning out phone-probe() to ${list.length} phone(s)`, 'debug'); + + const results = await Promise.all(list.map((phone) => probeOne(hub, phone, timeoutMs))); + + const okCount = results.filter((r) => r.ok).length; + logger(LOG_SCOPE, `Phone probe finished: ${okCount}/${list.length} succeeded`, 'debug'); + return results; +} + +/** + * @param {object[]} phones + * @param {object} [opts] + * @returns {Promise} + */ +export async function probeRawAll(phones, opts = {}) { + const list = Array.isArray(phones) ? phones : []; + if (list.length === 0) return []; + const hub = opts.hub || getDectRelayHub(); + const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS; + + logger(LOG_SCOPE, `Fanning out phone-probe-raw() to ${list.length} phone(s)`, 'debug'); + + const results = await Promise.all(list.map((phone) => probeRawOne(hub, phone, timeoutMs))); + + const okCount = results.filter((r) => r.ok).length; + logger(LOG_SCOPE, `Phone probe-raw finished: ${okCount}/${list.length} succeeded`, 'debug'); + return results; +} + +export async function probeOne(hub, phone, timeoutMs = DEFAULT_TIMEOUT_MS) { + if (!phone?.ip) { + return { + phone, ok: false, probes: null, statusJson: null, statusXml: null, parsed: null, verdict: null, + elapsedMs: null, + error: { code: 'NO_IP', message: 'phone has no IP address' }, + }; + } + const started = Date.now(); + try { + const { result, elapsedMs } = await hub.phoneProbe(phone.ip, { timeoutMs }); + const mpp = buildMppProbeView({ + statusJson: result?.statusJson, + statusXml: result?.statusXml, + downloadStatusJson: result?.downloadStatusJson, + nsJson: result?.nsJson, + systemJson: result?.systemJson, + probes: result?.probes, + }); + const summary = result?.summary || null; + return { + phone, + ok: !!(summary?.statusJsonFound || summary?.statusXmlFound || mpp.parsed), + probes: result?.probes || null, + statusJson: result?.statusJson || null, + statusXml: result?.statusXml || null, + downloadStatusJson: result?.downloadStatusJson || null, + nsJson: result?.nsJson || null, + systemJson: result?.systemJson || null, + cfgParsed: result?.cfgParsed || null, + parsed: mpp.parsed, + verdict: mpp.verdict, + mpp, + summary, + elapsedMs: elapsedMs ?? (Date.now() - started), + error: null, + }; + } catch (err) { + return { + phone, + ok: false, + probes: null, + statusJson: null, + statusXml: null, + parsed: null, + verdict: null, + elapsedMs: Date.now() - started, + error: { + code: err?.code || 'UNKNOWN', + message: err?.message || String(err), + hint: hintFor(err?.code), + }, + }; + } +} + +export async function probeRawOne(hub, phone, timeoutMs = DEFAULT_TIMEOUT_MS) { + if (!phone?.ip) { + return { + phone, ok: false, probes: null, statusJson: null, statusXml: null, parsed: null, verdict: null, + byteLength: null, elapsedMs: null, + error: { code: 'NO_IP', message: 'phone has no IP address' }, + }; + } + const started = Date.now(); + try { + const { result, elapsedMs } = await hub.phoneProbeRaw(phone.ip, { timeoutMs }); + const mpp = buildMppProbeView({ + statusJson: result?.statusJson, + statusXml: result?.statusXml, + downloadStatusJson: result?.downloadStatusJson, + nsJson: result?.nsJson, + systemJson: result?.systemJson, + probes: result?.probes, + }); + const summary = result?.summary || null; + return { + phone, + ok: !!(summary?.statusJsonFound || summary?.statusXmlFound || result?.statusJson || result?.statusXml), + probes: result?.probes || null, + statusJson: result?.statusJson || null, + statusXml: result?.statusXml || null, + downloadStatusJson: result?.downloadStatusJson || null, + nsJson: result?.nsJson || null, + systemJson: result?.systemJson || null, + byteLength: result?.byteLength ?? null, + cfgParsed: result?.cfgParsed || null, + parsed: mpp.parsed, + verdict: mpp.verdict, + mpp, + summary, + elapsedMs: elapsedMs ?? (Date.now() - started), + error: null, + }; + } catch (err) { + return { + phone, + ok: false, + probes: null, + statusJson: null, + statusXml: null, + parsed: null, + verdict: null, + byteLength: null, + elapsedMs: Date.now() - started, + error: { + code: err?.code || 'UNKNOWN', + message: err?.message || String(err), + hint: hintFor(err?.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 phone did not respond in time.'; + case RelayErrorCodes.DISCONNECTED: + return 'Relay agent disconnected while this command was in flight. Try again.'; + case 'AGENT_CONFIG': + return 'Relay agent configuration is incomplete — check dect-relay-agent/.env and restart.'; + default: + return null; + } +} diff --git a/services/phoneDiscovery.js b/services/phoneDiscovery.js new file mode 100644 index 0000000..5f5e12b --- /dev/null +++ b/services/phoneDiscovery.js @@ -0,0 +1,185 @@ +// services/phoneDiscovery.js +// +// Turn collectPhoneStatus() desk phones into relay targets on 10.x. +// Pure — no I/O. + +import { isTenDotIp, normalizeMac } from './dectDiscovery.js'; + +// Webex /devices product strings vary: "Cisco CP-7841", "Cisco 7841", etc. +const MPP_PRODUCT_PATTERNS = [ + /(?:^|\s)CP-\d+/i, + /^Cisco\s+CP-/i, + /^Cisco\s+78\d{2}\b/i, + /^Cisco\s+88\d{2}\b/i, + /^Cisco\s+68\d{2}\b/i, + /\bCP-78\d{2}\b/i, + /\bCP-88\d{2}\b/i, + /\bCP-68\d{2}\b/i, +]; + +// Room / collaboration devices — never probe via MPP phone relay. +const ROOM_PRODUCT_PATTERNS = [ + /room kit/i, + /room bar/i, + /room 55/i, + /room 70/i, + /room 90/i, + /board pro/i, + /desk pro/i, + /webex desk pro/i, + /\bcodec\b/i, + /webex room/i, + /dbs-210/i, + /dect/i, +]; + +/** + * @param {object} phoneStatus collectPhoneStatus() output + * @returns {{ phones: object[], warnings: object[], inventory: object }} + */ +export function discoverDeskPhones(phoneStatus) { + const phones = []; + const warnings = []; + const seenIps = new Set(); + const seenMacs = new Set(); + const skipped = []; + + const raw = extractPhoneList(phoneStatus); + + for (const phone of raw) { + if (!isMppDeskPhone(phone)) { + skipped.push({ + mac: phone.mac || null, + product: phone.product || phone.model || null, + name: phone.name || phone.displayName || null, + reason: 'not classified as MPP desk phone', + }); + continue; + } + + const ip = pickIp(phone); + const mac = pickMac(phone); + const name = phone.name || phone.displayName || `Phone ${mac || '?'}`; + + if (!mac) { + warnings.push({ mac: null, ip, name, reason: 'phone has no MAC address in inventory' }); + continue; + } + if (!ip) { + warnings.push({ mac, ip: null, name, reason: 'no IP address available (phone may be unreachable)' }); + continue; + } + if (!isTenDotIp(ip)) { + warnings.push({ + mac, + ip, + name, + reason: `phone IP ${ip} is not on the corporate 10.0.0.0/8 network; skipping`, + }); + continue; + } + if (seenIps.has(ip)) { + warnings.push({ mac, ip, name, reason: `duplicate IP ${ip} — keeping first entry` }); + continue; + } + if (seenMacs.has(mac)) { + warnings.push({ mac, ip, name, reason: `duplicate MAC ${mac} — keeping first entry` }); + continue; + } + seenIps.add(ip); + seenMacs.add(mac); + + phones.push({ + mac, + ip, + name, + webexId: phone.id || null, + product: phone.product || phone.model || null, + source: phone.meraki?.ip ? 'meraki' : 'webex', + }); + } + + if (raw.length > 0 && phones.length === 0 && skipped.length > 0) { + warnings.push({ + mac: null, + ip: null, + name: null, + reason: `${raw.length} Webex device(s) in inventory but none selected for MPP probe`, + skipped, + }); + } + + return { + phones, + warnings, + inventory: { + webexDeviceCount: raw.length, + mppSelected: phones.length, + skipped, + }, + }; +} + +export function extractPhoneList(phoneStatus) { + if (Array.isArray(phoneStatus?.phones)) return phoneStatus.phones; + if (Array.isArray(phoneStatus?.phones?.data)) return phoneStatus.phones.data; + return []; +} + +export function isMppDeskPhone(phone) { + if (!phone || typeof phone !== 'object') return false; + + const product = String(phone.product || phone.model || '').trim(); + const name = String(phone.name || phone.displayName || '').trim(); + const haystack = `${product} ${name}`.trim(); + + for (const re of ROOM_PRODUCT_PATTERNS) { + if (re.test(haystack)) return false; + } + for (const re of MPP_PRODUCT_PATTERNS) { + if (re.test(haystack)) return true; + } + + return false; +} + +function pickIp(phone) { + const merakiIp = phone?.meraki?.ip; + if (typeof merakiIp === 'string' && merakiIp.trim()) return merakiIp.trim(); + const webexIp = phone?.ipAddress; + if (typeof webexIp === 'string' && webexIp.trim() && webexIp !== '—') return webexIp.trim(); + return null; +} + +/** Webex sometimes omits MAC; Meraki client match is a reliable fallback. */ +function pickMac(phone) { + const candidates = [ + phone?.mac, + phone?.meraki?.mac, + phone?.meraki?.client?.mac, + ]; + for (const raw of candidates) { + if (!raw || raw === '—') continue; + const normalized = normalizeMac(raw); + if (normalized) return normalized; + } + return null; +} + +/** + * Build a manual probe target (bypasses Webex discovery). + * @param {string} ip + * @param {object} [attrs] + */ +export function manualPhoneTarget(ip, attrs = {}) { + const trimmed = String(ip || '').trim(); + if (!trimmed) return null; + return { + mac: attrs.mac || null, + ip: trimmed, + name: attrs.name || `manual ${trimmed}`, + webexId: null, + product: attrs.product || null, + source: 'manual', + }; +} diff --git a/services/phoneStatus/capturePhoneProbe.js b/services/phoneStatus/capturePhoneProbe.js new file mode 100644 index 0000000..cf1543c --- /dev/null +++ b/services/phoneStatus/capturePhoneProbe.js @@ -0,0 +1,137 @@ +// services/phoneStatus/capturePhoneProbe.js +// +// Fetch MPP phone probe data from store desk phones via the on-prem relay. + +import { collectPhoneStatus } from '../phoneService.js'; +import { discoverDeskPhones, manualPhoneTarget } from '../phoneDiscovery.js'; +import { probeRawAll, probeRawOne } from '../phoneCollectorService.js'; +import { getDectRelayHub } from '../dectRelayHub.js'; +import { logger } from '../../utils/logger.js'; + +const LOG_SCOPE = 'phone:capture'; + +function relayStatus() { + try { + return getDectRelayHub().status(); + } catch (err) { + logger(LOG_SCOPE, `Relay status unavailable: ${err.message}`, 'warn'); + return { connected: false }; + } +} + +function mapProbeResult(r) { + return { + mac: r.phone?.mac || null, + ip: r.phone?.ip || null, + webexId: r.phone?.webexId || null, + name: r.phone?.name || null, + product: r.phone?.product || null, + ok: r.ok, + byteLength: r.byteLength, + statusJson: r.statusJson || null, + statusXml: r.statusXml || null, + downloadStatusJson: r.downloadStatusJson || null, + nsJson: r.nsJson || null, + systemJson: r.systemJson || null, + cfgParsed: r.cfgParsed || null, + probes: r.probes, + parsed: r.parsed, + verdict: r.verdict, + mpp: r.mpp || null, + summary: r.summary, + elapsedMs: r.elapsedMs, + error: r.error, + }; +} + +/** + * Probe a single phone IP via the connected relay (bot process only). + * @param {string} targetIp + */ +export async function capturePhoneProbeDirect(targetIp) { + const ip = String(targetIp || '').trim(); + if (!ip) { + const err = new Error('targetIp is required'); + err.code = 'INVALID_IP'; + throw err; + } + + if (!process.env.DECT_RELAY_AGENT_TOKEN) { + const err = new Error('DECT_RELAY_AGENT_TOKEN is not configured on this bot'); + err.code = 'RELAY_NOT_CONFIGURED'; + throw err; + } + + const relay = relayStatus(); + const phone = manualPhoneTarget(ip); + const result = await probeRawOne(getDectRelayHub(), phone); + + return { + relay, + phones: [mapProbeResult(result)], + }; +} + +/** + * @param {string} storeNum + * @param {object} [opts] + * @param {string} [opts.phoneFilter] + * @returns {Promise} + */ +export async function capturePhoneProbe(storeNum, opts = {}) { + const store = String(storeNum || '').trim(); + if (!store || !/^\d{2,4}$/.test(store)) { + const err = new Error('storeNum must be a 2–4 digit store number'); + err.code = 'INVALID_STORE'; + throw err; + } + + if (!process.env.DECT_RELAY_AGENT_TOKEN) { + const err = new Error('DECT_RELAY_AGENT_TOKEN is not configured on this bot'); + err.code = 'RELAY_NOT_CONFIGURED'; + throw err; + } + + const relay = relayStatus(); + + const phoneData = await collectPhoneStatus(store); + const { phones, warnings: discoveryWarnings, inventory } = discoverDeskPhones(phoneData || {}); + + let targets = phones; + const phoneFilter = (opts.phoneFilter || '').trim(); + if (phoneFilter) { + targets = filterPhones(phones, phoneFilter); + if (targets.length === 0) { + const err = new Error(`No discovered phone matched "${phoneFilter}" for store ${store}`); + err.code = 'PHONE_NOT_FOUND'; + err.knownPhones = phones.map((p) => ({ ip: p.ip, mac: p.mac, name: p.name, product: p.product })); + throw err; + } + } + + const results = await probeRawAll(targets); + + return { + storeNum: store, + relay, + discoveryWarnings, + inventory, + phones: results.map(mapProbeResult), + }; +} + +function filterPhones(phones, raw) { + const needle = String(raw).trim().toLowerCase(); + const needleMac = needle.replace(/[^0-9a-f]/g, ''); + return phones.filter((p) => { + if (p.ip && p.ip.toLowerCase() === needle) return true; + if (p.mac) { + const macHex = p.mac.replace(/[^0-9a-fA-F]/g, '').toLowerCase(); + if (macHex === needleMac && needleMac.length === 12) return true; + if (p.mac.toLowerCase() === needle) return true; + } + if (p.name && p.name.toLowerCase().includes(needle)) return true; + if (p.product && p.product.toLowerCase().includes(needle)) return true; + return false; + }); +} diff --git a/services/renderers/mppPhoneDiagnosticsRenderer.js b/services/renderers/mppPhoneDiagnosticsRenderer.js new file mode 100644 index 0000000..c3dd58b --- /dev/null +++ b/services/renderers/mppPhoneDiagnosticsRenderer.js @@ -0,0 +1,204 @@ +// services/renderers/mppPhoneDiagnosticsRenderer.js +// +// MPP desk phone diagnostics follow-up for /phonestatus (via relay). + +import { formatDisplayTime } from '../../utils/time.js'; + +/** + * @param {Array} results probeAll() output + * @param {object} opts + * @param {string} opts.storeNum + * @param {boolean} [opts.verbose=false] Tier 3 fields + * @param {boolean} [opts.footer=true] + * @returns {string} + */ +export function renderMppPhoneDiagnosticsMarkdown(results, opts = {}) { + const { storeNum, verbose = false, footer = true } = opts; + const list = Array.isArray(results) ? results : []; + if (list.length === 0) return ''; + + let out = `**MPP Desk Phone Diagnostics — Store ${storeNum}**\n\n`; + + for (const r of list) { + out += renderOnePhone(r, { verbose }); + out += '\n'; + } + + if (footer) { + out += `\n*Phone diagnostics pulled at ${formatDisplayTime()} via the DECT relay.*`; + if (!verbose) { + out += ' Pass `verbose` or `debug` for extension/debug counters.'; + } + out += ' Raw capture: `node scripts/probePhone.js raw `'; + } + + return out.trim(); +} + +function renderOnePhone(r, { verbose }) { + const label = r.phone?.name || r.phone?.product || `Phone ${r.phone?.mac || '?'}`; + const ip = r.phone?.ip || r.parsed?.network?.ipv4 || '?'; + + if (!r.ok) { + return `⚠️ **${label}** (${ip}) — probe failed: ${r.error?.message || 'unknown error'}` + + (r.error?.hint ? `\n _${r.error.hint}_` : ''); + } + + const mpp = r.mpp || {}; + const parsed = mpp.parsed || r.parsed || {}; + const verdict = mpp.verdict || r.verdict || {}; + const device = parsed.device || {}; + const network = parsed.network || {}; + const phoneStatus = parsed.phoneStatus || {}; + const line1 = parsed.lines?.find((l) => l.index === 1) || parsed.lines?.[0]; + const neighbor = mpp.networkNeighbor || {}; + const download = mpp.download || {}; + const system = mpp.system || {}; + + const icon = verdict.healthy ? '✅' : '⚠️'; + const fwShort = shortenFirmware(device.firmware); + let out = `${icon} **${label}** (${ip})\n`; + out += ` ${device.product || '?'} · ${device.mac || '?'} · ${fwShort}\n`; + + if (line1?.registrationState) { + out += ` Ext ${line1.index}: ${line1.registrationState}`; + if (line1.lastRegistrationIp) out += ` → ${line1.lastRegistrationIp}`; + if (line1.lastRegistrationAt) out += ` (last ${line1.lastRegistrationAt})`; + out += '\n'; + } + + const uptime = phoneStatus.elapsedTime || '?'; + const vlan = network.vlan ? `VLAN ${network.vlan}` : null; + const link = phoneStatus.linkSpeed || neighbor.portSpeed || null; + const netBits = [uptime !== '?' ? `uptime ${uptime}` : null, vlan, link].filter(Boolean); + if (netBits.length) out += ` ${netBits.join(' · ')}\n`; + + if (neighbor.switchDevice) { + const swIp = neighbor.switchIp ? ` (${neighbor.switchIp})` : ''; + out += ` Switch: ${neighbor.switchDevice} ${neighbor.switchPort || ''}${swIp}\n`; + } + + if (parsed.rebootHistory?.length) { + out += ` Last reboot: ${formatReboot(parsed.rebootHistory[0])}\n`; + } + + if (download.latestProvisioning) { + const prov = download.latestProvisioning; + const host = prov.url ? hostFromUrl(prov.url) : '?'; + const result = prov.succeeded ? 'OK' : (prov.failed ? 'FAILED' : (prov.result || '?')); + out += ` Last resync: ${host} · ${result}\n`; + } + + if (download.latestFirmwareUpgrade) { + const fw = download.latestFirmwareUpgrade; + if (fw.succeeded) { + out += ` Firmware upgrade: OK\n`; + } else if (fw.failed) { + out += ` Firmware upgrade: ${fw.result}\n`; + } + } + + if (phoneStatus.dot1xStatus) { + out += ` 802.1X: ${phoneStatus.dot1xStatus}${phoneStatus.dot1xProtocol ? ` (${phoneStatus.dot1xProtocol})` : ''}\n`; + } + + if (phoneStatus.ledLine1Color) { + out += ` Line 1 LED: ${phoneStatus.ledLine1Color}${phoneStatus.ledLine1Cadence ? ` ${phoneStatus.ledLine1Cadence}` : ''}\n`; + } + + if (parsed.sip?.messagesSent || parsed.sip?.messagesRecv) { + out += ` SIP msgs: ${parsed.sip.messagesSent || 0} sent / ${parsed.sip.messagesRecv || 0} recv`; + if (phoneStatus.sipBytesSent) out += ` (${phoneStatus.sipBytesSent}B / ${phoneStatus.sipBytesRecv || 0}B)`; + out += '\n'; + } + + if (system.webServer) { + out += ` Web server: ${system.webServer}`; + if (system.proxyMode) out += ` · proxy ${system.proxyMode}`; + out += '\n'; + } + + if (line1?.messageWaiting && line1.messageWaiting !== 'No') { + out += ` Message waiting: ${line1.messageWaiting}\n`; + } + if (line1?.hotelingState && line1.hotelingState !== 'Disabled') { + out += ` Hoteling: ${line1.hotelingState}\n`; + } + + if (Array.isArray(verdict.warnings) && verdict.warnings.length > 0) { + for (const w of verdict.warnings) { + out += ` ⚠️ ${w}\n`; + } + } + + if (verbose) { + out += renderVerboseTier(r, parsed, neighbor, mpp.probes); + } + + return out; +} + +function renderVerboseTier(r, parsed, neighbor, probes) { + let out = ''; + + const otherLines = (parsed.lines || []).filter((l) => l.index !== 1); + for (const line of otherLines) { + out += ` Ext ${line.index}: ${line.registrationState || '?'}\n`; + } + + const ps = parsed.phoneStatus || {}; + if (ps.ipv6Status || ps.ipv6Address) { + out += ` IPv6: ${ps.ipv6Status || '?'} ${ps.ipv6Address || ''}\n`.trimEnd() + '\n'; + } + if (ps.tr069Feature) out += ` TR-069: ${ps.tr069Feature}\n`; + if (ps.multicastRx != null || ps.multicastTx != null) { + out += ` Paging multicast: rx ${ps.multicastRx || 0} / tx ${ps.multicastTx || 0}\n`; + } + if (ps.streamingRx != null) out += ` XML streaming rx: ${ps.streamingRx}\n`; + + const counters = neighbor.errorCounters || {}; + const counterKeys = Object.keys(counters); + if (counterKeys.length > 0) { + out += ' Ethernet errors:\n'; + for (const k of counterKeys.slice(0, 8)) { + out += ` ${k}: ${counters[k]}\n`; + } + } + + const probeList = Array.isArray(probes) ? probes : []; + if (probeList.length > 0) { + out += ' Probe paths:\n'; + out += ' ```\n'; + out += ' STATUS BYTES PATH\n'; + for (const p of probeList) { + const status = p.status == null ? 'ERR' : String(p.status); + out += ` ${status.padEnd(6)} ${String(p.sizeBytes || 0).padEnd(6)} ${p.path}\n`; + } + out += ' ```\n'; + } + + return out; +} + +function shortenFirmware(fw) { + if (!fw || typeof fw !== 'string') return '?'; + const m = fw.match(/(sip78xx[^\s]+)/i); + return m ? m[1] : (fw.length > 36 ? `${fw.slice(0, 36)}…` : fw); +} + +function formatReboot(raw) { + if (!raw) return '?'; + const m = raw.match(/^(\w+)\(([^)]+)\)/); + return m ? `${m[1]} ${m[2]}` : raw; +} + +function hostFromUrl(url) { + if (!url || typeof url !== 'string') return '?'; + try { + const normalized = url.startsWith('http') ? url : `https://${url}`; + const u = new URL(normalized); + return u.hostname; + } catch { + return url.length > 30 ? `${url.slice(0, 30)}…` : url; + } +} diff --git a/services/renderers/phoneStatusRenderer.js b/services/renderers/phoneStatusRenderer.js index 811cff0..dfd71e8 100644 --- a/services/renderers/phoneStatusRenderer.js +++ b/services/renderers/phoneStatusRenderer.js @@ -35,6 +35,8 @@ import { simpleTimeAgo, formatBytes, formatDisplayTime } from '../../utils/time. * 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. + * @param {number} [opts.mppFollowUpPhoneCount=0] + * When > 0, emits MPP desk-phone relay diagnostics loading hint. * @param {boolean} [opts.wanFollowUpEnabled=false] * When true, emits a "⏳ WAN metrics loading…" line just above the * footer. Same rationale as `dectFollowUpBaseCount` — chat handler @@ -49,6 +51,7 @@ export function renderPhoneStatusMarkdown(data, opts = {}) { detailed = false, footer = true, dectFollowUpBaseCount = 0, + mppFollowUpPhoneCount = 0, wanFollowUpEnabled = false, } = opts; @@ -138,6 +141,11 @@ export function renderPhoneStatusMarkdown(data, opts = {}) { } reply += '\n'; }); + + if (mppFollowUpPhoneCount > 0) { + const n = mppFollowUpPhoneCount; + reply += `_⏳ MPP phone diagnostics loading for ${n} desk phone${n === 1 ? '' : 's'} via relay — a follow-up message will arrive shortly._\n\n`; + } } // DECT Basestations diff --git a/tests/ciscoMppPhone.downloadStatus.test.js b/tests/ciscoMppPhone.downloadStatus.test.js new file mode 100644 index 0000000..40632a6 --- /dev/null +++ b/tests/ciscoMppPhone.downloadStatus.test.js @@ -0,0 +1,25 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { parseDownloadStatusJson, downloadStatusWarnings } from '../integrations/cisco-mpp-phone/downloadStatusJson.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE = fs.readFileSync(path.join(__dirname, 'fixtures/mpp/download-status-782.json'), 'utf8'); + +test('parseDownloadStatusJson: extracts provisioning and firmware history', () => { + const parsed = parseDownloadStatusJson(FIXTURE); + assert.equal(parsed.latestProvisioning.succeeded, true); + assert.match(parsed.latestProvisioning.url, /cisco\.sipflash\.com/); + assert.equal(parsed.latestFirmwareUpgrade.succeeded, true); + assert.match(parsed.latestFirmwareUpgrade.url, /binaries\.webex\.com/); +}); + +test('downloadStatusWarnings: flags MIC cert failure', () => { + const parsed = parseDownloadStatusJson(FIXTURE); + const warnings = downloadStatusWarnings(parsed); + assert.ok(warnings.some((w) => /MIC cert/i.test(w))); + assert.equal(parsed.micCert.failed, true); +}); diff --git a/tests/ciscoMppPhone.nsJson.test.js b/tests/ciscoMppPhone.nsJson.test.js new file mode 100644 index 0000000..2d3830f --- /dev/null +++ b/tests/ciscoMppPhone.nsJson.test.js @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { parseNsJson } from '../integrations/cisco-mpp-phone/nsJson.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE = fs.readFileSync(path.join(__dirname, 'fixtures/mpp/ns-782.json'), 'utf8'); + +test('parseNsJson: extracts LLDP neighbor and port speed', () => { + const parsed = parseNsJson(FIXTURE); + assert.equal(parsed.switchDevice, 'SW00782R'); + assert.equal(parsed.switchPort, 'Port 45'); + assert.equal(parsed.switchIp, '10.229.105.251'); + assert.match(parsed.portSpeed, /100/); +}); + +test('parseNsJson: omits zero error counters', () => { + const parsed = parseNsJson(FIXTURE); + assert.equal(Object.keys(parsed.errorCounters).length, 0); +}); diff --git a/tests/ciscoMppPhone.statusJson.test.js b/tests/ciscoMppPhone.statusJson.test.js new file mode 100644 index 0000000..8a05beb --- /dev/null +++ b/tests/ciscoMppPhone.statusJson.test.js @@ -0,0 +1,31 @@ +// Parser tests for MPP Status.json (live store 782 fixture from HAR) + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { parseStatusJson, summarizePhoneHealthFromJson } from '../integrations/cisco-mpp-phone/statusJson.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const LIVE = fs.readFileSync(path.join(__dirname, 'fixtures/mpp/status-782-live.json'), 'utf8'); + +test('parseStatusJson: live 782 fixture extracts registration + product', () => { + const parsed = parseStatusJson(LIVE); + assert.equal(parsed.device.product, 'CP-7841-3PCC'); + assert.equal(parsed.device.mac, 'cc:98:91:4f:67:99'); + assert.match(parsed.device.firmware, /sip78xx/); + assert.equal(parsed.network.ipv4, '10.43.206.157'); + assert.equal(parsed.lines[0].registrationState, 'Registered'); + assert.equal(parsed.registration, 'Registered'); + assert.ok(parsed.rebootHistory.length >= 1); + assert.ok(Object.keys(parsed.fields).length > 20); +}); + +test('summarizePhoneHealthFromJson: registered phone is healthy', () => { + const parsed = parseStatusJson(LIVE); + const verdict = summarizePhoneHealthFromJson(parsed); + assert.equal(verdict.healthy, true); + assert.equal(verdict.warnings.length, 0); +}); diff --git a/tests/ciscoMppPhone.systemJson.test.js b/tests/ciscoMppPhone.systemJson.test.js new file mode 100644 index 0000000..9335bc5 --- /dev/null +++ b/tests/ciscoMppPhone.systemJson.test.js @@ -0,0 +1,17 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { parseSystemJson } from '../integrations/cisco-mpp-phone/systemJson.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE = fs.readFileSync(path.join(__dirname, 'fixtures/mpp/system-782.json'), 'utf8'); + +test('parseSystemJson: extracts web server and proxy', () => { + const parsed = parseSystemJson(FIXTURE); + assert.equal(parsed.webServer, 'Yes'); + assert.equal(parsed.proxyMode, 'Off'); + assert.equal(parsed.dot1xCertSelect, 'Manufacturing installed'); +}); diff --git a/tests/ciscoMppPhone.test.js b/tests/ciscoMppPhone.test.js new file mode 100644 index 0000000..0fc61b9 --- /dev/null +++ b/tests/ciscoMppPhone.test.js @@ -0,0 +1,165 @@ +// Unit tests for integrations/cisco-mpp-phone/ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import https from 'node:https'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { createMppPhoneClient, tryRequest } from '../integrations/cisco-mpp-phone/client.js'; +import { runReadProbes, fetchStatusJson, summarizeProbes } from '../integrations/cisco-mpp-phone/probes.js'; +import { parseStatusJson, summarizePhoneHealthFromJson } from '../integrations/cisco-mpp-phone/statusJson.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'mpp'); +const STATUS_XML = fs.readFileSync(path.join(FIXTURE_DIR, 'status-782-sample.xml'), 'utf8'); +const STATUS_JSON = fs.readFileSync(path.join(FIXTURE_DIR, 'status-782-live.json'), 'utf8'); +const KEY = fs.readFileSync(path.join(FIXTURE_DIR, 'test-key.pem'), 'utf8'); +const CERT = fs.readFileSync(path.join(FIXTURE_DIR, 'test-cert.pem'), 'utf8'); + +const TEST_USER = 'admin'; +const TEST_PASS = 'test-phone-pass'; + +function startMockPhoneServer({ openJson = false } = {}) { + return new Promise((resolve) => { + const server = https.createServer({ key: KEY, cert: CERT }, (req, res) => { + const openPaths = openJson && ( + req.url === '/Status.json' + || req.url === '/Download%20Status.json' + || req.url === '/ns.json' + || req.url === '/basic/System.json' + || req.url === '/' + ); + if (!openPaths) { + const auth = req.headers.authorization || ''; + const expected = `Basic ${Buffer.from(`${TEST_USER}:${TEST_PASS}`).toString('base64')}`; + if (auth !== expected) { + res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="phone"' }); + res.end('unauthorized'); + return; + } + } + if (req.url === '/Status.json' || req.url === '/admin/status.xml' || req.url === '/status.xml') { + res.writeHead(200, { 'Content-Type': req.url.endsWith('.json') ? 'application/json' : 'application/xml' }); + res.end(req.url.endsWith('.json') ? STATUS_JSON : STATUS_XML); + return; + } + if (req.url === '/admin/cfg.xml') { + res.writeHead(200, { 'Content-Type': 'application/xml' }); + res.end('Yessip.webex.com'); + return; + } + if (req.url === '/') { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('MPP phone'); + return; + } + res.writeHead(404); + res.end('not found'); + }); + server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + resolve({ server, port, close: () => new Promise((r) => server.close(() => r())) }); + }); + }); +} + +test('parseStatusJson: extracts key fields from fixture', () => { + const parsed = parseStatusJson(STATUS_JSON); + assert.equal(parsed.device.mac, 'cc:98:91:4f:67:99'); + assert.equal(parsed.registration, 'Registered'); +}); + +test('summarizePhoneHealthFromJson: registered phone is healthy', () => { + const parsed = parseStatusJson(STATUS_JSON); + const verdict = summarizePhoneHealthFromJson(parsed); + assert.equal(verdict.healthy, true); +}); + +test('createMppPhoneClient: fetches status.xml over self-signed HTTPS', async () => { + const mock = await startMockPhoneServer(); + try { + const client = createMppPhoneClient({ + host: `127.0.0.1:${mock.port}`, + user: TEST_USER, + password: TEST_PASS, + timeoutMs: 5000, + }); + const r = await tryRequest(client, { path: '/admin/status.xml' }); + assert.equal(r.status, 200); + assert.ok(r.sizeBytes > 0); + assert.match(r.snippet, /MAC_Address|Product_Name|Registered/); + } finally { + await mock.close(); + } +}); + +test('runReadProbes: returns structured rows against mock phone', async () => { + const mock = await startMockPhoneServer(); + try { + const client = createMppPhoneClient({ + host: `127.0.0.1:${mock.port}`, + user: TEST_USER, + password: TEST_PASS, + timeoutMs: 5000, + }); + const probes = await runReadProbes(client); + const summary = summarizeProbes(probes); + assert.ok(probes.length >= 2); + assert.equal(summary.statusJsonFound, true); + assert.equal(summary.authOk, true); + } finally { + await mock.close(); + } +}); + +test('fetchStatusJson: returns raw JSON body', async () => { + const mock = await startMockPhoneServer(); + try { + const client = createMppPhoneClient({ + host: `127.0.0.1:${mock.port}`, + user: TEST_USER, + password: TEST_PASS, + timeoutMs: 5000, + }); + const { rawJson, byteLength } = await fetchStatusJson(client); + assert.ok(rawJson.includes('Product Name')); + assert.ok(byteLength > 0); + } finally { + await mock.close(); + } +}); + +test('createMppPhoneClient: fetches Status.json without auth (Webex MPP web UI)', async () => { + const mock = await startMockPhoneServer({ openJson: true }); + try { + const client = createMppPhoneClient({ + host: `127.0.0.1:${mock.port}`, + timeoutMs: 5000, + }); + const r = await tryRequest(client, { path: '/Status.json' }); + assert.equal(r.status, 200); + assert.ok(r.sizeBytes > 0); + const { rawJson } = await fetchStatusJson(client); + assert.ok(rawJson.includes('Product Name')); + } finally { + await mock.close(); + } +}); + +test('createMppPhoneClient: rejects wrong password', async () => { + const mock = await startMockPhoneServer(); + try { + const client = createMppPhoneClient({ + host: `127.0.0.1:${mock.port}`, + user: TEST_USER, + password: 'wrong', + timeoutMs: 5000, + }); + const r = await tryRequest(client, { path: '/admin/status.xml' }); + assert.equal(r.status, 401); + } finally { + await mock.close(); + } +}); diff --git a/tests/dectRelayHub.test.js b/tests/dectRelayHub.test.js index f17f695..6e95fd1 100644 --- a/tests/dectRelayHub.test.js +++ b/tests/dectRelayHub.test.js @@ -349,3 +349,27 @@ test('hub: execAction routes action name into type field', async () => { await closeAll(); } }); + +test('hub: phoneProbe RPC uses targetIp and returns probe summary', async () => { + const { hub, port, closeAll } = await makeHub(); + try { + const ws = connectAgent(port); + await waitOpen(ws); + attachAutoAgent(ws, async (msg) => { + assert.equal(msg.type, 'phone-probe'); + assert.equal(msg.targetIp, '10.4.11.50'); + return { + probes: [{ path: '/admin/status.xml', status: 200, sizeBytes: 100 }], + summary: { okCount: 1, total: 4, authOk: true, statusXmlFound: true }, + statusXml: '', + parsed: { fields: {} }, + verdict: { healthy: true, warnings: [], info: [] }, + }; + }); + const { result } = await hub.phoneProbe('10.4.11.50'); + assert.equal(result.summary.statusXmlFound, true); + assert.equal(result.probes[0].path, '/admin/status.xml'); + } finally { + await closeAll(); + } +}); diff --git a/tests/fixtures/mpp/download-status-782.json b/tests/fixtures/mpp/download-status-782.json new file mode 100644 index 0000000..fa66df0 --- /dev/null +++ b/tests/fixtures/mpp/download-status-782.json @@ -0,0 +1,34 @@ +[{ + "line": "one", + "type": 9, + "name": "Firmware Upgrade Status" +}, { + "line": "one", + "type": 12, + "value": "[05/19/2026 03:08:43][https://binaries.webex.com:443/cisco-mpp-78xx-stable/20260430130452/sip78xx.12-0-7MPP0501-20260317-aa82ce7433.loads]Upgrade Succeeded.", + "name": "Firmware Upgrade Status 1", + "index": 420, + "tab": 27 +}, { + "line": "one", + "type": 9, + "name": "Provisioning Status" +}, { + "line": "one", + "type": 12, + "value": "[1718][07/28/2026 15:30:59][https://cisco.sipflash.com:443/*]Resync Succeeded.", + "name": "Provisioning Status 1", + "index": 488, + "tab": 27 +}, { + "line": "one", + "type": 9, + "name": "MIC Cert Refresh Status" +}, { + "line": "one", + "type": 12, + "value": "[07/28/2026 16:30:50][http://sudirenewal.cisco.com:80/test.cer]MIC Cert Download Failed. Reason: Error - File not found", + "name": "MIC Cert Provisioning Status", + "index": 506, + "tab": 27 +}] diff --git a/tests/fixtures/mpp/ns-782.json b/tests/fixtures/mpp/ns-782.json new file mode 100644 index 0000000..d49d7cb --- /dev/null +++ b/tests/fixtures/mpp/ns-782.json @@ -0,0 +1,35 @@ +[{ + "line": "one", + "type": 9, + "name": "Network Port Information" +}, { + "type": 1, + "value": "0", + "name": "RxDropPkts", + "index": "sc-13", + "tab": -1 +}, { + "type": 1, + "value": "SW00782R", + "name": "LLDPNeighborDeviceId", + "index": "sc-49", + "tab": -1 +}, { + "type": 1, + "value": "10.229.105.251", + "name": "LLDPNeighborIP", + "index": "sc-50", + "tab": -1 +}, { + "type": 1, + "value": "Port 45", + "name": "LLDPNeighborPort", + "index": "sc-51", + "tab": -1 +}, { + "type": 1, + "value": "100F ", + "name": "PortSpeed", + "index": "sc-52", + "tab": -1 +}] diff --git a/tests/fixtures/mpp/status-782-live.json b/tests/fixtures/mpp/status-782-live.json new file mode 100644 index 0000000..a835af7 --- /dev/null +++ b/tests/fixtures/mpp/status-782-live.json @@ -0,0 +1,745 @@ +[{ + "line": "one", + "type": 9, + "name": "System Information" + }, { + "type": 12, + "value": "SEPCC98914F6799", + "name": "Host Name", + "index": -15767, + "tab": 1 + }, { + "type": 12, + "value": "", + "name": "Domain", + "index": -15766, + "tab": 1 + }, { + "type": 12, + "value": "ntp.broadcloudpbx.net", + "name": "Primary NTP Server", + "index": -15735, + "tab": 1 + }, { + "type": 12, + "value": "", + "name": "Secondary NTP Server", + "index": -15734, + "tab": 1 + }, { + "line": "one", + "type": 9, + "name": "IPv4 Information" + }, { + "type": 12, + "value": "OK", + "name": "IP Status", + "index": -15764, + "tab": 1 + }, { + "type": 12, + "value": "DHCP", + "name": "Connection Type", + "index": -15759, + "tab": 1 + }, { + "type": 12, + "value": "10.43.206.157", + "name": "Current IP", + "index": -15765, + "tab": 1 + }, { + "type": 12, + "value": "255.255.255.240", + "name": "Current Netmask", + "index": -15763, + "tab": 1 + }, { + "type": 12, + "value": "10.43.206.145", + "name": "Current Gateway", + "index": -15762, + "tab": 1 + }, { + "type": 12, + "value": "10.96.65.21", + "name": "Primary DNS", + "index": -15761, + "tab": 1 + }, { + "line": "one", + "type": 12, + "value": "10.97.227.252 ", + "name": "Secondary DNS", + "index": -15760, + "tab": 1 + }, { + "line": "one", + "type": 9, + "name": "IPv6 Information" + }, { + "type": 12, + "value": "Initializing", + "name": "IP Status_IPV6", + "index": -15752, + "tab": 1 + }, { + "type": 12, + "value": "DHCP", + "name": "Connection Type_IPV6", + "index": -15753, + "tab": 1 + }, { + "type": 12, + "value": "::", + "name": "Current IP_IPV6", + "index": -15756, + "tab": 1 + }, { + "type": 12, + "value": "0", + "name": "Prefix Length_IPV6", + "index": -15755, + "tab": 1 + }, { + "type": 12, + "value": "::", + "name": "Current Gateway_IPV6", + "index": -15754, + "tab": 1 + }, { + "type": 12, + "value": "", + "name": "Primary DNS_IPV6", + "index": -15751, + "tab": 1 + }, { + "line": "one", + "type": 12, + "value": "", + "name": "Secondary DNS_IPV6", + "index": -15750, + "tab": 1 + }, { + "line": "one", + "type": 9, + "name": "Reboot History" + }, { + "type": 12, + "value": "Provisioning(07/28/2026 15:29:46)", + "name": "Reboot Reason 1", + "index": -15706, + "tab": 1 + }, { + "type": 12, + "value": "Provisioning(05/19/2026 03:08:44)", + "name": "Reboot Reason 2", + "index": -15705, + "tab": 1 + }, { + "type": 12, + "value": "Upgrade(04/23/2026 03:11:46)", + "name": "Reboot Reason 3", + "index": -15704, + "tab": 1 + }, { + "type": 12, + "value": "Upgrade(03/05/2026 00:02:43)", + "name": "Reboot Reason 4", + "index": -15703, + "tab": 1 + }, { + "type": 12, + "value": "Cloud Triggered(02/26/2026 14:59:51)", + "name": "Reboot Reason 5", + "index": -15702, + "tab": 1 + }, { + "line": "one", + "type": 9, + "name": "VPN Status" + }, { + "type": 12, + "value": "No", + "name": "VPN Connected", + "index": -15719, + "tab": 1 + }, { + "type": 12, + "value": "", + "name": "Client Address", + "index": -15718, + "tab": 1 + }, { + "type": 12, + "value": "", + "name": "Client Netmask", + "index": -15717, + "tab": 1 + }, { + "type": 12, + "value": "", + "name": "Bytes Sent", + "index": -15711, + "tab": 1 + }, { + "type": 12, + "value": "", + "name": "Bytes Recv", + "index": -15712, + "tab": 1 + }, { + "line": "one", + "type": 9, + "name": "Product Information" + }, { + "type": 12, + "value": "CP-7841-3PCC", + "name": "Product Name", + "index": 8, + "tab": 1 + }, { + "type": 12, + "value": "WZP21281JHO", + "name": "Serial Number", + "index": 7, + "tab": 1 + }, { + "type": 12, + "value": "V05", + "name": "VID", + "index": 16, + "tab": 1 + }, { + "type": 12, + "value": "CC98914F6799", + "name": "MAC Address", + "index": 6, + "tab": 1 + }, { + "type": 12, + "value": "sip78xx.12-0-7MPP0501-20260317-aa82ce7433.loads", + "name": "Software Version", + "index": 10, + "tab": 1 + }, { + "type": 12, + "value": "33", + "name": "Hardware Version", + "index": 9, + "tab": 1 + }, { + "type": 12, + "value": "Installed", + "name": "Client Certificate", + "index": 13, + "tab": 1 + }, { + "type": 12, + "value": "WxC", + "name": "Transition Authorization Type", + "index": 430, + "tab": 1 + }, { + "line": "one", + "type": 9, + "name": "Phone Status" + }, { + "type": 12, + "value": "7/28/2026 04:20:26 PM", + "name": "Current Time", + "index": -65520, + "tab": 1 + }, { + "type": 12, + "value": "22 days and 07:20:35", + "name": "Elapsed Time", + "index": -65519, + "tab": 1 + }, { + "type": 12, + "value": "53", + "name": "SIP Messages Sent", + "index": -65514, + "tab": 1 + }, { + "type": 12, + "value": "47687", + "name": "SIP Bytes Sent", + "index": -65512, + "tab": 1 + }, { + "type": 12, + "value": "52", + "name": "SIP Messages Recv", + "index": -65513, + "tab": 1 + }, { + "type": 12, + "value": "37743", + "name": "SIP Bytes Recv", + "index": -65511, + "tab": 1 + }, { + "type": 12, + "value": "2006647", + "name": "Network Packets Sent", + "index": -65460, + "tab": 1 + }, { + "type": 12, + "value": "2545022", + "name": "Network Packets Recv", + "index": -65459, + "tab": 1 + }, { + "type": 12, + "value": "", + "name": "External IP", + "index": -65500, + "tab": 1 + }, { + "type": 12, + "value": "4095", + "name": "Operational VLAN ID", + "index": -65466, + "tab": 1 + }, { + "type": 12, + "value": "100M Full", + "name": "SW Port", + "index": -65465, + "tab": 1 + }, { + "type": 12, + "value": "Disabled", + "name": "PC Port", + "index": -65464, + "tab": 1 + }, { + "type": 12, + "value": "Same image", + "name": "Upgrade Status", + "index": -65458, + "tab": 1 + }, { + "type": 12, + "value": "AUTO", + "name": "SW Port Config", + "index": -65463, + "tab": 1 + }, { + "type": 12, + "value": "Disabled", + "name": "PC Port Config", + "index": -65462, + "tab": 1 + }, { + "line": "one", + "type": 9, + "name": "Dot1x Authentication" + }, { + "type": 12, + "value": "Authenticated", + "name": "Transaction status", + "index": -15396, + "tab": 1 + }, { + "type": 12, + "value": "None", + "name": "Protocol", + "index": -15395, + "tab": 1 + }, { + "line": "one", + "type": 9, + "name": "LED Status" + }, { + "type": 12, + "value": "Steady", + "name": "Line 1 LED Cadence", + "index": -16112, + "tab": 1 + }, { + "type": 12, + "value": "Green", + "name": "Line 1 LED Color", + "index": -16111, + "tab": 1 + }, { + "type": 12, + "value": "Steady", + "name": "Line 2 LED Cadence", + "index": -16110, + "tab": 1 + }, { + "type": 12, + "value": "Off", + "name": "Line 2 LED Color", + "index": -16109, + "tab": 1 + }, { + "type": 12, + "value": "Steady", + "name": "Line 3 LED Cadence", + "index": -16108, + "tab": 1 + }, { + "type": 12, + "value": "Off", + "name": "Line 3 LED Color", + "index": -16107, + "tab": 1 + }, { + "type": 12, + "value": "Steady", + "name": "Line 4 LED Cadence", + "index": -16106, + "tab": 1 + }, { + "type": 12, + "value": "Off", + "name": "Line 4 LED Color", + "index": -16105, + "tab": 1 + }, { + "type": 12, + "value": "Steady", + "name": "Headset LED Cadence", + "index": -16080, + "tab": 1 + }, { + "type": 12, + "value": "Off", + "name": "Headset LED Color", + "index": -16079, + "tab": 1 + }, { + "type": 12, + "value": "Steady", + "name": "Speaker LED Cadence", + "index": -16078, + "tab": 1 + }, { + "type": 12, + "value": "Off", + "name": "Speaker LED Color", + "index": -16077, + "tab": 1 + }, { + "type": 12, + "value": "Steady", + "name": "Mute LED Cadence", + "index": -16076, + "tab": 1 + }, { + "type": 12, + "value": "Off", + "name": "Mute LED Color", + "index": -16075, + "tab": 1 + }, { + "type": 12, + "value": "Steady", + "name": "Mwi LED Cadence", + "index": -16074, + "tab": 1 + }, { + "type": 12, + "value": "Off", + "name": "Mwi LED Color", + "index": -16073, + "tab": 1 + }, { + "line": "one", + "type": 9, + "name": "Ext 1 Status" + }, { + "type": 12, + "value": "Registered", + "name": "Registration State", + "index": -65263, + "tab": 1 + }, { + "type": 12, + "value": "7/28/2026 16:20:13", + "name": "Last Registration At", + "index": -65262, + "tab": 1 + }, { + "type": 12, + "value": "150.253.156.211", + "name": "Last Registration IP", + "index": -65261, + "tab": 1 + }, { + "type": 12, + "value": "80", + "name": "Next Registration In Seconds", + "index": -65260, + "tab": 1 + }, { + "type": 12, + "value": "No", + "name": "Message Waiting", + "index": -65259, + "tab": 1 + }, { + "type": 12, + "value": "", + "name": "Mapped SIP Port", + "index": -65255, + "tab": 1 + }, { + "type": 12, + "value": "Disabled", + "name": "Hoteling State", + "index": -65249, + "tab": 1 + }, { + "type": 12, + "value": "None", + "name": "Extended Function Status", + "index": -65254, + "tab": 1 + }, { + "line": "one", + "type": 9, + "name": "Ext 2 Status" + }, { + "type": 12, + "value": "Not Registered", + "name": "Registration State", + "index": -65007, + "tab": 1 + }, { + "type": 12, + "value": "", + "name": "Last Registration At", + "index": -65006, + "tab": 1 + }, { + "type": 12, + "value": "", + "name": "Last Registration IP", + "index": -65005, + "tab": 1 + }, { + "type": 12, + "value": "", + "name": "Next Registration In Seconds", + "index": -65004, + "tab": 1 + }, { + "type": 12, + "value": "No", + "name": "Message Waiting", + "index": -65003, + "tab": 1 + }, { + "type": 12, + "value": "", + "name": "Mapped SIP Port", + "index": -64999, + "tab": 1 + }, { + "type": 12, + "value": "Disabled", + "name": "Hoteling State", + "index": -64993, + "tab": 1 + }, { + "type": 12, + "value": "None", + "name": "Extended Function Status", + "index": -64998, + "tab": 1 + }, { + "line": "one", + "type": 9, + "name": "Ext 3 Status" + }, { + "type": 12, + "value": "Not Registered", + "name": "Registration State", + "index": -64751, + "tab": 1 + }, { + "type": 12, + "value": "", + "name": "Last Registration At", + "index": -64750, + "tab": 1 + }, { + "type": 12, + "value": "", + "name": "Last Registration IP", + "index": -64749, + "tab": 1 + }, { + "type": 12, + "value": "", + "name": "Next Registration In Seconds", + "index": -64748, + "tab": 1 + }, { + "type": 12, + "value": "No", + "name": "Message Waiting", + "index": -64747, + "tab": 1 + }, { + "type": 12, + "value": "", + "name": "Mapped SIP Port", + "index": -64743, + "tab": 1 + }, { + "type": 12, + "value": "Disabled", + "name": "Hoteling State", + "index": -64737, + "tab": 1 + }, { + "type": 12, + "value": "None", + "name": "Extended Function Status", + "index": -64742, + "tab": 1 + }, { + "line": "one", + "type": 9, + "name": "Ext 4 Status" + }, { + "type": 12, + "value": "Not Registered", + "name": "Registration State", + "index": -64495, + "tab": 1 + }, { + "type": 12, + "value": "", + "name": "Last Registration At", + "index": -64494, + "tab": 1 + }, { + "type": 12, + "value": "", + "name": "Last Registration IP", + "index": -64493, + "tab": 1 + }, { + "type": 12, + "value": "", + "name": "Next Registration In Seconds", + "index": -64492, + "tab": 1 + }, { + "type": 12, + "value": "No", + "name": "Message Waiting", + "index": -64491, + "tab": 1 + }, { + "type": 12, + "value": "", + "name": "Mapped SIP Port", + "index": -64487, + "tab": 1 + }, { + "type": 12, + "value": "Disabled", + "name": "Hoteling State", + "index": -64481, + "tab": 1 + }, { + "type": 12, + "value": "None", + "name": "Extended Function Status", + "index": -64486, + "tab": 1 + }, { + "line": "one", + "type": 9, + "name": "Paging Status" + }, { + "type": 12, + "value": "0", + "name": "Multicast Rx Pkts", + "index": -15393, + "tab": 1 + }, { + "type": 12, + "value": "0", + "name": "Multicast Tx Pkts", + "index": -15392, + "tab": 1 + }, { + "line": "one", + "type": 9, + "name": "XML Streaming Status" + }, { + "type": 12, + "value": "0", + "name": "Streaming Rx Pkts", + "index": -15391, + "tab": 1 + }, { + "line": "one", + "type": 9, + "name": "TR-069 Status" + }, { + "type": 12, + "value": "Disabled", + "name": "TR-069 Feature", + "index": -15477, + "tab": 1 + }, { + "type": 12, + "value": "20 s", + "name": "Periodic Inform Time", + "index": -15476, + "tab": 1 + }, { + "type": 12, + "value": "0/0/0 00:00:00", + "name": "Last Inform Time", + "index": -15475, + "tab": 1 + }, { + "type": 12, + "value": "", + "name": "Last Transaction Status", + "index": -15474, + "tab": 1 + }, { + "type": 12, + "value": "0/0/0 00:00:00 - 0/0/0 00:00:00", + "name": "Last Session", + "index": -15473, + "tab": 1 + }, { + "type": 12, + "value": "", + "name": "ParameterKey", + "index": -15472, + "tab": 1 + }, { + "line": "one", + "type": 9, + "name": "PRT Status" + }, { + "line": "one", + "type": 12, + "value": "", + "name": "PRT Generation Status", + "index": 475, + "tab": 1 + }, { + "line": "one", + "type": 12, + "value": "", + "name": "PRT Upload Status", + "index": 476, + "tab": 1 + }] \ No newline at end of file diff --git a/tests/fixtures/mpp/status-782-sample.xml b/tests/fixtures/mpp/status-782-sample.xml new file mode 100644 index 0000000..182daf1 --- /dev/null +++ b/tests/fixtures/mpp/status-782-sample.xml @@ -0,0 +1,26 @@ + + + + Cisco CP-7841 + FCH2345L0AB + f0b2e56789ab + sip78xx.14-0-1-0201-1 + + + 10.4.11.50 + 120 + CP-7841-782 + 10.4.11.1 + + + 50782 + Store 782 + Registered + Idle + + + [07/28/26 08:12:38] User Triggered + [07/27/26 14:30:10] Provisioning + + Complete + diff --git a/tests/fixtures/mpp/system-782.json b/tests/fixtures/mpp/system-782.json new file mode 100644 index 0000000..c3cb356 --- /dev/null +++ b/tests/fixtures/mpp/system-782.json @@ -0,0 +1,29 @@ +[{ + "line": "one", + "type": 9, + "name": "System Configuration" +}, { + "type": 12, + "value": "Yes", + "name": "Enable Web Server", + "index": 45, + "tab": 2 +}, { + "type": 4, + "options": ["Auto", "Manual", "Off"], + "value": "Off", + "name": "Proxy Mode", + "index": 329, + "tab": 2 +}, { + "line": "one", + "type": 9, + "name": "802.1X Authentication" +}, { + "type": 4, + "options": ["Manufacturing installed", "Custom installed"], + "value": "Manufacturing installed", + "name": "Certificate Select", + "index": 292, + "tab": 2 +}] diff --git a/tests/mppPhoneDiagnosticsRenderer.test.js b/tests/mppPhoneDiagnosticsRenderer.test.js new file mode 100644 index 0000000..bab7efa --- /dev/null +++ b/tests/mppPhoneDiagnosticsRenderer.test.js @@ -0,0 +1,73 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { buildMppProbeView } from '../integrations/cisco-mpp-phone/aggregateProbe.js'; +import { renderMppPhoneDiagnosticsMarkdown } from '../services/renderers/mppPhoneDiagnosticsRenderer.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const STATUS = fs.readFileSync(path.join(__dirname, 'fixtures/mpp/status-782-live.json'), 'utf8'); +const DOWNLOAD = fs.readFileSync(path.join(__dirname, 'fixtures/mpp/download-status-782.json'), 'utf8'); +const NS = fs.readFileSync(path.join(__dirname, 'fixtures/mpp/ns-782.json'), 'utf8'); +const SYSTEM = fs.readFileSync(path.join(__dirname, 'fixtures/mpp/system-782.json'), 'utf8'); + +function okResult() { + const mpp = buildMppProbeView({ + statusJson: STATUS, + downloadStatusJson: DOWNLOAD, + nsJson: NS, + systemJson: SYSTEM, + probes: [ + { path: '/Status.json', status: 200, sizeBytes: 12564 }, + { path: '/ns.json', status: 200, sizeBytes: 9474 }, + ], + }); + return { + ok: true, + phone: { name: 'Store 00782 CP-7841', ip: '10.43.206.157', mac: 'CC98914F6799' }, + parsed: mpp.parsed, + verdict: mpp.verdict, + mpp, + probes: mpp.probes, + }; +} + +test('renderMppPhoneDiagnosticsMarkdown: tier 1+2 default output', () => { + const md = renderMppPhoneDiagnosticsMarkdown([okResult()], { storeNum: '782', footer: false }); + assert.match(md, /\*\*MPP Desk Phone Diagnostics — Store 782\*\*/); + assert.match(md, /Registered/); + assert.match(md, /SW00782R/); + assert.match(md, /Port 45/); + assert.match(md, /cisco\.sipflash\.com/); + assert.match(md, /MIC cert/i); + assert.doesNotMatch(md, /Probe paths:/); +}); + +test('renderMppPhoneDiagnosticsMarkdown: verbose includes probe table', () => { + const md = renderMppPhoneDiagnosticsMarkdown([okResult()], { storeNum: '782', verbose: true, footer: false }); + assert.match(md, /Probe paths:/); + assert.match(md, /\/Status\.json/); + assert.match(md, /Ext 2:/); +}); + +test('renderMppPhoneDiagnosticsMarkdown: relay failure line', () => { + const md = renderMppPhoneDiagnosticsMarkdown([{ + ok: false, + phone: { name: 'Phone', ip: '10.1.1.1' }, + error: { message: 'relay offline', hint: 'check agent' }, + }], { storeNum: '782', footer: false }); + assert.match(md, /probe failed: relay offline/); + assert.match(md, /check agent/); +}); + +test('buildMppProbeView: merges MIC warning into verdict', () => { + const mpp = buildMppProbeView({ + statusJson: STATUS, + downloadStatusJson: DOWNLOAD, + nsJson: NS, + }); + assert.equal(mpp.verdict.healthy, false); + assert.ok(mpp.verdict.warnings.some((w) => /MIC cert/i.test(w))); +}); diff --git a/tests/phoneDiscovery.test.js b/tests/phoneDiscovery.test.js new file mode 100644 index 0000000..f0dfa2f --- /dev/null +++ b/tests/phoneDiscovery.test.js @@ -0,0 +1,103 @@ +// Unit tests for services/phoneDiscovery.js + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + discoverDeskPhones, + isMppDeskPhone, +} from '../services/phoneDiscovery.js'; + +const phone = (attrs = {}) => ({ + mac: 'f0:b2:e5:67:89:ab', + name: 'Register 1', + product: 'Cisco CP-7841', + ipAddress: '10.4.11.50', + meraki: {}, + ...attrs, +}); + +test('isMppDeskPhone: accepts CP-78xx products', () => { + assert.equal(isMppDeskPhone({ product: 'Cisco CP-7841' }), true); + assert.equal(isMppDeskPhone({ product: 'Cisco 7841', capabilities: ['xapi'] }), true); + assert.equal(isMppDeskPhone({ model: 'CP-8851' }), true); + assert.equal(isMppDeskPhone({ name: 'Store 00782 CP-7841', product: 'Cisco 7841' }), true); +}); + +test('isMppDeskPhone: rejects non-desk devices', () => { + assert.equal(isMppDeskPhone({ product: 'Cisco Webex Room Kit' }), false); + assert.equal(isMppDeskPhone({ product: 'DBS-210-3PC' }), false); +}); + +test('discoverDeskPhones: empty input', () => { + assert.deepEqual(discoverDeskPhones({}), { + phones: [], + warnings: [], + inventory: { webexDeviceCount: 0, mppSelected: 0, skipped: [] }, + }); +}); + +test('discoverDeskPhones: happy path with nested phones.data', () => { + const { phones, warnings } = discoverDeskPhones({ + phones: { + status: 'success', + data: [phone({ product: 'Cisco 7841', meraki: { ip: '10.4.11.50' } })], + }, + }); + assert.equal(warnings.length, 0); + assert.equal(phones.length, 1); + assert.equal(phones[0].ip, '10.4.11.50'); + assert.equal(phones[0].source, 'meraki'); + assert.equal(phones[0].product, 'Cisco 7841'); +}); + +test('discoverDeskPhones: prefers Meraki IP over Webex', () => { + const { phones } = discoverDeskPhones({ + phones: { data: [phone({ ipAddress: '10.9.9.9', meraki: { ip: '10.4.11.50' } })] }, + }); + assert.equal(phones[0].ip, '10.4.11.50'); +}); + +test('discoverDeskPhones: skips non-10.x', () => { + const { phones, warnings } = discoverDeskPhones({ + phones: { data: [phone({ ipAddress: '192.168.1.10', meraki: {} })] }, + }); + assert.equal(phones.length, 0); + assert.match(warnings[0].reason, /10\.0\.0\.0\/8/); +}); + +test('discoverDeskPhones: skips non-MPP inventory', () => { + const { phones } = discoverDeskPhones({ + phones: { + data: [phone({ product: 'Cisco Webex Desk Pro' })], + }, + }); + assert.equal(phones.length, 0); +}); + +test('discoverDeskPhones: uses Meraki MAC when Webex MAC is placeholder', () => { + const { phones, warnings } = discoverDeskPhones({ + phones: { + data: [phone({ + mac: '—', + meraki: { ip: '10.4.11.50', mac: 'f0:b2:e5:67:89:ab' }, + })], + }, + }); + assert.equal(warnings.length, 0); + assert.equal(phones.length, 1); + assert.equal(phones[0].mac, 'f0:b2:e5:67:89:ab'); +}); + +test('discoverDeskPhones: dedupes by IP', () => { + const { phones, warnings } = discoverDeskPhones({ + phones: { + data: [ + phone({ mac: 'f0:b2:e5:67:89:ab', meraki: { ip: '10.4.11.50' } }), + phone({ mac: 'aa:bb:cc:dd:ee:ff', meraki: { ip: '10.4.11.50' } }), + ], + }, + }); + assert.equal(phones.length, 1); + assert.match(warnings[0].reason, /duplicate IP/i); +}); diff --git a/tests/renderers.test.js b/tests/renderers.test.js index 08ce40a..01d9bc2 100644 --- a/tests/renderers.test.js +++ b/tests/renderers.test.js @@ -292,6 +292,18 @@ test('phone renderer: dectFollowUpBaseCount === 0 emits no loading hint (default assert.doesNotMatch(md, /diagnostics loading/); }); +test('phone renderer: mppFollowUpPhoneCount > 0 emits MPP loading hint', () => { + const data = { + phones: { data: [{ name: 'Store CP-7841', status: 'connected', lastSeen: new Date().toISOString() }] }, + dectBasestations: [], + dectHandsets: [], + }; + const md = renderPhoneStatusMarkdown(data, { + storeNum: '782', footer: false, mppFollowUpPhoneCount: 1, + }); + assert.match(md, /MPP phone diagnostics loading for 1 desk phone/); +}); + // ───────────────────────────────────────────────────────────── // DECT follow-up message (renderDectDiagnosticsMarkdown) // ─────────────────────────────────────────────────────────────