From 7a52e8acead514354cc7f2340e02162d74809dbd Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Mon, 5 Oct 2026 11:21:12 +0800 Subject: [PATCH 1/9] Feat(mac): answer MAC Lookup from the local IEEE registries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 1 - .gitignore | 4 + api/AGENTS.md | 6 +- api/mac-checker.js | 74 +++-------- backend-server.js | 15 ++- common/datasets.js | 54 ++++++-- common/mac-input.js | 22 ++++ common/offline-data.js | 6 +- common/oui-db.js | 237 ++++++++++++++++++++++++++++++++++++ frontend/utils/mac-input.js | 3 + tests/api-handlers.test.js | 24 +++- tests/datasets.test.js | 61 +++++++++- tests/mac-input.test.js | 32 +++++ tests/oui-db.test.js | 184 ++++++++++++++++++++++++++++ 14 files changed, 636 insertions(+), 87 deletions(-) create mode 100644 common/mac-input.js create mode 100644 common/oui-db.js create mode 100644 frontend/utils/mac-input.js create mode 100644 tests/mac-input.test.js create mode 100644 tests/oui-db.test.js diff --git a/.env.example b/.env.example index 726c6e60..f166dacf 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,6 @@ IPINFO_API_KEY="" CLOUDFLARE_API_KEY="" IPAPIIS_API_KEY="" IP2LOCATION_API_KEY="" -MAC_LOOKUP_API_KEY="" RIPESTAT_SOURCE_APP="" # Shareable diagnostic reports — stored in Cloudflare Workers KV. Both values # below must be set (together with CLOUDFLARE_API_KEY, which additionally diff --git a/.gitignore b/.gitignore index b56f2d3a..8dc3872c 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,10 @@ common/peeringdb-db/.caida-update-state.json common/peeringdb-db/.caida-update.lock common/peeringdb-db/*.bak common/peeringdb-db/*.next +# IEEE MAC registries — downloaded at boot, refreshed daily. +common/oui-db/*.csv +common/oui-db/*.bak +common/oui-db/*.next # Dataset updater (common/dataset-updater.js): state (+ its atomic-write # temp files) and the lock directory, in every dataset directory. common/*-db/.dataset-state.json* diff --git a/api/AGENTS.md b/api/AGENTS.md index 158568df..797a1e4c 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -42,7 +42,7 @@ behind `dns-resolver` (gated by `tests/dns-resolvers-data.test.js`). no prefix or relationship counts — the ASN Profile has both. - **`/api/whois` `?q=`:** IPs (RDAP, whoiser fallback), domains (whoiser, RDAP fallback), `AS` (RDAP autnum); all carry a `__raw` block. -- **Offline datasets** (MaxMind, CAIDA, PeeringDB) are rows in +- **Offline datasets** (MaxMind, CAIDA, PeeringDB, IEEE MAC registries) are rows in `common/datasets.js`, run by one engine, `common/dataset-updater.js` (lock, atomic state, publish, boot download, daily schedule). A row holds only its own logic — find the newest remote @@ -56,7 +56,9 @@ behind `dns-resolver` (gated by `tests/dns-resolvers-data.test.js`). reloaded by the engine's watcher. MaxMind only with its credentials (City + ASN publish together); PeeringDB only with a Cloudflare key; its 116 MB dump is stream-distilled (never parsed whole) to a ~6 MB per-ASN index without - contact data (`common/peeringdb-distill.js`). + contact data (`common/peeringdb-distill.js`). The IEEE row (MAC Lookup) + needs no key: five registry CSVs published as downloaded, answered by the + longest covering assignment (`common/oui-db.js`). ## Conventions diff --git a/api/mac-checker.js b/api/mac-checker.js index 70a3bd86..73a6e17d 100644 --- a/api/mac-checker.js +++ b/api/mac-checker.js @@ -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; -} \ No newline at end of file diff --git a/backend-server.js b/backend-server.js index 5dbd1b92..87d4df7a 100644 --- a/backend-server.js +++ b/backend-server.js @@ -54,6 +54,7 @@ import { } from './common/service-status-store.js'; import { isAsRelLoaded } from './common/as-rel-db.js'; import { isAsOrgLoaded } from './common/as-org-db.js'; +import { isOuiLoaded } from './common/oui-db.js'; import { runOfflineBootstrap, requireOfflineData } from './common/offline-data.js'; import { initUpstreamUserAgent } from './common/upstream-ua.js'; @@ -252,6 +253,7 @@ app.use('/api', requireReferer); // 503 while that data is still downloading at boot, a no-op afterwards. const needsMaxMind = requireOfflineData([isMaxMindReady]); const needsAsGraph = requireOfflineData([isAsRelLoaded, isAsOrgLoaded]); +const needsOui = requireOfflineData([isOuiLoaded]); const needsServiceStatus = requireOfflineData([isServiceStatusPrimed]); const FIVE_MIN_CACHE = 5 * 60; @@ -302,7 +304,7 @@ app.get('/api/cfradar', cacheable((req) => RADAR_VIEWS[req.query.view]?.ttl, { c // and append-only BGP routing history. app.get('/api/asn-history', requireValidPrefix(), cacheable(THIRTY_DAYS_CACHE), asnHistoryHandler); app.get('/api/asn-connectivity', requireValidASN(), needsAsGraph, cacheable(THIRTY_DAYS_CACHE), asnConnectivityHandler); -app.get('/api/macchecker', cacheable(THIRTY_DAYS_CACHE), macChecker); +app.get('/api/macchecker', needsOui, cacheable(THIRTY_DAYS_CACHE), macChecker); // Long Cache app.get('/api/map', cacheable(ONE_YEAR_CACHE), mapHandler); // Non-cacheable routes — auth-context, debug tools, or per-request lookups. @@ -352,11 +354,12 @@ if (process.env.SENTRY_DSN_BACKEND) { // Boot sequence. Snapshots already on disk are in memory before the listener -// opens (CAIDA / PeeringDB load at import, MaxMind just below), so a restart -// serves at once. Missing ones download behind the listener: meanwhile only -// the routes reading them answer 503 (requireOfflineData above). A failed -// download stays non-fatal — that route then serves degraded (MaxMind → 503; -// CAIDA → empty graph, RIPEstat fallback or no peering section). +// opens (CAIDA / PeeringDB / IEEE load at import, MaxMind just below), so a +// restart serves at once. Missing ones download behind the listener: +// meanwhile only the routes reading them answer 503 (requireOfflineData +// above). A failed download stays non-fatal — that route then serves degraded +// (MaxMind / IEEE → 503; CAIDA → empty graph, RIPEstat fallback or no +// peering section). const bootBackend = async () => { const { cityDbPath, asnDbPath } = getMaxMindDbPaths(); if (fs.existsSync(cityDbPath) && fs.existsSync(asnDbPath)) { diff --git a/common/datasets.js b/common/datasets.js index 70d01c39..86618424 100644 --- a/common/datasets.js +++ b/common/datasets.js @@ -11,9 +11,12 @@ // peeringdb PeeringDB dump (CAIDA mirror), common/peeringdb-db.js // distilled to a per-ASN index; only with the Cloudflare key, // as the ASN Profile is hidden without it +// oui IEEE MAC registries (MA-L / MA-M common/oui-db.js +// / MA-S / IAB / CID), published together as downloaded // // Sources: https://dev.maxmind.com/geoip/geolite2-free-geolocation-data · -// https://publicdata.caida.org/datasets/ (CAIDA AUA) · https://www.peeringdb.com +// https://publicdata.caida.org/datasets/ (CAIDA AUA) · https://www.peeringdb.com · +// https://standards.ieee.org/products-programs/regauth/ import fsp from 'fs/promises'; import path from 'path'; @@ -28,6 +31,7 @@ import { PEERINGDB_DB_DIR, PEERINGDB_FILE, readPeeringdbIndex, reloadPeeringdbDa import { distillPeeringdbDump } from './peeringdb-distill.js'; import { hasRadarApiKey } from './cf-radar.js'; import { MAXMIND_DB_DIR, MAXMIND_CITY_DB, MAXMIND_ASN_DB, reloadMaxMindDatabases, isMaxMindReady } from './maxmind-service.js'; +import { OUI_DB_DIR, OUI_REGISTRIES, OUI_FILES, readOuiRegistries, reloadOuiDatabase, isOuiLoaded } from './oui-db.js'; // The pre-engine CAIDA updater's state: same { identifier, updatedAt } shape. const CAIDA_LEGACY_STATE = { file: '.caida-update-state.json', toState: (json) => json }; @@ -41,6 +45,13 @@ const fetchCompressed = (file, format) => async ({ remote, tempDir, signal }) => return { [file]: staged }; }; +// Last-Modified of each file a row publishes together, joined: any one +// changing is a new version. null when a file doesn't say — an unknown +// version, always fetched rather than taken for unchanged. Exported for +// tests (and the legacy MaxMind state mapping below). +export const joinedIdentifier = (lastModifieds) => + (lastModifieds.every(Boolean) ? lastModifieds.join(' | ') : null); + // ---------- validators ---------- // Healthy as2org resolves ~110k ASNs; <50k indicates truncation or schema change. @@ -129,18 +140,11 @@ const maxmindAuth = () => ({ Authorization: `Basic ${Buffer.from(`${process.env.MAXMIND_ACCOUNT_ID}:${process.env.MAXMIND_LICENSE_KEY}`).toString('base64')}`, }); -// Last-Modified of each edition, joined: either edition changing is a new -// version. null when an edition doesn't say — an unknown version, always -// fetched rather than taken for unchanged. Exported for tests (and the -// legacy-state mapping below). -export const maxmindIdentifier = (lastModifieds) => - (lastModifieds.every(Boolean) ? lastModifieds.join(' | ') : null); - // The pre-engine MaxMind updater kept { [editionId]: { lastModified, updatedAt } }. const MAXMIND_LEGACY_STATE = { file: '.maxmind-update-state.json', toState: (json) => ({ - identifier: maxmindIdentifier(MAXMIND_EDITIONS.map(({ editionId }) => json[editionId]?.lastModified)), + identifier: joinedIdentifier(MAXMIND_EDITIONS.map(({ editionId }) => json[editionId]?.lastModified)), updatedAt: json['GeoLite2-City']?.updatedAt, }), }; @@ -176,7 +180,7 @@ export const datasets = [ if (!res.ok) throw new Error(`Failed to check ${editionId}: HTTP ${res.status}`); lastModifieds.push(res.headers.get('last-modified')); } - return { identifier: maxmindIdentifier(lastModifieds) }; + return { identifier: joinedIdentifier(lastModifieds) }; }, fetch: async ({ tempDir, signal }) => { const staged = {}; @@ -270,4 +274,34 @@ export const datasets = [ reload: reloadPeeringdbDatabase, isLoaded: isPeeringdbLoaded, }, + { + id: 'oui', + dir: OUI_DB_DIR, + files: OUI_FILES, + // IEEE regenerates the files daily; a file's Last-Modified is its version. + findRemote: async ({ signal } = {}) => { + const lastModifieds = []; + for (const { file, url } of OUI_REGISTRIES) { + const res = await fetchUpstream(url, { method: 'HEAD', signal }); + if (!res.ok) throw new Error(`Failed to check ${file}: HTTP ${res.status}`); + lastModifieds.push(res.headers.get('last-modified')); + } + return { identifier: joinedIdentifier(lastModifieds) }; + }, + // Plain GETs: the IEEE server rejects ranged requests. + fetch: async ({ tempDir, signal }) => { + const staged = {}; + for (const { file, url } of OUI_REGISTRIES) { + staged[file] = path.join(tempDir, file); + await downloadToFile(url, staged[file], { signal }); + } + return staged; + }, + // Every file must parse as its registry, above its row floor. + validate: async (staged) => { + readOuiRegistries((file) => staged[file]); + }, + reload: reloadOuiDatabase, + isLoaded: isOuiLoaded, + }, ]; diff --git a/common/mac-input.js b/common/mac-input.js new file mode 100644 index 00000000..202bc211 --- /dev/null +++ b/common/mac-input.js @@ -0,0 +1,22 @@ +// MAC Lookup query parsing, shared by the /api/macchecker handler and the +// MacChecker form (via frontend/utils/mac-input.js). A query is a full +// 48-bit address or any leading part of one, down to a 24-bit vendor prefix, +// in the usual notations: colon / hyphen pairs, Cisco dotted quads, or bare +// hex. + +export const MAC_HEX_LENGTH = 12; +export const MAC_PREFIX_MIN_HEX = 6; + +// Separators accepted between hex digits: `:` `-` `.` and whitespace. +const SEPARATORS = /[\s:.-]/g; + +/** + * Upper-case bare hex of a full address or a prefix (6–12 hex digits), or + * null for anything else. + */ +export const normalizeMacQuery = (input) => { + if (typeof input !== 'string') return null; + const hex = input.replace(SEPARATORS, '').toUpperCase(); + if (hex.length < MAC_PREFIX_MIN_HEX || hex.length > MAC_HEX_LENGTH) return null; + return /^[0-9A-F]+$/.test(hex) ? hex : null; +}; diff --git a/common/offline-data.js b/common/offline-data.js index b5ce69e4..17d82e31 100644 --- a/common/offline-data.js +++ b/common/offline-data.js @@ -1,4 +1,4 @@ -// Boot-time readiness of the offline datasets (MaxMind, CAIDA, PeeringDB) and +// Boot-time readiness of the offline datasets (common/datasets.js) and // the primed service-status snapshot. The backend listens before any of them // is downloaded; runOfflineBootstrap marks that window, and requireOfflineData // answers 503 on a route whose data is still missing inside it. Outside the @@ -6,8 +6,8 @@ // A response assembled from independent parts (the ASN Profile) instead asks // isStillLoading per part and leaves just that part out, uncached. // -// Snapshots already on disk are loaded before the listener opens (CAIDA and -// PeeringDB at import, MaxMind in backend-server.js), so an ordinary restart +// Snapshots already on disk are loaded before the listener opens (CAIDA, +// PeeringDB and IEEE at import, MaxMind in backend-server.js), so a restart // never hits the gate — only a first boot or a newly added dataset does. let booting = false; diff --git a/common/oui-db.js b/common/oui-db.js new file mode 100644 index 00000000..3d0bf28f --- /dev/null +++ b/common/oui-db.js @@ -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 + * `… `, 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); diff --git a/frontend/utils/mac-input.js b/frontend/utils/mac-input.js new file mode 100644 index 00000000..1d488377 --- /dev/null +++ b/frontend/utils/mac-input.js @@ -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'; diff --git a/tests/api-handlers.test.js b/tests/api-handlers.test.js index b7c87513..928161e3 100644 --- a/tests/api-handlers.test.js +++ b/tests/api-handlers.test.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 -------------------------------------- diff --git a/tests/datasets.test.js b/tests/datasets.test.js index a5121c54..4c92bba9 100644 --- a/tests/datasets.test.js +++ b/tests/datasets.test.js @@ -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, []); + }); +}); diff --git a/tests/mac-input.test.js b/tests/mac-input.test.js new file mode 100644 index 00000000..1a8788e2 --- /dev/null +++ b/tests/mac-input.test.js @@ -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); + }); +}); diff --git a/tests/oui-db.test.js b/tests/oui-db.test.js new file mode 100644 index 00000000..072059b9 --- /dev/null +++ b/tests/oui-db.test.js @@ -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('Request Rejected', 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 }); + }); +}); From e57b9e331d430a0065bb070010ab20eade201467 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Mon, 5 Oct 2026 11:21:12 +0800 Subject: [PATCH 2/9] Feat(mac): prefix queries and example pills in MAC Lookup The form validates with the shared common/mac-input.js parser, so vendor prefixes go through. A collapsed "See examples" fold under the input, as in IP Calculator, holds one pill per kind of answer: full address, vendor prefix, MA-M block, private registration, multicast and randomized. Browsers expose no MAC address, so there is no "my address" pill. Changelog entry in every full locale. Co-Authored-By: Claude Opus 5.5 --- .../components/advanced-tools/MacChecker.vue | 66 ++++++++++++++++--- frontend/data/changelog.json | 11 ++++ frontend/locales/en.json | 9 ++- frontend/locales/fr.json | 9 ++- frontend/locales/pt-BR.json | 9 ++- frontend/locales/ru.json | 9 ++- frontend/locales/zh-TW.json | 9 ++- frontend/locales/zh.json | 9 ++- 8 files changed, 116 insertions(+), 15 deletions(-) diff --git a/frontend/components/advanced-tools/MacChecker.vue b/frontend/components/advanced-tools/MacChecker.vue index 92732f85..019188e7 100644 --- a/frontend/components/advanced-tools/MacChecker.vue +++ b/frontend/components/advanced-tools/MacChecker.vue @@ -1,3 +1,8 @@ +