collabSupport/integrations/cisco-dect/client.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

182 lines
7.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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