Refactor every /voicediag check to declare a top-level `standards` object so the desired state is legible without reading run() logic and can drive a documented reference table. Upgrade callForwarding to error severity, tighten voicemail with three send-to-VM error paths + a `stop_sending_to_voicemail` remediation, and add a `disable_hoteling` remediation. Add a port-hygiene check bucket under services/voiceDiag/checks/port (portType, portVlan, portPoe, portEnabled) that reuses the phone- status snapshot to enforce switchport standards. Configurable via VOICE_STANDARD_PHONE_VLAN (default 102) and VOICE_STANDARD_ENABLED (kill-switch). Preserve Meraki `portType`/`voiceVlan`/`dataVlan` through the enrichment chain so the checks have clean data to read. Add an "apply all N fixes" combined card that shows up when 2+ remediations are available. New confirm_voicediag_all / cancel_voicediag_all actions run each fix in sequence (readable audit trail, no per-person write-throttle stacking), accumulate individual failures into a summary rather than aborting. Adds regression tests asserting every check exposes .standards, plus coverage for port checks, kill-switch, and combined-card iteration. 63 tests in the checks file, 188 total, all green. Co-authored-by: Cursor <cursoragent@cursor.com>
121 lines
4.1 KiB
JavaScript
121 lines
4.1 KiB
JavaScript
// src/services/voiceDiag/checks/port/portVlan.js
|
|
//
|
|
// Store phone standard: every desk phone / DECT base is tagged into
|
|
// a specific VLAN (currently 102 for AE, on the data-side of the
|
|
// port; may migrate to voice VLAN in the future — hence the env-
|
|
// configurable target and the "either side" comparison below).
|
|
//
|
|
// The check compares `client.vlan` (the actual VLAN the phone is
|
|
// seeing) against the standard rather than the port's `voiceVlan`
|
|
// or `dataVlan` configuration alone — this makes it correct today
|
|
// (data VLAN carries voice) and correct tomorrow if we move phones
|
|
// onto a proper voice VLAN, without touching the check.
|
|
//
|
|
// Env override:
|
|
// VOICE_STANDARD_PHONE_VLAN=102 (default 102)
|
|
//
|
|
// Skip rules:
|
|
// - Wireless: skipped.
|
|
// - Trunk uplink: skipped (portType check owns the trunk warning).
|
|
// - No VLAN info at all: reported as unknown.
|
|
//
|
|
// No remediation — VLAN misassignment is a Meraki port-config
|
|
// change out of scope for this round.
|
|
|
|
import { collectPortDevices, isWireless, portLabelFor, maybeSkippedByKillSwitch } from './_helpers.js';
|
|
|
|
/** Configurable per env — falls back to 102 if unset or non-numeric. */
|
|
function getExpectedVlan() {
|
|
const raw = process.env.VOICE_STANDARD_PHONE_VLAN;
|
|
const parsed = raw ? Number(raw) : 102;
|
|
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 4095) return 102;
|
|
return parsed;
|
|
}
|
|
|
|
// Exported for the standards reference table + tests. Read at call
|
|
// time so tests can mutate process.env without needing to reset
|
|
// module state.
|
|
export const PORT_VLAN_STANDARDS = Object.freeze({
|
|
get vlan() { return getExpectedVlan(); },
|
|
envKey: 'VOICE_STANDARD_PHONE_VLAN',
|
|
});
|
|
|
|
export const portVlanCheck = {
|
|
id: 'portVlan',
|
|
label: 'Switchport VLAN',
|
|
requires: ['phoneStatus'],
|
|
scope: null,
|
|
standards: PORT_VLAN_STANDARDS,
|
|
|
|
async run(ctx) {
|
|
const skip = maybeSkippedByKillSwitch(portVlanCheck);
|
|
if (skip) return skip;
|
|
const expected = getExpectedVlan();
|
|
const devices = collectPortDevices(ctx.phoneStatus);
|
|
if (devices.length === 0) {
|
|
return {
|
|
status: 'skipped',
|
|
message: 'No wired devices with Meraki port data to inspect.',
|
|
details: null,
|
|
remediation: null,
|
|
};
|
|
}
|
|
|
|
const perDevice = devices.map((d) => {
|
|
if (isWireless(d)) {
|
|
return { ...portLabelFor(d), vlan: null, verdict: 'wireless' };
|
|
}
|
|
const portType = d.meraki?.portType;
|
|
if (portType && String(portType).toLowerCase() === 'trunk') {
|
|
return { ...portLabelFor(d), vlan: d.meraki?.vlan ?? null, verdict: 'trunk-skip' };
|
|
}
|
|
const vlan = d.meraki?.vlan;
|
|
if (vlan === undefined || vlan === null || vlan === '') {
|
|
return { ...portLabelFor(d), vlan: null, verdict: 'unknown' };
|
|
}
|
|
const num = Number(vlan);
|
|
const verdict = num === expected ? 'compliant' : 'wrong';
|
|
return { ...portLabelFor(d), vlan: num, verdict };
|
|
});
|
|
|
|
const wrong = perDevice.filter((r) => r.verdict === 'wrong');
|
|
const unknown = perDevice.filter((r) => r.verdict === 'unknown');
|
|
|
|
const details = {
|
|
expectedVlan: expected,
|
|
total: perDevice.length,
|
|
compliant: perDevice.filter((r) => r.verdict === 'compliant').length,
|
|
wrong: wrong.length,
|
|
unknown: unknown.length,
|
|
wireless: perDevice.filter((r) => r.verdict === 'wireless').length,
|
|
trunkSkipped: perDevice.filter((r) => r.verdict === 'trunk-skip').length,
|
|
offenders: wrong,
|
|
};
|
|
|
|
if (wrong.length > 0) {
|
|
const worstLine = wrong.map((w) => `${w.deviceLabel}@${w.portLabel} (VLAN ${w.vlan})`).join(', ');
|
|
return {
|
|
status: 'warn',
|
|
message: `${wrong.length} device(s) on the wrong VLAN (expected ${expected}): ${worstLine}.`,
|
|
details,
|
|
remediation: null,
|
|
};
|
|
}
|
|
|
|
if (unknown.length > 0) {
|
|
return {
|
|
status: 'warn',
|
|
message: `${unknown.length} device(s) have no VLAN info from Meraki.`,
|
|
details,
|
|
remediation: null,
|
|
};
|
|
}
|
|
|
|
return {
|
|
status: 'ok',
|
|
message: `All ${details.compliant} wired device(s) on VLAN ${expected}.`,
|
|
details,
|
|
remediation: null,
|
|
};
|
|
},
|
|
};
|