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.
221 lines
10 KiB
JavaScript
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' });
|
|
}
|