// Decoupled LAN scanner: pure parser + a thin arp-scan runner (exec injected // for tests). The repo/cron own persistence — this module only produces rows. import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; const pexec = promisify(execFile); // A locally-administered (randomized) MAC has bit 0x02 set in its first octet. export function isRandomizedMac(mac) { const first = parseInt(String(mac).split(':')[0], 16); return Number.isFinite(first) && (first & 0x02) === 0x02; } // Keep only "IPMAC[vendor]" lines; ignore banner/footer/garbage. export function parseArpScan(text) { const re = /^(\d{1,3}(?:\.\d{1,3}){3})\s+([0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})\s*(.*)$/; const out = []; for (const line of String(text).split('\n')) { const m = line.match(re); if (!m) continue; const mac = m[2].toLowerCase(); out.push({ ip: m[1], mac, vendor: m[3].trim(), randomized: isRandomizedMac(mac) }); } return out; } // Run arp-scan on the local /24. `exec(file, args) -> {stdout}` injected for tests. export async function runScan({ exec = pexec } = {}) { const { stdout } = await exec('arp-scan', ['--localnet', '--plain', '--retry=2']); return parseArpScan(stdout); }