广告位 · 页头(后台-模块管理-新建模块后替换此区域)

如何在Node.js中实现Zeek日志的Mock2Image可视化?

Zeek(原名Bro)在流量分析场景下会持续输出conn.log、dns.log、http.log等文本日志。原始日志虽然信息完整,但当需要快速判断某台主机是否在进行端口扫描、某个域名是否出现异常解析时,表格形式的可读性并不高。把日志汇总成图片能够直观呈现时间维度上的流量变化,本文将说明如何用Node.js实现一个从Zeek日志到PNG图片的转换器,也就是常说的Mock2Image。

如何在Node.js中实现Zeek日志的Mock2Image可视化?

一、解析Zeek日志:用流式方式提取字段

Zeek日志的第一行通常以#separator开头,后面跟着#set_separator、#empty_field、#unset_field、#path、#open、#fields、#types等元信息。真正决定列含义的是#fields行,它列出了所有字段名,例如conn.log里的ts、uid、id.orig_h、id.orig_p、id.resp_h、id.resp_p、proto、service、duration、orig_bytes、resp_bytes等。如果直接使用split('\t')切分数据行,必须保证列顺序与#fields一致。实际日志中可能存在空字段,Zeek会输出占位符“-”,需要统一转成null或0。

在Node.js中,使用readline模块配合fs.createReadStream是相对稳妥的做法。readline会按行触发事件,不会一次性加载整个文件。解析时先收集以#开头的头部信息,当遇到#fields时记录字段名数组,之后所有非#开头的数据行都按该数组转成对象。这里有一个性能细节:避免在每行都调用JSON.stringify,只在最终聚合时使用。下面代码演示了如何把conn.log转换为按源IP聚合的连接数和字节数。

const fs = require('fs');
const readline = require('readline');
async function parseZeekConnLog(filePath) {
  const rl = readline.createInterface({
    input: fs.createReadStream(filePath),
    crlfDelay: Infinity
  });
  let fields = [];
  const aggregate = new Map();
  for await (const line of rl) {
    if (line.startsWith('#')) {
      if (line.startsWith('#fields')) {
        fields = line.slice(8).split('\t');
      }
      continue;
    }
    if (!fields.length) continue;
    const values = line.split('\t');
    const record = {};
    for (let i = 0; i < fields.length; i++) {
      record[fields[i]] = values[i] === '-' ? null : values[i];
    }
    const orig = record['id.orig_h'];
    const bytes = Number(record.orig_bytes || 0) + Number(record.resp_bytes || 0);
    if (!orig) continue;
    if (!aggregate.has(orig)) aggregate.set(orig, { conns: 0, bytes: 0 });
    const entry = aggregate.get(orig);
    entry.conns++;
    entry.bytes += bytes;
  }
  return aggregate;
}

解析出的Map可以直接用于绘图。需要注意,如果日志文件是压缩的.gz,还要先用zlib解压,Node.js的readline不能直接读压缩流,可以通过zlib.createGunzip()接到createReadStream上。这个场景在大型网络环境中很常见。

二、从聚合数据生成Mock2Image图片

有了按主机聚合的数据,下一步是视觉编码。常见的做法是将每个源IP画成一个矩形,矩形高度代表连接数,颜色代表字节数或协议分布。为了减少依赖安装难度,这里不直接使用node-canvas,而是构造SVG字符串,再用sharp渲染为PNG。sharp在多数Linux发行版上可以通过npm直接安装预编译二进制,macOS和Windows也有对应包,省去了编译Cairo的麻烦。

SVG的构建逻辑很简单:先计算最大值,用于归一化矩形高度。然后为每个主机生成一个<rect>元素,设置x、y、width、height和fill。颜色可以用HSL根据字节数线性插值,也可以直接使用固定色板。最后把生成的SVG字符串传给sharp,指定输出格式为png。

const sharp = require('sharp');
function buildSvg(aggregate) {
  const entries = [...aggregate.entries()].sort((a, b) => b[1].conns - a[1].conns).slice(0, 50);
  const maxConns = entries[0] ? entries[0][1].conns : 1;
  const width = 1000;
  const rowHeight = 24;
  const height = entries.length * rowHeight + 40;
  let shapes = '';
  entries.forEach(([ip, data], index) => {
    const barWidth = Math.round((data.conns / maxConns) * 600);
    const hue = Math.min(120, Math.round((data.bytes / (maxConns * 10000)) * 120));
    shapes += `<rect x="220" y="${20 + index * rowHeight}" width="${barWidth}" height="${rowHeight - 6}" fill="hsl(${hue}, 70%, 50%)" />`;
    shapes += `<text x="10" y="${20 + index * rowHeight + 14}" font-size="12" fill="#333">${ip}</text>`;
  });
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">${shapes}</svg>`;
}
async function svgToPng(svg, outputPath) {
  await sharp(Buffer.from(svg)).png().toFile(outputPath);
}

SVG方式虽然比node-canvas少了像素级控制,但对柱状图、折线图、热力图这类基础可视化已经完全够用。如果后续需要更复杂的交互式图表,可以替换为node-echarts服务端渲染或直接生成HTML报告,但那会引入更多依赖。Mock2Image的核心价值在于快速、离线、无浏览器依赖地把日志变成可查看的图片文件。

三、批量处理与性能优化

单个conn.log文件转换为一张汇总图只是一个起点。在生产环境中,Zeek通常会按小时或按大小滚动日志,可能同时存在几十个文件需要处理。逐个读取并用单线程生成PNG效率很低,而且容易因为某个文件过大导致事件循环阻塞。Node.js的异步I/O可以帮助并行处理,但要注意同时打开的文件描述符数量和sharp内部线程池的大小。

一个相对稳妥的做法是使用自定义并发队列。比如设置最多同时处理4个文件,每处理完一个再拉取下一个。对于每个文件内部,继续用readline流式读取,聚合完成后立刻释放原始数据。绘图时如果源IP很多,不要把所有条目都画出来,可以限制只显示前50或前100个连接数最高的主机,否则图片会变得很拥挤。以下代码给出一个简单的并发控制示例。

async function processFiles(filePaths, concurrency = 4) {
  const results = [];
  let index = 0;
  async function worker() {
    while (index < filePaths.length) {
      const current = filePaths[index++];
      const aggregate = await parseZeekConnLog(current);
      const svg = buildSvg(aggregate);
      const outPath = current.replace(/\.log$/, '.png');
      await svgToPng(svg, outPath);
      results.push(outPath);
    }
  }
  const workers = Array.from({ length: concurrency }, () => worker());
  await Promise.all(workers);
  return results;
}

在内存方面,如果聚合的Map非常庞大,可以考虑使用Map的临时导出或分片统计。另一个常见问题是sharp处理大SVG时内存占用会上升,建议每张图的宽度控制在1200像素以内,条数控制在200条以下。如果确实需要展示更多主机,可以改成按网段聚合,而不是按单个IP聚合,这样既能减少绘图元素,又能从更高维度观察网络行为。

最后提醒一点,如果日志中带有IPv6地址,文本排序和展示时要注意长度对齐,可以通过padEnd补齐,或者将IPv6地址压缩显示。这些小细节会直接影响生成图片的可读性。总体来看,用Node.js实现Zeek日志的Mock2Image转换并不复杂,借助流式解析、SVG构图和sharp渲染,可以快速搭建一个离线可视化工具。

广告位 · 文章底部(后台-模块管理替换此区域)