collabSupport/integrations/cisco-dect/probes.js
jmcqueen bc56b0a0fb Add Cisco DBS-210 DECT base spike (HTTP Digest client + safe probes)
Spike scaffolding for reverse-engineering the local admin UI on a
Cisco DBS-210 DECT base station. Not wired into the bot yet -- the
plan is a status.xml data-collector next, then a per-store relay
that fronts these calls over a websocket back to the bot.

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

221 lines
10 KiB
JavaScript

// src/integrations/cisco-dect/probes.js
//
// Individual probe / action functions against a DBS-210 base station.
// Every function takes an axios client from client.js and returns a
// ProbeResult or an object built from one, so the CLI runner has a
// uniform envelope to print.
//
// URL map is derived from reverse-engineering the actual admin UI JS
// (see .dect-samples/dbs210-*.{html,js} pulled from a live base, and
// specifically dbs210-gen.js `LoadPage(...)` call sites).
//
// ⚠️ IMPORTANT SAFETY MODEL — READ BEFORE ADDING NEW PATHS ⚠️
//
// The DBS-210 admin UI uses a Cisco SPA-family legacy pattern where
// ACTIONS are triggered by simple GET navigation, not POST + form.
// GETting `/reboot.html` reboots the base. GETting `/DefaultEeprom.html`
// factory-resets it. There is no confirmation dialog on the server
// side — the browser JS shows the confirm() prompt, but the server
// happily executes on any authenticated GET. The legacy alias
// `/admin/reboot.htm` doesn't even enforce the CSRF token.
//
// Our previous probe list included `/admin/reboot.htm` as a "guess"
// and REBOOTED the user's lab base while probing. Never again. Any
// URL that mutates state MUST live in MUTATING_ACTION_PATHS below,
// which is NOT touched by runReadProbes() and is only reachable via
// explicit triggerX() functions gated by the CLI's --execute flag.
import { tryRequest } from './client.js';
// ─── Read-safe endpoints ────────────────────────────────────────────
//
// SSR HTML pages (the whole admin UI's page set from main.html's left
// nav) plus the two machine-readable XML endpoints referenced by
// gen.js. All confirmed by the browser HAR + JS grep — no more
// guessing. Every one of these is idempotent as far as we know.
export const READ_PROBE_PATHS = [
// Home + machine-readable data endpoints first — most useful for
// "am I connected and authenticated?" and for the eventual data
// collector.
{ path: '/main.html', purpose: 'home/status page (SSR HTML)' },
{ path: '/admin/status.xml', purpose: 'machine-readable status XML (called by GetStausXml() in gen.js)' },
{ path: '/Settings.xml', purpose: 'machine-readable settings XML (called by GetSettingsXml() in gen.js)' },
// Left-nav pages — SSR HTML, useful for scraping specific data.
{ path: '/Ext.html', purpose: 'extensions page' },
{ path: '/Servers.html', purpose: 'SIP servers page' },
{ path: '/Network.html', purpose: 'network config page' },
{ path: '/Management.html', purpose: 'management page (holds REBOOT_OPTION button)' },
{ path: '/Fwu.html', purpose: 'firmware update page' },
{ path: '/CountryTimeDate.html',purpose: 'country/time page' },
{ path: '/Security.html', purpose: 'security page' },
{ path: '/License.html', purpose: 'license info page' },
];
// ─── Mutating action endpoints — QUARANTINED ────────────────────────
//
// Every entry here triggers a real side-effect on the device with a
// bare authenticated GET. NEVER include these in runReadProbes().
// They're exported only so the triggerX() functions below have a
// single source of truth for the URL strings.
export const MUTATING_ACTION_PATHS = Object.freeze({
REBOOT: '/reboot.html',
FORCE_REBOOT: '/forcereboot.html',
REBOOT_CHAIN: '/rebootchain.html',
FORCE_REBOOT_CHAIN: '/forcerebootchain.html',
FACTORY_RESET: '/DefaultEeprom.html',
RECONFIGURE_TREE: '/reconfiguredecttree.html',
});
// ─── Read-only helpers ──────────────────────────────────────────────
/**
* Fire every read-only probe and return an array of ProbeResults.
* Sequential so output is readable and the DBS-210 (which is not
* exactly a beefy web server) doesn't get stampeded.
*/
export async function runReadProbes(client) {
const results = [];
for (const { path, purpose } of READ_PROBE_PATHS) {
const r = await tryRequest(client, { method: 'GET', path });
results.push({ ...r, purpose });
}
return results;
}
/**
* Fetch an arbitrary path with no CSRF token. Only intended for
* safe reads — the CLI runner's `get` subcommand routes here.
*/
export async function getPath(client, path) {
return tryRequest(client, { method: 'GET', path });
}
/**
* Pull `/main.html`, parse out the CSRF token from the meta tag, and
* return it. Every mutating action needs to include this as
* `?csrf_token=<value>` — the JS on the real page does the same when
* building any state-changing URL.
*
* Notable exception: the legacy `/admin/reboot.htm` alias does NOT
* enforce CSRF (verified: our tokenless probe rebooted the base).
* That alias is intentionally NOT exposed by triggerReboot() —
* always take the modern `/reboot.html` path so future firmware
* that tightens CSRF enforcement doesn't silently break us.
*
* @returns {Promise<string|null>} The CSRF token, or null if the page
* doesn't expose one (older firmware).
*/
export async function fetchCsrfToken(client) {
const r = await tryRequest(client, { method: 'GET', path: '/main.html' });
if (!r.status || r.status >= 400) {
throw new Error(`Cannot fetch /main.html for CSRF token (status: ${r.status ?? 'ERR'})`);
}
// The full body isn't in the ProbeResult (only a snippet), so
// re-request for the raw HTML. Cheap on LAN, and keeps the pure
// ProbeResult shape clean for the probe runner.
const raw = await client.get('/main.html');
const body = raw.data || '';
// Meta tag shape (from real page): <meta name = "csrf-token" content = "0C6502...."/>
const m = body.match(/<meta\s+name\s*=\s*["']csrf-token["']\s+content\s*=\s*["']([^"']+)["']/i);
return m ? m[1] : null;
}
// ─── Mutating actions ───────────────────────────────────────────────
//
// All follow the same shape: build the URL, decide dry-run vs execute,
// and only in execute mode actually hit the device. Dry-run returns
// the planned request so the CLI can print exactly what WOULD happen
// before you type --execute.
//
// The `planned.body` field stays `null` for these — remember, the
// DBS-210 admin UI takes actions on GET, not POST. Emitting body
// info would be misleading.
async function performAction(client, { path, dryRun, kind }) {
const csrfToken = dryRun ? '(will fetch on execute)' : await fetchCsrfToken(client);
const url = dryRun
? `${path}?csrf_token=${csrfToken}`
: `${path}?csrf_token=${encodeURIComponent(csrfToken)}`;
const planned = { method: 'GET', path: url, body: null };
if (dryRun) return { dryRun: true, kind, planned };
const result = await tryRequest(client, { method: 'GET', path: url });
return { dryRun: false, kind, planned, result };
}
/**
* Reboot this single base station. Normal reboot waits for the base
* to be idle; forced reboot happens within ~1 minute regardless.
*
* IMPORTANT: this hits `/reboot.html` (the CSRF-protected modern
* endpoint), NOT the legacy `/admin/reboot.htm` alias that our early
* probe accidentally triggered.
*
* @param {object} args
* @param {boolean} [args.forced=false]
* @param {boolean} [args.dryRun=true]
*/
export async function triggerReboot(client, { forced = false, dryRun = true } = {}) {
const path = forced ? MUTATING_ACTION_PATHS.FORCE_REBOOT : MUTATING_ACTION_PATHS.REBOOT;
return performAction(client, { path, dryRun, kind: forced ? 'force-reboot' : 'reboot' });
}
/**
* Reboot every base station in the multi-cell chain. Only meaningful
* on the primary. Forced variant kills active calls immediately.
*/
export async function triggerRebootChain(client, { forced = false, dryRun = true } = {}) {
const path = forced ? MUTATING_ACTION_PATHS.FORCE_REBOOT_CHAIN : MUTATING_ACTION_PATHS.REBOOT_CHAIN;
return performAction(client, { path, dryRun, kind: forced ? 'force-reboot-chain' : 'reboot-chain' });
}
/**
* Factory reset (EEPROM default). Nukes all settings — the base will
* lose its Webex Calling provisioning and have to be re-provisioned
* from Control Hub. User has confirmed this reliably works from the
* UI on their fleet.
*/
export async function triggerFactoryReset(client, { dryRun = true } = {}) {
return performAction(client, {
path: MUTATING_ACTION_PATHS.FACTORY_RESET,
dryRun, kind: 'factory-reset',
});
}
/**
* Reconfigure the DECT synchronization source tree. Doesn't reboot,
* but recomputes which base is sync-source-for-which. Useful when
* multi-cell mesh geometry has drifted.
*/
export async function triggerReconfigureDectTree(client, { dryRun = true } = {}) {
return performAction(client, {
path: MUTATING_ACTION_PATHS.RECONFIGURE_TREE,
dryRun, kind: 'reconfigure-dect-tree',
});
}
// ─── Legacy helpers kept for CLI wiring ─────────────────────────────
//
// These wrap the more-targeted functions above, matching the older
// CLI subcommand names (syslog / config / prt) so the existing
// script keeps working. NONE of these are proven paths yet — they
// remain best-effort GETs against candidate URLs to be replaced once
// we've reverse-engineered the real syslog / config / PRT endpoints
// from the SSR pages.
export async function fetchSyslog(client) {
return tryRequest(client, { method: 'GET', path: '/Management.html' });
}
export async function fetchConfigExport(client) {
// Real endpoint TBD — the Security.html and Settings.xml pages are
// both plausible. For now this just fetches the Settings XML which
// is at least machine-readable.
return tryRequest(client, { method: 'GET', path: '/Settings.xml' });
}
export async function fetchPrt(client) {
// Real endpoint TBD — see .dect-samples/ analysis needed.
return tryRequest(client, { method: 'GET', path: '/Management.html' });
}