collabSupport/commands/igmpFix.js
jmcqueen d112e45eb6 Detect and inline-remediate Meraki IGMP snooping in /phonestatus
DECT basestations rely on multicast for handset discovery/registration.
When Meraki switches have IGMP snooping enabled without a querier —
or per-switch overrides that deviate from a DECT-safe policy — those
frames are pruned and DECT handsets silently fail to register.

- integrations/meraki/switches.js: getSwitchMulticastSettings,
  setSwitchMulticastSettings, and pure summarizeMulticast verdict fn.
- services/phoneService.js: fires the multicast fetch as soon as the
  network id is known, overlapping DECT enrichment; attaches
  data.multicast summary to the collector output. Non-fatal on error.
- services/renderers/phoneStatusRenderer.js: emits a single warning
  line inside the DECT Basestations section only when needsFix is
  true, itemizing which parts deviate (default snoop, default flood,
  N overrides).
- commands/igmpFix.js: frozen DECT_SAFE_MULTICAST_PAYLOAD constant
  ({snoop:false, flood:true, overrides:[]}), adaptive-card builder,
  and confirm/cancel handlers.
- commands/phoneStatus.js: appends the adaptive card when needsFix
  and the trigger came from chat (skipped on HTTP path).
- index.js: IGMP_FIX_ACTIONS set + dispatch branch mirroring the
  HOST_ASSIGN pattern (one-shot pending lookup, censorActionCard,
  domain call).
- utils/pendingIgmpFixes.js: 15-min TTL pending-card store.
- tests: 11 summarizer cases (all deviation permutations + malformed
  input), 4 renderer cases (each warning-line shape + silence when
  needsFix false or no DECT), and a frozen-constant regression guard
  on the PUT payload. Full suite: 47/47 passing.
2026-07-02 14:10:33 -04:00

176 lines
6.3 KiB
JavaScript

