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.
182 lines
7.6 KiB
JavaScript
182 lines
7.6 KiB
JavaScript
// 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,
|
||
};
|
||
}
|