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