// services/enrichment/merakiMatcher.js // // Unified, high-quality Meraki client matching logic. // This is the single source of truth for matching MDM/Atlas devices // to Meraki clients across the entire application. // // Extracted and unified from multiple locations (avDeviceBuilder, deviceService, etc.) // to eliminate duplication and make improvements in one place. import { logger } from '../../utils/logger.js'; import { normalizePlayerName } from '../../utils/normalize.js'; import { normalizeMac } from './normalizers.js'; /** * Finds the best matching Meraki client for a given device (MDM or Atlas). * Uses multiple strategies in priority order for maximum reliability, * especially with country-prefixed identifiers (US, CA, etc.). * * @param {Object} device - The source device (from MDM or Atlas) * @param {Array} allMerakiClients - List of Meraki clients * @param {Object} [options] * @param {boolean} [options.debug] - Enable extra debug logging for a specific device * @returns {Object|null} The best matching Meraki client, or null */ export function findBestMerakiClientMatch(device, allMerakiClients, options = {}) { if (!device || !allMerakiClients || allMerakiClients.length === 0) { return null; } const identifier = (device.identifier || '').toUpperCase().trim(); const username = ( device.mdmData?.UserName || device.mdmData?.DeviceFriendlyName || device.mdmData?.DeviceReportedName || '' ).toUpperCase().trim(); // Extract country prefix if present (US, CA, MX, EU, AU, ...) const countryMatch = identifier.match(/^(US|CA|MX|EU|AU)/i); const countryPrefix = countryMatch ? countryMatch[1].toUpperCase() : 'US'; // Stripped version without country prefix (e.g. 001024MSCAE) const strippedId = identifier.replace(/^(US|CA|MX|EU|AU)/i, '').trim(); // Atlas support: extract IP/MAC from atlasData or summary (for AMPs in rich/build paths) // This allows IP-based matching when name/desc doesn't match (common for AMPs) const atlasIp = ( device.atlasData?.state?.IpAddress || device.atlasData?.ipAddress || device.atlasDataSummary?.ipAddress || device.ip || '' ).trim().toLowerCase(); const atlasMac = normalizeMac( device.atlasData?.mac || device.atlasDataSummary?.macAddress || '' ); const shouldDebug = options.debug || identifier.startsWith('CA'); if (shouldDebug) { logger('enrichment:meraki', `Matching attempt for ${identifier}`); logger('enrichment:meraki', `Username: ${username}, Stripped: ${strippedId}, atlasIp: ${atlasIp}, atlasMac: ${atlasMac}`); } // Track the best (most recent by lastSeen) match across *all* strategies. // This handles the case of multiple client entries for the same AV identifier (e.g. historical // connections on different ports/APs over the timespan window). We return the freshest one. let bestMatch = null; let bestTime = -1; for (const listItem of allMerakiClients) { // Unwrap: in chat shape, the "clients" list items from enrichAll are wrapped {name, meraki: {...client data...}, ...} // In rich shape, they are raw client objects with description/user/mac/ip at top. // Support both so matching works uniformly for MDM and Atlas in both paths. const c = listItem.meraki || listItem; // the inner client data if wrapped const desc = (c.description || listItem.name || '').toUpperCase().trim(); const userField = (c.user || '').toUpperCase().trim(); const clientMac = normalizeMac(c.mac); let matchedThis = false; // Strategy 1: Exact full identifier match (best case) if (desc === identifier || userField === identifier) { matchedThis = true; if (shouldDebug) logger('enrichment:meraki', `✓ Exact match for ${identifier}`); } // Strategy 2: Username / friendly name match if (username && (desc.includes(username) || userField.includes(username))) { matchedThis = true; if (shouldDebug) logger('enrichment:meraki', `✓ Username match for ${identifier}`); } // Strategy 3: Stripped ID match (handles CA001024MSCAE vs 001024MSCAE) if (strippedId && (desc.includes(strippedId) || userField.includes(strippedId))) { matchedThis = true; if (shouldDebug) logger('enrichment:meraki', `✓ Stripped ID match for ${identifier}`); } // Strategy 4: Country + stripped fallback if (countryPrefix && strippedId) { const countryStripped = `${countryPrefix}${strippedId}`; if (desc.includes(countryStripped) || userField.includes(countryStripped)) { matchedThis = true; if (shouldDebug) logger('enrichment:meraki', `✓ Country+stripped match for ${identifier}`); } } // Strategy 5: MAC address fallback (very reliable) // Support various shapes: mdmData.MacAddress (AV), direct .mac or .MacAddress (phones, Webex, etc.) // Also atlas for AMPs (from atlasData or summary in base prepared devices) const deviceMac = device.mdmData?.MacAddress || device.mac || device.MacAddress || atlasMac; if (deviceMac) { const devMac = normalizeMac(deviceMac); if (clientMac && clientMac === devMac) { matchedThis = true; if (shouldDebug) logger('enrichment:meraki', `✓ MAC match for ${identifier}`); } } // Strategy 6: IP address match (for Atlas AMPs that may not expose friendly name in Meraki clients) if (atlasIp) { const clientIp = (c.ip || '').trim().toLowerCase(); if (clientIp && clientIp === atlasIp) { matchedThis = true; if (shouldDebug) logger('enrichment:meraki', `✓ IP match for ${identifier}`); } } if (matchedThis) { // Pick the most recent by lastSeen (Meraki provides lastSeen on client records). // This ensures that when multiple entries exist for the same device (different ports/sessions), // we attach the current/freshest one (e.g. the one on port 35 for VW01). const thisTime = c.lastSeen ? Date.parse(c.lastSeen) || 0 : 0; if (bestMatch === null || thisTime > bestTime) { bestMatch = listItem; bestTime = thisTime; } // Continue scanning to find any even fresher match via other strategies or later records. } } if (bestMatch) { const chosen = bestMatch.meraki || bestMatch; if (shouldDebug || identifier.includes('2477')) { logger('enrichment:meraki', `✓ Most recent Meraki client chosen for ${identifier} (lastSeen=${chosen.lastSeen || 'n/a'}, port=${chosen.switchport || chosen.recentDevicePort || 'n/a'})`); } return bestMatch; // return the list item (wrapped or raw) for consistent downstream handling } if (shouldDebug) { logger('enrichment:meraki', `✗ No match found for ${identifier}`, 'debug'); } return null; } /** * Convenience wrapper that also enriches the device object in place * (for backward compatibility during migration). */ export async function enrichDeviceWithBestMerakiMatch(device, allMerakiClients, portStatusCache) { const match = findBestMerakiClientMatch(device, allMerakiClients); if (!match) { return device; } // Basic enrichment (can be extended later) device.meraki = { ...(device.meraki || {}), client: match, connectionType: match.recentDeviceConnection === 'Wireless' || match.ssid ? 'Wireless' : 'Wired', mac: match.mac, ip: match.ip, // Note: port enrichment can be added here or kept separate }; return device; }