// integrations/cisco-mpp-phone/probes.js // // Read-only probes against Cisco MPP desk phones (Webex Calling). // Current-release Webex MPP firmware uses JSON endpoints on the SPA // web UI (not legacy /admin/status.xml). import { tryRequest } from './client.js'; import { parseCfgXml } from './statusXml.js'; export const READ_PROBE_PATHS = [ { path: '/', purpose: 'user web landing (SPA shell)' }, { path: '/Status.json', purpose: 'primary status JSON (Info > Status tab)' }, { path: '/Download%20Status.json', purpose: 'provisioning + firmware upgrade history' }, { path: '/ns.json', purpose: 'network statistics JSON' }, { path: '/basic/System.json', purpose: 'system configuration JSON (admin fields)' }, // Legacy fallbacks (older MPP loads / CUCM-style) { path: '/admin/status.xml', purpose: 'legacy admin status XML' }, { path: '/status.xml', purpose: 'legacy user status XML' }, { path: '/admin/cfg.xml', purpose: 'legacy admin config XML' }, ]; export const EXPLORE_PROBE_PATHS = [ { path: '/Debug%20Info.json', purpose: 'debug info JSON' }, { path: '/basic/init.json', purpose: 'UI layout metadata' }, { path: '/admin/advanced', purpose: 'legacy admin HTML landing' }, ]; export const MUTATING_ACTION_PATHS = Object.freeze({ REBOOT: '/admin/reboot', RESYNC: '/admin/resync', UPGRADE: '/admin/upgrade', }); export async function runProbeList(client, pathList) { const results = []; for (const { path, purpose } of pathList) { const r = await tryRequest(client, { method: 'GET', path }); results.push({ ...r, purpose }); } return results; } export async function runReadProbes(client) { return runProbeList(client, READ_PROBE_PATHS); } export async function runExploreProbes(client) { return runProbeList(client, [...READ_PROBE_PATHS, ...EXPLORE_PROBE_PATHS]); } export async function runPhoneProbe(client, { includeBodies = false } = {}) { const started = Date.now(); const probes = await runReadProbes(client); const statusJson = await fetchBodyIfOk(probes, client, '/Status.json'); const statusXml = !statusJson ? (await fetchBodyIfOk(probes, client, '/admin/status.xml') || await fetchBodyIfOk(probes, client, '/status.xml')) : null; const downloadStatusJson = await fetchBodyIfOk(probes, client, '/Download%20Status.json'); const nsJson = await fetchBodyIfOk(probes, client, '/ns.json'); const systemJson = await fetchBodyIfOk(probes, client, '/basic/System.json'); const cfgXml = await fetchBodyIfOk(probes, client, '/admin/cfg.xml'); const cfgParsed = cfgXml ? parseCfgXml(cfgXml) : null; const summary = summarizeProbes(probes, { statusJson, statusXml }); return { probes: includeBodies ? await attachBodies(client, probes) : probes, summary, statusJson, downloadStatusJson: downloadStatusJson || null, nsJson: nsJson || null, systemJson: systemJson || null, statusXml, cfgXml: includeBodies ? cfgXml : (cfgXml ? truncate(cfgXml, 4000) : null), cfgParsed: cfgParsed?.highlights || null, byteLength: statusJson ? Buffer.byteLength(statusJson, 'utf8') : (statusXml ? Buffer.byteLength(statusXml, 'utf8') : null), elapsedMs: Date.now() - started, }; } export async function fetchStatusJson(client) { const r = await tryRequest(client, { method: 'GET', path: '/Status.json' }); if (r.status === 200 && r.sizeBytes > 0) { const body = await fetchBody(client, '/Status.json'); return { rawJson: body, rawXml: null, byteLength: Buffer.byteLength(body || '', 'utf8'), probe: r, path: '/Status.json', }; } const xml = await fetchStatusXml(client); return { rawJson: null, rawXml: xml.rawXml, byteLength: xml.byteLength, probe: xml.probe, path: xml.path, }; } export async function fetchStatusXml(client) { for (const path of ['/admin/status.xml', '/status.xml']) { const r = await tryRequest(client, { method: 'GET', path }); if (r.status === 200 && r.sizeBytes > 0) { const body = await fetchBody(client, path); return { rawXml: body, byteLength: Buffer.byteLength(body || '', 'utf8'), probe: r, path }; } } const err = new Error('phone returned no Status.json or status.xml'); err.code = 'PHONE_BAD_STATUS'; throw err; } async function fetchBodyIfOk(probes, client, path) { const p = probes.find((row) => row.path === path); if (p?.status === 200 && p.sizeBytes > 0) { return fetchBody(client, path); } return null; } async function fetchBody(client, path) { try { const res = await client.get(path); return typeof res.data === 'string' ? res.data : JSON.stringify(res.data ?? ''); } catch { return null; } } async function attachBodies(client, probes) { const out = []; for (const p of probes) { if (p.status === 200 && p.sizeBytes > 0) { const body = await fetchBody(client, p.path); out.push({ ...p, body: body ?? null }); } else { out.push({ ...p, body: null }); } } return out; } function truncate(str, max) { if (!str || str.length <= max) return str; return `${str.slice(0, max)}\n`; } export function summarizeProbes(probes, { statusJson = null, statusXml = null } = {}) { const okCount = probes.filter((p) => p.status === 200).length; const authOk = probes.some((p) => p.status === 200); const statusJsonFound = probes.some( (p) => p.path === '/Status.json' && p.status === 200 && p.sizeBytes > 0, ) || !!statusJson; const statusXmlFound = probes.some( (p) => (p.path === '/admin/status.xml' || p.path === '/status.xml') && p.status === 200 && p.sizeBytes > 0, ) || !!statusXml; return { okCount, total: probes.length, authOk, statusJsonFound, statusXmlFound, statusJsonBytes: statusJson ? Buffer.byteLength(statusJson, 'utf8') : 0, statusXmlBytes: statusXml ? Buffer.byteLength(statusXml, 'utf8') : 0, forbidden: probes.filter((p) => p.status === 403).map((p) => p.path), unauthorized: probes.filter((p) => p.status === 401).map((p) => p.path), notFound: probes.filter((p) => p.status === 404).map((p) => p.path), okPaths: probes.filter((p) => p.status === 200).map((p) => p.path), }; } export async function getPath(client, path) { return tryRequest(client, { method: 'GET', path }); }