import fs from 'fs'; import csv from 'csv-parser'; import path from 'path'; import axios from 'axios'; import pLimit from 'p-limit'; import { Parser } from 'json2csv'; var merakiNetworks = []; var config = JSON.parse(fs.readFileSync('./config/config.json')); let lastPortFetchTime = 0; const MIN_DELAY_MS = 150; // ~6–7 calls/sec — safe under Meraki's 10/sec steady limit const MAX_RETRIES = 5; const BASE_BACKOFF_MS = 1000; // 1s, 2s, 4s, 8s, 16s... // Global cache for device info (serial → {model, name, type, ...}) const deviceInfoCache = new Map(); // Assume you have these functions from your previous code // e.g., findMerakiNetworkId(storeNum) → returns network ID // getMerakiClients(networkId) → returns array of clients with description, vlan, switchport, status, etc. async function getMerakiDeviceInfo(serial) { if (!serial || serial === 'N/A') return null; if (deviceInfoCache.has(serial)) { return deviceInfoCache.get(serial); } const url = `https://api.meraki.com/api/v1/devices/${serial}`; for (let attempt = 1; attempt <= 3; attempt++) { try { const response = await fetch(url, { method: 'GET', headers: { 'X-Cisco-Meraki-API-Key': config.auth.meraki.apiKey, 'Content-Type': 'application/json' } }); if (response.status === 429) { let retryAfterSec = 2; const retryAfter = response.headers.get('retry-after'); if (retryAfter) retryAfterSec = parseInt(retryAfter, 10) || 2; console.warn(`429 on device info for ${serial} - wait ${retryAfterSec}s`); await new Promise(r => setTimeout(r, retryAfterSec * 1000 + 300)); continue; } if (!response.ok) { console.warn(`Device info fetch failed for ${serial}: HTTP ${response.status}`); return null; } const info = await response.json(); deviceInfoCache.set(serial, info); return info; } catch (err) { console.warn(`Device info attempt ${attempt} error for ${serial}: ${err.message}`); if (attempt === 3) return null; await new Promise(r => setTimeout(r, 1000 * attempt)); } } return null; } async function throttledGetMerakiSwitchPortConfig(serial, portId) { if (!serial || !portId) { throw new Error('Missing serial or portId for port config fetch'); } const url = `https://api.meraki.com/api/v1/devices/${serial}/switch/ports/${portId}`; // Step 1: Enforce minimum delay between calls const now = Date.now(); const timeSinceLast = now - lastPortFetchTime; if (timeSinceLast < MIN_DELAY_MS) { const waitMs = MIN_DELAY_MS - timeSinceLast; console.log(`Throttling port fetch for ${serial}/${portId} — waiting ${waitMs}ms`); await new Promise(resolve => setTimeout(resolve, waitMs)); } // Update timestamp right before the request (more accurate for burst control) lastPortFetchTime = Date.now(); // Step 2: Attempt the request with retries on 429 and transient errors for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { const response = await fetch(url, { method: 'GET', headers: { 'X-Cisco-Meraki-API-Key': config.auth.meraki.apiKey, 'Content-Type': 'application/json' } }); // Handle 429 specifically if (response.status === 429) { let retryAfterSec = 2; // default fallback // Respect Retry-After header if present const retryAfterHeader = response.headers.get('retry-after'); if (retryAfterHeader) { const parsed = parseInt(retryAfterHeader, 10); if (!isNaN(parsed) && parsed > 0) { retryAfterSec = parsed; } } console.warn( `429 Too Many Requests on ${serial}/${portId} ` + `(attempt ${attempt}/${MAX_RETRIES}) — waiting ${retryAfterSec}s` ); await new Promise(resolve => setTimeout(resolve, retryAfterSec * 1000 + 200)); // +200ms jitter continue; // retry } if (!response.ok) { const errorText = await response.text().catch(() => 'Unknown error'); throw new Error(`Meraki API failed (${response.status}): ${errorText}`); } const data = await response.json(); console.log(`Successfully fetched port config for ${serial}/${portId}`); return data; } catch (err) { console.error( `Port fetch error for ${serial}/${portId} (attempt ${attempt}/${MAX_RETRIES}): ${err.message}` ); if (attempt === MAX_RETRIES) { throw new Error( `Failed to fetch port ${portId} on switch ${serial} after ${MAX_RETRIES} attempts: ${err.message}` ); } // Exponential backoff for non-429 errors too (e.g., 500s, network blips) const backoffMs = BASE_BACKOFF_MS * Math.pow(2, attempt - 1) + Math.random() * 200; // jitter console.log(`Backing off for ${Math.round(backoffMs)}ms before retry ${attempt + 1}`); await new Promise(resolve => setTimeout(resolve, backoffMs)); } } // Should never reach here due to throw on last attempt throw new Error('Unexpected exit from retry loop'); } // Helper: Fetch a single switch port configuration from Meraki API async function getMerakiSwitchPortConfig(serial, portId) { if (!serial || !portId) { throw new Error('Missing serial or portId for port config fetch'); } const url = `https://api.meraki.com/api/v1/devices/${serial}/switch/ports/${portId}`; try { const response = await fetch(url, { method: 'GET', headers: { 'X-Cisco-Meraki-API-Key': config.auth.meraki.apiKey, 'Content-Type': 'application/json' } }); if (!response.ok) { const errorText = await response.text().catch(() => 'Unknown error'); throw new Error(`Meraki API failed (${response.status}): ${errorText}`); } return await response.json(); } catch (err) { throw new Error(`Failed to fetch port ${portId} on switch ${serial}: ${err.message}`); } } // Helper: Fetch all clients for a network (with basic pagination support) async function getMerakiClients(networkId) { let allClients = []; let startingAfter = null; const perPage = 1000; do { let url = `https://api.meraki.com/api/v1/networks/${networkId}/clients?perPage=${perPage}`; if (startingAfter) { url += `&startingAfter=${encodeURIComponent(startingAfter)}`; } for (let attempt = 1; attempt <= 5; attempt++) { try { const response = await fetch(url, { method: 'GET', headers: { 'X-Cisco-Meraki-API-Key': config.auth.meraki.apiKey, 'Content-Type': 'application/json' } }); if (response.status === 429) { let retryAfterSec = 2; const retryAfterHeader = response.headers.get('retry-after'); if (retryAfterHeader) { const parsed = parseInt(retryAfterHeader, 10); if (!isNaN(parsed) && parsed > 0) retryAfterSec = parsed; } console.warn(`429 on clients page for ${networkId} (attempt ${attempt}) - waiting ${retryAfterSec}s`); await new Promise(r => setTimeout(r, retryAfterSec * 1000 + 300)); // jitter continue; } if (!response.ok) { throw new Error(`Clients fetch failed: ${response.status}`); } const clients = await response.json(); allClients = allClients.concat(clients); startingAfter = clients.length === perPage ? clients[clients.length - 1].id : null; break; // success → next page } catch (err) { if (attempt === 5) throw err; const backoff = 1000 * Math.pow(2, attempt - 1) + Math.random() * 500; console.log(`Retry backoff ${Math.round(backoff)}ms for ${networkId}`); await new Promise(r => setTimeout(r, backoff)); } } } while (startingAfter); return allClients; } async function getAllMerakiSwitchPorts(serial) { if (!serial || serial === 'N/A') return []; // Check if it's actually a switch const device = await getMerakiDeviceInfo(serial); if (!device || !device.model?.startsWith('MS')) { const model = device?.model || 'unknown'; console.log(`Skipping ports fetch for non-switch device ${serial} (model: ${model})`); return []; } console.log(`Fetching ports for confirmed switch ${serial} (model: ${device.model})`); const url = `https://api.meraki.com/api/v1/devices/${serial}/switch/ports`; for (let attempt = 1; attempt <= 3; attempt++) { try { const response = await fetch(url, { method: 'GET', headers: { 'X-Cisco-Meraki-API-Key': config.auth.meraki.apiKey, 'Content-Type': 'application/json' } }); if (response.status === 429) { let retryAfterSec = 2; const retryAfter = response.headers.get('retry-after'); if (retryAfter) retryAfterSec = parseInt(retryAfter, 10) || 2; console.warn(`429 on ports fetch for ${serial} - wait ${retryAfterSec}s`); await new Promise(r => setTimeout(r, retryAfterSec * 1000 + 300)); continue; } if (!response.ok) { const errText = await response.text().catch(() => 'Unknown'); throw new Error(`HTTP ${response.status}: ${errText}`); } const ports = await response.json(); console.log(`Fetched ${ports.length} ports for switch ${serial}`); return ports; } catch (err) { console.warn(`Ports fetch attempt ${attempt} failed for ${serial}: ${err.message}`); if (attempt === 3) return []; await new Promise(r => setTimeout(r, 1000 * attempt)); } } return []; } // Main function – now outputs CSV directly async function processCSVAndEnrichMeraki(csvFilePath, outputCsvPath = 'enriched-meraki-output.csv') { const results = []; // 1. Parse input CSV await new Promise((resolve, reject) => { fs.createReadStream(csvFilePath) .pipe(csv()) .on('data', row => results.push(row)) .on('end', () => { console.log(`Input CSV parsed - ${results.length} rows`); resolve(); }) .on('error', err => reject(err)); }); // 2. Cache networks if not already done if (merakiNetworks.length === 0) { await cacheMerakiNetworks(); } // 3. Build unique store → network map const storeToNetwork = new Map(); const limit = pLimit(4); const networkPromises = results.map(row => { const friendlyName = row.device_friendly_name?.trim() || ''; if (!friendlyName) return Promise.resolve(); const storeNum = friendlyName.match(/(\d{6})/)?.[0]; if (!storeNum || storeToNetwork.has(storeNum)) return Promise.resolve(); return limit(() => findMerakiNetworkId(storeNum) .then(netId => { if (netId) storeToNetwork.set(storeNum, netId); }) .catch(() => { }) ); }); await Promise.all(networkPromises); console.log(`Found ${storeToNetwork.size} unique networks`); // 4. Pre-fetch clients per network const networkClientCache = new Map(); const clientPromises = [...storeToNetwork.entries()].map(([storeNum, netId]) => limit(async () => { try { const clients = await getMerakiClients(netId); networkClientCache.set(netId, clients); console.log(`Clients fetched: ${clients.length} for store ${storeNum}`); } catch (err) { console.error(`Clients fetch failed for store ${storeNum}: ${err.message}`); networkClientCache.set(netId, []); } }) ); await Promise.all(clientPromises); // 5. Enrich rows – collect unique serials first const enrichedRows = results.map(row => ({ ...row, meraki: null, error: null })); const serialToPorts = new Map(); const uniqueSerials = new Set(); // Phase A: Match clients & collect serials for (let i = 0; i < results.length; i++) { const row = results[i]; const friendlyName = row.device_friendly_name?.trim() || ''; if (!friendlyName) { enrichedRows[i].error = 'Missing friendly name'; continue; } if (match.recentDeviceConnection !== 'Wired' || !match.switchport) { enrichedRows[i].error = 'Wireless or non-switch connected client'; continue; } const storeNum = friendlyName.match(/(\d{6})/)?.[0]; if (!storeNum) continue; const netId = storeToNetwork.get(storeNum); if (!netId) continue; const clients = networkClientCache.get(netId) || []; const match = clients.find(c => c.description?.toUpperCase().trim() === friendlyName.toUpperCase().trim() ); if (!match) { enrichedRows[i].error = 'No matching Meraki client'; continue; } const merakiInfo = { deviceName: match.recentDeviceName || 'N/A', deviceSerial: match.recentDeviceSerial || 'N/A', switchport: match.switchport || 'N/A', vlan: match.vlan || 'N/A', status: match.status || 'Unknown', connection: match.recentDeviceConnection || 'N/A', lastSeen: match.lastSeen || 'N/A', ip: match.ip || 'N/A', mac: match.mac || 'N/A' }; enrichedRows[i].meraki = merakiInfo; if (merakiInfo.deviceSerial !== 'N/A') { uniqueSerials.add(merakiInfo.deviceSerial); } } // Phase B: Pre-fetch all ports for unique switches if (uniqueSerials.size > 0) { console.log(`Fetching ports for ${uniqueSerials.size} unique switches...`); const portPromises = [...uniqueSerials].map(serial => limit(async () => { const ports = await getAllMerakiSwitchPorts(serial); serialToPorts.set(serial, ports); }) ); await Promise.all(portPromises); } // Phase C: Final enrichment from cache for (const row of enrichedRows) { const meraki = row.meraki; if (!meraki || meraki.deviceSerial === 'N/A' || meraki.switchport === 'N/A') { if (meraki) { Object.assign(meraki, { portType: 'N/A', accessPolicyType: 'N/A', stickyMacEnabled: false, stickyMacList: [], stickyMacAllowListLimit: 0, stickyMacCount: 0, portEnabled: 'N/A' }); } continue; } const ports = serialToPorts.get(meraki.deviceSerial) || []; const portConfig = ports.find(p => String(p.port || p.portId || p.number) === String(meraki.switchport) ); if (portConfig) { meraki.portType = portConfig.type || 'N/A'; meraki.accessPolicyType = portConfig.accessPolicyType || 'N/A'; meraki.stickyMacEnabled = portConfig.accessPolicyType === 'Sticky MAC allow list'; meraki.stickyMacList = portConfig.stickyMacAllowList || []; meraki.stickyMacAllowListLimit = portConfig.stickyMacAllowListLimit || 0; meraki.stickyMacCount = meraki.stickyMacList.length; meraki.portEnabled = portConfig.enabled ? 'Enabled' : 'Disabled'; } else { meraki.portType = 'Not Found'; meraki.portEnabled = 'Not Found'; } } // 6. Convert enriched data → CSV const flattened = enrichedRows.map(item => { const m = item.meraki || {}; const stickyList = Array.isArray(m.stickyMacList) ? m.stickyMacList.join(', ') : ''; return { device_id: item.device_id || '', device_friendly_name: item.device_friendly_name || '', device_enrollment_user_name: item.device_enrollment_user_name || '', _device_platform: item._device_platform || '', device_os_version: item.device_os_version || '', device_last_seen_utc: item.device_last_seen_utc || '', device_enrollment_status: item.device_enrollment_status || '', device_model_name: item.device_model_name || '', _device_mac_address: item._device_mac_address || '', device_mac_address: item.device_mac_address || '', meraki_deviceName: m.deviceName || '', meraki_deviceSerial: m.deviceSerial || '', meraki_switchport: m.switchport || '', meraki_vlan: m.vlan || '', meraki_status: m.status || '', meraki_connection: m.connection || '', meraki_lastSeen: m.lastSeen || '', meraki_ip: m.ip || '', meraki_mac: m.mac || '', meraki_portType: m.portType || 'N/A', meraki_accessPolicyType: m.accessPolicyType || 'N/A', meraki_portEnabled: m.portEnabled || 'N/A', meraki_stickyMacEnabled: m.stickyMacEnabled === true ? 'true' : 'false', meraki_stickyMacList: stickyList, meraki_stickyMacAllowListLimit: m.stickyMacAllowListLimit ?? '', meraki_stickyMacCount: m.stickyMacCount ?? '', error: item.error || '' }; }); const fields = [ 'device_id', 'device_friendly_name', 'device_enrollment_user_name', '_device_platform', 'device_os_version', 'device_last_seen_utc', 'device_enrollment_status', 'device_model_name', '_device_mac_address', 'device_mac_address', 'meraki_deviceName', 'meraki_deviceSerial', 'meraki_switchport', 'meraki_vlan', 'meraki_status', 'meraki_connection', 'meraki_lastSeen', 'meraki_ip', 'meraki_mac', 'meraki_portType', 'meraki_accessPolicyType', 'meraki_portEnabled', 'meraki_stickyMacEnabled', 'meraki_stickyMacList', 'meraki_stickyMacAllowListLimit', 'meraki_stickyMacCount', 'error' ]; const parser = new Parser({ fields }); const csvContent = parser.parse(flattened); fs.writeFileSync('enriched-meraki-intermediate.json', JSON.stringify(enrichedRows, null, 2)); fs.writeFileSync(outputCsvPath, csvContent); console.log(`Enrichment complete. Output CSV saved to: ${outputCsvPath}`); console.log(`Processed ${enrichedRows.length} rows`); return enrichedRows; // optional – for further chaining if needed } async function findMerakiNetworkId(storeNum) { var startTime = new Date().getTime(); var networkSearchTerm = Number(storeNum).toString().padStart(5, "0"); try { // Filter networks by partial name match (case-insensitive) const matchingNetworks = merakiNetworks.filter(net => (net.name || '').toLowerCase().includes(networkSearchTerm.toLowerCase()) ); if (matchingNetworks.length === 0) { return null; } if (matchingNetworks.length > 1) { console.log(`Multiple networks match '${networkSearchTerm}':`); matchingNetworks.forEach(net => console.log(`- ${net.name} (ID: ${net.id})`)); console.log('Using the first match...'); } return matchingNetworks[0].id; } catch (error) { console.error(`Error finding Meraki Network: ${error}`); return null; } } async function cacheMerakiNetworks() { try { let allNetworks = []; let nextUrl = `https://api.meraki.com/api/v1/organizations/${config.auth.meraki.orgId}/networks?perPage=1000`; while (nextUrl) { const response = await axios.get(nextUrl, { headers: { 'X-Cisco-Meraki-API-Key': config.auth.meraki.apiKey, 'Content-Type': 'application/json' } }); const pageNetworks = response.data; allNetworks = allNetworks.concat(pageNetworks); //logger(`cacheMerakiNetworks()`, `Fetched ${pageNetworks.length} networks (total so far: ${allNetworks.length})`); // Log raw header //logger(`cacheMerakiNetworks()`, `Link header raw: ${response.headers.link || '(none)'}`); // Robust next URL extraction const linkHeader = response.headers.link; let foundNext = null; if (linkHeader) { //logger(`cacheMerakiNetworks()`, `Full Link header (raw): ${linkHeader}`); const parts = linkHeader.split(','); //logger(`cacheMerakiNetworks()`, `Split into ${parts.length} parts`); for (const part of parts) { const trimmed = part.trim(); //logger(`cacheMerakiNetworks()`, `Examining part: "${trimmed}"`); // Forgiving checks: lower case, no quotes required, partial match const lowerTrimmed = trimmed.toLowerCase(); if (lowerTrimmed.includes('rel=next') || lowerTrimmed.includes('rel="next"') || lowerTrimmed.includes("rel='next'")) { //logger(`cacheMerakiNetworks()`, `→ Detected rel=next in: "${trimmed}"`); const urlMatch = trimmed.match(/<([^>]+)>/); if (urlMatch && urlMatch[1]) { foundNext = urlMatch[1].trim(); // extra trim just in case //logger(`cacheMerakiNetworks()`, `→ Extracted next URL: ${foundNext}`); break; } else { //logger(`cacheMerakiNetworks()`, `→ URL match failed on that part`); } } } } nextUrl = foundNext; if (!nextUrl) { //logger(`cacheMerakiNetworks()`, `No next page detected – ending loop`); } else { //logger(`cacheMerakiNetworks()`, `Advancing to next URL: ${nextUrl}`); } } merakiNetworks = allNetworks; logger(`cacheMerakiNetworks()`, `Cached ${merakiNetworks.length} networks. (${new Date().getTime() - startTime}ms)`); } catch (error) { console.error('Meraki Networks API error:', error.message); if (error.response) { console.error('Status:', error.response.status); console.error('Data:', error.response.data); } } } async function rebootWorkspaceOneDevice(deviceId) { const baseUrl = `https://${awHost}/api`; // e.g., as123.awmdm.com const headers = { 'Authorization': `Basic ${Buffer.from('your_username:your_password').toString('base64')}`, // or use API key method 'aw-tenant-code': tenantCode, 'Accept': 'application/json', 'Content-Type': 'application/json' }; try { const response = await axios.post( `${baseUrl}/mdm/devices/${deviceId}/commands`, { Command: 'RebootDevice' }, // or 'RestartDevice' for iOS/tvOS { headers } ); console.log('Reboot command sent successfully:', response.data); return response.data; } catch (error) { console.error('Error sending reboot:'); console.error(error.response?.data || error.message); throw error; } } /* async function updateSwitchPort(networkId, serial, portId, settings) { const apiKey = 'YOUR_MERAKI_API_KEY_HERE'; const baseUrl = 'https://api.meraki.com/api/v1'; try { const response = await axios.put( `${baseUrl}/networks/${networkId}/devices/${serial}/switch/ports/${portId}`, settings, { headers: { 'X-Cisco-Meraki-API-Key': apiKey, 'Content-Type': 'application/json' } } ); console.log('Success:', response.data); return response.data; } catch (error) { console.error('Error updating port:'); console.error(error.response?.data || error.message); throw error; } } // Usage examples updateSwitchPort('L_123456789012345678', 'Q3LU-ABCDE-12345', '8', false); // Disable port 8 // updateSwitchPort('L_123456789012345678', 'Q3LU-ABCDE-12345', '8', true); // Enable port 8*/ function logger(functionName, message) { var d = new Date(); var year = d.getFullYear(); var month = (d.getMonth() + 1).toString().padStart(2, "0"); var day = d.getDate().toString().padStart(2, "0"); let logFile = path.join(`./logs/${year}${month}${day}.log`); console.log(d.toLocaleString() + " " + functionName + ": " + message); fs.appendFileSync(logFile, d.toLocaleString() + " " + functionName + ": " + message + "\n"); } async function sendMdmQuery(deviceId, apiKey, tenantCode, awHost) { const baseUrl = `https://${awHost}/api`; // e.g., as1991.awmdm.com const headers = { 'Authorization': `Basic ${Buffer.from('your_username:your_password').toString('base64')}`, // or API key method 'aw-tenant-code': tenantCode, 'Accept': 'application/json', 'Content-Type': 'application/json' }; try { const response = await axios.post( `${baseUrl}/mdm/devices/${deviceId}/commands`, { Command: 'QueryDevice' }, { headers } ); console.log('Query command sent successfully:', response.data); return response.data; } catch (error) { console.error('Error sending MDM Query:'); if (error.response) { console.error('Status:', error.response.status); console.error('Response:', error.response.data); } else { console.error(error.message); } throw error; } } // Usage example const csvFile = 'appleTV.csv'; const inputCsv = 'appleTV.csv'; await processCSVAndEnrichMeraki(inputCsv, 'enriched-meraki-output.csv') .catch(err => console.error('Fatal error:', err));