The bot runs in the public cloud and can't reach the 10.x/8 network
where DBS-210 bases live. This phase adds a data-center-resident relay
agent that dials outbound over WSS to the bot, and lets /phonestatus
post a follow-up message with per-base health after its main output
has already shipped.
Bot side (services/):
- dectRelayHub.js: WebSocket upgrade handler on /dect-relay/ws with
bearer-token auth (constant-time compare, header + Sec-WebSocket-
Protocol fallback for header-stripping proxies). Promise-based RPC
API with per-call timeouts, mid-flight-disconnect rejection, and
clean replacement of a stale agent socket when a newer one connects.
- dectDiscovery.js: pure filter that turns a phoneService result into
a list of reachable bases. Enforces the "must be on 10.0.0.0/8"
guardrail per requirements, dedups by IP + MAC, prefers Meraki-live
IP over Webex-cached IP.
- dectCollectorService.js: fan-out layer over the hub. collectAll()
runs one RPC per base in parallel with per-base error isolation —
one bad base never fails the batch.
Phone-status integration:
- Renderer gets a dectFollowUpBaseCount opt that emits an italic
"diagnostics loading for N base(s)..." hint inside the DECT section
of the main message.
- New exported renderDectDiagnosticsMarkdown() renders the follow-up
message: healthy/warning icon per base, uptime + firmware summary,
structured Power Loss reboot line, and per-base failure hints (e.g.
"relay accepted the request but the base did not respond in time").
- commands/phoneStatus.js discovers reachable bases synchronously
(pure), sends the main message, then fires collectAll() and posts
the follow-up as a separate message. Failures logged, never thrown
back to the user.
- Chat only: HTTP callers keep their single-message contract.
Agent side (dect-relay-agent/):
- Standalone Node process with its own package.json (only ws, axios,
dotenv). Reuses the shared integrations/cisco-dect/{client,probes,
statusXml}.js modules from the parent workspace so there's no code
duplication.
- Auto-reconnect with exponential backoff + jitter.
- Dispatches collect / reboot / force-reboot / reboot-chain /
force-reboot-chain / factory-reset / reconfigure-tree.
- DECT admin credentials live ONLY on the agent (never on the bot).
Shared bearer token gates the WSS handshake.
- README.md covers install, config, wire protocol, and safety model.
Env / infra:
- .env.example: adds DECT_RELAY_AGENT_TOKEN + optional DECT_RELAY_PATH
and DECT_COLLECT_TIMEOUT_MS. Reframes DECT_TEST_* as the local-dev
test harness rather than the production path.
- index.js: captures the http.Server from app.listen() and attaches
the relay hub when DECT_RELAY_AGENT_TOKEN is set; graceful shutdown
now closes the hub so in-flight RPCs get rejected cleanly.
- Adds "ws" to bot dependencies.
Tests (99 -> 113):
- tests/dectDiscovery.test.js: 13 cases covering the 10.x guardrail,
MAC normalization, IP source preference, dedup, and warning shape.
- tests/dectRelayHub.test.js: 14 integration cases using a real
ws pair on an ephemeral 127.0.0.1 port — auth (missing / wrong /
correct via header / correct via protocol fallback), hello frame,
RPC round-trip with correlation, agent error surfacing, concurrent
out-of-order replies, timeout, mid-flight disconnect, replacement
of a stale socket, and execAction routing.
- tests/renderers.test.js: 8 new cases for the DECT-follow-up loading
hint (plural / singular / off) and the diagnostics renderer (empty,
healthy, warning, power-loss dedup, active RTP, error hint, footer).
129 lines
4.8 KiB
JavaScript
129 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,
|
|
// 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;
|
|
}
|