Merge pull request #494 from jason5ng32/feat/mac-oui-offline

MAC Lookup from the local IEEE registries, with prefix queries and examples
This commit is contained in:
Jason Ng authored and GitHub committed 2026-10-05 11:43:36 +08:00
commit af45bcee18
24 files changed
+863 -105

No files matched your search

-1
View File
@@ -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
+4
View File
@@ -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*
+4 -2
View File
@@ -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<n>` (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
+19 -58
View File
@@ -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;
}
export default createMacChecker(lookupMac);
+9 -6
View File
@@ -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)) {
+13
View File
@@ -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 {
+44 -10
View File
@@ -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,
},
];
+22
View File
@@ -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;
};
+3 -3
View File
@@ -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;
+259
View File
@@ -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
* `… <country> <postal code>`, 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);
@@ -1,3 +1,8 @@
<!-- MAC Lookup (advanced tool, slug `macchecker`): a full address or a
vendor prefix (common/mac-input.js), answered by /api/macchecker from the
local IEEE registries. A collapsed fold under the input holds example
pills, one per kind of answer. Browsers expose no MAC address, so there
is no "my address" pill. -->
<template>
<div class="mac-checker-section my-4 space-y-4">
<!-- Top note -->
@@ -13,7 +18,7 @@
:placeholder="t('macchecker.Placeholder')"
v-model="queryMAC" @keyup.enter="onSubmit" :aria-invalid="errorMsg !== ''" />
<Button variant="action"
:disabled="macCheckStatus === 'running' || !queryMAC"
:disabled="macCheckStatus === 'running' || !queryMAC.trim()"
@click="onSubmit" class="cursor-pointer">
<Spinner v-if="macCheckStatus === 'running'" />
<template v-else>
@@ -22,6 +27,27 @@
</Button>
</div>
<p v-if="errorMsg" class="text-sm text-destructive">{{ errorMsg }}</p>
<!-- Example inputs, tap to run. Collapsed by default; the chevron
turns from › to ⌄ via data-state. -->
<Collapsible>
<CollapsibleTrigger>
<button type="button" :class="triggerClass">
<ChevronRight class="size-4 transition-transform duration-200" />{{ t('macchecker.Examples') }}
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<ToggleGroup :model-value="picked" type="single" variant="outline" :spacing="2"
:disabled="macCheckStatus === 'running'"
class="mt-2 flex-wrap justify-start" @update:model-value="(v) => v && runPreset(v)">
<ToggleGroupItem v-for="example in EXAMPLES" :key="example.input" :value="example.input"
:class="tagClass" :aria-label="`${t(example.labelKey)}: ${example.input}`">
<span class="text-muted-foreground">{{ t(example.labelKey) }}</span>
<span class="font-mono">{{ example.input }}</span>
</ToggleGroupItem>
</ToggleGroup>
</CollapsibleContent>
</Collapsible>
</div>
<!-- Result area -->
@@ -91,13 +117,16 @@ import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import getCountryName from '@/data/country-name.js';
import { CircleCheck, CircleX, Factory, ListChecks, Search } from '@lucide/vue';
import { normalizeMacQuery } from '@/utils/mac-input.js';
import { ChevronRight, CircleCheck, CircleX, Factory, ListChecks, Search } from '@lucide/vue';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Spinner } from '@/components/ui/spinner';
import { Icon } from '@iconify/vue';
import { Label } from '@/components/ui/label';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
const { t } = useI18n();
@@ -108,6 +137,22 @@ const macCheckResult = ref({});
const macCheckStatus = ref('idle');
const queryMAC = ref('');
const errorMsg = ref('');
// Which example pill the current result came from, if any.
const picked = ref('');
// One example per kind of answer; the pills double as syntax documentation.
const EXAMPLES = [
{ labelKey: 'macchecker.exampleFull', input: 'F0:2F:4B:01:0A:AA' },
{ labelKey: 'macchecker.examplePrefix', input: '3C:5A:B4' },
{ labelKey: 'macchecker.exampleSubBlock', input: 'F0:40:AF:91:23:45' },
{ labelKey: 'macchecker.examplePrivate', input: 'AC:DE:48:00:11:22' },
{ labelKey: 'macchecker.exampleMulticast', input: '01:00:5E:00:00:FB' },
{ labelKey: 'macchecker.exampleRandom', input: '42:9F:C3:15:7A:E0' },
];
// Pills and fold trigger match IpCalculator's.
const tagClass = 'group h-7 rounded-full px-2.5 text-xs cursor-pointer';
const triggerClass = 'flex items-center gap-1 text-xs text-muted-foreground cursor-pointer hover:text-foreground [&[data-state=open]>svg]:rotate-90';
const leftItems = computed(() => [
{ key: 'macPrefix' },
@@ -127,23 +172,28 @@ const tableItems = computed(() => [
]);
const validateInput = (input) => {
if (!input) return null;
const normalizedInput = input.replace(/[:-]/g, '').replace(/\s+/g, '');
if (normalizedInput.length !== 12 || !/^[0-9A-Fa-f]+$/.test(normalizedInput)) {
errorMsg.value = t('macchecker.invalidMAC');
return null;
}
return normalizedInput;
const hex = normalizeMacQuery(input);
if (!hex) errorMsg.value = t('macchecker.invalidMAC');
return hex;
};
const onSubmit = () => {
trackEvent('Section', 'StartClick', 'MACChecker');
errorMsg.value = '';
macCheckResult.value = {};
const query = validateInput(queryMAC.value);
const raw = queryMAC.value.trim();
// Empty input (Enter in a cleared field) just clears the last answer.
if (!raw) return;
trackEvent('Section', 'StartClick', 'MACChecker');
picked.value = EXAMPLES.some((e) => e.input === raw) ? raw : '';
const query = validateInput(raw);
if (query) getMacInfo(query);
};
const runPreset = (input) => {
queryMAC.value = input;
onSubmit();
};
const getMacInfo = async (query) => {
macCheckStatus.value = 'running';
try {
+11
View File
@@ -2185,6 +2185,17 @@
"pt-BR": "Melhorada a experiência durante a inicialização do servidor"
}
},
{
"type": "improve",
"change": {
"en": "MAC Lookup now reads the official IEEE offline data",
"zh": "MAC 地址查询改用 IEEE 官方离线数据",
"zh-TW": "MAC 位址查詢改用 IEEE 官方離線資料",
"fr": "La recherche MAC s'appuie désormais sur les données offline de l'IEEE",
"ru": "Поиск MAC теперь использует официальные данные offline IEEE",
"pt-BR": "A consulta MAC agora usa os dados offline do IEEE"
}
},
{
"type": "fix",
"change": {
+8 -1
View File
@@ -902,7 +902,14 @@
"isLocal": "Local address",
"property": "Address property",
"value": "Value",
"manufacturer": "Manufacturer details"
"manufacturer": "Manufacturer details",
"Examples": "See examples",
"exampleFull": "Full address",
"examplePrefix": "Vendor prefix",
"exampleSubBlock": "Small block",
"examplePrivate": "Private registration",
"exampleMulticast": "Multicast",
"exampleRandom": "Randomized"
},
"ipcalculator": {
"Title": "IP Calculator",
+8 -1
View File
@@ -902,7 +902,14 @@
"isLocal": "Adresse locale",
"property": "Propriété de l'adresse",
"value": "Valeur",
"manufacturer": "Détails du fabricant"
"manufacturer": "Détails du fabricant",
"Examples": "Voir des exemples",
"exampleFull": "Adresse complète",
"examplePrefix": "Préfixe constructeur",
"exampleSubBlock": "Petit bloc",
"examplePrivate": "Enregistrement privé",
"exampleMulticast": "Multicast",
"exampleRandom": "Aléatoire"
},
"ipcalculator": {
"Title": "Calculateur IP",
+8 -1
View File
@@ -902,7 +902,14 @@
"isLocal": "Endereço local",
"property": "Propriedade do endereço",
"value": "Valor",
"manufacturer": "Detalhes do fabricante"
"manufacturer": "Detalhes do fabricante",
"Examples": "Ver exemplos",
"exampleFull": "Endereço completo",
"examplePrefix": "Prefixo do fabricante",
"exampleSubBlock": "Bloco pequeno",
"examplePrivate": "Registro privado",
"exampleMulticast": "Multicast",
"exampleRandom": "Aleatório"
},
"ipcalculator": {
"Title": "Calculadora de IP",
+8 -1
View File
@@ -902,7 +902,14 @@
"isLocal": "Локальный адрес",
"property": "Свойство адреса",
"value": "Значение",
"manufacturer": "Сведения о производителе"
"manufacturer": "Сведения о производителе",
"Examples": "Примеры",
"exampleFull": "Полный адрес",
"examplePrefix": "Префикс производителя",
"exampleSubBlock": "Малый блок",
"examplePrivate": "Скрытая регистрация",
"exampleMulticast": "Многоадресный",
"exampleRandom": "Случайный"
},
"ipcalculator": {
"Title": "IP-калькулятор",
+8 -1
View File
@@ -902,7 +902,14 @@
"isLocal": "本地位址",
"property": "位址屬性",
"value": "值",
"manufacturer": "製造商資訊"
"manufacturer": "製造商資訊",
"Examples": "查看用例",
"exampleFull": "完整位址",
"examplePrefix": "廠商字首",
"exampleSubBlock": "小區塊",
"examplePrivate": "隱私註冊",
"exampleMulticast": "多播",
"exampleRandom": "隨機位址"
},
"ipcalculator": {
"Title": "IP 計算器",
+8 -1
View File
@@ -902,7 +902,14 @@
"isLocal": "本地地址",
"property": "地址属性",
"value": "值",
"manufacturer": "制造商信息"
"manufacturer": "制造商信息",
"Examples": "查看用例",
"exampleFull": "完整地址",
"examplePrefix": "厂商前缀",
"exampleSubBlock": "小区块",
"examplePrivate": "隐私注册",
"exampleMulticast": "多播",
"exampleRandom": "随机地址"
},
"ipcalculator": {
"Title": "IP 计算器",
+3
View File
@@ -0,0 +1,3 @@
// Thin re-export of common/mac-input.js (shared with /api/macchecker), so
// front-end code keeps importing `@/utils/mac-input.js`.
export { normalizeMacQuery } from '../../common/mac-input.js';
+31 -2
View File
@@ -19,11 +19,12 @@ import getUserInfoHandler from '../api/get-user-info.js';
import getWhoisHandler from '../api/get-whois.js';
import cfRadarHandler from '../api/cf-radar.js';
import invisibilityHandler from '../api/invisibility-test.js';
import macCheckerHandler from '../api/mac-checker.js';
import macCheckerHandler, { createMacChecker } from '../api/mac-checker.js';
import githubStarsHandler from '../api/github-stars.js';
import personaEvaluateHandler from '../api/persona.js';
import asnProfileHandler, { peeringdbReadiness, connectivityReadiness } from '../api/asn-profile.js';
import { isAsRelLoaded } from '../common/as-rel-db.js';
import { describeMac } from '../common/oui-db.js';
import { isCompleteProfile } from '../common/asn-profile.js';
import updateAchievementHandler from '../api/update-user-achievement.js';
import ipcheckIngHandler from '../api/ipcheck-ing.js';
@@ -64,7 +65,7 @@ function createResponse() {
// Back up env keys touched by any test here; restore after each case.
const ENV_KEYS = [
'IPCHECKING_API_KEY', 'IPCHECKING_API_ENDPOINT',
'MAC_LOOKUP_API_KEY', 'IPAPIIS_API_KEY',
'IPAPIIS_API_KEY',
'IPINFO_API_KEY', 'IP2LOCATION_API_KEY',
'CLOUDFLARE_API_KEY',
'CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_KV_NAMESPACE_ID',
@@ -587,6 +588,34 @@ describe('mac-checker handler', () => {
assert.equal(res.statusCode, 400);
assert.deepEqual(res.body, { error: 'Invalid MAC address' });
});
it('rejects a prefix shorter than 24 bits', async () => {
const res = createResponse();
await macCheckerHandler(createRequest({ query: { mac: '3C:5A' } }), res);
assert.equal(res.statusCode, 400);
});
// Over fixture registries, so the answer doesn't depend on a download.
const fixtureBlocks = new Map([
['3C5AB4', { registry: 'MA-L', company: 'Google, Inc.', address: 'Mountain View US 94043', country: 'US' }],
]);
it('answers a vendor prefix in any notation from the registries', async () => {
const handler = createMacChecker((hex) => describeMac(fixtureBlocks, hex));
const res = createResponse();
await handler(createRequest({ query: { mac: '3c5a.b4' } }), res);
assert.equal(res.statusCode, 200);
assert.equal(res.body.found, true);
assert.equal(res.body.company, 'Google, Inc.');
assert.equal(res.body.blockSize, 16777216);
});
it('answers 503 when no registries are loaded', async () => {
const res = createResponse();
await createMacChecker(() => null)(createRequest({ query: { mac: '3C:5A:B4' } }), res);
assert.equal(res.statusCode, 503);
assert.deepEqual(res.body, { error: 'MAC database unavailable' });
});
});
// -- update-user-achievement handler --------------------------------------
+41
View File
@@ -493,6 +493,47 @@ describe('watchDatasets', () => {
}
});
it('does not take its own publish for a foreign change while its reload is still running', async () => {
// A reload slower than the settle window: the watcher sees the new
// files before this process has recorded them as its own.
const { row, log } = makeRow();
row.reload = async (reason) => { await sleep(120); log.reloads.push(reason); };
await updateDataset(row);
const stop = watchDatasets([row], fast);
try {
row.version = 'v2';
await updateDataset(row);
await sleep(150);
assert.deepEqual(log.reloads, ['auto update', 'auto update']);
} finally {
stop();
}
});
it('still picks up files someone else replaces while this process is publishing', async () => {
const { row, log } = makeRow();
let replaceDuringReload = false;
row.reload = async (reason) => {
if (replaceDuringReload) {
replaceDuringReload = false;
for (const file of row.files) fs.writeFileSync(path.join(row.dir, file), `${file} by hand, longer\n`);
await sleep(120);
}
log.reloads.push(reason);
};
await updateDataset(row);
const stop = watchDatasets([row], fast);
try {
row.version = 'v2';
replaceDuringReload = true;
await updateDataset(row);
await sleep(200);
assert.deepEqual(log.reloads, ['auto update', 'auto update', 'file change']);
} finally {
stop();
}
});
it('picks up files dropped into an empty dataset, once all are there', async () => {
const { row, log } = makeRow({ enabled: () => false }); // no credentials: hand-placed only
const stop = watchDatasets([row], fast);
+55 -6
View File
@@ -1,8 +1,9 @@
// Tests for common/datasets.js — the dataset rows run through the engine
// (common/dataset-updater.js): the MaxMind row (two editions published
// together; the credential gate), the PeeringDB row (no decompression; a
// distill step; the Cloudflare-key gate), a decompressing CAIDA row, and the
// rows reading the pre-engine updaters' state. Every row is re-pointed at a temp dir
// distill step; the Cloudflare-key gate), a decompressing CAIDA row, the IEEE
// MAC registries row (five plain files published together), and the rows
// reading the pre-engine updaters' state. Every row is re-pointed at a temp dir
// and `fetch` is stubbed: no network, nothing written into the repo's
// dataset directories.
@@ -15,12 +16,13 @@ import { after, afterEach, describe, it } from 'node:test';
import * as tar from 'tar';
import { datasets, findPeeringdbDump, maxmindIdentifier, hasMaxMindCredentials } from '../common/datasets.js';
import { datasets, findPeeringdbDump, joinedIdentifier, hasMaxMindCredentials } from '../common/datasets.js';
import { updateDataset, isRowEnabled } from '../common/dataset-updater.js';
import { PEERINGDB_FILE, readPeeringdbIndex, expandNet, isPeeringdbLoaded } from '../common/peeringdb-db.js';
import { isAsOrgLoaded } from '../common/as-org-db.js';
import { isAsRelLoaded } from '../common/as-rel-db.js';
import { isMaxMindReady } from '../common/maxmind-service.js';
import { OUI_REGISTRIES, isOuiLoaded } from '../common/oui-db.js';
import { setUpstreamUserAgent } from '../common/fetch-with-timeout.js';
const realFetch = globalThis.fetch;
@@ -86,6 +88,7 @@ describe('every row', () => {
it('asks its reader whether a usable snapshot loaded, so the boot download repairs a refused file', () => {
assert.equal(row('maxmind').isLoaded, isMaxMindReady);
assert.equal(row('peeringdb').isLoaded, isPeeringdbLoaded);
assert.equal(row('oui').isLoaded, isOuiLoaded);
});
});
@@ -271,7 +274,7 @@ describe('maxmind row', () => {
});
const { dataset, reloads } = tempRow('maxmind', { validate: async () => {} }); // real mmdb check needs real files
const result = await updateDataset(dataset);
assert.equal(result.identifier, maxmindIdentifier(['Fri, 02 Oct 2026 16:46:10 GMT', 'Sun, 04 Oct 2026 08:30:32 GMT']));
assert.equal(result.identifier, joinedIdentifier(['Fri, 02 Oct 2026 16:46:10 GMT', 'Sun, 04 Oct 2026 08:30:32 GMT']));
assert.equal(fs.readFileSync(path.join(dataset.dir, 'GeoLite2-City.mmdb'), 'utf8'), 'city-db');
assert.equal(fs.readFileSync(path.join(dataset.dir, 'GeoLite2-ASN.mmdb'), 'utf8'), 'asn-db');
assert.deepEqual(reloads, ['auto update']);
@@ -293,11 +296,57 @@ describe('maxmind row', () => {
});
it('names no version when an edition omits Last-Modified (always fetched)', () => {
assert.equal(maxmindIdentifier(['Fri, 02 Oct 2026 16:46:10 GMT', null]), null);
assert.equal(maxmindIdentifier(['a', 'b']), 'a | b');
assert.equal(joinedIdentifier(['Fri, 02 Oct 2026 16:46:10 GMT', null]), null);
assert.equal(joinedIdentifier(['a', 'b']), 'a | b');
});
it('keeps MAXMIND_AUTO_UPDATE as its legacy schedule flag', () => {
assert.equal(row('maxmind').legacyAutoUpdateEnv, 'MAXMIND_AUTO_UPDATE');
});
});
describe('oui row', () => {
const csvOf = (registry) => `Registry,Assignment,Organization Name,Organization Address\n${registry},ABCDEF0,Vendor,City US 1\n`;
// HEAD answers each file's Last-Modified, GET its CSV; records the requests.
const stubIeee = () => {
const requested = [];
globalThis.fetch = async (url, init = {}) => {
const spec = OUI_REGISTRIES.find((entry) => entry.url === String(url));
requested.push(`${init.method || 'GET'} ${spec.file}${init.headers?.Range ? ' ranged' : ''}`);
if (init.method === 'HEAD') return new Response(null, { headers: { 'last-modified': `day of ${spec.file}` } });
return new Response(csvOf(spec.registry));
};
return requested;
};
it('is never gated: MAC Lookup needs no key', () => {
assert.equal(row('oui').enabled, undefined);
assert.equal(isRowEnabled(row('oui')), true);
});
it('downloads the five registries with plain GETs and publishes them together', async () => {
const requested = stubIeee();
const { dataset, reloads } = tempRow('oui', { validate: async () => {} }); // the real floors are tested below
const result = await updateDataset(dataset);
assert.equal(result.identifier, joinedIdentifier(OUI_REGISTRIES.map(({ file }) => `day of ${file}`)));
assert.deepEqual(fs.readdirSync(dataset.dir).sort(), ['.dataset-state.json', 'cid.csv', 'iab.csv', 'mam.csv', 'oui.csv', 'oui36.csv']);
assert.equal(fs.readFileSync(path.join(dataset.dir, 'mam.csv'), 'utf8'), csvOf('MA-M'));
assert.deepEqual(reloads, ['auto update']);
assert.deepEqual(requested.filter((entry) => entry.startsWith('GET')).sort(),
OUI_REGISTRIES.map(({ file }) => `GET ${file}`).sort(), 'one unranged GET per file');
// Same Last-Modifieds again → only the version checks.
requested.length = 0;
assert.deepEqual(await updateDataset(dataset), { updated: false, reason: 'not-modified' });
assert.ok(requested.every((entry) => entry.startsWith('HEAD')));
});
it('refuses to publish registries below their row floors', async () => {
stubIeee();
const { dataset, reloads } = tempRow('oui');
await assert.rejects(updateDataset(dataset), /MA-L has only 0 assignments/);
assert.deepEqual(fs.readdirSync(dataset.dir), []);
assert.deepEqual(reloads, []);
});
});
+32
View File
@@ -0,0 +1,32 @@
// Tests for common/mac-input.js — the MAC Lookup query parser shared by
// /api/macchecker and the MacChecker form (through frontend/utils/mac-input.js).
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { normalizeMacQuery } from '../common/mac-input.js';
import { normalizeMacQuery as bridged } from '../frontend/utils/mac-input.js';
describe('normalizeMacQuery', () => {
it('accepts a full address in every common notation, as upper-case bare hex', () => {
for (const input of ['f0:2f:4b:01:0a:aa', 'F0-2F-4B-01-0A-AA', 'f02f.4b01.0aaa', 'F02F4B010AAA', ' f0 2f 4b 01 0a aa ']) {
assert.equal(normalizeMacQuery(input), 'F02F4B010AAA', input);
}
});
it('accepts any prefix from a 24-bit vendor prefix up', () => {
assert.equal(normalizeMacQuery('3c:5a:b4'), '3C5AB4');
assert.equal(normalizeMacQuery('F0:40:AF:9'), 'F040AF9');
assert.equal(normalizeMacQuery('8C-1F-64-34-A'), '8C1F6434A');
});
it('rejects too short, too long, non-hex and non-string input', () => {
for (const input of ['3C:5A', '', 'F0:2F:4B:01:0A:AA:BB', 'G0:2F:4B', 'F0_2F_4B', 'not-a-mac', null, undefined, 42]) {
assert.equal(normalizeMacQuery(input), null, String(input));
}
});
it('is what the front-end bridge re-exports', () => {
assert.equal(bridged, normalizeMacQuery);
});
});
+204
View File
@@ -0,0 +1,204 @@
// Tests for common/oui-db.js — parsing the IEEE MAC registries (CSV, the
// country read off the address) and the longest-assignment lookup behind
// /api/macchecker. Fixtures are written to a temp dir; the repo's
// common/oui-db/ is never read.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { after, describe, it } from 'node:test';
import {
OUI_REGISTRIES, parseCsv, countryFromAddress, parseRegistry, readOuiRegistries, describeMac, macFlags,
} from '../common/oui-db.js';
const HEADER = 'Registry,Assignment,Organization Name,Organization Address';
const spec = (registry) => OUI_REGISTRIES.find((entry) => entry.registry === registry);
describe('parseCsv', () => {
it('handles quoted commas, doubled quotes, CRLF and a missing final newline', () => {
const text = 'a,"b, c","say ""hi"""\r\nd,,"multi\nline"';
assert.deepEqual(parseCsv(text), [['a', 'b, c', 'say "hi"'], ['d', '', 'multi\nline']]);
});
it('refuses text that ends inside a quoted field', () => {
assert.throws(() => parseCsv('a,"cut sh'), /inside a quoted field/);
});
});
describe('countryFromAddress', () => {
it('reads the code before the postal code', () => {
assert.equal(countryFromAddress('80 West Tasman Drive San Jose CA US 94568'), 'US');
assert.equal(countryFromAddress('No.388 Ning Qiao Road Shanghai CN 201206'), 'CN');
assert.equal(countryFromAddress('The Pentagon Abingdon Oxfordshire GB OX14 3YP'), 'GB');
});
it('is not fooled by Dutch postcode letters that are country codes', () => {
assert.equal(countryFromAddress('ESP 237 Eindhoven Noord-Brabant NL 5633 AD'), 'NL');
assert.equal(countryFromAddress('High Tech Campus 27 Eindhoven NB NL 5656 AE'), 'NL');
});
it('takes a trailing code, or one before free-text postal fields', () => {
assert.equal(countryFromAddress('STRATUMSED K31 THE NL'), 'NL');
assert.equal(countryFromAddress('1006, Block A, HONG HK KONG'), 'HK');
assert.equal(countryFromAddress('7F., 116, HOU-KANG ST., TAIWAN TAIWAN TW R.O.C.'), 'TW');
});
it('prefers a country code after the postal code to a state before it', () => {
assert.equal(countryFromAddress('Dover DE 19901 US'), 'US');
assert.equal(countryFromAddress('PO Box 165, Goodwood SA 5034 AU'), 'AU');
assert.equal(countryFromAddress('Via Leini San Maurizio Canavese IT 10077 TO'), 'IT', 'Italian province after the CAP');
});
it('answers null when no country code is there', () => {
assert.equal(countryFromAddress('VIA MACCANI, 169 ITALY'), null);
assert.equal(countryFromAddress(''), null);
});
});
describe('parseRegistry', () => {
it('keeps rows of its own registry and length, with the country derived', () => {
const text = `${HEADER}\n`
+ 'MA-M,F040AF9,Raspberry Pi (Trading) Ltd,"Maurice Wilkes Building Cambridge GB CB4 0DS "\n'
+ 'MA-L,F040AF,Wrong Registry,\n'
+ 'MA-M,F040AF,Too Short,\n'
+ 'MA-M,F040AFZ,Not Hex,\n';
assert.deepEqual(parseRegistry(text, spec('MA-M')), [['F040AF9', {
registry: 'MA-M',
company: 'Raspberry Pi (Trading) Ltd',
address: 'Maurice Wilkes Building Cambridge GB CB4 0DS',
country: 'GB',
}]]);
});
it('refuses a file that is not an IEEE registry CSV', () => {
assert.throws(() => parseRegistry('<html>Request Rejected</html>', spec('MA-L')), /unexpected CSV header/);
});
it('refuses a file cut short mid-row', () => {
const whole = `${HEADER}\r\nMA-L,F02F4B,"Apple, Inc.",1 Infinite Loop Cupertino CA US 95014\r\n`;
assert.equal(parseRegistry(whole, spec('MA-L')).length, 1);
assert.throws(() => parseRegistry(whole.slice(0, -10), spec('MA-L')), /truncated/);
});
});
describe('readOuiRegistries', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'myip-oui-test-'));
after(() => fs.rmSync(root, { recursive: true, force: true }));
// `count` synthetic rows per registry, hex counting up from `base`.
const writeRegistries = (dir, { short } = {}) => {
fs.mkdirSync(dir, { recursive: true });
for (const { registry, file, hexLength, min } of OUI_REGISTRIES) {
const count = registry === short ? min - 1 : min;
const rows = Array.from({ length: count }, (_, i) =>
`${registry},${i.toString(16).toUpperCase().padStart(hexLength, '0')},Vendor ${i},Street City US 12345`);
fs.writeFileSync(path.join(dir, file), `${HEADER}\n${rows.join('\n')}\n`);
}
};
it('merges every registry into one map', () => {
const dir = path.join(root, 'ok');
writeRegistries(dir);
const blocks = readOuiRegistries((file) => path.join(dir, file));
assert.equal(blocks.get('0000000').registry, 'MA-M');
assert.equal(blocks.get('000000000').registry, 'IAB', 'IAB is read after MA-S');
});
it('refuses a registry below its row floor, and a missing file', () => {
const dir = path.join(root, 'short');
writeRegistries(dir, { short: 'MA-S' });
assert.throws(() => readOuiRegistries((file) => path.join(dir, file)), /MA-S has only \d+ assignments/);
const partial = path.join(root, 'partial');
writeRegistries(partial);
fs.rmSync(path.join(partial, 'cid.csv'));
assert.throws(() => readOuiRegistries((file) => path.join(partial, file)), /ENOENT/);
});
});
describe('describeMac', () => {
const block = (registry, company, address = '', country = null) => ({ registry, company, address, country });
const blocks = new Map([
['F040AF', block('MA-L', 'IEEE Registration Authority', '445 Hoes Lane Piscataway NJ US 08554', 'US')],
['F040AF9', block('MA-M', 'Raspberry Pi (Trading) Ltd', 'Cambridge GB CB4 0DS', 'GB')],
['8C1F6434A', block('MA-S', 'Raspberry Pi (Trading) Ltd', 'Cambridge GB CB4 0DS', 'GB')],
['ACDE48', block('MA-L', 'Private')],
['EA2701', block('CID', 'ACCE Technology Corp.', 'Hsinchu City TW 300024', 'TW')],
['00005E', block('MA-L', 'ICANN, IANA Department', 'Los Angeles CA US 90094-2536', 'US')],
]);
it('answers the longest assignment covering the address, with its range', () => {
assert.deepEqual(describeMac(blocks, 'F040AF912345'), {
success: true,
found: true,
macPrefix: 'F0:40:AF:9',
company: 'Raspberry Pi (Trading) Ltd',
address: 'Cambridge GB CB4 0DS',
country: 'GB',
blockStart: 'F0:40:AF:90:00:00',
blockEnd: 'F0:40:AF:9F:FF:FF',
blockSize: 1048576,
blockType: 'MA-M',
isPrivate: false,
isMulticast: false,
isUnicast: true,
isLocal: false,
isGlobal: true,
isRand: false,
});
const small = describeMac(blocks, '8C1F6434A123');
assert.equal(small.blockType, 'MA-S');
assert.equal(small.blockSize, 4096);
assert.equal(small.blockEnd, '8C:1F:64:34:AF:FF');
});
it('falls back to the parent prefix outside an assigned sub-block, and for a prefix too short to reach one', () => {
assert.equal(describeMac(blocks, 'F040AF100000').company, 'IEEE Registration Authority');
const prefix = describeMac(blocks, 'F040AF');
assert.equal(prefix.blockType, 'MA-L');
assert.equal(prefix.blockSize, 16777216);
assert.equal(describeMac(blocks, 'F040AF9').blockType, 'MA-M');
});
it('marks a private registration, N/A for its blank address', () => {
const result = describeMac(blocks, 'ACDE48001122');
assert.equal(result.isPrivate, true);
assert.equal(result.address, 'N/A');
assert.equal(result.country, 'N/A');
});
it('answers an uncovered address with found: false and its flags', () => {
const result = describeMac(blocks, '429FC3157AE0');
assert.equal(result.found, false);
assert.equal(result.macPrefix, '42:9F:C3');
assert.equal(result.company, 'N/A');
assert.equal(result.blockSize, 'N/A');
assert.equal(result.isRand, true);
});
it('resolves a multicast address to the block it derives from, shown as its multicast range', () => {
const result = describeMac(blocks, '01005E0000FB');
assert.equal(result.company, 'ICANN, IANA Department');
assert.equal(result.macPrefix, '01:00:5E');
assert.equal(result.blockStart, '01:00:5E:00:00:00');
assert.equal(result.blockEnd, '01:00:5E:FF:FF:FF');
assert.equal(result.isMulticast, true);
});
it('matches a Company ID in locally administered space, which is assigned, not random', () => {
const result = describeMac(blocks, 'EA2701000000');
assert.equal(result.blockType, 'CID');
assert.equal(result.isLocal, true);
assert.equal(result.isRand, false);
assert.equal(describeMac(blocks, 'EA2801000000').isRand, true, 'uncovered local unicast stays likely random');
});
});
describe('macFlags', () => {
it('reads the multicast and locally administered bits of the first octet', () => {
assert.deepEqual(macFlags('01005E'), { isMulticast: true, isUnicast: false, isLocal: false, isGlobal: true, isRand: false });
assert.deepEqual(macFlags('020000'), { isMulticast: false, isUnicast: true, isLocal: true, isGlobal: false, isRand: true });
assert.deepEqual(macFlags('030000'), { isMulticast: true, isUnicast: false, isLocal: true, isGlobal: false, isRand: false });
});
});