// src/services/dectDiscovery.js // // Turn "the list of DECT basestations we already know about for a // store" into "the list of bases the DECT relay should actually try // to talk to". Pure, no I/O — the input comes straight from // collectPhoneStatus() output (or /phone/devices/build), so this // module just filters and normalizes. // // The single hard rule enforced here is the 10.x network guard: every // production DECT base at AE lives on the 10.0.0.0/8 corporate // network. Anything with a different first octet is either a // leftover, a mis-inventoried device, or the base has been swapped // out and not yet re-Merakied — either way the relay should NOT try // to talk to it (a random 192.168.x.x on some client's laptop is not // something we want to Digest-auth into). We flag those cases as // warnings so the caller can surface them. // Cisco DECT MAC OUI prefixes (first three octets of the MAC). // Not enforced hard — some fleets have odd MACs — but used as a // tie-breaker when the Webex API's baseStation entries are noisy. // Kept exported so tests + future callers can extend. export const CISCO_DECT_MAC_OUI_PREFIXES = new Set([ '6cab05', // observed on lab DBS-210-3PC '00040f', // classic Cisco DECT range ]); /** * Discover reachable DBS-210 bases for a store from a collectPhoneStatus * result. * * @param {object} phoneStatus collectPhoneStatus() output * @returns {object} discovery { bases: [...], warnings: [...] } * - bases: [{ mac, ip, name, source }] ready to hand to the relay * - warnings: [{ mac, ip, reason }] bases we deliberately excluded */ export function discoverDectBases(phoneStatus) { const bases = []; const warnings = []; const seenIps = new Set(); const seenMacs = new Set(); const raw = Array.isArray(phoneStatus?.dectBasestations) ? phoneStatus.dectBasestations : []; for (const base of raw) { // Pick the best IP source. Meraki's live client scan is more // trustworthy than the Webex API record (which lags device DHCP // renewals), so we prefer it. Webex's ipAddress is the fallback. const ip = pickIp(base); const mac = normalizeMac(base.mac); const name = base.name || base.displayName || `Basestation ${mac || '?'}`; if (!mac) { warnings.push({ mac: null, ip, reason: 'base has no MAC address in inventory' }); continue; } if (!ip) { warnings.push({ mac, ip: null, reason: 'no IP address available (base may be unreachable)' }); continue; } if (!isTenDotIp(ip)) { warnings.push({ mac, ip, reason: `base IP ${ip} is not on the corporate 10.0.0.0/8 network; skipping (production bases should always be 10.x)`, }); continue; } if (seenIps.has(ip)) { warnings.push({ mac, ip, reason: `duplicate IP ${ip} in discovery result — keeping the first entry` }); continue; } if (seenMacs.has(mac)) { warnings.push({ mac, ip, reason: `duplicate MAC ${mac} in discovery result — keeping the first entry` }); continue; } seenIps.add(ip); seenMacs.add(mac); bases.push({ mac, ip, name, // Track where the IP came from — useful in logs if a base is // reachable via one source but not the other. source: base.meraki?.ip ? 'meraki' : 'webex', }); } return { bases, warnings }; } // ─── Helpers ──────────────────────────────────────────────────────── /** * Test whether an IP string is on 10.0.0.0/8 (i.e. first octet is 10). * Accepts plain IPv4 dotted strings; anything else returns false * (we're intentionally conservative here — CIDRs, IPv6, hostnames all * fall through to "not on 10.x" so the relay never touches them). */ export function isTenDotIp(value) { if (typeof value !== 'string') return false; const m = value.trim().match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); if (!m) return false; const octets = [m[1], m[2], m[3], m[4]].map(Number); if (octets.some((o) => o < 0 || o > 255)) return false; return octets[0] === 10; } /** * Normalize a MAC address to lowercase-colon-separated form * (`aa:bb:cc:dd:ee:ff`). Returns null if the input doesn't look like * a 12-hex-nibble MAC. */ export function normalizeMac(mac) { if (!mac || typeof mac !== 'string') return null; const hex = mac.replace(/[^0-9a-fA-F]/g, '').toLowerCase(); if (hex.length !== 12) return null; return hex.match(/../g).join(':'); } function pickIp(base) { const merakiIp = base?.meraki?.ip; if (typeof merakiIp === 'string' && merakiIp.trim()) return merakiIp.trim(); const webexIp = base?.ipAddress; if (typeof webexIp === 'string' && webexIp.trim() && webexIp !== '—') return webexIp.trim(); return null; }