mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-04 08:55:47 +00:00
Feat(mac): answer MAC Lookup from the local IEEE registries
MAC Lookup no longer calls maclookup.app. The five public IEEE registries (MA-L, MA-M, MA-S, IAB, CID) are a new `oui` dataset row — no key needed, published as downloaded, refused below per-registry row floors — and common/oui-db.js answers the longest assignment covering a query, the group bit masked so a multicast address resolves to the block it derives from. The country is read off the end of the registered address. ?mac= now takes a vendor prefix as well as a full address (6–12 hex digits, colon / hyphen / dotted / bare), parsed by common/mac-input.js, which the form shares. An address no block covers is still answered, with its flags. The route is gated by requireOfflineData while the registries download at boot. MAC_LOOKUP_API_KEY is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
83cba6f3ba
commit
7a52e8acea
14 files changed
+636
-87
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
+16
-58
@@ -1,68 +1,26 @@
|
||||
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) {
|
||||
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 = lookupMac(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;
|
||||
}
|
||||
+9
-6
@@ -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)) {
|
||||
|
||||
+44
-10
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
// 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). */
|
||||
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 (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 || '');
|
||||
|
||||
/**
|
||||
* 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 taken first — Dutch postcodes end in letter pairs that
|
||||
* are themselves country codes — then 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])) return 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.
|
||||
*/
|
||||
export const parseRegistry = (text, { registry, hexLength }) => {
|
||||
const rows = parseCsv(text.replace(/^/, ''));
|
||||
if (rows[0]?.join(',').trim() !== CSV_HEADER) throw new Error(`${registry}: unexpected CSV header`);
|
||||
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. A randomized address (Wi-Fi privacy, VMs) is a locally
|
||||
* administered unicast one.
|
||||
*/
|
||||
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). 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,
|
||||
};
|
||||
}
|
||||
const hostDigits = MAC_HEX_LENGTH - assignment.length;
|
||||
return {
|
||||
success: true,
|
||||
found: true,
|
||||
macPrefix: pairs(assignment),
|
||||
company: block.company || NA,
|
||||
address: block.address || NA,
|
||||
country: block.country || NA,
|
||||
blockStart: pairs(assignment + '0'.repeat(hostDigits)),
|
||||
blockEnd: pairs(assignment + 'F'.repeat(hostDigits)),
|
||||
blockSize: 16 ** hostDigits,
|
||||
blockType: block.registry,
|
||||
isPrivate: block.company.toLowerCase() === 'private',
|
||||
...flags,
|
||||
};
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 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);
|
||||
@@ -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';
|
||||
@@ -24,6 +24,7 @@ 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 { isOuiLoaded } 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,27 @@ 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);
|
||||
});
|
||||
|
||||
// Reads the repo's local registries: answered when they're on disk,
|
||||
// 503 when this checkout never downloaded them.
|
||||
it('answers a vendor prefix from the local registries, or 503 without them', async () => {
|
||||
const res = createResponse();
|
||||
await macCheckerHandler(createRequest({ query: { mac: '3c:5a:b4' } }), res);
|
||||
if (!isOuiLoaded()) {
|
||||
assert.equal(res.statusCode, 503);
|
||||
assert.deepEqual(res.body, { error: 'MAC database unavailable' });
|
||||
return;
|
||||
}
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.body.success, true);
|
||||
assert.equal(res.body.macPrefix.slice(0, 8), '3C:5A:B4');
|
||||
});
|
||||
});
|
||||
|
||||
// -- update-user-achievement handler --------------------------------------
|
||||
|
||||
+55
-6
@@ -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, []);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
// 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']]);
|
||||
});
|
||||
});
|
||||
|
||||
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('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/);
|
||||
});
|
||||
});
|
||||
|
||||
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, flags kept', () => {
|
||||
const result = describeMac(blocks, '01005E0000FB');
|
||||
assert.equal(result.company, 'ICANN, IANA Department');
|
||||
assert.equal(result.macPrefix, '00:00:5E');
|
||||
assert.equal(result.isMulticast, true);
|
||||
});
|
||||
|
||||
it('matches a Company ID in locally administered space', () => {
|
||||
const result = describeMac(blocks, 'EA2701000000');
|
||||
assert.equal(result.blockType, 'CID');
|
||||
assert.equal(result.isLocal, true);
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user