NetBSD NPF(NetBSD Packet Filter)作为内核级包过滤子系统,其配置文件通常位于 /etc/npf.conf,由一系列规则行构成。当规则数量增长后,仅靠文本阅读很难判断数据包流向、规则优先级及冲突关系。Node.js 生态提供了高效的文件处理与绘图能力,因此很适合实现一个把 NPF 规则转换为图像表达的工具——Mock2Image。本文从规则解析、中间表示建模、SVG 渲染三个方面给出完整实现路径。

NPF 规则语法与解析器设计
NetBSD NPF 的规则采用逐行描述,典型过滤规则形如 pass in on wm0 proto tcp from any to 192.168.1.10 port 22。该行表示允许 TCP 协议数据包从任意地址进入 wm0 接口,目标地址为 192.168.1.10 且目标端口为 22。除此之外,NPF 还支持 block、nat、rdr 等动作,以及用 group "name" 将规则分组。配置文件允许使用 # 开头的注释行,也支持在行尾添加反斜杠实现续行。这些语法细节对解析器的健壮性提出了一定要求。
解析器的核心工作是先将物理行转换为逻辑行,再对每个逻辑行进行 token 化。物理行转换时,需要跳过空行和纯注释行,合并所有以反斜杠结尾的续行。逻辑行的 token 化使用空白字符分隔,但要注意地址组和端口范围内部可能包含花括号、逗号和连字符,不能简单按空格拆分。一个实用的折中方案是先用正则提取出关键字与独立值,再对地址和端口字段做二次解析。下面给出基础解析器实现,它能够识别常见的 NPF 字段并生成结构化对象。
const fs = require('fs');
function parseNpfConfig(text) {
const lines = text.split(/\r?\n/);
const logicalLines = [];
let pending = '';
for (let line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
if (trimmed.endsWith('\\')) {
pending += trimmed.slice(0, -1) + ' ';
} else {
pending += trimmed;
logicalLines.push(pending.trim());
pending = '';
}
}
if (pending.trim()) logicalLines.push(pending.trim());
return logicalLines.map(parseRule);
}
function parseRule(line) {
const tokens = line.split(/\s+/);
const rule = { action: tokens[0], options: {} };
for (let i = 1; i < tokens.length; i++) {
const t = tokens[i];
if (t === 'in' || t === 'out') {
rule.direction = t;
} else if (t === 'on') {
rule.iface = tokens[++i];
} else if (t === 'proto') {
rule.proto = tokens[++i];
} else if (t === 'from') {
rule.from = tokens[++i];
} else if (t === 'to') {
rule.to = tokens[++i];
} else if (t === 'port') {
rule.port = tokens[++i];
} else if (t === 'group') {
rule.group = tokens[++i];
}
}
return rule;
}
上述解析器已经可以处理大多数扁平规则,但真实环境中往往存在地址集合和端口范围。例如 from { 10.0.0.1, 10.0.0.2 } 或 port 1000-2000。此时需要增强解析逻辑,将花括号包裹的内容保留为完整 token 再处理。另外,规则在文件中的先后顺序直接影响匹配优先级,因此中间表示必须保留索引或顺序信息。建议在解析结果中为每个规则添加 order 字段,便于后续布局时按序排列。
中间表示与图形布局策略
得到解析后的规则对象数组后,下一步是构建适合图形渲染的中间表示。Mock2Image 的核心思想是把每个过滤规则转换为一条从源到目标的路径,路径上经过一个动作节点。具体来说,接口名、源地址、目标地址和端口都可以成为图中的节点,而动作(pass/block/nat)作为连接这些节点的中间节点。边上的标签用来描述协议类型或附加条件。这样的图结构能够直观展示数据包在接口上的流转路径,也能暴露规则间的潜在冲突。
中间表示可以采用邻接表存储,节点类型分为 interface、address、port 和 action。每个节点带有唯一 ID、类型标识和显示文本。边对象包含源节点 ID、目标节点 ID 以及可选的标签文本。构建中间表示时,对于每个规则,先创建或查找接口节点,再创建源地址节点,然后创建动作节点,最后连接到目标地址和端口节点。若规则是 nat 类型,还需要额外创建转换后地址节点,并在动作节点旁标注原始地址到转换后地址的映射关系。
function buildGraph(rules) {
const nodes = [];
const edges = [];
const nodeIndex = new Map();
function getNode(type, label) {
const key = type + ':' + label;
if (!nodeIndex.has(key)) {
const id = nodes.length;
nodes.push({ id, type, label });
nodeIndex.set(key, id);
}
return nodeIndex.get(key);
}
rules.forEach((rule, order) => {
const ifaceId = getNode('interface', rule.iface || 'any');
const srcId = getNode('address', rule.from || 'any');
const dstId = getNode('address', rule.to || 'any');
const actionId = getNode('action', rule.action + '#' + order);
edges.push({ from: ifaceId, to: actionId, label: rule.direction || '' });
edges.push({ from: srcId, to: actionId, label: rule.proto || '' });
edges.push({ from: actionId, to: dstId, label: rule.port || '' });
});
return { nodes, edges };
}
布局算法的目标是在二维平面上分配节点坐标,使图形清晰易读。由于 NPF 规则通常按从上到下匹配,采用垂直分层布局比较合适。可以将接口节点放在最左侧,地址节点放在最右侧,动作节点位于中间列。每个规则的三个节点按规则顺序纵向排列,这样规则间的先后关系一目了然。为了避免同一列节点重叠,需要根据每个节点的预估高度累加 y 坐标。对于复杂分组规则,可以按照 group 字段分配水平偏移,形成分组块。
实际实现中,可以使用一个简单的网格布局算法:先统计每个列的最大节点数,然后设定行高和列宽,依次为每个规则分配 y 区间。节点宽度根据标签文本长度估算,接口名称较长时可以截断或缩小字号。边线使用三次贝塞尔曲线或折线连接,避免多条边完全重叠。对于 NAT 规则较多的场景,可以引入不同颜色区分动作类型,例如绿色表示 pass,红色表示 block,蓝色表示 nat,这样一眼就能识别风险规则。
SVG 渲染与命令行工具封装
SVG 是 Mock2Image 输出的首选格式,因为它是矢量图形,可以在浏览器中无损缩放,也方便用 sharp 等库转换为 PNG 或 PDF。纯手写 SVG 字符串的方式虽然灵活,但需要处理文本转义和坐标精度。Node.js 的 sharp 库主要擅长光栅化,并不擅长从零构建图形,因此更推荐先手写 SVG 字符串,再按需调用 sharp 转换。手写 SVG 时要特别注意将标签文本中的 <、> 和 & 转义为对应实体,避免破坏 XML 结构。
下面给出一个简化的 SVG 生成函数,它接收中间表示和节点坐标,输出完整的 SVG 文本。节点用矩形加文本表示,边用带箭头标记的路径表示。文本位置需根据节点中心计算,使用 text-anchor="middle" 实现水平居中。对于长标签,可以启用 textLength 或手动截断,保证图形不溢出画布。
function renderSvg(graph, positions) {
const { nodes, edges } = graph;
let svg = '<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="800">';
svg += '<defs><marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">';
svg += '<path d="M0,0 L0,6 L9,3 z" fill="#333" /></marker></defs>';
edges.forEach(edge => {
const p1 = positions[edge.from];
const p2 = positions[edge.to];
svg += `<line x1="${p1.x}" y1="${p1.y}" x2="${p2.x}" y2="${p2.y}" stroke="#333" marker-end="url(#arrow)" />`;
});
nodes.forEach(node => {
const pos = positions[node.id];
const w = 120;
const h = 40;
svg += `<rect x="${pos.x - w / 2}" y="${pos.y - h / 2}" width="${w}" height="${h}" rx="4" fill="#eee" stroke="#666" />`;
svg += `<text x="${pos.x}" y="${pos.y + 5}" text-anchor="middle" font-size="14">${escapeXml(node.label)}</text>`;
});
svg += '</svg>';
return svg;
}
function escapeXml(str) {
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
}
命令行入口需要解析用户输入的 NPF 文件路径和输出格式。使用 process.argv 可以轻松获取参数,例如 node mock2image.js /etc/npf.conf output.svg。解析完规则并构建图后,先完成布局计算,再渲染 SVG。如果指定了 --png 参数,则调用 sharp 的 sharp(Buffer.from(svg)).png().toFile() 输出位图。错误处理方面,文件不存在、规则语法异常或输出目录不可写都应该输出明确的错误信息,帮助使用者快速修正。
集成测试可以用一个包含多条规则和 NAPT 规则的样例 npf.conf,生成 SVG 后在浏览器中检查节点位置和边的连接是否正确。还可以用 Node.js 的断言库验证解析器对地址组、端口范围和续行的处理结果。经过这样的流水线设计,Mock2Image 不仅能在本地辅助 NPF 规则审计,还能作为 CI 流程的一部分,在每次配置变更时自动生成规则图并归档,极大降低防火墙策略维护成本。
Node.jsNetBSD NPFMock2Image修改时间:2026-08-26 19:07:46