简介:这是一份面向C语言与Lua开发者的nDPI深度包检测实践项目,以玩具级概念验证方式展示如何用Lua脚本驱动libndpi。程序通过main.lua读取pcap文件并逐一检查数据包,对成功关联的协议触发自定义回调操作;回调函数遵循function f(id, packet)模板,便于读者按协议ID定制处理逻辑。资源包共19个文件,体积约951KB,主要包含10个C头文件、1个C源文件、1个Lua脚本、Makefile、编译好的共享库(.so)以及一个pcap示例包,并附带README说明,涵盖接口声明、依赖配置与编译运行步骤。当前已有320人学习下载。读者可获得完整源码与目录结构,借助make命令直接编译生成libndpiu;通过阅读libndpilua的C封装,还能理解nDPI在Lua层的基本调用流程,适合作为网络协议分析与Lua/C混合编程的入门参考。
1. 为什么拿 Lua 调 nDPI:一个玩具程序背后的流量识别需求
某次排查线上流量异常,抓了半小时 pcap,打开一看密密麻麻全是传输层协议,应用层该有的 HTTP、DNS 标签一个都没出来。手动过滤又不知道内部服务到底走的什么协议,那一刻我意识到,手里缺的不是抓包工具,而是一个能把流量自动归类成「这是什么应用」的小引擎。nDPI 是开源的深度包检测库,能识别上千种应用层协议,而 Lua 是写测试程序最顺手的胶水语言,ndpi-lua 这个方向,就是在 Lua 里通过 FFI 绑定 nDPI 的 C 接口,让抓回来的 pcap 文件能批量、快速地打出协议标签。
它解决的核心问题,是把「流量识别」从在黑匣子里点按钮,变成一段自己能改、能跑、能打印中间结果的脚本。适合三类人:做网络运维想快速验证协议特征的工程师,做安全分析需要批量标注 pcap 的研究者,以及想在 Lua 里调用 C 库、又不想写一整套 C 扩展的同学。本文顺着标题把这条路径完整走一遍,包括原理、最小代码和四个必踩的坑。
2. nDPI 与 Lua 绑定的工作原理:一条从字节流到协议名的链路
2.1 nDPI 的检测流程:先分流后识别的两级结构
nDPI 之所以能在流量识别这件事上站住脚,靠的不是单一算法,而是把检测拆成两个阶段。第一阶段是分流,也叫ndpi_flow的管理:每一条五元组(源 IP、源端口、目的 IP、目的端口、协议)对应一个独立的 flow 结构体,nDPI 内部维护一张 flow 表,新到的包按五元组查表,查到就继续用旧状态,查不到就新建条目。这个设计让检测器天然是有状态的,不用担心跨包特征被拆散。
第二阶段才是真正的协议识别。nDPI 内部按「先快速判断,再深度探测」的顺序工作:先查端口映射表(比如 80 端口优先怀疑 HTTP,53 端口优先怀疑 DNS),端口命中不了就进入签名匹配,用内置的模式串去 payload 里做多模式匹配。如果签名也匹配不上,再对 SSL/TLS 流量解析握手报文里的 SNI 字段,对未知流量做行为统计。这个流程决定了使用方式:你喂给 nDPI 的不是一条流量的「结论」,而是一个一个的原始数据包,结论是在包不断流入的过程中逐步收敛的。
对 Lua 绑定来说,理解这个分级流程的意义在于:调用ndpi_process_packet不是一次性的,同一个 flow 可能要喂十几个包才能确定协议。玩具程序如果只喂每个流的前两个包就收工,很大概率拿到的是Unknown。这也是为什么后来我在代码里加了「最大检测包数」和「检测超时」两个参数,让每个 flow 有足够的样本做判断,又不至于拖慢整个文件。
2.2 LuaJIT FFI 作为连接层:为什么玩具程序不用 C 扩展
把 Lua 和 C 库连起来有几条路:手写 C wrapper 然后用package.loadlib加载,或用 LuaJIT 自带的 FFI 库直接声明 C 函数签名。我在 ndpi-lua 这个玩具程序里用的是 FFI,原因是写 wrapper 的成本对「玩具」来说太高了:nDPI 头文件里结构体和函数众多,手动转成 Lua 的 C 扩展,光是处理ndpi_protocol这种嵌套结构就要写几十行样板代码。
FFI 的玩法是直接把 C 头文件里的声明誊进 Lua 代码,比如ffi.cdef[[ int ndpi_init_detection_module(...); ]],然后就能像调用 Lua 函数一样调 C 函数。这里有个关键技术点:FFI 不帮你做类型转换,它做的是内存布局的映射。也就是说,C 结构体里的字段偏移和大小必须和实际库完全一致,否则读出来的全是垃圾值。实践上我会在 cdef 里只声明用到的字段,并且用ffi.sizeof打印验证结构体大小,避免 32 位和 64 位环境下对齐方式不同导致的结构体错位。
FFI 的另一个优势是回调函数可以直接用 Lua 闭包。nDPI 在检测到协议时不会主动跳出来告诉你,而是要求调用方设置回调;在 C 里你要传函数指针,在 FFI 里传一个 Lua function 进去,库内部触发时自动通过 FFI 的 callback 机制把你写的 Lua 函数拉起来执行。这个机制让玩具程序的输出逻辑非常灵活:检测结果可以直接打印,也可以塞进一个 Lua table 里统一汇总,不用碰 C 层的数据结构。
2.3 数据生命周期:谁分配内存、谁负责释放
Lua 有自动垃圾回收,C 没有,nDPI 更是大量使用堆内存,所以 FFI 绑定的核心难点不在调用,而在内存归属。ndpi_init_detection_module返回的检测模块指针、ndpi_flow_malloc分配的 flow 内存,都是由 C 侧管理的,Lua 的 GC 完全不知道它们的存在。如果只调用不释放,跑完一个 pcap 文件就会发现内存涨了一大截,连 LuaJIT 自己也救不回来。
我一般会在程序里把清理动作集中收口,用ffi.gc给指针挂一个析构函数,比如ffi.gc(module, ndpi_exit_detection_module)。这样当 Lua 侧不再引用这个指针时,GC 会顺便调一次 C 回收函数,做到内存生命周期与 Lua 变量一致。注意ffi.gc只对 cdata 对象有效,普通 lightuserdata 挂不上。还有一点:ndpi_free和free不能混用,nDPI 内部有自己封装的内存分配器,谁分配谁释放,跨库释放是未定义行为,轻则内存泄漏,重则崩溃。
数据生命周期还涉及另一个问题:data 指针的借用。ndpi_process_packet里的 payload 参数只是借用,不需要你把整个包拷贝进 Lua。正确做法是让 nDPI 读完即走,不要在 Lua 侧保留对 packet data 的引用,否则一个 GC 周期后数据可能已被回收,再读就是悬空指针。玩具程序里最省心的写法是把包读进固定的 buffer,喂给 nDPI 后立刻丢弃引用。
3. 用 Lua 写一个 nDPI 检测程序:最小可运行版本
3.1 准备环境:编译 nDPI 与确认动态库
这个玩具程序依赖两样东西:nDPI 编译出的动态库,和 LuaJIT(因为要用 FFI,原生 Lua 5.3 没有完整支持)。nDPI 用标准 autotools 流程构建,步骤不复杂,但有一个细节值得注意:默认编译产出的是静态库,而 FFI 只能加载动态库,所以 configure 的时候要显式加上--enable-shared。
git clone <nDPI 源码仓库地址> cd nDPI ./autogen.sh ./configure --enable-shared --enable-debug make -j$(nproc)构建完成后检查src/lib/.libs/目录下是否有libndpi.so。如果只有.a文件,说明 configure 参数没生效,回看 config.log 里有没有关于 shared 的报错。另一个经验是,debug 版本会打印大量内部日志,玩具程序调试阶段建议保留,到批量跑 pcap 时再切回 release 版,性能差距明显。
把libndpi.so拷到/usr/local/lib后执行ldconfig,然后用lua侧验证库能加载:
local ffi = require("ffi") -- 只声明当前用到的两个函数,减少 cdef 出错面 ffi.cdef[[ void *ndpi_init_detection_module(void); void ndpi_exit_detection_module(void *mod); ]] local ndpi = ffi.load("ndpi") local mod = ndpi.ndpi_init_detection_module() assert(mod ~= nil, "nDPI 初始化失败") print("nDPI 模块已加载, 指针: ", tonumber(ffi.cast("uintptr_t", mod))) ndpi.ndpi_exit_detection_module(mod)这段代码的逻辑是把「加载动态库」和「调用初始化函数」拆成两步:ffi.load只负责把符号表映射进来,真正的 C 函数调用发生在下一行。ffi.cast把 cdata 指针转成整数打印,纯粹是为了确认指针非空,生产环境里可以删掉。如果ffi.load报找不到库,用ldd检查 nDPI 的依赖是否齐全;如果ndpi_init_detection_module返回 nil,多半是 cdef 声明和实际库的导出符号不匹配,用nm -D libndpi.so | grep ndpi_init核对符号名。
3.2 读取 pcap 文件并逐包喂给检测器
pcap 文件的解析如果完全自己写需要处理全局头和每包头的字节序,玩具程序里我直接用 libpcap 提供的 API 来读,FFI 声明头文件里的pcap_open_offline和pcap_next_ex就够了。注意 libpcap 的结构体pcap_pkthdr在不同平台上字段偏移有差异,cdef 里必须按本机实际声明,建议先跑一个空程序用ffi.sizeof打印校验。
local ffi = require("ffi") ffi.cdef[[ // libpcap 核心结构体,按 64 位 Linux 声明 struct timeval { long tv_sec; long tv_usec; }; struct pcap_pkthdr { struct timeval ts; uint32_t caplen; uint32_t len; }; typedef struct pcap pcap_t; pcap_t *pcap_open_offline(const char *fname, char *errbuf); int pcap_next_ex(pcap_t *p, struct pcap_pkthdr **hdr, const unsigned char **data); void pcap_close(pcap_t *p); ]]这里把pcap_next_ex的返回值和输出参数都声明出来了,调用时要注意:hdr和data都是指针的指针,在 Lua 侧需要先创建对应的 cdata 容器,然后传引用进去。libpcap 成功读取时返回 1,到达文件末尾返回 0,出错返回 -1,循环跳出条件建议按返回值判断,而不是看数据指针是否为空,因为空指针在 FFI 里会被自动转换为 nil,代码能跑,但你对错误原因就一无所知了。
local errbuf = ffi.new("char[256]") local pcap = ffi.C.pcap_open_offline(argv[1], errbuf) if pcap == nil then error("打开 pcap 失败: " .. ffi.string(errbuf)) end local hdr = ffi.new("struct pcap_pkthdr*") local data = ffi.new("const unsigned char*") local ret = ffi.C.pcap_next_ex(pcap, &hdr, &data) while ret == 1 do local caplen = tonumber(hdr[0].caplen) -- 把 C 指针复制到 Lua 字符串,再交给 nDPI 检测 local packet = ffi.string(data, caplen) feed_packet_to_ndpi(packet, caplen) ret = ffi.C.pcap_next_ex(pcap, &hdr, &data) end这段代码最容易被忽略的是ffi.string(data, caplen)这行:data指向的是 libpcap 内部 buffer,下一次循环就会被覆盖,所以必须立刻拷贝成 Lua 字符串。代价是每次都要做一次内存拷贝,对于测试程序来说完全可接受;如果以后要提速,可以直接把 C 指针和长度传给ndpi_process_packet,让 nDPI 直接读 libpcap 的 buffer,省掉中间拷贝。
3.3 解析检测结果并映射协议名
nDPI 的检测结果分为两层:主协议(master_protocol)和应用协议(app_protocol),比如 TLS 里面的 HTTP 流量,master 是 TLS,app 才是 HTTP。玩具程序要把这两层都打出来,否则看到一串 TLS 也不知道里面跑的是什么。ndpi_protocol结构体里存的是协议 ID,需要再用ndpi_get_proto_name转成字符串。
ffi.cdef[[ typedef struct ndpi_protocol { int master_protocol; int app_protocol; } ndpi_protocol; // 核心检测函数:喂一个 IP 数据包,返回检测结果 ndpi_protocol ndpi_detection_process_packet( void *ndpi_struct, void *flow, const unsigned char *packet, uint16_t packet_len, uint32_t current_tick, void *src, void *dst ); const char *ndpi_get_proto_name(void *ndpi_struct, int proto_id); ]] local proto = ffi.C.ndpi_detection_process_packet( mod, flow, data, len, tick, src_id, dst_id ) local name = ffi.string(ffi.C.ndpi_get_proto_name(mod, proto.app_protocol))current_tick这个参数是 nDPI 内部做会话超时和流量统计用的时间基准,单位是毫秒。玩具程序里不需要真实时钟,直接用一个从 0 开始、每包自增的计数器都行,但要保证它是单调递增的,否则 nDPI 内部的时间窗口计算会错乱。src和dst是 nDPI 的地址结构指针,可以传 NULL,nDPI 会从 packet 里自己解析 IP 头。把协议名转换成 Lua 字符串后,按五元组聚合成一张表,最后统一输出。
local result = {} local key = string.format("%s:%d-%s:%d", src_ip, src_port, dst_ip, dst_port) if not result[key] then result[key] = { app = name, master = master_name, packets = 0 } end result[key].packets = result[key].packets + 1 -- 同一 flow 的后续包,协议名会从 Unknown 逐步收敛到具体协议这段代码处理的是「一个流会被检测多次」的场景。nDPI 在 flow 未确定协议之前,每包都会返回当前的猜测结果;早期包返回 Unknown,往后才收敛。如果只在循环末尾打印,最后总能拿到结论;但如果想观察识别过程,或者想知道一个 flow 到底需要多少包才能定论,就得像我这样按 key 累加状态。玩具程序里我额外记录了一个detected_at字段,标记协议从 Unknown 变成具体名字时已经喂了多少包,这对评估 nDPI 在不同场景下的检测效率很有参考价值。
4. 玩具程序实测中的 5 个踩坑点与排查思路
4.1 现象一:所有流量都识别成 Unknown
跑通第一版程序时,我拿一个只含 DNS 请求的 pcap 测试,结果所有 flow 的 app_protocol 都是 0,打印出来全是 Unknown。当时第一反应是 nDPI 没初始化好,检查了ndpi_init_detection_module返回的指针又没有问题。后来逐包打印 packet 长度才发现,我把caplen和len搞混了:pcap 头里caplen是包里实际截获的字节数,len是原始包长度。抓包文件里len可能比caplen大(因为网卡 snaplen 限制),而我喂给 nDPI 的是按len取的缓冲区,多出来的部分全是未初始化的内存垃圾,nDPI 的 payload 签名匹配自然全部失配。
修复方式是统一用caplen作为数据长度。ffi.string(data, caplen)拷贝多少、ndpi_process_packet收多少,全都是 caplen。另一个隐蔽原因:我在把 IP 头传给 nDPI 时,忘了检查这个包是不是 IP 包。pcap 文件里可能包含 Linux cooked capture 头(SLL)、VLAN 标签甚至非 IP 报文,nDPI 的入口函数默认从 IP 层开始解析,遇到非 IP 帧会直接返回 Unknown。对策是先按 pcap 的链路头类型做偏移,跳过 Ethernet 和 VLAN 字段再交给 nDPI。
-- 错误写法:直接用 data 起点 ndpi_detection_process_packet(mod, flow, data, caplen, tick, nil, nil) -- 正确写法:跳过 Ethernet 头(14字节) 和可选 VLAN(4字节) local l2_offset = 14 local ether_type = lua_bytes_to_u16(data, 12) if ether_type == 0x8100 then -- VLAN l2_offset = 18 end ndpi_detection_process_packet(mod, flow, data + l2_offset, caplen - l2_offset, tick, nil, nil)这个偏移量参数是 nDPI 检测准确度的隐形杀手。TCP 和 UDP 还好说,端口特征在传输层就能命中;但 HTTP、TLS 这类应用层协议靠的是 payload 里的关键字和握手报文,一旦从错误的位置开始解析,签名全对不上。排查时先在 Lua 里把传入的偏移量打印出来,对照 Wireshark 的报文确认 IP 头起始位置即可。
4.2 现象二:Lua 栈溢出或 nil 值错误
FFI 回调的坑比直接调用多得多。我在给 nDPI 设置协议探测回调时,一开始把回调函数直接定义为 Lua function,跑起来报bad callback错误。查资料才知道,LuaJIT 的 FFI callback 只能在「Lua 调用 C」这一侧注册,如果这个 C 函数是在某个线程里被调起来的、或者发生的位置不在 Lua 的调用栈里,Lua 闭包根本没法被正确拉起。nDPI 内部是用线程池做包处理的,回调从 C 线程触发时,LuaJIT 的 VM 根本没准备好,一碰就栈溢出。
绕过的办法是不要在回调里写业务逻辑。我在 C 侧留一个队列指针,回调里只向队列写入协议 ID,Lua 主循环在每次调用ndpi_process_packet之后再从队列里取数据,回到 Lua 的上下文再处理。这虽然多了一层延迟,但对玩具程序完全够用,而且彻底避开了回调线程模型的问题。
另一个常见 nil 错误出在ffi.string的参数上。ndpi_get_proto_name返回的是const char*,如果传入的协议 ID 无效,nDPI 内部会返回一个空字符串而不是释放后的指针,这块本身没问题;但如果你把返回的指针直接存起来,等 flow 处理完再读,大概率已经被 nDPI 内部 realloc 了。正确做法是每次需要名字时现调现转,不要缓存 C 指针。
4.3 现象三:分片包导致检测结果不稳定
IP 分片是 nDPI 的经典盲区。我用一个经过 1500 字节 MTU 分片后的 HTTP 请求测试,前几个分片里 payload 被切得七零八落,HTTP 的GET /关键字根本凑不齐,nDPI 直接返回 Unknown。到了最后一个分片,引用 nDPI 内部的碎片重组逻辑才把应用层数据拼起来,但此时 flow 的检测状态已经被前面的失败结果污染了。
nDPI 的处理方式是让调用方负责分片重组。它暴露了一个ndpi_workflow模块,内部会维护分片表,但玩具程序里我选择更省事的方案:在喂包之前先做简单的 IP 分片判断。IPv4 头里的fragment_offset字段非零、或者MF标志位为 1,就说明是分片包,直接把这类包过滤掉,只喂首分片给 nDPI。因为 nDPI 对绝大多数应用层协议的签名提取只需要第一个分片的前几百字节,后续分片对识别结果没有正向帮助。
local frag_flags = lua_bytes_to_u16(data, 6) -- 第 6 字节是 fragment offset 高字节 local mf = (frag_flags & 0x2000) ~= 0 local offset = (frag_flags & 0x1FFF) * 8 if mf or offset ~= 0 then -- 非首分片直接跳过,首分片 (offset==0) 继续喂 if offset ~= 0 then return end end这个过滤在玩具程序里算是「偷懒但正确」。真实场景里如果需要检测依赖跨分片特征的协议(比如部分 P2P 控制协议),就不能这么粗暴,而是要在 nDPI 的ndpi_workflow上做正经重组。我后来把这个过滤做成了开关,默认为 true,需要时再关掉。
4.4 现象四:内存只增不减
跑完一个 1.2GB 的 pcap,程序 RSS 从 80MB 涨到 1.6GB。起初怀疑是 flow 表没回收,但 nDPI 内部有 idle timeout 机制,正常情况不该这么夸张。用 valgrind 跑了一遍才发现,泄漏的真正原因是我每次循环都调ndpi_flow_malloc新建 flow 对象,但 flow 的释放条件是「检测完成」或「超时」,而 nDPI 的判断依据是current_tick和 flow 的最后活跃时间。我一直用同一个 tick 值喂包,所有 flow 看起来都是「刚刚活跃」,永远不超时,flow 表无限膨胀。
解决方式很笨但有效:把current_tick改成随真实时间递增。每个包进来时调os.time()或者更精确的ffi.C.gettimeofday,让 nDPI 的 idle timeout 正常工作。同时显式把 timeout 调小,从默认值改成 30 秒,让不活跃的 flow 更快释放。
local tv = ffi.new("struct timeval") ffi.C.gettimeofday(tv, nil) local tick_ms = tonumber(tv.tv_sec) * 1000 + math.floor(tonumber(tv.tv_usec) / 1000) -- 使用真实时间后,nDPI 内部 GC 才能正确回收过期 flow这个坑的隐蔽性在于,代码逻辑完全没变,只是时钟从假变真,内存问题就消失了。我后来在代码注释里写了句“不要用伪时间戳,除非你想逼着 nDPI 记住所有会话”。
4.5 现象五:协议名和 Wireshark 对不上
同一条 pcap,Wireshark 显示是 QUIC,我的程序打出来是 TLS。查了 nDPI 的源码发现,它对 QUIC 的识别分两个版本:初始握手阶段通过 UDP 443 端口和 QUIC 固定字段判定为 QUIC,但一旦握手完成进入 1-RTT 加密阶段,payload 全是密文,nDPI 会退化为「TLS-like」的分类。Wireshark 能识别是因为它解析了连接发起时的版本协商和传输参数,而 nDPI 的 flow 状态在包数超过上限后就不再更新。
这个现象不是 bug,是深度包检测的固有边界。nDPI 对加密流量的识别策略是「能握手就识别,握手失败就归类为加密通用型」。玩具程序里合理的处理好方式是:把Unknown和TLS-like这些结果单独归到「未识别」桶里,不要直接丢弃也不要比对 Wireshark 期望一刀切一致。我在输出里加了一列 confidence 字段,按检测阶段(端口/签名/握手/统计)打等级,这样同一个 flow 的多次检测结果可以按 confidence 排序,而不是只信最后一次。
5. 把玩具程序变成验流量特征的工具:批量验证与回归基线
玩具程序跑通只是第一步,真正让它有价值的是能成为一个可重复的验证工具。我后期的用法是拿它做回归基线:把线上抓的典型流量按业务类型分成多个 pcap,每份打上一个期望协议标签,程序跑完自动对比实际输出和期望,不一致的进报告。比如 DNS 请求的 pcap,期望 100% 识别为 DNS;HTTP 下载的 pcap,期望识别为 HTTP 而不是被端口特征误导成 TLS。
批量验证的关键是输出稳定可比较。我会把每个 flow 的最终结果输出成一行 CSV:五元组、包数、master 协议、app 协议、confidence、首包时间、末包时间。然后针对同一份 pcap 跑不同 nDPI 版本或不同参数,直接 diff CSV,看哪些流被改了标签。这个做法帮我发现过几次升级 nDPI 后协议标签变化带来的兼容问题,比如某一版本把某些 P2P 流量从BitTorrent改成了Unknown,如果没有基线脚本,这种变化几乎不可能通过人工抽查发现。
最后分享一个习惯:玩具程序的参数不要写死。协议掩码、检测超时、分片过滤开关、每流最大包数,这些都用环境变量或命令行参数暴露出来,因为当你拿同一个 pcap 调 nDPI 参数时,最痛苦的就是反复改代码重启。我后来把这套东西封装成了一个小的 shell 脚本,循环跑不同参数组合并把结果输出到同名目录,对比起来一目了然。希望帮到你。
本文还有配套的精品资源,点击获取