// src/commands/igmpFix.js
//
// Adaptive-card inline remediation for Meraki switch multicast policy
// on a store's network. Not a standalone chat command — the card is
// emitted by `/phonestatus` (see commands/phoneStatus.js) whenever the
// collector reports `data.multicast.needsFix`. Confirm and Cancel
// flow through the framework's `attachmentAction` event dispatched
// from index.js, which follows the same pending-card / one-shot /
// censor pattern as /webexhost and /offboarduser.
//
// End-state target (per plan): every store network's switch multicast
// settings pinned to:
// defaultSettings.igmpSnoopingEnabled = false
// defaultSettings.floodUnknownMulticastTrafficEnabled = true
// overrides = []
//
// The target is a hardcoded constant. We do NOT read-then-mutate the
// current snapshot at PUT time — the multicast object schema has only
// these two fields, so there's nothing else to preserve, and a
// constant PUT sidesteps read/write races.
import { logger } from '../utils/logger.js';
import { describeRequester } from '../utils/requester.js';
import { setSwitchMulticastSettings } from '../integrations/meraki/switches.js';
// Exported so tests can regression-guard the payload shape and so the
// applyDectSafeMulticast function has a single source of truth.
export const DECT_SAFE_MULTICAST_PAYLOAD = Object.freeze({
defaultSettings: {
igmpSnoopingEnabled: false,
floodUnknownMulticastTrafficEnabled: true,
},
overrides: [],
});
/**
* Build the adaptive-card object shown in-chat under a /phonestatus
* reply when multicast policy deviates from DECT-safe defaults. The
* card renders whatever the summarizer flagged as deviating so the
* clicker knows exactly what will change.
*
* @param {object} args
* @param {string} args.storeNum
* @param {string} args.networkName
* @param {object} args.summary data.multicast summary shape
* @param {string} args.cardId uuid stashed in pendingIgmpFixes
* @returns {object} Adaptive Card 1.3 JSON.
*/
export function buildIgmpFixCard({ storeNum, networkName, summary, cardId }) {
const facts = [];
if (summary.defaultSnoopOn) {
facts.push({ title: 'Default IGMP snooping', value: 'ON → will change to OFF' });
} else {
facts.push({ title: 'Default IGMP snooping', value: 'OFF (no change)' });
}
if (summary.defaultFloodOff) {
facts.push({ title: 'Default flood-unknown multicast', value: 'OFF → will change to ON' });
} else {
facts.push({ title: 'Default flood-unknown multicast', value: 'ON (no change)' });
}
const overrideCount = summary.deviatingOverrides?.length || 0;
facts.push({
title: 'Switch/stack overrides',
value: overrideCount > 0
? `${overrideCount} deviating → all overrides cleared`
: 'none (no change)',
});
return {
type: 'AdaptiveCard',
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
version: '1.3',
body: [
{
type: 'TextBlock',
size: 'Medium',
weight: 'Bolder',
text: `Apply DECT-safe multicast on ${networkName || `store ${storeNum}`}?`,
wrap: true,
},
{
type: 'TextBlock',
text:
`This will PUT the Meraki switch multicast settings for this network so IGMP snooping is OFF, ` +
`flood-unknown-multicast is ON, and all per-switch overrides are cleared. Intended to unblock DECT ` +
`handset registration for store ${storeNum}.`,
wrap: true,
spacing: 'Small',
},
{
type: 'FactSet',
spacing: 'Medium',
facts,
},
],
actions: [
{
type: 'Action.Submit',
title: '✅ Apply DECT-safe defaults',
data: { action: 'confirm_igmp_fix', cardId },
},
{
type: 'Action.Submit',
title: '❌ Cancel',
data: { action: 'cancel_igmp_fix', cardId },
},
],
};
}
/**
* Confirm handler — PUTs the constant DECT-safe payload to the
* network's multicast endpoint. Called from index.js after the
* pending-card lookup + one-shot delete + card censor.
*
* Signature matches applyHostAssignConfirmation from /webexhost so the
* dispatch in index.js stays uniform across all inline-card actions.
*
* @param {object} bot bot-framework per-room bot
* @param {object} data pending-card payload (see pendingIgmpFixes)
* @param {string} _roomId unused; bot is already room-scoped
* @param {object} requester from extractRequester(trigger)
*/
export async function applyDectSafeMulticast(bot, data, _roomId, requester) {
const { networkId, networkName, storeNum, summary } = data;
logger(
'igmp:audit',
`CONFIRMED DECT-safe multicast on ${networkName} (${networkId}) for store ${storeNum} ` +
`by ${describeRequester(requester)} — was: snoop=${summary?.defaultSnoopOn ? 'on' : 'off'}, ` +
`flood=${summary?.defaultFloodOff ? 'off' : 'on'}, overrides=${summary?.deviatingOverrides?.length ?? 0}`,
);
try {
await setSwitchMulticastSettings(networkId, DECT_SAFE_MULTICAST_PAYLOAD);
} catch (err) {
logger(
'igmp:audit',
`FAILED DECT-safe multicast on ${networkName} for store ${storeNum}: ${err.message}`,
'error',
);
await bot.say(
'markdown',
`❌ Failed to update multicast config on **${networkName || `store ${storeNum}`}**: ${err.message}`,
);
return;
}
await bot.say(
'markdown',
`✅ Multicast pinned to DECT-safe defaults on **${networkName || `store ${storeNum}`}**: ` +
`IGMP snoop=OFF, flood-unknown=ON, all switch overrides cleared. ` +
`Re-run \`/phonestatus ${storeNum}\` to verify.`,
);
logger(
'igmp:audit',
`COMPLETED DECT-safe multicast on ${networkName} for store ${storeNum}`,
);
}
/**
* Cancel handler — logs the cancel and posts a friendly line. No
* mutation. Signature matches cancelHostAssignCard for uniform
* dispatch in index.js.
*/
export async function cancelIgmpFixCard(bot, data, _roomId, requester) {
const { networkName, storeNum } = data;
await bot.say(
'markdown',
`❌ Multicast change cancelled for **${networkName || `store ${storeNum}`}**. No changes were made.`,
);
logger(
'igmp:audit',
`CANCELLED DECT-safe multicast on ${networkName} for store ${storeNum} by ${describeRequester(requester)}`,
);
}