collabSupport/services/dectDiscovery.js
jmcqueen 2b8c4e06aa Improve /dectstatus with handset RF context and cleaner base cards.
Surface handset registrations and RSSI, tighten reboot health to 7 days,
and consolidate Base / Handsets & RF / Network & RTP sections.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 09:02:55 -04:00

130 lines
4.8 KiB
JavaScript

// 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,
webexId: base.id || null,
// 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;
}