// 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, }; }, };