MAC 地址与厂商识别
MAC 查询通过本地 IEEE 注册表,将完整 MAC 地址或地址前缀解析为厂商、注册地址、国家/地区、分配块范围及地址属性。它不仅回答“属于哪个厂商”,也能在厂商未命中时返回单播、组播、本地管理及可能随机化等标志。
目的与范围
本文覆盖 /api/macchecker 的请求校验、本地注册表解析、最长前缀匹配、响应结构和运行边界。主要面向维护查询 API、排查识别结果或扩展注册表支持的开发者。
离线数据下载、统一更新调度、通用 HTTP 缓存和前端交互属于相邻能力,本文仅说明已经核实的连接点,不将其完整机制并入本页。当前阅读范围未覆盖这些机制的实现,也未覆盖完整测试文件,因此不对更新周期、启动等待状态码或测试覆盖率作保证。
概述
实现将“输入是什么”和“注册表记录了什么”分开处理:
normalizeMacQuery将输入转换为 6~12 位大写十六进制字符串。createMacChecker执行 HTTP 参数检查,调用可注入的查询函数并返回 JSON。describeMac在注册块Map中按 36、28、24 位顺序查询,生成厂商信息及范围。macFlags从原始输入的首字节计算地址属性;这些属性不依赖是否找到厂商。
因此,found: false 是有效查询结果,并非请求失败;isRand 也只是基于地址位的可能随机化判断,不是设备行为的直接证据。
依据:mac-input.js、mac-checker.js、oui-db.js。
架构与入口
Sources: backend-server.js、backend-server.js、mac-checker.js、oui-db.js
图中上半部分是实际路由与处理器关系,下半部分是数据构建和纯匹配函数。lookupMac 的导入及注入已核实,但其函数体不在本次已读取片段中,因此图中没有补画它与加载器之间的内部调用。
服务将 needsOui 定义为 requireOfflineData([isOuiLoaded]),随后按 needsOui → cacheable(THIRTY_DAYS_CACHE) → macChecker 注册路由。路由附近注释把 IEEE 注册信息归入 30 天缓存类别;缓存中间件具体如何设置响应头、如何处理错误响应,不在本次核验范围。
输入规范化
接受的是十六进制内容,而非严格分组格式
规范化器先移除空白、冒号、连字符和句点,再转换大写,最后检查长度和字符集。它支持完整 48 位 MAC,也支持至少 24 位的前缀;7、9 等奇数位长度同样合法,能够表达 MA-M、MA-S 等非整字节边界。
下面是完整的实际规范化函数:
1export const normalizeMacQuery = (input) => {
2 if (typeof input !== 'string') return null;
3 const hex = input.replace(SEPARATORS, '').toUpperCase();
4 if (hex.length < MAC_PREFIX_MIN_HEX || hex.length > MAC_HEX_LENGTH) return null;
5 return /^[0-9A-F]+$/.test(hex) ? hex : null;
6};Source: mac-input.js
这里的 SEPARATORS 为 /[\s:.-]/g,MAC_PREFIX_MIN_HEX 为 6,MAC_HEX_LENGTH 为 12。分隔符出现的位置没有单独校验,所以不能将它描述为“严格验证六组冒号分隔字节”。输入允许格式化差异,但移除分隔符后必须全部为十六进制字符。
另一个细节是 HTTP 处理器会先执行 String(mac),而直接调用 normalizeMacQuery 时,非字符串会返回 null。两者的入参边界并不完全相同。
IEEE 数据模型与解析
五种注册表,共用一个索引
每条解析记录是以大写 assignment 为键的二元组,值包含 registry、company、address、country。加载器将这些记录合并到一个 Map 中,不按注册表建立独立查询接口。
| 注册类别 | CSV 文件名 | 前缀位数 | 十六进制位数 | 分配块地址数 | 最低有效记录数 |
|---|---|---|---|---|---|
| MA-L | oui.csv | 24 | 6 | 16,777,216 | 28,000 |
| MA-M | mam.csv | 28 | 7 | 1,048,576 | 4,500 |
| MA-S | oui36.csv | 36 | 9 | 4,096 | 5,000 |
| IAB | iab.csv | 36 | 9 | 4,096 | 3,200 |
| CID | cid.csv | 24 | 6 | 16,777,216 | 150 |
文件名为注册表配置中的数据资源名,而不是已核验的仓库内 CSV 文件链接。配置定义见 oui-db.js。地址数由实现中的 16 ** (12 - assignment.length) 得出。
MA-M、MA-S 和 IAB 可能位于登记给 IEEE Registration Authority 的 MA-L 范围内,因此若仅按前三字节识别,就可能返回注册机构而不是更具体的分配对象。最长前缀优先正是为了解决这类覆盖关系。IAB 已停止新的分配,但历史记录仍被保留;CID 表示本地管理空间中的 Company ID。
CSV 解析和完整性校验
解析分为两层:
parseCsv(text)逐字符处理 CSV,允许引号字段内出现逗号、换行及双引号转义。文件结束时若仍处于引号字段内,抛出CSV ends inside a quoted field。parseRegistry(text, spec)去除开头 BOM,核对固定表头,并要求原始文本以换行结尾;不满足时抛错。随后逐行筛选注册类别、assignment 长度和十六进制字符。
无效数据行被跳过,不是逐行抛错;有效行的公司名称去除首尾空白,地址再将连续空白折叠为一个空格。记录数下限由下一层 readOuiRegistries 验证,而非 parseRegistry 自身验证。
1export const readOuiRegistries = (pathOf) => {
2 const blocks = new Map();
3 for (const spec of OUI_REGISTRIES) {
4 const entries = parseRegistry(fs.readFileSync(pathOf(spec.file), 'utf8'), spec);
5 if (entries.length < spec.min) {
6 throw new Error(`${spec.registry} has only ${entries.length} assignments (expected ≥ ${spec.min})`);
7 }
8 for (const [hex, block] of entries) blocks.set(hex, block);
9 }
10 return blocks;
11};Source: oui-db.js
这个函数只有在所有文件都成功读取并达到阈值后才返回新 Map。最低记录数按解析后的条目数计算,而不是按最终 Map 的去重键数计算。若存在相同 assignment,后执行的 blocks.set 会覆盖前面的值。
国家/地区来自地址启发式解析
CSV 没有独立的国家列,countryFromAddress 只检查地址末尾三个空白分隔词:
- 使用两位大写字母及
Intl.DisplayNames(['en'], { type: 'region', fallback: 'none' })判断候选代码。 - 优先检查“国家代码后跟包含数字的邮编”模式。
- 若邮编之后还有国家代码,通常采用后者,以避免把州代码误当国家。
- 对
NL和IT特殊处理,避免把邮编尾部字母或省份缩写误当另一国家。 - 没有上述模式时,从右向左选择可识别国家代码;仍无匹配则返回
null。
该字段是注册地址的推断结果,不是设备的实时地理位置。源码中的例子及逻辑见 oui-db.js。
最长前缀匹配与属性计算
查找键和显示前缀有意不同
describeMac 的查找过程先清除首字节的最低位,即组播位,再按 9、7、6 位十六进制前缀尝试匹配:
1 const firstOctet = (parseInt(hex.slice(0, 2), 16) & 0xfe).toString(16).toUpperCase().padStart(2, '0');
2 const key = firstOctet + hex.slice(2);
3 let assignment = null;
4 for (const length of HEX_LENGTHS) {
5 if (key.length >= length && blocks.has(key.slice(0, length))) {
6 assignment = key.slice(0, length);
7 break;
8 }
9 }Source: oui-db.js
清除组播位用于查找所属分配块,但不会清除本地管理位。命中后,显示前缀通过 hex.slice(0, 2) + assignment.slice(2) 恢复查询的原始首字节。因此组播查询获得的是对应组播形式的地址范围,而不是一个不包含查询地址的单播范围。
源码以 01:00:5E:… 为例:它按 00:00:5E 查找 IANA 记录,显示范围则为 01:00:5E:00:00:00 至 01:00:5E:FF:FF:FF。见 oui-db.js。
前缀查询不会猜测更细分的厂商
只有 key.length >= length 时才尝试该长度的匹配。6 位查询无法区分同一 MA-L 下的多个 MA-M 或 MA-S 分配块,因此不会枚举子块或任意选择厂商。结果始终是当前输入能够确定的最长已登记前缀。
属性标志及其含义
| 字段 | 计算依据 | 应如何解读 |
|---|---|---|
isMulticast | 首字节 & 0x01 非零 | 组播位已设置 |
isUnicast | !isMulticast | 与组播互补 |
isLocal | 首字节 & 0x02 非零 | 本地管理位已设置 |
isGlobal | !isLocal | 与本地管理互补,不等同于公网可达 |
isRand | 初始为 isLocal && !isMulticast | 可能为随机化地址;命中 CID 后置为 false |
isPrivate | 公司名称小写后严格等于 private | 注册条目标记,与 isLocal 含义不同 |
没有命中厂商时仍计算首字节标志,但 isPrivate 固定为 false。CID 命中会取消随机化推断,因为这是登记过的本地地址空间,而非仅凭本地管理位判断的未知地址。
请求执行流程与实际用法
Source: mac-checker.js
此图从处理器开始;上游离线数据守卫可能先处理请求,其内部行为不在图中展开。
基础用法:生产处理器绑定本地查询函数
仓库直接通过工厂创建默认导出的处理器:
export default createMacChecker(lookupMac);Source: mac-checker.js
服务端将这一默认导出命名为 macChecker 并用于 GET 路由。它不是每次请求重新下载 IEEE 数据的在线厂商代理:已核实的匹配核心是对传入 Map 的本地查找。
扩展用法:可注入查询函数的处理器
以下工厂也是实际测试接缝,源代码注释明确说明 lookup 可替换为基于 fixture 注册表的查询函数:
1export const createMacChecker = (lookup) => async (req, res) => {
2 const { mac } = req.query;
3 if (!mac) {
4 return res.status(400).json({ error: 'No MAC address provided' });
5 }
6 const hex = normalizeMacQuery(String(mac));
7 if (!hex) {
8 return res.status(400).json({ error: 'Invalid MAC address' });
9 }
10
11 // Past the boot window (requireOfflineData) only a failed download leaves this empty.
12 const result = lookup(hex);
13 if (!result) {
14 return res.status(503).json({ error: 'MAC database unavailable' });
15 }
16 res.json(result);
17};Source: mac-checker.js
虽然返回的处理器是 async 函数,内部 lookup(hex) 并没有 await。因此扩展时应保持查询函数同步返回结果对象或假值的契约,不能仅凭处理器声明为 async 就替换成异步远程查询。
HTTP API 参考
GET /api/macchecker
| 参数 | 位置 | 要求 | 说明 |
|---|---|---|---|
mac | Query string | 必填 | 完整地址或前缀;规范化后必须为 6~12 位十六进制字符串 |
处理器先检查参数是否为假值,再执行字符串转换和规范化。空白或仅有分隔符的非空字符串会进入格式校验,然后得到 Invalid MAC address,而不是缺少参数错误。
状态码与错误体
| 情况 | 处理器响应 | JSON 内容 |
|---|---|---|
mac 为假值 | 400 | error: 'No MAC address provided' |
| 规范化失败 | 400 | error: 'Invalid MAC address' |
lookup(hex) 返回假值 | 503 | error: 'MAC database unavailable' |
| 匹配到注册块 | 正常 JSON 响应,通常 200 | success: true, found: true 及详细字段 |
| 没有注册块覆盖 | 正常 JSON 响应,通常 200 | success: true, found: false 及标志字段 |
这里列出的是处理器自身的分支,不包含全局中间件可能生成的响应。依据:mac-checker.js。
成功响应字段
| 字段 | 命中时 | 未命中时 |
|---|---|---|
success | true | true |
found | true | false |
macPrefix | 分配块前缀,保留查询首字节,每两位以冒号分隔 | 查询前 6 位,每两位以冒号分隔 |
company | 公司名,空值回退为 'N/A' | 'N/A' |
address | 规范化后的注册地址,空值回退为 'N/A' | 'N/A' |
country | 推断国家代码,空值回退为 'N/A' | 'N/A' |
blockStart | 前缀后补 0 至 12 位,再格式化 | 'N/A' |
blockEnd | 前缀后补 F 至 12 位,再格式化 | 'N/A' |
blockSize | 数字,16 ** hostDigits | 字符串 'N/A' |
blockType | MA-L、MA-M、MA-S、IAB 或 CID | 'N/A' |
isPrivate | 公司名是否为 private | false |
isMulticast、isUnicast、isLocal、isGlobal | 首字节标志 | 首字节标志 |
isRand | 首字节推断,但 CID 命中时为 false | 首字节推断 |
消费者应注意 blockSize 不是固定数字类型。MA-M、MA-S 的前缀包含奇数位十六进制数字,pairs 允许末尾只有一位,因此 macPrefix 不保证全部分组都为两个字符,也不保证是完整 MAC 地址。
依据:oui-db.js。
内部函数参考
源码为 JavaScript,以下参数和返回说明来自函数体,并非仓库中的静态类型声明。
| 实际声明 | 参数与返回 | 失败方式/调用约束 |
|---|---|---|
normalizeMacQuery(input) | 输入字符串;返回大写裸十六进制字符串或 null | 非字符串、长度错误、非十六进制内容返回 null |
createMacChecker(lookup) | 接受查询函数;返回异步 Express 风格处理器 | 查询函数应同步返回;处理器没有局部 try/catch |
parseCsv(text) | CSV 字符串 → 字段行数组 | 未闭合引号抛出 Error |
countryFromAddress(address) | 地址 → 国家代码或 null | 基于末尾词的启发式推断 |
parseRegistry(text, { registry, hexLength }) | CSV 与注册表规格 → [assignment, block] 数组 | 错误表头、未以换行结束或 CSV 引号错误会抛出异常 |
readOuiRegistries(pathOf) | 文件路径解析函数 → 合并后的 Map | 同步读取错误、解析错误、记录数不足会向调用者抛出 |
macFlags(hex) | 至少能表达首字节的十六进制字符串 → 五个布尔属性 | 不负责完整输入校验 |
describeMac(blocks, hex) | 注册表 Map 与规范化输入 → 完整结果对象 | 约定输入已经过规范化,不额外验证参数 |
实现参考:mac-input.js、mac-checker.js、oui-db.js。lookupMac 仅核实了导入和使用,未读取函数体,不在此补写内部返回或异常约定。
配置与数据生命周期
这里的 MAC 专用参数是代码常量,而不是已经核实可由环境变量覆盖的选项。
| 配置/常量 | 类型 | 当前值 | 作用 |
|---|---|---|---|
MAC_HEX_LENGTH | number | 12 | 完整 MAC 十六进制长度和范围补齐长度 |
MAC_PREFIX_MIN_HEX | number | 6 | 最小查询前缀 |
OUI_DB_DIR | string | 模块目录下的 oui-db | 五个本地 CSV 的加载目录 |
IEEE_BASE | string | https://standards-oui.ieee.org | 注册表下载地址的基础域名 |
OUI_REGISTRIES | array | 五种注册表规格 | 文件、URL、前缀长度及最低记录数 |
HEX_LENGTHS | number[] | 从注册表推导为 [9, 7, 6] | 匹配顺序,不是独立人工维护列表 |
LETTERED_POSTCODE | Set | NL、IT | 国家解析时的邮编特例 |
可见的加载代码先检查所有文件是否存在。如果任一文件缺失,会记录 info 级日志,将模块级 blocks 置为 null,然后返回;注释说明首次启动会随后下载。文件齐全时,使用 readOuiRegistries 同步读取,并在成功后记录 assignment 数量和耗时。
这一片段不够证明加载失败后是否保留旧快照、何时热重载或如何监听文件变化。数据更新系统虽在服务入口被导入,具体调度与故障恢复实现未在本页核验。依据:oui-db.js、backend-server.js。
故障、边界与并发
应区分三种“无结果”
- 输入无效:规范化失败,处理器返回 400。
- 数据库不可用:查询返回假值,处理器返回 503;这不等同于厂商未登记。
- 查询有效但未登记:返回
success: true, found: false,仍保留地址属性。
这一区分对前端提示与监控非常重要:将 found: false 计作后端故障会把正常的未知或本地管理地址误报为服务异常。
数据损坏的防护边界
加载层防护包括固定表头、CSV 引号完整性、末尾换行和最低有效记录数。它们可捕获一部分截断或错误下载,但并不是文件内容的真实性校验:有换行且条目数达标的错误内容仍可能通过这些检查。逐行非法记录会被跳过,因此需要结合最终有效数量理解加载错误。
异常和一致性
readOuiRegistries 在局部新建 Map,不会在每读一行时修改模块级 blocks;但不能据此推断整个下载和热更新流程具有跨文件原子性。describeMac 只读取传入索引,没有可见写操作。处理器本身没有重试或异常捕获,查询函数抛出的异常如何变成 HTTP 响应,需查看上层错误处理,本文不作推断。
性能、运维与扩展
- 查询成本固定且较小:最多尝试三种前缀长度,执行
Map.has/Map.get,不会逐条遍历厂商记录。 - 加载成本集中:
readOuiRegistries使用fs.readFileSync;CSV 解析和合并均为同步执行。加载期间会占用 Node.js 执行线程,不应将该读取路径放入每次请求。 - 内存使用随数据量增长:解析会构造行数组、条目数组和最终索引;当前证据不足以给出具体内存数值。
- 排查顺序:先检查 HTTP 状态,再区分
found,随后检查输入长度是否足以定位子分配块,最后检查本地五表是否齐全及加载日志。 - 扩展注册表:新增规格会参与
OUI_FILES与HEX_LENGTHS推导,但还需要验证下载器兼容性、CSV 格式、最小记录数和特殊地址语义,不能只添加 URL 就宣称端到端支持。 - 扩展国家推断:应针对地址尾部格式增加有依据的规则,而不是把推断值当作源 CSV 的权威国家字段。
以上性能结论来自本地匹配及同步加载代码,不是基准测试结果。参考:oui-db.js、oui-db.js、oui-db.js。
测试接缝与验证重点
已核实 createMacChecker(lookup) 为 fixture 测试保留了依赖注入入口;本次没有读取完整测试实现,不声明以下情况已有测试覆盖。维护时建议重点验证:6~12 位边界、混合分隔符、MA-L 与子块重叠、组播范围转换、CID 取消随机化标志、未命中响应、CSV 截断及最低记录数失败。
相关链接
- 查询输入契约:见 mac-input.js。
- HTTP 处理与注入入口:见 mac-checker.js。
- 需要理解厂商识别和地址范围计算时,见 oui-db.js 的匹配实现。
- 需要继续追踪离线启动和缓存接入时,从 backend-server.js 的路由定义 进入相邻基础设施实现;这些机制不由本页展开。