diff --git a/.env.example b/.env.example index 726c6e60..f166dacf 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,6 @@ IPINFO_API_KEY="" CLOUDFLARE_API_KEY="" IPAPIIS_API_KEY="" IP2LOCATION_API_KEY="" -MAC_LOOKUP_API_KEY="" RIPESTAT_SOURCE_APP="" # Shareable diagnostic reports — stored in Cloudflare Workers KV. Both values # below must be set (together with CLOUDFLARE_API_KEY, which additionally diff --git a/.gitignore b/.gitignore index b56f2d3a..8dc3872c 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,10 @@ common/peeringdb-db/.caida-update-state.json common/peeringdb-db/.caida-update.lock common/peeringdb-db/*.bak common/peeringdb-db/*.next +# IEEE MAC registries — downloaded at boot, refreshed daily. +common/oui-db/*.csv +common/oui-db/*.bak +common/oui-db/*.next # Dataset updater (common/dataset-updater.js): state (+ its atomic-write # temp files) and the lock directory, in every dataset directory. common/*-db/.dataset-state.json* diff --git a/api/AGENTS.md b/api/AGENTS.md index 158568df..797a1e4c 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -42,7 +42,7 @@ behind `dns-resolver` (gated by `tests/dns-resolvers-data.test.js`). no prefix or relationship counts — the ASN Profile has both. - **`/api/whois` `?q=`:** IPs (RDAP, whoiser fallback), domains (whoiser, RDAP fallback), `AS` (RDAP autnum); all carry a `__raw` block. -- **Offline datasets** (MaxMind, CAIDA, PeeringDB) are rows in +- **Offline datasets** (MaxMind, CAIDA, PeeringDB, IEEE MAC registries) are rows in `common/datasets.js`, run by one engine, `common/dataset-updater.js` (lock, atomic state, publish, boot download, daily schedule). A row holds only its own logic — find the newest remote @@ -56,7 +56,9 @@ behind `dns-resolver` (gated by `tests/dns-resolvers-data.test.js`). reloaded by the engine's watcher. MaxMind only with its credentials (City + ASN publish together); PeeringDB only with a Cloudflare key; its 116 MB dump is stream-distilled (never parsed whole) to a ~6 MB per-ASN index without - contact data (`common/peeringdb-distill.js`). + contact data (`common/peeringdb-distill.js`). The IEEE row (MAC Lookup) + needs no key: five registry CSVs published as downloaded, answered by the + longest covering assignment (`common/oui-db.js`). ## Conventions diff --git a/api/mac-checker.js b/api/mac-checker.js index 70a3bd86..057e11ca 100644 --- a/api/mac-checker.js +++ b/api/mac-checker.js @@ -1,68 +1,29 @@ -import { fetchUpstream } from '../common/fetch-with-timeout.js'; -import logger from '../common/logger.js'; +// /api/macchecker?mac= — MAC Lookup against the local IEEE registries +// (common/oui-db.js). `mac` is a full address or a vendor prefix of 6–12 +// hex digits (common/mac-input.js). The answer names the block that covers +// it, its range and the first-octet flags; an address no block covers is +// still a 200 with `found: false`. -// A canonical MAC address is 48 bits = 12 hex chars. Accepting shorter -// strings let the upstream API receive partial prefixes and return -// undefined behavior; tighten to exactly 12 hex chars. -const isValidMAC = (address) => { - const normalizedAddress = address.replace(/[:-]/g, ''); - return normalizedAddress.length === 12 && /^[0-9A-Fa-f]+$/.test(normalizedAddress); -} +import { normalizeMacQuery } from '../common/mac-input.js'; +import { lookupMac } from '../common/oui-db.js'; -export default async (req, res) => { - // Get IP address from request - let macAddress = req.query.mac; - if (!macAddress) { +// `lookup` is injectable so tests can run the handler over fixture registries. +export const createMacChecker = (lookup) => async (req, res) => { + const { mac } = req.query; + if (!mac) { return res.status(400).json({ error: 'No MAC address provided' }); - } else { - macAddress = macAddress.replace(/:/g, '').replace(/-/g, ''); } - - // Check if address is valid - if (!isValidMAC(macAddress)) { + const hex = normalizeMacQuery(String(mac)); + if (!hex) { return res.status(400).json({ error: 'Invalid MAC address' }); } - - const token = process.env.MAC_LOOKUP_API_KEY || ''; - - const url_hasToken = `https://api.maclookup.app/v2/macs/${macAddress}?apiKey=${token}`; - const url_noToken = `https://api.maclookup.app/v2/macs/${macAddress}`; - const url = token ? url_hasToken : url_noToken; - - try { - const apiRes = await fetchUpstream(url); - const json = await apiRes.json(); - if (json.success !== true) { - return res.json({ success: false, error: json.error || 'Data not found' }); - } - res.json(modifyData(json)); - } catch (e) { - logger.error({ err: e, mac: macAddress }, 'mac-checker handler failed'); - res.status(500).json({ error: e.message }); + // Past the boot window (requireOfflineData) only a failed download leaves this empty. + const result = lookup(hex); + if (!result) { + return res.status(503).json({ error: 'MAC database unavailable' }); } + res.json(result); }; - -const modifyData = (data) => { - // Check if address is unicast/multicast and local/global - const firstByte = parseInt(data.macPrefix.substring(0, 2), 16); - const isMulticast = (firstByte & 0x01) === 0x01; - const isLocal = (firstByte & 0x02) === 0x02; - - data.isMulticast = isMulticast ? true : false; - data.isLocal = isLocal ? true : false; - data.isGlobal = !isLocal ? true : false; - data.isUnicast = !isMulticast ? true : false; - data.macPrefix = data.macPrefix ? data.macPrefix.match(/.{1,2}/g).join(':') : 'N/A'; - data.company = data.company ? data.company : 'N/A'; - data.country = data.country ? data.country : 'N/A'; - data.address = data.address ? data.address : 'N/A'; - data.updated = data.updated ? data.updated : 'N/A'; - data.blockStart = data.blockStart ? data.blockStart.match(/.{1,2}/g).join(':') : 'N/A'; - data.blockEnd = data.blockEnd ? data.blockEnd.match(/.{1,2}/g).join(':') : 'N/A'; - data.blockSize = data.blockSize ? data.blockSize : 'N/A'; - data.blockType = data.blockType ? data.blockType : 'N/A'; - - return data; -} \ No newline at end of file +export default createMacChecker(lookupMac); diff --git a/backend-server.js b/backend-server.js index 5dbd1b92..87d4df7a 100644 --- a/backend-server.js +++ b/backend-server.js @@ -54,6 +54,7 @@ import { } from './common/service-status-store.js'; import { isAsRelLoaded } from './common/as-rel-db.js'; import { isAsOrgLoaded } from './common/as-org-db.js'; +import { isOuiLoaded } from './common/oui-db.js'; import { runOfflineBootstrap, requireOfflineData } from './common/offline-data.js'; import { initUpstreamUserAgent } from './common/upstream-ua.js'; @@ -252,6 +253,7 @@ app.use('/api', requireReferer); // 503 while that data is still downloading at boot, a no-op afterwards. const needsMaxMind = requireOfflineData([isMaxMindReady]); const needsAsGraph = requireOfflineData([isAsRelLoaded, isAsOrgLoaded]); +const needsOui = requireOfflineData([isOuiLoaded]); const needsServiceStatus = requireOfflineData([isServiceStatusPrimed]); const FIVE_MIN_CACHE = 5 * 60; @@ -302,7 +304,7 @@ app.get('/api/cfradar', cacheable((req) => RADAR_VIEWS[req.query.view]?.ttl, { c // and append-only BGP routing history. app.get('/api/asn-history', requireValidPrefix(), cacheable(THIRTY_DAYS_CACHE), asnHistoryHandler); app.get('/api/asn-connectivity', requireValidASN(), needsAsGraph, cacheable(THIRTY_DAYS_CACHE), asnConnectivityHandler); -app.get('/api/macchecker', cacheable(THIRTY_DAYS_CACHE), macChecker); +app.get('/api/macchecker', needsOui, cacheable(THIRTY_DAYS_CACHE), macChecker); // Long Cache app.get('/api/map', cacheable(ONE_YEAR_CACHE), mapHandler); // Non-cacheable routes — auth-context, debug tools, or per-request lookups. @@ -352,11 +354,12 @@ if (process.env.SENTRY_DSN_BACKEND) { // Boot sequence. Snapshots already on disk are in memory before the listener -// opens (CAIDA / PeeringDB load at import, MaxMind just below), so a restart -// serves at once. Missing ones download behind the listener: meanwhile only -// the routes reading them answer 503 (requireOfflineData above). A failed -// download stays non-fatal — that route then serves degraded (MaxMind → 503; -// CAIDA → empty graph, RIPEstat fallback or no peering section). +// opens (CAIDA / PeeringDB / IEEE load at import, MaxMind just below), so a +// restart serves at once. Missing ones download behind the listener: +// meanwhile only the routes reading them answer 503 (requireOfflineData +// above). A failed download stays non-fatal — that route then serves degraded +// (MaxMind / IEEE → 503; CAIDA → empty graph, RIPEstat fallback or no +// peering section). const bootBackend = async () => { const { cityDbPath, asnDbPath } = getMaxMindDbPaths(); if (fs.existsSync(cityDbPath) && fs.existsSync(asnDbPath)) { diff --git a/common/dataset-updater.js b/common/dataset-updater.js index 832b2644..4db70586 100644 --- a/common/dataset-updater.js +++ b/common/dataset-updater.js @@ -120,6 +120,10 @@ const writeState = (row, state) => // The published files as this process last loaded them (mtime + size per // file), so the watcher can tell someone else's publish from its own. const loadedFingerprints = new Map(); +// Rows this process is publishing right now: from the first file move until +// the reload has recorded the new fingerprints, the watcher must not take +// the changing files for someone else's — it defers its check instead. +const ownPublishes = new Set(); const fingerprint = (row) => row.files.map((file) => { try { const stat = fs.statSync(path.join(row.dir, file)); @@ -253,6 +257,7 @@ export const updateDataset = async (row, { signal, reason = 'auto update', wait // publishes a whole set, and the next boot does so before trusting // the files. work.throwIfAborted(); + ownPublishes.add(row.id); await writeState(row, { ...state, identifier: null, publishing: true }); await publish(row, staged, { signal: work, lockLost: lock.lost }); // A whole set is in place: record it unless the lock went meanwhile. @@ -262,6 +267,7 @@ export const updateDataset = async (row, { signal, reason = 'auto update', wait await reloadRow(row, reason); return { updated: true, identifier: remote.identifier }; } finally { + ownPublishes.delete(row.id); await fsp.rm(tempDir, { recursive: true, force: true }); await lock.release().catch(() => {}); } @@ -443,6 +449,13 @@ export const watchDatasets = (rows, { intervalMs = 5000, settleMs = 1000 } = {}) timer.unref?.(); return; } + // This process's own publish records its files once reloaded; + // look again after it, so a change made meanwhile still counts. + if (ownPublishes.has(row.id)) { + timer = setTimeout(settle, settleMs); + timer.unref?.(); + return; + } if (current === loadedFingerprints.get(row.id) || !isPresent(row)) return; const published = Object.fromEntries(row.files.map((file) => [file, path.join(row.dir, file)])); try { diff --git a/common/datasets.js b/common/datasets.js index 70d01c39..86618424 100644 --- a/common/datasets.js +++ b/common/datasets.js @@ -11,9 +11,12 @@ // peeringdb PeeringDB dump (CAIDA mirror), common/peeringdb-db.js // distilled to a per-ASN index; only with the Cloudflare key, // as the ASN Profile is hidden without it +// oui IEEE MAC registries (MA-L / MA-M common/oui-db.js +// / MA-S / IAB / CID), published together as downloaded // // Sources: https://dev.maxmind.com/geoip/geolite2-free-geolocation-data · -// https://publicdata.caida.org/datasets/ (CAIDA AUA) · https://www.peeringdb.com +// https://publicdata.caida.org/datasets/ (CAIDA AUA) · https://www.peeringdb.com · +// https://standards.ieee.org/products-programs/regauth/ import fsp from 'fs/promises'; import path from 'path'; @@ -28,6 +31,7 @@ import { PEERINGDB_DB_DIR, PEERINGDB_FILE, readPeeringdbIndex, reloadPeeringdbDa import { distillPeeringdbDump } from './peeringdb-distill.js'; import { hasRadarApiKey } from './cf-radar.js'; import { MAXMIND_DB_DIR, MAXMIND_CITY_DB, MAXMIND_ASN_DB, reloadMaxMindDatabases, isMaxMindReady } from './maxmind-service.js'; +import { OUI_DB_DIR, OUI_REGISTRIES, OUI_FILES, readOuiRegistries, reloadOuiDatabase, isOuiLoaded } from './oui-db.js'; // The pre-engine CAIDA updater's state: same { identifier, updatedAt } shape. const CAIDA_LEGACY_STATE = { file: '.caida-update-state.json', toState: (json) => json }; @@ -41,6 +45,13 @@ const fetchCompressed = (file, format) => async ({ remote, tempDir, signal }) => return { [file]: staged }; }; +// Last-Modified of each file a row publishes together, joined: any one +// changing is a new version. null when a file doesn't say — an unknown +// version, always fetched rather than taken for unchanged. Exported for +// tests (and the legacy MaxMind state mapping below). +export const joinedIdentifier = (lastModifieds) => + (lastModifieds.every(Boolean) ? lastModifieds.join(' | ') : null); + // ---------- validators ---------- // Healthy as2org resolves ~110k ASNs; <50k indicates truncation or schema change. @@ -129,18 +140,11 @@ const maxmindAuth = () => ({ Authorization: `Basic ${Buffer.from(`${process.env.MAXMIND_ACCOUNT_ID}:${process.env.MAXMIND_LICENSE_KEY}`).toString('base64')}`, }); -// Last-Modified of each edition, joined: either edition changing is a new -// version. null when an edition doesn't say — an unknown version, always -// fetched rather than taken for unchanged. Exported for tests (and the -// legacy-state mapping below). -export const maxmindIdentifier = (lastModifieds) => - (lastModifieds.every(Boolean) ? lastModifieds.join(' | ') : null); - // The pre-engine MaxMind updater kept { [editionId]: { lastModified, updatedAt } }. const MAXMIND_LEGACY_STATE = { file: '.maxmind-update-state.json', toState: (json) => ({ - identifier: maxmindIdentifier(MAXMIND_EDITIONS.map(({ editionId }) => json[editionId]?.lastModified)), + identifier: joinedIdentifier(MAXMIND_EDITIONS.map(({ editionId }) => json[editionId]?.lastModified)), updatedAt: json['GeoLite2-City']?.updatedAt, }), }; @@ -176,7 +180,7 @@ export const datasets = [ if (!res.ok) throw new Error(`Failed to check ${editionId}: HTTP ${res.status}`); lastModifieds.push(res.headers.get('last-modified')); } - return { identifier: maxmindIdentifier(lastModifieds) }; + return { identifier: joinedIdentifier(lastModifieds) }; }, fetch: async ({ tempDir, signal }) => { const staged = {}; @@ -270,4 +274,34 @@ export const datasets = [ reload: reloadPeeringdbDatabase, isLoaded: isPeeringdbLoaded, }, + { + id: 'oui', + dir: OUI_DB_DIR, + files: OUI_FILES, + // IEEE regenerates the files daily; a file's Last-Modified is its version. + findRemote: async ({ signal } = {}) => { + const lastModifieds = []; + for (const { file, url } of OUI_REGISTRIES) { + const res = await fetchUpstream(url, { method: 'HEAD', signal }); + if (!res.ok) throw new Error(`Failed to check ${file}: HTTP ${res.status}`); + lastModifieds.push(res.headers.get('last-modified')); + } + return { identifier: joinedIdentifier(lastModifieds) }; + }, + // Plain GETs: the IEEE server rejects ranged requests. + fetch: async ({ tempDir, signal }) => { + const staged = {}; + for (const { file, url } of OUI_REGISTRIES) { + staged[file] = path.join(tempDir, file); + await downloadToFile(url, staged[file], { signal }); + } + return staged; + }, + // Every file must parse as its registry, above its row floor. + validate: async (staged) => { + readOuiRegistries((file) => staged[file]); + }, + reload: reloadOuiDatabase, + isLoaded: isOuiLoaded, + }, ]; diff --git a/common/mac-input.js b/common/mac-input.js new file mode 100644 index 00000000..202bc211 --- /dev/null +++ b/common/mac-input.js @@ -0,0 +1,22 @@ +// MAC Lookup query parsing, shared by the /api/macchecker handler and the +// MacChecker form (via frontend/utils/mac-input.js). A query is a full +// 48-bit address or any leading part of one, down to a 24-bit vendor prefix, +// in the usual notations: colon / hyphen pairs, Cisco dotted quads, or bare +// hex. + +export const MAC_HEX_LENGTH = 12; +export const MAC_PREFIX_MIN_HEX = 6; + +// Separators accepted between hex digits: `:` `-` `.` and whitespace. +const SEPARATORS = /[\s:.-]/g; + +/** + * Upper-case bare hex of a full address or a prefix (6–12 hex digits), or + * null for anything else. + */ +export const normalizeMacQuery = (input) => { + if (typeof input !== 'string') return null; + const hex = input.replace(SEPARATORS, '').toUpperCase(); + if (hex.length < MAC_PREFIX_MIN_HEX || hex.length > MAC_HEX_LENGTH) return null; + return /^[0-9A-F]+$/.test(hex) ? hex : null; +}; diff --git a/common/offline-data.js b/common/offline-data.js index b5ce69e4..17d82e31 100644 --- a/common/offline-data.js +++ b/common/offline-data.js @@ -1,4 +1,4 @@ -// Boot-time readiness of the offline datasets (MaxMind, CAIDA, PeeringDB) and +// Boot-time readiness of the offline datasets (common/datasets.js) and // the primed service-status snapshot. The backend listens before any of them // is downloaded; runOfflineBootstrap marks that window, and requireOfflineData // answers 503 on a route whose data is still missing inside it. Outside the @@ -6,8 +6,8 @@ // A response assembled from independent parts (the ASN Profile) instead asks // isStillLoading per part and leaves just that part out, uncached. // -// Snapshots already on disk are loaded before the listener opens (CAIDA and -// PeeringDB at import, MaxMind in backend-server.js), so an ordinary restart +// Snapshots already on disk are loaded before the listener opens (CAIDA, +// PeeringDB and IEEE at import, MaxMind in backend-server.js), so a restart // never hits the gate — only a first boot or a newly added dataset does. let booting = false; diff --git a/common/oui-db.js b/common/oui-db.js new file mode 100644 index 00000000..ac1601a4 --- /dev/null +++ b/common/oui-db.js @@ -0,0 +1,259 @@ +// Local IEEE MAC address block lookup for /api/macchecker. common/datasets.js +// downloads the five public IEEE registries as published (CSV, one per +// block size); this module parses them into one map keyed by assignment +// hex and answers the longest assignment covering a query. +// +// MA-L oui.csv 24-bit vendor prefix (the classic OUI) +// MA-M mam.csv 28-bit block +// MA-S oui36.csv 36-bit block +// IAB iab.csv 36-bit block (retired; existing assignments remain) +// CID cid.csv 24-bit Company ID (locally administered space) +// +// MA-M / MA-S / IAB blocks sit inside MA-L prefixes registered to "IEEE +// Registration Authority", so the longest assignment wins. Each CSV row is +// `Registry,Assignment,Organization Name,Organization Address`; the country +// is not a column, it is read off the end of the address. +// +// Source: https://standards.ieee.org/products-programs/regauth/ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import logger from './logger.js'; +import { MAC_HEX_LENGTH } from './mac-input.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +export const OUI_DB_DIR = path.join(__dirname, 'oui-db'); + +const IEEE_BASE = 'https://standards-oui.ieee.org'; + +// `min` is the row floor below which a download counts as truncated +// (roughly 70% of the 2026 sizes; IAB is frozen at ~4.6k). +export const OUI_REGISTRIES = [ + { registry: 'MA-L', file: 'oui.csv', url: `${IEEE_BASE}/oui/oui.csv`, hexLength: 6, min: 28000 }, + { registry: 'MA-M', file: 'mam.csv', url: `${IEEE_BASE}/oui28/mam.csv`, hexLength: 7, min: 4500 }, + { registry: 'MA-S', file: 'oui36.csv', url: `${IEEE_BASE}/oui36/oui36.csv`, hexLength: 9, min: 5000 }, + { registry: 'IAB', file: 'iab.csv', url: `${IEEE_BASE}/iab/iab.csv`, hexLength: 9, min: 3200 }, + { registry: 'CID', file: 'cid.csv', url: `${IEEE_BASE}/cid/cid.csv`, hexLength: 6, min: 150 }, +]; +export const OUI_FILES = OUI_REGISTRIES.map(({ file }) => file); + +// Longest first: a sub-block beats the IEEE RA prefix holding it. +const HEX_LENGTHS = [...new Set(OUI_REGISTRIES.map(({ hexLength }) => hexLength))].sort((a, b) => b - a); + +const CSV_HEADER = 'Registry,Assignment,Organization Name,Organization Address'; + +/* ------------------------------------------------------------------ */ +/* Parse */ +/* ------------------------------------------------------------------ */ + +/** + * RFC 4180 CSV text → rows of fields (quoted fields may hold commas, `""`, + * newlines). Throws on text that ends inside a quoted field. + */ +export const parseCsv = (text) => { + const rows = []; + let row = []; + let field = ''; + let quoted = false; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (quoted) { + if (c !== '"') field += c; + else if (text[i + 1] === '"') { field += '"'; i++; } + else quoted = false; + } else if (c === '"') quoted = true; + else if (c === ',') { row.push(field); field = ''; } + else if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; } + else if (c !== '\r') field += c; + } + if (quoted) throw new Error('CSV ends inside a quoted field'); + if (field || row.length) { row.push(field); rows.push(row); } + return rows; +}; + +const regionNames = new Intl.DisplayNames(['en'], { type: 'region', fallback: 'none' }); +const isCountryCode = (token) => /^[A-Z]{2}$/.test(token || '') && Boolean(regionNames.of(token)); +const hasDigit = (token) => /\d/.test(token || ''); + +// Countries whose postal code is followed by letters that can read as a +// country code: Dutch postcodes ("5633 AD"), Italian CAP + province ("10077 TO"). +const LETTERED_POSTCODE = new Set(['NL', 'IT']); + +/** + * ISO country code at the end of an IEEE address, or null. The address ends + * `… `, where the postal code is zero to two tokens + * of free text ("94568", "5633 AD", "R.O.C."). A code followed by a token + * with a digit is the country — unless another code trails the postal code + * ("Dover DE 19901 US", a state before the country), which then wins, save + * after a lettered-postcode country. Otherwise the rightmost code. + */ +export const countryFromAddress = (address) => { + const tokens = String(address || '').trim().split(/\s+/).slice(-3); + for (let i = 0; i < tokens.length - 1; i++) { + if (!isCountryCode(tokens[i]) || !hasDigit(tokens[i + 1])) continue; + const trailing = tokens.slice(i + 2).find(isCountryCode); + return trailing && !LETTERED_POSTCODE.has(tokens[i]) ? trailing : tokens[i]; + } + for (let i = tokens.length - 1; i >= 0; i--) { + if (isCountryCode(tokens[i])) return tokens[i]; + } + return null; +}; + +/** + * One registry's CSV text → [assignment, { registry, company, address, + * country }] entries. Throws on a file that isn't an IEEE registry CSV, or + * one cut short. + */ +export const parseRegistry = (text, { registry, hexLength }) => { + const rows = parseCsv(text.replace(/^\uFEFF/, '')); + if (rows[0]?.join(',').trim() !== CSV_HEADER) throw new Error(`${registry}: unexpected CSV header`); + // IEEE ends every row, the last included, with CRLF: a file that stops + // mid-row was cut short. + if (!text.endsWith('\n')) throw new Error(`${registry}: file ends mid-row (truncated)`); + const entries = []; + for (const [rowRegistry, assignment, company, address] of rows.slice(1)) { + const hex = (assignment || '').trim().toUpperCase(); + if (rowRegistry?.trim() !== registry || hex.length !== hexLength || !/^[0-9A-F]+$/.test(hex)) continue; + const cleanAddress = (address || '').trim().replace(/\s+/g, ' '); + entries.push([hex, { + registry, + company: (company || '').trim(), + address: cleanAddress, + country: countryFromAddress(cleanAddress), + }]); + } + return entries; +}; + +/** + * Read every registry file into one Map; `pathOf(file)` locates each. + * Throws when a file is missing, malformed or below its row floor. + */ +export const readOuiRegistries = (pathOf) => { + const blocks = new Map(); + for (const spec of OUI_REGISTRIES) { + const entries = parseRegistry(fs.readFileSync(pathOf(spec.file), 'utf8'), spec); + if (entries.length < spec.min) { + throw new Error(`${spec.registry} has only ${entries.length} assignments (expected ≥ ${spec.min})`); + } + for (const [hex, block] of entries) blocks.set(hex, block); + } + return blocks; +}; + +/* ------------------------------------------------------------------ */ +/* Lookup */ +/* ------------------------------------------------------------------ */ + +const NA = 'N/A'; +const pairs = (hex) => hex.match(/.{1,2}/g).join(':'); + +/** + * Bits the first octet carries, valid for any query of at least two hex + * digits. `isRand` is a likely-randomized address (Wi-Fi privacy, VMs): + * locally administered unicast, the only mark randomization leaves on an + * address. describeMac clears it for a registered Company ID's range. + */ +export const macFlags = (hex) => { + const firstOctet = parseInt(hex.slice(0, 2), 16); + const isMulticast = (firstOctet & 0x01) === 0x01; + const isLocal = (firstOctet & 0x02) === 0x02; + return { + isMulticast, + isUnicast: !isMulticast, + isLocal, + isGlobal: !isLocal, + isRand: isLocal && !isMulticast, + }; +}; + +/** + * The /api/macchecker answer for normalized hex `hex` (see + * common/mac-input.js) against `blocks`: the longest assignment that + * covers it, its address range, and the first-octet flags. The group bit is + * not part of an assignment, so a multicast address (01:00:5E:…) resolves + * to the block it derives from (00:00:5E, IANA), shown as that block's + * multicast range (01:00:5E:00:00:00–01:00:5E:FF:FF:FF). An address no + * block covers is still answered (`found: false`) — its flags are the point + * for a randomized one. + */ +export const describeMac = (blocks, hex) => { + const firstOctet = (parseInt(hex.slice(0, 2), 16) & 0xfe).toString(16).toUpperCase().padStart(2, '0'); + const key = firstOctet + hex.slice(2); + let assignment = null; + for (const length of HEX_LENGTHS) { + if (key.length >= length && blocks.has(key.slice(0, length))) { + assignment = key.slice(0, length); + break; + } + } + const block = assignment ? blocks.get(assignment) : null; + const flags = macFlags(hex); + if (!block) { + return { + success: true, found: false, macPrefix: pairs(hex.slice(0, 6)), + company: NA, address: NA, country: NA, + blockStart: NA, blockEnd: NA, blockSize: NA, blockType: NA, + isPrivate: false, ...flags, + }; + } + // Shown in the query's own form: a multicast query gets its block's + // multicast range, which holds the address asked about. + const shown = hex.slice(0, 2) + assignment.slice(2); + const hostDigits = MAC_HEX_LENGTH - assignment.length; + return { + success: true, + found: true, + macPrefix: pairs(shown), + company: block.company || NA, + address: block.address || NA, + country: block.country || NA, + blockStart: pairs(shown + '0'.repeat(hostDigits)), + blockEnd: pairs(shown + 'F'.repeat(hostDigits)), + blockSize: 16 ** hostDigits, + blockType: block.registry, + isPrivate: block.company.toLowerCase() === 'private', + ...flags, + // A CID range is assigned local space (IEEE 802c ELI), not random. + isRand: flags.isRand && block.registry !== 'CID', + }; +}; + +/* ------------------------------------------------------------------ */ +/* Load */ +/* ------------------------------------------------------------------ */ + +let blocks = null; + +const loadDatabase = () => { + if (!OUI_FILES.every((file) => fs.existsSync(path.join(OUI_DB_DIR, file)))) { + // Info, not warn: a first boot downloads them right after. + logger.info({ dir: OUI_DB_DIR }, '⚠️ IEEE MAC registries not present; MAC Lookup waits for the download'); + blocks = null; + return; + } + const start = Date.now(); + try { + blocks = readOuiRegistries((file) => path.join(OUI_DB_DIR, file)); + logger.info(`📦 IEEE MAC registries loaded — ${blocks.size} assignments in ${Date.now() - start}ms`); + } catch (error) { + // A previously loaded copy keeps serving. + logger.warn({ err: error, dir: OUI_DB_DIR }, '⚠️ Failed to parse IEEE MAC registries'); + } +}; + +loadDatabase(); + +/** Reload the registries after the dataset updater publishes fresh files. */ +export const reloadOuiDatabase = (reason = 'reload') => { + logger.info(`🔄 Reloading IEEE MAC registries (${reason})`); + loadDatabase(); +}; + +/** Whether all five registries are loaded. */ +export const isOuiLoaded = () => blocks !== null; + +/** describeMac against the loaded registries; null when none are loaded. */ +export const lookupMac = (hex) => (blocks ? describeMac(blocks, hex) : null); diff --git a/frontend/components/advanced-tools/MacChecker.vue b/frontend/components/advanced-tools/MacChecker.vue index 92732f85..bb83a66e 100644 --- a/frontend/components/advanced-tools/MacChecker.vue +++ b/frontend/components/advanced-tools/MacChecker.vue @@ -1,3 +1,8 @@ +