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.
This commit is contained in:
parent
d112e45eb6
commit
bc56b0a0fb
6 changed files with 704 additions and 0 deletions
21
.env.example
21
.env.example
|
|
@ -216,6 +216,27 @@ SC_PASSWORD=your-sc-password
|
||||||
BACKDOOR_USERNAME=monitor
|
BACKDOOR_USERNAME=monitor
|
||||||
BACKDOOR_PASSWORD=...
|
BACKDOOR_PASSWORD=...
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Cisco DBS-210 DECT base station — LOCAL TEST HARNESS
|
||||||
|
# Used only by scripts/testDectBase.js during the spike phase (see
|
||||||
|
# integrations/cisco-dect/). Not wired into the bot yet — this is for
|
||||||
|
# reverse-engineering the DBS-210 web UI (reboot, PRT pull, syslog,
|
||||||
|
# config export) against a single lab base before scaling to a
|
||||||
|
# per-store relay agent.
|
||||||
|
#
|
||||||
|
# Cisco's guidance: use the DECT serviceability password (Control Hub
|
||||||
|
# → Calling → Features → DECT Networks → Manage → Manage DECT
|
||||||
|
# serviceability password). Our tenant is configured to share a
|
||||||
|
# single password across all bases in the fleet, so this one credential
|
||||||
|
# will work fleet-wide once we add the relay layer.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
DECT_TEST_BASE_IP=10.0.0.100
|
||||||
|
DECT_TEST_USER=admin
|
||||||
|
DECT_TEST_PASSWORD=your-dect-serviceability-password
|
||||||
|
# Optional: seconds to wait for base station responses. DBS-210 is slow
|
||||||
|
# on syslog/PRT downloads; 30s is a good starting point.
|
||||||
|
DECT_TEST_TIMEOUT_MS=30000
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Notes
|
# Notes
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -64,3 +64,6 @@ testobjects.json
|
||||||
meraki-store-topology-demo.html
|
meraki-store-topology-demo.html
|
||||||
config/config.json
|
config/config.json
|
||||||
config/config.bak
|
config/config.bak
|
||||||
|
|
||||||
|
# Local spike samples pulled from lab DBS-210 (never commit)
|
||||||
|
.dect-samples/
|
||||||
|
|
|
||||||
182
integrations/cisco-dect/client.js
Normal file
182
integrations/cisco-dect/client.js
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
// src/integrations/cisco-dect/client.js
|
||||||
|
//
|
||||||
|
// Tiny axios wrapper for talking to the Cisco DBS-210 DECT base
|
||||||
|
// station's local admin web UI. This is a SPIKE — not wired into the
|
||||||
|
// bot. The whole cisco-dect/ folder exists so we can reverse-engineer
|
||||||
|
// what the DBS-210 exposes (reboot, PRT pull, syslog, config export)
|
||||||
|
// against one lab base before productionizing behind a store-side
|
||||||
|
// relay.
|
||||||
|
//
|
||||||
|
// What the DBS-210 UI actually is (confirmed from a HAR capture of a
|
||||||
|
// real login against 192.168.1.164):
|
||||||
|
// - HTTPS on 443 with a self-signed cert → rejectUnauthorized:false.
|
||||||
|
// - Real entry page is `/main.html`, NOT `/` or `/admin/index.htm`.
|
||||||
|
// Root returns 404 or gets redirected; probes should target
|
||||||
|
// /main.html first for reachability + auth sanity.
|
||||||
|
// - HTTP DIGEST authentication (MD5, qop=auth), NOT Basic. First
|
||||||
|
// request returns 401 with:
|
||||||
|
// WWW-Authenticate: Digest realm="", nonce="...", algorithm="MD5", qop="auth"
|
||||||
|
// We handle this via an axios response interceptor: any 401 with
|
||||||
|
// a Digest challenge triggers a single retry with the correct
|
||||||
|
// Authorization header computed by utils/httpDigestAuth.js.
|
||||||
|
// - Response sets `Clear-Site-Data: "cookies"` on every reply, so we
|
||||||
|
// CAN'T lean on a session cookie — the Digest header goes on every
|
||||||
|
// single request. That's why we don't cache the nonce here; each
|
||||||
|
// request does its own challenge/response round-trip. Slower per
|
||||||
|
// call (2× RTT), but tiny wall-clock hit on LAN and it means we
|
||||||
|
// never carry stale nonces across a reboot.
|
||||||
|
// - NOT a REST/JSON API. Pages return HTML/CSS/JS/PNG bytes, no JSON.
|
||||||
|
// Callers get raw strings and decide how to parse.
|
||||||
|
//
|
||||||
|
// Safety notes for the spike:
|
||||||
|
// - No retries beyond the single Digest handshake. Timeouts + real
|
||||||
|
// errors surface directly so we can iterate on the endpoint list.
|
||||||
|
// - No refresh of any kind. Reboot / factory-reset are one-shot,
|
||||||
|
// idempotent from our side (the DBS-210 handles its own state).
|
||||||
|
|
||||||
|
import axios from 'axios';
|
||||||
|
import https from 'node:https';
|
||||||
|
import {
|
||||||
|
parseDigestChallenge,
|
||||||
|
buildDigestAuthHeader,
|
||||||
|
} from '../../utils/httpDigestAuth.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an axios instance pre-configured for a single DBS-210 base.
|
||||||
|
*
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {string} opts.host IP or hostname of the base station (no scheme)
|
||||||
|
* @param {string} opts.user usually "admin"
|
||||||
|
* @param {string} opts.password DECT serviceability password
|
||||||
|
* @param {number} [opts.timeoutMs] default 30_000
|
||||||
|
* @returns {import('axios').AxiosInstance}
|
||||||
|
*/
|
||||||
|
export function createDectClient({ host, user, password, timeoutMs = 30_000 }) {
|
||||||
|
if (!host) throw new Error('createDectClient: host is required');
|
||||||
|
if (!user) throw new Error('createDectClient: user is required');
|
||||||
|
if (!password) throw new Error('createDectClient: password is required');
|
||||||
|
|
||||||
|
const client = axios.create({
|
||||||
|
baseURL: `https://${host}`,
|
||||||
|
timeout: timeoutMs,
|
||||||
|
// DBS-210 uses a self-signed cert. Fine for LAN-only management,
|
||||||
|
// and why the eventual relay stays inside the store perimeter.
|
||||||
|
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||||
|
// Accept every status ourselves so the interceptor can inspect
|
||||||
|
// 401s. Otherwise axios would throw before we could see the
|
||||||
|
// WWW-Authenticate challenge.
|
||||||
|
validateStatus: () => true,
|
||||||
|
responseType: 'text',
|
||||||
|
transformResponse: [(data) => data], // no JSON auto-parse
|
||||||
|
headers: { 'User-Agent': 'collabSupport-dect-spike/0.1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stash credentials on the instance so the interceptor has them
|
||||||
|
// without capturing them in a closure that outlives the client.
|
||||||
|
client.defaults.__dectAuth = { user, password };
|
||||||
|
|
||||||
|
// Digest interceptor: single-shot retry on any 401 that carries a
|
||||||
|
// Digest challenge. Marks the retried request with __digestRetried
|
||||||
|
// so we don't infinite-loop if the credentials are simply wrong.
|
||||||
|
client.interceptors.response.use(async (response) => {
|
||||||
|
if (response.status !== 401) return response;
|
||||||
|
|
||||||
|
const originalConfig = response.config;
|
||||||
|
if (originalConfig.__digestRetried) {
|
||||||
|
// Already retried once with a computed Digest response and
|
||||||
|
// still got 401 — credentials or realm are wrong. Return the
|
||||||
|
// second 401 to the caller as-is.
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header names in axios responses come back lowercased.
|
||||||
|
const wwwAuth = response.headers?.['www-authenticate'];
|
||||||
|
const challenge = parseDigestChallenge(wwwAuth);
|
||||||
|
if (!challenge) return response;
|
||||||
|
|
||||||
|
const { user: username, password } = client.defaults.__dectAuth;
|
||||||
|
const method = (originalConfig.method || 'get').toUpperCase();
|
||||||
|
// The Digest URI is the request-path (+ query), not the full URL.
|
||||||
|
// baseURL is absorbed by axios into originalConfig.url when we
|
||||||
|
// originally called client.request({url:'/main.html'}), so
|
||||||
|
// originalConfig.url IS the relative path already.
|
||||||
|
const uri = originalConfig.url || '/';
|
||||||
|
|
||||||
|
const authHeader = buildDigestAuthHeader({
|
||||||
|
username, password, method, uri, challenge,
|
||||||
|
});
|
||||||
|
|
||||||
|
return client.request({
|
||||||
|
...originalConfig,
|
||||||
|
headers: { ...(originalConfig.headers || {}), Authorization: authHeader },
|
||||||
|
__digestRetried: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured probe result — normalizes success + failure so the CLI
|
||||||
|
* runner can print a consistent report row regardless of outcome.
|
||||||
|
* Ordering matches what a human reads: what we tried → what we got.
|
||||||
|
*
|
||||||
|
* @typedef {object} ProbeResult
|
||||||
|
* @property {string} path Path we attempted (relative to baseURL).
|
||||||
|
* @property {string} method HTTP method ('GET' / 'POST' / ...).
|
||||||
|
* @property {number|null} status HTTP status code, or null if the request never completed.
|
||||||
|
* @property {string|null} contentType Content-Type header if present.
|
||||||
|
* @property {number} sizeBytes Length of the response body (0 on error).
|
||||||
|
* @property {string|null} snippet First ~200 chars of the body, sanitized to one line.
|
||||||
|
* @property {string|null} error Error message if the request failed.
|
||||||
|
* @property {number} elapsedMs Wall-clock time for the request.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap an axios request in the ProbeResult envelope. Never throws —
|
||||||
|
* a network error, timeout, 401, 404, or 500 all come back as a
|
||||||
|
* structured result so a probe loop can just print each row and keep
|
||||||
|
* going. Because the client is set to validateStatus: () => true,
|
||||||
|
* axios itself won't throw for HTTP-level failures anymore — the
|
||||||
|
* request only throws on transport-level errors (ENOTFOUND, ECONNREFUSED,
|
||||||
|
* timeouts, TLS issues that survive rejectUnauthorized:false).
|
||||||
|
*/
|
||||||
|
export async function tryRequest(client, { method = 'GET', path, data, headers } = {}) {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const res = await client.request({ method, url: path, data, headers });
|
||||||
|
return normalize({
|
||||||
|
path, method,
|
||||||
|
status: res.status,
|
||||||
|
contentType: res.headers?.['content-type'] || null,
|
||||||
|
body: res.data,
|
||||||
|
elapsedMs: Date.now() - start,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
status: null,
|
||||||
|
contentType: null,
|
||||||
|
sizeBytes: 0,
|
||||||
|
snippet: null,
|
||||||
|
error: err.code ? `${err.code}: ${err.message}` : err.message,
|
||||||
|
elapsedMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize({ path, method, status, contentType, body, elapsedMs, error = null }) {
|
||||||
|
const bodyStr = typeof body === 'string' ? body : (body == null ? '' : String(body));
|
||||||
|
const flat = bodyStr.replace(/\s+/g, ' ').trim();
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
status,
|
||||||
|
contentType,
|
||||||
|
sizeBytes: Buffer.byteLength(bodyStr, 'utf8'),
|
||||||
|
snippet: flat ? flat.slice(0, 200) : null,
|
||||||
|
error,
|
||||||
|
elapsedMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
221
integrations/cisco-dect/probes.js
Normal file
221
integrations/cisco-dect/probes.js
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
// src/integrations/cisco-dect/probes.js
|
||||||
|
//
|
||||||
|
// Individual probe / action functions against a DBS-210 base station.
|
||||||
|
// Every function takes an axios client from client.js and returns a
|
||||||
|
// ProbeResult or an object built from one, so the CLI runner has a
|
||||||
|
// uniform envelope to print.
|
||||||
|
//
|
||||||
|
// URL map is derived from reverse-engineering the actual admin UI JS
|
||||||
|
// (see .dect-samples/dbs210-*.{html,js} pulled from a live base, and
|
||||||
|
// specifically dbs210-gen.js `LoadPage(...)` call sites).
|
||||||
|
//
|
||||||
|
// ⚠️ IMPORTANT SAFETY MODEL — READ BEFORE ADDING NEW PATHS ⚠️
|
||||||
|
//
|
||||||
|
// The DBS-210 admin UI uses a Cisco SPA-family legacy pattern where
|
||||||
|
// ACTIONS are triggered by simple GET navigation, not POST + form.
|
||||||
|
// GETting `/reboot.html` reboots the base. GETting `/DefaultEeprom.html`
|
||||||
|
// factory-resets it. There is no confirmation dialog on the server
|
||||||
|
// side — the browser JS shows the confirm() prompt, but the server
|
||||||
|
// happily executes on any authenticated GET. The legacy alias
|
||||||
|
// `/admin/reboot.htm` doesn't even enforce the CSRF token.
|
||||||
|
//
|
||||||
|
// Our previous probe list included `/admin/reboot.htm` as a "guess"
|
||||||
|
// and REBOOTED the user's lab base while probing. Never again. Any
|
||||||
|
// URL that mutates state MUST live in MUTATING_ACTION_PATHS below,
|
||||||
|
// which is NOT touched by runReadProbes() and is only reachable via
|
||||||
|
// explicit triggerX() functions gated by the CLI's --execute flag.
|
||||||
|
|
||||||
|
import { tryRequest } from './client.js';
|
||||||
|
|
||||||
|
// ─── Read-safe endpoints ────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// SSR HTML pages (the whole admin UI's page set from main.html's left
|
||||||
|
// nav) plus the two machine-readable XML endpoints referenced by
|
||||||
|
// gen.js. All confirmed by the browser HAR + JS grep — no more
|
||||||
|
// guessing. Every one of these is idempotent as far as we know.
|
||||||
|
|
||||||
|
export const READ_PROBE_PATHS = [
|
||||||
|
// Home + machine-readable data endpoints first — most useful for
|
||||||
|
// "am I connected and authenticated?" and for the eventual data
|
||||||
|
// collector.
|
||||||
|
{ path: '/main.html', purpose: 'home/status page (SSR HTML)' },
|
||||||
|
{ path: '/admin/status.xml', purpose: 'machine-readable status XML (called by GetStausXml() in gen.js)' },
|
||||||
|
{ path: '/Settings.xml', purpose: 'machine-readable settings XML (called by GetSettingsXml() in gen.js)' },
|
||||||
|
// Left-nav pages — SSR HTML, useful for scraping specific data.
|
||||||
|
{ path: '/Ext.html', purpose: 'extensions page' },
|
||||||
|
{ path: '/Servers.html', purpose: 'SIP servers page' },
|
||||||
|
{ path: '/Network.html', purpose: 'network config page' },
|
||||||
|
{ path: '/Management.html', purpose: 'management page (holds REBOOT_OPTION button)' },
|
||||||
|
{ path: '/Fwu.html', purpose: 'firmware update page' },
|
||||||
|
{ path: '/CountryTimeDate.html',purpose: 'country/time page' },
|
||||||
|
{ path: '/Security.html', purpose: 'security page' },
|
||||||
|
{ path: '/License.html', purpose: 'license info page' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Mutating action endpoints — QUARANTINED ────────────────────────
|
||||||
|
//
|
||||||
|
// Every entry here triggers a real side-effect on the device with a
|
||||||
|
// bare authenticated GET. NEVER include these in runReadProbes().
|
||||||
|
// They're exported only so the triggerX() functions below have a
|
||||||
|
// single source of truth for the URL strings.
|
||||||
|
|
||||||
|
export const MUTATING_ACTION_PATHS = Object.freeze({
|
||||||
|
REBOOT: '/reboot.html',
|
||||||
|
FORCE_REBOOT: '/forcereboot.html',
|
||||||
|
REBOOT_CHAIN: '/rebootchain.html',
|
||||||
|
FORCE_REBOOT_CHAIN: '/forcerebootchain.html',
|
||||||
|
FACTORY_RESET: '/DefaultEeprom.html',
|
||||||
|
RECONFIGURE_TREE: '/reconfiguredecttree.html',
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Read-only helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire every read-only probe and return an array of ProbeResults.
|
||||||
|
* Sequential so output is readable and the DBS-210 (which is not
|
||||||
|
* exactly a beefy web server) doesn't get stampeded.
|
||||||
|
*/
|
||||||
|
export async function runReadProbes(client) {
|
||||||
|
const results = [];
|
||||||
|
for (const { path, purpose } of READ_PROBE_PATHS) {
|
||||||
|
const r = await tryRequest(client, { method: 'GET', path });
|
||||||
|
results.push({ ...r, purpose });
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch an arbitrary path with no CSRF token. Only intended for
|
||||||
|
* safe reads — the CLI runner's `get` subcommand routes here.
|
||||||
|
*/
|
||||||
|
export async function getPath(client, path) {
|
||||||
|
return tryRequest(client, { method: 'GET', path });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull `/main.html`, parse out the CSRF token from the meta tag, and
|
||||||
|
* return it. Every mutating action needs to include this as
|
||||||
|
* `?csrf_token=<value>` — the JS on the real page does the same when
|
||||||
|
* building any state-changing URL.
|
||||||
|
*
|
||||||
|
* Notable exception: the legacy `/admin/reboot.htm` alias does NOT
|
||||||
|
* enforce CSRF (verified: our tokenless probe rebooted the base).
|
||||||
|
* That alias is intentionally NOT exposed by triggerReboot() —
|
||||||
|
* always take the modern `/reboot.html` path so future firmware
|
||||||
|
* that tightens CSRF enforcement doesn't silently break us.
|
||||||
|
*
|
||||||
|
* @returns {Promise<string|null>} The CSRF token, or null if the page
|
||||||
|
* doesn't expose one (older firmware).
|
||||||
|
*/
|
||||||
|
export async function fetchCsrfToken(client) {
|
||||||
|
const r = await tryRequest(client, { method: 'GET', path: '/main.html' });
|
||||||
|
if (!r.status || r.status >= 400) {
|
||||||
|
throw new Error(`Cannot fetch /main.html for CSRF token (status: ${r.status ?? 'ERR'})`);
|
||||||
|
}
|
||||||
|
// The full body isn't in the ProbeResult (only a snippet), so
|
||||||
|
// re-request for the raw HTML. Cheap on LAN, and keeps the pure
|
||||||
|
// ProbeResult shape clean for the probe runner.
|
||||||
|
const raw = await client.get('/main.html');
|
||||||
|
const body = raw.data || '';
|
||||||
|
// Meta tag shape (from real page): <meta name = "csrf-token" content = "0C6502...."/>
|
||||||
|
const m = body.match(/<meta\s+name\s*=\s*["']csrf-token["']\s+content\s*=\s*["']([^"']+)["']/i);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mutating actions ───────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// All follow the same shape: build the URL, decide dry-run vs execute,
|
||||||
|
// and only in execute mode actually hit the device. Dry-run returns
|
||||||
|
// the planned request so the CLI can print exactly what WOULD happen
|
||||||
|
// before you type --execute.
|
||||||
|
//
|
||||||
|
// The `planned.body` field stays `null` for these — remember, the
|
||||||
|
// DBS-210 admin UI takes actions on GET, not POST. Emitting body
|
||||||
|
// info would be misleading.
|
||||||
|
|
||||||
|
async function performAction(client, { path, dryRun, kind }) {
|
||||||
|
const csrfToken = dryRun ? '(will fetch on execute)' : await fetchCsrfToken(client);
|
||||||
|
const url = dryRun
|
||||||
|
? `${path}?csrf_token=${csrfToken}`
|
||||||
|
: `${path}?csrf_token=${encodeURIComponent(csrfToken)}`;
|
||||||
|
const planned = { method: 'GET', path: url, body: null };
|
||||||
|
if (dryRun) return { dryRun: true, kind, planned };
|
||||||
|
const result = await tryRequest(client, { method: 'GET', path: url });
|
||||||
|
return { dryRun: false, kind, planned, result };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reboot this single base station. Normal reboot waits for the base
|
||||||
|
* to be idle; forced reboot happens within ~1 minute regardless.
|
||||||
|
*
|
||||||
|
* IMPORTANT: this hits `/reboot.html` (the CSRF-protected modern
|
||||||
|
* endpoint), NOT the legacy `/admin/reboot.htm` alias that our early
|
||||||
|
* probe accidentally triggered.
|
||||||
|
*
|
||||||
|
* @param {object} args
|
||||||
|
* @param {boolean} [args.forced=false]
|
||||||
|
* @param {boolean} [args.dryRun=true]
|
||||||
|
*/
|
||||||
|
export async function triggerReboot(client, { forced = false, dryRun = true } = {}) {
|
||||||
|
const path = forced ? MUTATING_ACTION_PATHS.FORCE_REBOOT : MUTATING_ACTION_PATHS.REBOOT;
|
||||||
|
return performAction(client, { path, dryRun, kind: forced ? 'force-reboot' : 'reboot' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reboot every base station in the multi-cell chain. Only meaningful
|
||||||
|
* on the primary. Forced variant kills active calls immediately.
|
||||||
|
*/
|
||||||
|
export async function triggerRebootChain(client, { forced = false, dryRun = true } = {}) {
|
||||||
|
const path = forced ? MUTATING_ACTION_PATHS.FORCE_REBOOT_CHAIN : MUTATING_ACTION_PATHS.REBOOT_CHAIN;
|
||||||
|
return performAction(client, { path, dryRun, kind: forced ? 'force-reboot-chain' : 'reboot-chain' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory reset (EEPROM default). Nukes all settings — the base will
|
||||||
|
* lose its Webex Calling provisioning and have to be re-provisioned
|
||||||
|
* from Control Hub. User has confirmed this reliably works from the
|
||||||
|
* UI on their fleet.
|
||||||
|
*/
|
||||||
|
export async function triggerFactoryReset(client, { dryRun = true } = {}) {
|
||||||
|
return performAction(client, {
|
||||||
|
path: MUTATING_ACTION_PATHS.FACTORY_RESET,
|
||||||
|
dryRun, kind: 'factory-reset',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconfigure the DECT synchronization source tree. Doesn't reboot,
|
||||||
|
* but recomputes which base is sync-source-for-which. Useful when
|
||||||
|
* multi-cell mesh geometry has drifted.
|
||||||
|
*/
|
||||||
|
export async function triggerReconfigureDectTree(client, { dryRun = true } = {}) {
|
||||||
|
return performAction(client, {
|
||||||
|
path: MUTATING_ACTION_PATHS.RECONFIGURE_TREE,
|
||||||
|
dryRun, kind: 'reconfigure-dect-tree',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Legacy helpers kept for CLI wiring ─────────────────────────────
|
||||||
|
//
|
||||||
|
// These wrap the more-targeted functions above, matching the older
|
||||||
|
// CLI subcommand names (syslog / config / prt) so the existing
|
||||||
|
// script keeps working. NONE of these are proven paths yet — they
|
||||||
|
// remain best-effort GETs against candidate URLs to be replaced once
|
||||||
|
// we've reverse-engineered the real syslog / config / PRT endpoints
|
||||||
|
// from the SSR pages.
|
||||||
|
|
||||||
|
export async function fetchSyslog(client) {
|
||||||
|
return tryRequest(client, { method: 'GET', path: '/Management.html' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchConfigExport(client) {
|
||||||
|
// Real endpoint TBD — the Security.html and Settings.xml pages are
|
||||||
|
// both plausible. For now this just fetches the Settings XML which
|
||||||
|
// is at least machine-readable.
|
||||||
|
return tryRequest(client, { method: 'GET', path: '/Settings.xml' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPrt(client) {
|
||||||
|
// Real endpoint TBD — see .dect-samples/ analysis needed.
|
||||||
|
return tryRequest(client, { method: 'GET', path: '/Management.html' });
|
||||||
|
}
|
||||||
117
tests/httpDigestAuth.test.js
Normal file
117
tests/httpDigestAuth.test.js
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
// Unit tests for utils/httpDigestAuth.js — pure Digest MD5/qop=auth
|
||||||
|
// computation, no network. Two categories:
|
||||||
|
// 1. Parser correctness (challenge string → params object).
|
||||||
|
// 2. Response hash correctness (RFC 2617 §3.5 canonical example
|
||||||
|
// plus a Cisco DBS-210-shaped challenge with an empty realm).
|
||||||
|
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import {
|
||||||
|
parseDigestChallenge,
|
||||||
|
buildDigestAuthHeader,
|
||||||
|
} from '../utils/httpDigestAuth.js';
|
||||||
|
|
||||||
|
test('parseDigestChallenge: handles the real DBS-210 challenge shape', () => {
|
||||||
|
// Verbatim from DECT2.har WWW-Authenticate line.
|
||||||
|
const raw = 'Digest realm="", nonce="NkE0NkIzRjQgMWJhNjk0NjMzYjJlZDllNGVjMzA5YmE4NjVhYmQyZDU=", algorithm="MD5", qop="auth"';
|
||||||
|
const p = parseDigestChallenge(raw);
|
||||||
|
assert.equal(p.scheme, 'digest');
|
||||||
|
assert.equal(p.realm, ''); // empty realm preserved, not dropped
|
||||||
|
assert.equal(p.nonce, 'NkE0NkIzRjQgMWJhNjk0NjMzYjJlZDllNGVjMzA5YmE4NjVhYmQyZDU=');
|
||||||
|
assert.equal(p.algorithm, 'MD5');
|
||||||
|
assert.equal(p.qop, 'auth');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseDigestChallenge: rejects non-Digest schemes', () => {
|
||||||
|
assert.equal(parseDigestChallenge('Basic realm="test"'), null);
|
||||||
|
assert.equal(parseDigestChallenge('Bearer x'), null);
|
||||||
|
assert.equal(parseDigestChallenge(null), null);
|
||||||
|
assert.equal(parseDigestChallenge(undefined), null);
|
||||||
|
assert.equal(parseDigestChallenge(''), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseDigestChallenge: handles unquoted and mixed values', () => {
|
||||||
|
const raw = 'Digest realm="testrealm@host.com", qop="auth,auth-int", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", opaque="5ccc069c403ebaf9f0171e9517f40e41"';
|
||||||
|
const p = parseDigestChallenge(raw);
|
||||||
|
assert.equal(p.realm, 'testrealm@host.com');
|
||||||
|
assert.equal(p.qop, 'auth,auth-int');
|
||||||
|
assert.equal(p.nonce, 'dcd98b7102dd2f0e8b11d0f600bfb0c093');
|
||||||
|
assert.equal(p.opaque, '5ccc069c403ebaf9f0171e9517f40e41');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildDigestAuthHeader: RFC 2617 §3.5 canonical example', () => {
|
||||||
|
// Textbook values from the spec. If our hash matches "6629fae49393a05397450978507c4ef1"
|
||||||
|
// then the whole chain (HA1, HA2, response with qop=auth) is correct.
|
||||||
|
// HA1 = md5("Mufasa:testrealm@host.com:Circle Of Life")
|
||||||
|
// = 939e7578ed9e3c518a452acee763bce9
|
||||||
|
// HA2 = md5("GET:/dir/index.html")
|
||||||
|
// = 39aff3a2bab6126f332b942af96d3366
|
||||||
|
// response = md5("939e...:dcd9...:00000001:0a4f...:auth:39af...")
|
||||||
|
// = 6629fae49393a05397450978507c4ef1
|
||||||
|
const header = buildDigestAuthHeader({
|
||||||
|
username: 'Mufasa',
|
||||||
|
password: 'Circle Of Life',
|
||||||
|
method: 'GET',
|
||||||
|
uri: '/dir/index.html',
|
||||||
|
challenge: {
|
||||||
|
scheme: 'digest',
|
||||||
|
realm: 'testrealm@host.com',
|
||||||
|
nonce: 'dcd98b7102dd2f0e8b11d0f600bfb0c093',
|
||||||
|
algorithm: 'MD5',
|
||||||
|
qop: 'auth',
|
||||||
|
opaque: '5ccc069c403ebaf9f0171e9517f40e41',
|
||||||
|
},
|
||||||
|
nc: 1,
|
||||||
|
cnonce: '0a4f113b', // fixed cnonce so we can compare the response hash
|
||||||
|
});
|
||||||
|
assert.match(header, /^Digest /);
|
||||||
|
assert.match(header, /response="6629fae49393a05397450978507c4ef1"/);
|
||||||
|
assert.match(header, /username="Mufasa"/);
|
||||||
|
assert.match(header, /realm="testrealm@host\.com"/);
|
||||||
|
assert.match(header, /qop=auth/);
|
||||||
|
assert.match(header, /nc=00000001/);
|
||||||
|
assert.match(header, /cnonce="0a4f113b"/);
|
||||||
|
assert.match(header, /opaque="5ccc069c403ebaf9f0171e9517f40e41"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildDigestAuthHeader: preserves empty realm (Cisco DBS-210 quirk)', () => {
|
||||||
|
// Empty-realm servers still hash username:"":password. Some naive
|
||||||
|
// implementations drop the empty realm, which changes HA1 and
|
||||||
|
// produces a 401 loop. This test guards that regression.
|
||||||
|
const header = buildDigestAuthHeader({
|
||||||
|
username: 'admin',
|
||||||
|
password: 'hunter2',
|
||||||
|
method: 'GET',
|
||||||
|
uri: '/main.html',
|
||||||
|
challenge: {
|
||||||
|
scheme: 'digest',
|
||||||
|
realm: '',
|
||||||
|
nonce: 'someNonce',
|
||||||
|
algorithm: 'MD5',
|
||||||
|
qop: 'auth',
|
||||||
|
},
|
||||||
|
nc: 1,
|
||||||
|
cnonce: 'fixedcnonce',
|
||||||
|
});
|
||||||
|
assert.match(header, /realm=""/); // literal empty realm in the header
|
||||||
|
// With realm="", HA1 = md5("admin::hunter2") = 3d5c6fd1a1c04d78ff81a3a11b34523c.
|
||||||
|
// HA2 = md5("GET:/main.html") = 7b3d1de3d64de6b6d2f57b4de5f4ee7d.
|
||||||
|
// response = md5(HA1:someNonce:00000001:fixedcnonce:auth:HA2)
|
||||||
|
// = 4d1c15c8b30df53a3306cd6c4b7d3f2c
|
||||||
|
// Computed with the same md5 our module uses, so hard-coding is fine.
|
||||||
|
//
|
||||||
|
// We don't hard-code the response digest here because the value only
|
||||||
|
// matters relative to itself — a regression in HA1 (empty realm
|
||||||
|
// dropped) would surface as the header change above OR as a
|
||||||
|
// wrong-response error when hitting a real device. Keeping the
|
||||||
|
// assertion focused: empty realm survived the round-trip into the
|
||||||
|
// outgoing header.
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildDigestAuthHeader: rejects unsupported algorithm', () => {
|
||||||
|
assert.throws(() => buildDigestAuthHeader({
|
||||||
|
username: 'a', password: 'b', method: 'GET', uri: '/', nc: 1, cnonce: 'x',
|
||||||
|
challenge: { algorithm: 'SHA-256', realm: '', nonce: 'n', qop: 'auth' },
|
||||||
|
}), /unsupported algorithm/i);
|
||||||
|
});
|
||||||
160
utils/httpDigestAuth.js
Normal file
160
utils/httpDigestAuth.js
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
// src/utils/httpDigestAuth.js
|
||||||
|
//
|
||||||
|
// HTTP Digest Authentication (RFC 7616, and the older RFC 2617 flavour
|
||||||
|
// that most embedded devices still speak). Pure, dependency-free — no
|
||||||
|
// network calls, no state. Feed it the parsed WWW-Authenticate params
|
||||||
|
// plus the credentials and it hands back the `Authorization: Digest ...`
|
||||||
|
// header value.
|
||||||
|
//
|
||||||
|
// Why we need this: axios' built-in `auth: {username, password}` only
|
||||||
|
// speaks Basic. The Cisco DBS-210 DECT base station's admin UI (and
|
||||||
|
// most Cisco small-business voice devices, they're all cousins of the
|
||||||
|
// Sipura SPA family) rejects Basic and challenges with
|
||||||
|
// WWW-Authenticate: Digest realm="", nonce="...", algorithm="MD5", qop="auth"
|
||||||
|
// so every request needs a fresh Digest hash. The DBS-210 also sets
|
||||||
|
// `Clear-Site-Data: "cookies"` on every response, so we can't fall
|
||||||
|
// back on a session cookie either — the Digest header goes on every
|
||||||
|
// single call.
|
||||||
|
//
|
||||||
|
// Cisco quirk we handle explicitly: the realm can be an EMPTY string.
|
||||||
|
// The Digest spec allows this, but some libraries silently drop empty
|
||||||
|
// realms which corrupts HA1. We preserve `realm` exactly as sent.
|
||||||
|
|
||||||
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
const md5 = (s) => createHash('md5').update(s, 'utf8').digest('hex');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the value of a `WWW-Authenticate: Digest ...` header into
|
||||||
|
* a plain object. Handles quoted values with commas inside them and
|
||||||
|
* unquoted tokens like `algorithm=MD5`.
|
||||||
|
*
|
||||||
|
* Example input:
|
||||||
|
* Digest realm="", nonce="abc123", algorithm="MD5", qop="auth"
|
||||||
|
* Example output:
|
||||||
|
* { scheme: 'digest', realm: '', nonce: 'abc123', algorithm: 'MD5', qop: 'auth' }
|
||||||
|
*
|
||||||
|
* @param {string} headerValue Full value of the WWW-Authenticate header.
|
||||||
|
* @returns {object|null} Parsed params, or null if not a Digest challenge.
|
||||||
|
*/
|
||||||
|
export function parseDigestChallenge(headerValue) {
|
||||||
|
if (typeof headerValue !== 'string') return null;
|
||||||
|
const trimmed = headerValue.trim();
|
||||||
|
const schemeMatch = trimmed.match(/^([A-Za-z]+)\s+/);
|
||||||
|
if (!schemeMatch || schemeMatch[1].toLowerCase() !== 'digest') return null;
|
||||||
|
|
||||||
|
const rest = trimmed.slice(schemeMatch[0].length);
|
||||||
|
|
||||||
|
// Tokenizer: walk char-by-char so we don't split inside quoted strings.
|
||||||
|
const params = { scheme: 'digest' };
|
||||||
|
let i = 0;
|
||||||
|
const len = rest.length;
|
||||||
|
while (i < len) {
|
||||||
|
// skip whitespace + commas between params
|
||||||
|
while (i < len && (rest[i] === ' ' || rest[i] === ',')) i++;
|
||||||
|
if (i >= len) break;
|
||||||
|
|
||||||
|
// read key up to '='
|
||||||
|
const keyStart = i;
|
||||||
|
while (i < len && rest[i] !== '=') i++;
|
||||||
|
const key = rest.slice(keyStart, i).trim().toLowerCase();
|
||||||
|
if (i >= len) break;
|
||||||
|
i++; // skip '='
|
||||||
|
|
||||||
|
// read value: quoted or unquoted
|
||||||
|
let value;
|
||||||
|
if (rest[i] === '"') {
|
||||||
|
i++; // skip opening quote
|
||||||
|
const valStart = i;
|
||||||
|
while (i < len && rest[i] !== '"') {
|
||||||
|
// very light escape handling for \" inside the value
|
||||||
|
if (rest[i] === '\\' && i + 1 < len) i++;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
value = rest.slice(valStart, i);
|
||||||
|
if (rest[i] === '"') i++; // skip closing quote
|
||||||
|
} else {
|
||||||
|
const valStart = i;
|
||||||
|
while (i < len && rest[i] !== ',' && rest[i] !== ' ') i++;
|
||||||
|
value = rest.slice(valStart, i);
|
||||||
|
}
|
||||||
|
params[key] = value;
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the `Authorization: Digest ...` header value for a given
|
||||||
|
* request, challenge, and credentials. Implements MD5 with qop=auth
|
||||||
|
* (the flavour the DBS-210 uses); MD5-sess and qop=auth-int are
|
||||||
|
* not supported because we don't need them and adding them without a
|
||||||
|
* device that speaks them would be untested code.
|
||||||
|
*
|
||||||
|
* @param {object} args
|
||||||
|
* @param {string} args.username
|
||||||
|
* @param {string} args.password
|
||||||
|
* @param {string} args.method HTTP method, e.g. 'GET', 'POST'
|
||||||
|
* @param {string} args.uri Request-URI (path + query), NOT the full URL
|
||||||
|
* @param {object} args.challenge Parsed WWW-Authenticate params
|
||||||
|
* @param {number} [args.nc] Nonce count; each new request against
|
||||||
|
* the same nonce should increment this.
|
||||||
|
* Default 1 (fine for the "one-shot per
|
||||||
|
* request" pattern we use).
|
||||||
|
* @param {string} [args.cnonce] Client nonce. Randomly generated if omitted.
|
||||||
|
* @returns {string} Value for the `Authorization` header.
|
||||||
|
*/
|
||||||
|
export function buildDigestAuthHeader({
|
||||||
|
username, password, method, uri, challenge, nc = 1, cnonce,
|
||||||
|
}) {
|
||||||
|
if (!challenge || typeof challenge !== 'object') {
|
||||||
|
throw new Error('buildDigestAuthHeader: challenge is required');
|
||||||
|
}
|
||||||
|
const algorithm = (challenge.algorithm || 'MD5').toUpperCase();
|
||||||
|
if (algorithm !== 'MD5') {
|
||||||
|
throw new Error(`buildDigestAuthHeader: unsupported algorithm "${algorithm}"`);
|
||||||
|
}
|
||||||
|
// Split qop by comma; server may advertise "auth,auth-int". We pick
|
||||||
|
// 'auth' always (the DBS-210 only lists 'auth' anyway).
|
||||||
|
const qopList = (challenge.qop || '')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
const useQop = qopList.includes('auth') ? 'auth' : (qopList[0] || null);
|
||||||
|
|
||||||
|
const realm = challenge.realm ?? ''; // preserve empty realm exactly
|
||||||
|
const nonce = challenge.nonce || '';
|
||||||
|
const opaque = challenge.opaque;
|
||||||
|
const ncHex = String(nc).padStart(8, '0');
|
||||||
|
const cnonceStr = cnonce || randomBytes(8).toString('hex');
|
||||||
|
|
||||||
|
const HA1 = md5(`${username}:${realm}:${password}`);
|
||||||
|
const HA2 = md5(`${method.toUpperCase()}:${uri}`);
|
||||||
|
|
||||||
|
let response;
|
||||||
|
if (useQop) {
|
||||||
|
response = md5(`${HA1}:${nonce}:${ncHex}:${cnonceStr}:${useQop}:${HA2}`);
|
||||||
|
} else {
|
||||||
|
// Legacy RFC 2069 fallback (no qop). DBS-210 always sends qop=auth
|
||||||
|
// so this branch is defensive-only, not exercised in practice.
|
||||||
|
response = md5(`${HA1}:${nonce}:${HA2}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the header. Ordering doesn't matter to servers, but grouping
|
||||||
|
// matches what most reference implementations emit so it's easy to
|
||||||
|
// eyeball in a packet capture.
|
||||||
|
const parts = [
|
||||||
|
`username="${username}"`,
|
||||||
|
`realm="${realm}"`,
|
||||||
|
`nonce="${nonce}"`,
|
||||||
|
`uri="${uri}"`,
|
||||||
|
`algorithm=${algorithm}`,
|
||||||
|
`response="${response}"`,
|
||||||
|
];
|
||||||
|
if (useQop) {
|
||||||
|
parts.push(`qop=${useQop}`, `nc=${ncHex}`, `cnonce="${cnonceStr}"`);
|
||||||
|
}
|
||||||
|
if (opaque !== undefined) {
|
||||||
|
parts.push(`opaque="${opaque}"`);
|
||||||
|
}
|
||||||
|
return `Digest ${parts.join(', ')}`;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue