做出口网关和访问控制这块的同学,这两年多少都有点焦虑:以前在 TCP 层做域名管控那套经验,好像一夜之间就不太灵了。抓包能看到大量发往 443 端口的 UDP 报文,但连接状态追不上,TCP RST 也打不进去,连以前最依赖的 SNI 都不像 TCP 时代那样在握手前几个包就能稳定抓到。这就是 QUIC 协议普及带来的连锁反应。这篇文章想聊的,是基于 C/C++ 在 QUIC 流量里做域名级识别与策略执行的一套完整实现路径:从 QUIC 头部解析、Initial 包里的 SNI 提取,到高性能匹配引擎和丢包决策,尽量把关键代码和踩坑点都摊开说。
先说清楚一个问题:为什么 QUIC 会让域名管控这件事变得困难,然后再讲实现。如果不懂这个"为什么",后面写代码的时候很容易在错误的方向上使劲。
1. QUIC 把传统域名管控逻辑打散之后,我们到底在追什么
1.1 传统 TCP 时代的域名拦截链路回顾
在 TCP 占绝对主导的时代,一套典型的域名管控流程是这样的:
客户端发起 DNS 查询,DNS 报文通常是明文 UDP,管控设备可以污染或重定向 DNS 响应,让域名解析到黑洞 IP。这是最早的手段,但现在客户端普遍用 DoH/DoT 了,DNS 层面能做的越来越少。
DNS 没拦住,TCP 握手时还有第二次机会。客户端发送的第一个 TCP SYN 包本身不带域名,但随后 TLS 握手阶段的 ClientHello 会以明文出现在 TCP 载荷里。设备可以做 DPI(深度包检测),在 TCP 载荷里匹配 SNI 字段,命中策略就注入 TCP RST 或直接丢弃握手包。这是目前绝大多数企业网关和内容过滤方案的核心逻辑,也是很多"域名封堵"产品的底层工作原理。
即便 TLS 连接已经建成了,TCP 连接状态也是可被感知的。设备通过四元组、序列号、确认号能持续跟踪这条连接,随时可以 RST 掉,或者用连接跟踪超时的方式静默切断。
这套管控体系能成立,有三个前提:连接状态可见、控制报文可注入、关键握手信息加密前可读取。TCP 面向连接的语义和 TLS ClientHello 明文的客观事实,共同支撑了这套逻辑。
1.2 QUIC 在协议设计上做了哪些"反拦截"设定
QUIC 协议最初的目标和内容管控没有半点关系,它就是为了降低连接延迟、改善弱网体验、提升安全性。但它的一些设计,恰好把上述三个前提全打破了。
第一,连接状态不可见。QUIC 基于 UDP,设备侧看到的是一条条无状态的 UDP 数据报。没有 SYN、ACK、FIN 这种控制面语义,四元组只能代表"UDP 端口上有流量在跑",代表不了连接状态。连接跟踪只能靠超时猜,没有精确的结束信号。
第二,控制报文无法注入。TCP 时代的 RST 注入,到了 QUIC 这里完全失效。往一条 UDP 流里伪造一个包,QUIC 层有连接 ID、包号、密钥认证多重校验,对端很容易识别这不是合法数据并丢弃。注入 ICMP Destination Unreachable 也没用,现代操作系统和 QUIC 协议栈大部分时候会直接忽略。最可靠的干预手段,反而回到了最简单的"静默丢包"。
第三,明文信息变少,但没完全消失。TLS 1.3 把握手过程中除 SNI 之外的大部分元数据都加密了;QUIC 里整个 TLS 握手数据都在 CRYPTO 帧里,但好在 ClientHello 中的 SNI 扩展仍然以明文形式传递。这给域名级识别留下了一个关键窗口。后面会详细讲这个窗口具体在哪、怎么抓住。
这三个前提失效,决定了 QUIC 时代域名管控的实现思路必须换一套:在无连接、不可注入、信息半加密的流量里,靠"读到 SNI、记住这条流、按策略丢弃"完成闭环。整个架构的核心是识别能力加连接缓存加丢包动作的组合,和 TCP 时代的逻辑有本质差异。
1.3 题目真正要解决的技术问题
综合来看,QUIC 域名封堵这个题目真正的技术难点有三个:
第一个是"认得出"。UDP 流量里哪些包是 QUIC?QUIC 包里哪个字段能作为稳定指纹?拿到一个 QUIC Initial 包后,怎么在不清洗 TLS 载荷的情况下找到 SNI?
第二个是"记得住"。QUIC 没有连接结束的显式信号,那连接跟踪表该设计成什么样?什么时候该把一条流的缓存淘汰掉?连接 ID 在跟踪里能起什么作用?
第三个是"管得住"。识别出目标域名后,用什么样的动作能达到可靠管控?为什么 RST 对 UDP 无效?静默丢包的实际效果如何验证?
下面几个章节就按这三个问题展开。看完之后,你可以直接用 C/C++ 把一条完整的 QUIC 域名识别与策略执行链路搭起来。
2. QUIC 流量里能读到什么:可观测信号拆解
想写解析代码,得先认识 QUIC 的包头。很多刚开始搞 QUIC DPI 的人会去翻协议标准,然后被各种扩展、帧类型搞懵。实际上做管控系统不需要完整实现 QUIC 协议,只需要把特定信号抓出来就够了。
2.1 认识 Long Header:固定 bit、Version 与连接 ID
QUIC 包头分 Long Header 和 Short Header 两种形态。管控系统最关心 Long Header,因为握手阶段的关键包(Initial、Handshake、0-RTT)都用 Long Header,而 Short Header 是握手完成后的 1-RTT 数据包,里面几乎没有可读的明文信息。
一个 Long Header 的核心结构是:第一个字节的高位部分标识 Header Form(0x80 表示 Long Header)和 Fixed bit(0x40,必须为 1,这是协议层面的强制校验位,也是 DPI 识别 QUIC 最稳定的指纹之一);接下来 4 个字节是 Version 字段,QUIC v1 的 Version 是 0x00000001,v2 是 0x6b3343cf——这是一个随机的版本号,不是 2,设计上有意为之;后面是 DCID(Destination Connection ID)和 SCID(Source Connection ID)的长度加内容。
在 C/C++ 里解析这个头部非常直接:
#define QUIC_LONG_HEADER 0x80 #define QUIC_FIXED_BIT 0x40 #define QUIC_TYPE_MASK 0x30 #define QUIC_INITIAL_TYPE 0x00 typedef struct { uint8_t first_byte; uint32_t version; uint8_t dcid_len; uint8_t dcid[20]; uint8_t scid_len; uint8_t scid[20]; uint8_t type; } quic_long_header_t; int quic_parse_long_header(const uint8_t *buf, size_t len, quic_long_header_t *hdr) { if (len < 7) return -1; hdr->first_byte = buf[0]; if ((hdr->first_byte & QUIC_LONG_HEADER) == 0) return -1; if ((hdr->first_byte & QUIC_FIXED_BIT) == 0) return -1; hdr->version = ntohl(*(uint32_t *)(buf + 1)); hdr->dcid_len = buf[5]; if (hdr->dcid_len > 20 || 6 + hdr->dcid_len + 1 > len) return -1; memcpy(hdr->dcid, buf + 6, hdr->dcid_len); uint32_t off = 6 + hdr->dcid_len; if (off >= len) return -1; hdr->scid_len = buf[off++]; if (off + hdr->scid_len > len) return -1; memcpy(hdr->scid, buf + off, hdr->scid_len); off += hdr->scid_len; hdr->type = (hdr->first_byte & QUIC_TYPE_MASK) >> 4; return 0; }这段代码有几个值得注意的细节。DCID 最大 20 字节是协议限制;Version 字段在网络字节序和主机字节序之间的转换非常容易出错,一不留神会把 0x00000001 判断成 0x01000000。我早期调代码在这个问题上卡了很久,建议在所有涉及 Version 比较的地方直接用 ntohl 统一转一次,不要在多个函数里各转各的。
2.2 Initial 包里的 CRYPTO 帧与 TLS ClientHello
解析出 Long Header 只是第一步,要拿到域名,还得继续往下拆 Initial 包的载荷。这一点集中体现了"为什么说 QUIC 的 SNI 仍然是可提取的"——因为 Initial 包在密钥协商之前,整个 CRYPTO 载荷都是明文,TLS 握手消息边界相对规整。只要知道 QUIC 帧的封装方式,SNI 就能用和 TCP 时代类似的启发式扫描找出来。
Initial 包的载荷遵循 QUIC 帧格式,流控、ACK、CRYPTO 都以帧的形式承载。CRYPTO 帧类型是 0x06,里面装载的就是 TLS 握手消息。提取思路是解析完 Long Header 后,从载荷起始位置开始循环解析帧头,找到 frame type 为 0x06,读取它的长度字段,把这段数据交给 TLS 解析函数。
有一种启发式做法在工程上更实用:不完整解析 QUIC 帧头,直接在载荷里扫描 TLS ContentType(0x01,表示 handshake),再往后读 4 字节握手消息长度,跳过 34 字节的握手头,在 ClientHello 的固定字段之后扫描扩展项,找 extension type 为 0x0000 的 SNI 扩展。这个扫描法在明文 ClientHello 的前提下,比精确解析 QUIC 帧更快更稳。
一个简化版 SNI 提取函数的核心逻辑如下:
static int extract_sni_from_clienthello(const uint8_t *tls_buf, size_t tls_len, char *sni, size_t sni_cap) { if (tls_len < 4) return -1; size_t off = 4; if (off + 34 > tls_len) return -1; off += 34; // 跳过 version(2) + random(32) if (off >= tls_len) return -1; uint8_t sid_len = tls_buf[off++]; if (off + sid_len > tls_len) return -1; off += sid_len; // 跳过 session_id if (off + 2 > tls_len) return -1; uint16_t cs_len = ntohs(*(uint16_t *)(tls_buf + off)); off += 2 + cs_len; // 跳过 cipher_suites if (off >= tls_len) return -1; uint8_t cm_len = tls_buf[off++]; off += cm_len; // 跳过 compression_methods if (off + 2 > tls_len) return -1; uint16_t ext_total = ntohs(*(uint16_t *)(tls_buf + off)); off += 2; uint16_t parsed = 0; while (parsed + 4 <= ext_total && off + 4 <= tls_len) { uint16_t ext_type = ntohs(*(uint16_t *)(tls_buf + off)); uint16_t ext_len = ntohs(*(uint16_t *)(tls_buf + off + 2)); if (ext_type == 0x0000 && ext_len > 5 && off + 4 + ext_len <= tls_len) { // SNI extension 内部结构: // server_name_list_len(2) + name_type(1) + name_len(2) + name uint16_t name_len = ntohs(*(uint16_t *)(tls_buf + off + 9)); if (name_len < sni_cap - 1 && off + 11 + name_len <= tls_len) { memcpy(sni, tls_buf + off + 11, name_len); sni[name_len] = '\0'; return 0; } } off += 4 + ext_len; parsed += 4 + ext_len; } return -1; }这段代码看起来长,但逻辑非常机械。两个容易出错的细节:一是 SNI 扩展内部还有 2 字节的 server_name_list 长度字段,偏移量容易算乱;二是客户端会在 ClientHello 里填充多个扩展,不能直接按固定偏移取,要按扩展链表顺序扫描。
2.3 ECH、DNS 与连接 ID:SNI 之外的旁路信号
ECH(Encrypted ClientHello)是 TLS 1.3 的扩展,目标是连 SNI 一起加密。如果 ECH 大规模部署,上面这套 SNI 明文提取逻辑就会失效。但现实是,ECH 的部署需要客户端和服务端配套升级,期间还有各种兼容性问题,实际流量占比很低。对管控系统来说,正确的态度是把 ECH 当成变量考虑,而不是立刻推翻现有架构。解析不到 SNI 时,靠其他信号兜底。
有两个旁路信号值得重视。第一个是连接 ID。QUIC 的连接 ID 由客户端随机生成,在同一连接的所有数据包中保持稳定,即使 IP 和端口变了也不会变。对照五元组和连接 ID,可以把一条连接在不同网络路径下的流量关联起来。第二个是 DNS 线索。如果管控设备能同时看到 DNS 解析流量,那么在某个 IP:端口上先出现某域名的解析响应、之后再出现 QUIC 流量,两者之间就有强关联。这些旁路信号决定了系统在 SNI 提取失败时还有没有"次优决策"能力,而不是直接变成盲区。
3. C/C++ 实现路径:从抓包到执行丢包的完整链路
3.1 整体架构:四模块用队列衔接
一个可落地的 QUIC 域名管控系统,我建议拆成四个模块:抓包模块、协议解析模块、策略匹配模块、执行动作模块。四个模块之间用无锁队列或环形缓冲区衔接,抓包线程只管把原始包放进队列,解析线程负责从队列里取包、解析、查策略、决策,动作线程负责实际丢包和日志统计。模块职责分清楚,后面调性能、修 bug 都会省力很多。
需要提前明确部署形态。旁路部署时,抓包用镜像口,丢包动作需要在另一个设备上执行,或者在同一设备上通过另一个物理接口下发指令;串联部署时,抓包模块和执行动作模块共用同一块网卡的收发路径,决策后直接丢弃,不需要跨设备通信。这两个场景的架构差异很大,下面按串联部署讲,这也是最常见的实现方式。
3.2 抓包层选型:libpcap、AF_PACKET 与 DPDK 的取舍
抓包层选型直接决定性能上限。三种常见方案:
- libpcap:开发效率最高,跨平台,适合原型验证和低流量场景。缺点是要经过内核协议栈和 BPF 过滤,性能天花板明显。
- AF_PACKET + PACKET_MMAP:通过 mmap 把内核环形缓冲区映射到用户态,减少一次拷贝,性能优于 libpcap,代码量也可控。这是很多高性能 DPI 产品的选择。
- DPDK:用 UIO/VFIO 绕过内核,用户态轮询网卡,性能最强,但部署复杂,CPU 占满,还要配置大页内存。适合几十 Gbps 的骨干链路。
我的实际项目经验是,先用 libpcap 验证解析逻辑,流量上来后切 AF_PACKET,真正到了骨干网再上 DPDK。不要一上来就 DPDK,调试成本会拖慢整个项目进度。
libpcap 的初始化代码大致如下:
char errbuf[PCAP_ERRBUF_SIZE]; pcap_t *handle = pcap_create("eth0", errbuf); pcap_set_snaplen(handle, 256); // 只要前 256 字节,够提取 SNI 了 pcap_set_promisc(handle, 1); pcap_set_timeout(handle, 100); pcap_activate(handle); struct bpf_program fp; pcap_compile(handle, &fp, "udp port 443", 0, PCAP_NETMASK_UNKNOWN); pcap_setfilter(handle, &fp);snaplen 值得单独说。提取 SNI 只需要抓到 QUIC 包头加部分 CRYPTO 帧,256 字节基本够用。snaplen 设太大收包吞吐量明显下降,设太小又可能导致 SNI 被截断。建议上线前先统计一下实际流量里 SNI 出现在第几个字节,再调这个参数。
3.3 核心解析:从 UDP 载荷到 SNI 字符串
把前面几个环节串起来,就是一个从 UDP 载荷到 SNI 的完整函数。入口接收脱去以太网头、IP 头的 UDP 载荷指针,出口是提取出的域名。
int quic_extract_domain(const uint8_t *udp_payload, size_t payload_len, char *domain, size_t domain_cap) { quic_long_header_t hdr; if (quic_parse_long_header(udp_payload, payload_len, &hdr) != 0) { return -1; // 不是 QUIC 长包头,或者被截断了 } if (hdr.version == 0) { return -2; // Version Negotiation 包,没有载荷 } size_t hdr_len = 6 + hdr.dcid_len + 1 + hdr.scid_len; if (hdr_len >= payload_len) return -3; // 在载荷中寻找 CRYPTO frame const uint8_t *cur = udp_payload + hdr_len; const uint8_t *end = udp_payload + payload_len; while (cur < end) { uint8_t frame_type = cur[0]; if (frame_type == 0x06) { // CRYPTO frame return extract_sni_from_clienthello(cur + 1, end - cur - 1, domain, domain_cap); } cur++; } return -4; }工程上这里还能继续打磨。QUIC 的帧长度和偏移用的是 VARINT 编码,不是定长字段,完整实现要正确解析变长整数。另外 Initial 包在路径 MTU 限制下可能被拆成多个包,每个包里只有一部分 CRYPTO 帧,必须做分片缓存和重组。
分片重组是 QUIC 解析里最容易出 bug 的地方。我遇到过 ClientHello 被拆在两个 UDP 包里,第一个包只到 session_id,第二个包才是 SNI 扩展。如果不做重组,SNI 永远提取不到。实际工程里要维护一个基于四元组加连接 ID 的 CRYPTO 分片缓冲区,按 offset 写入、按需拼接。
3.4 策略匹配:哈希表精确命中,前缀树解决通配
拿到域名后,要解决的是"怎么和策略库高效比对"的问题。三种做法各有适用场景:
- 哈希表精确匹配:适合完整域名精确名单,查询性能最好,每秒能完成几百万次查询。这是最高效的路径。
- 前缀树/后缀匹配:策略库里有大量通配规则(比如 .example.com)时,哈希表就难受了。用基于标签倒序的前缀树,比如 com -> example -> *,插入和查询都很快。
- 字符串全量扫描:策略库只有几十条时,线性扫描最简单,CPU 开销可忽略;规则上万条就必须走前两种方案。
实际项目里我推荐“哈希表精确匹配 + 前缀树通配匹配”双引擎。先查精确白名单,命中放行;没命中再走前缀树做后缀匹配。域名归一化要提前做,全部转小写、去掉末尾的点、IDN 转 punycode。这里有个特别容易被忽视的坑:不少客户端会带一个无意义的尾随点(比如 example.com.),如果库里存的是 example.com,不做归一化就会漏掉。归一化放在加载规则时做,不要在查询时做,这样查询路径少一次字符串处理。
3.5 丢包执行:UDP RST 是伪命题,静默丢包才是正解
有人会习惯性地在识别出目标域名后尝试注入 RST,这在 QUIC 场景下是无效的。QUIC 连接状态只在端到端之间维护,设备伪造的任何 UDP 包都会被连接 ID、包号、密钥认证三重校验识破。所以 QUIC 域名管控最可靠的动作是静默丢弃命中策略的数据包,让它得不到任何响应,客户端反复重试后超时失败。
这个动作在串联部署里实现起来很简单:解析线程命中策略后,直接丢弃这个包,不向网卡发送。但有一个细节容易被忽略——客户端重试次数是有限的,如果只丢 1-RTT 数据包而不丢 Initial 包,客户端可能已经完成握手进入正常通信了。最稳妥的策略是:一旦识别出该连接命中了策略,就把这条连接从 Initial 开始的所有后续包全部丢弃,持续一段时间。这个"持续一段时间"要多久,得靠连接跟踪表的超时配置来控制,后面章节专门讲。
4. 性能与误杀权衡:决定方案能不能上生产线的关键
4.1 单机吞吐、小包 PPS 与环形缓冲的背压问题
QUIC 流量最常见的是几十到几百字节的小包,小包场景考验的是包处理速率(PPS),不是带宽。一台普通 x86 服务器,用 AF_PACKET 加多队列加 CPU 亲和性,单核跑到 1~2M PPS 是正常的;DPDK 环境下单核可以到 10M PPS 以上。按这个量级估算,千兆出口可能只需要几个核就够了,万兆骨干网必须上 DPDK 或者多机负载。
环形缓冲的背压设计要特别注意。解析线程处理不过来时,环形缓冲区会被写满。这时候抓包线程应该丢新包,而不是丢老包,因为已经读到的包可能包含关键握手信息。如果反过来丢老包,连接跟踪状态会大量缺失,SNI 缓存命中率直线下降,系统的实际管控能力会大打折扣。
4.2 连接跟踪表设计与超时策略
QUIC 没有 FIN 标志,设备感知不到连接结束。连接跟踪表必须靠超时机制回收。我根据流量统计给的建议是:UDP 会话超时设 60 秒,QUIC 因为有长时间空闲后复用连接的情况,建议放宽到 120 秒。连接表项超时后标记为可回收,否则高连接数场景下内存会被慢慢吃光。
连接表的 key 建议用五元组(源 IP、目的 IP、源端口、目的端口、协议),同时额外存一份连接 ID。为什么还要存连接 ID?因为 QUIC 连接迁移会改变 IP 和端口,但连接 ID 不变。靠连接 ID 可以追踪到同一条连接在不同网络路径下的流量,这个信息在策略执行时非常有用,比如连接迁移后仍然能关联到之前记录的目标域名。
4.3 解不到 SNI 时的降级决策链
解不到 SNI 的情况不少:ECH 加密、分片超时、snaplen 截断、非标准 QUIC 实现都有可能导致提取失败。这种情况下一刀切放行等于让策略形同虚设,一刀切阻断又会误伤大量正常流量。
我建议把降级逻辑做成一条优先级链:第一优先看之前解析并缓存的该 IP 的 SNI 记录;第二优先看 DNS 响应缓存中的域名与 IP 映射;第三优先看该 IP 的端口和流量行为画像,比如短时间大量 UDP 443 流量且没有其他协议特征,很可能就是 QUIC 批量访问;最后才放弃决策,按默认策略处理。
这里需要配合日志回答一个问题:每一项决策是从哪个来源命中的?只有把命中来源分布统计出来,才能知道系统真正"看清"了多少流量。如果 SNI 命中率长期低于 50%,那就说明系统存在严重的识别盲区,必须回头优化解析逻辑或降级策略。
4.4 命中日志与统计:先看清自己再谈优化
生产级实现不能只闷头丢包,还要能回答"丢了多少、命中哪些规则、有没有误伤"。我一般用两个统计维度。本地计数器用原子变量或 per-CPU 数组,记录总包数、QUIC 包数、SNI 提取成功数、命中策略数、丢包数,这是实时诊断的基础;审计日志采样记录命中的五元组、域名、规则 ID、时间戳,异步写入磁盘或发到日志中心,用于事后分析和规则调优。
性能开销方面,每包加几次原子自增对整体处理性能影响可以忽略。关键是日志不能同步写,同步写磁盘会把整个数据面拖死,必须异步。
5. 绕过手法、误判场景与加固方向
5.1 ECH 与自定义 TLS 层带来的盲区
ECH 一旦铺开,SNI 提取的根基就不存在了。那管控是不是就彻底失效?也不是。从协议设计看,ECH 只是加密了 ClientHello 里的 SNI,服务端的证书、IP 地址、连接行为仍然可观测。可以利用已有情报库做 IP 画像,把某些服务使用的 IP 网段和域名做关联;也可以对连接建立后的证书指纹做被动识别,甚至主动探测。这是一场攻防对抗,不可能一劳永逸,但架构上不要把宝全部押在单一信号上。
从工程角度,建议在解析模块里做一个可配置开关:当某个目标 IP 的 QUIC 流量占比异常升高,但又一直提取不到 SNI 时,触发"重点跟踪",提高这个 IP 关联命中的缓存优先级,而不是直接放弃。这套机制在遇到新型客户端或协议变体时特别有用。
5.2 非标 UDP 端口与流量伪装
QUIC 标准端口是 443,但协议本身不强制,客户端完全可以把 QUIC 流量发到任意 UDP 端口。要识别这类流量,得靠 QUIC 本身的指纹。我在识别模块里干脆放弃单端口判断,统一对全部 UDP 流量做 QUIC 可能性评分,分数高就进完整解析流程。评分逻辑不复杂:第一字节 Fixed bit 加 Version 字段合法性,准确率已经非常高。
流量伪装是另一个方向。有人会在 UDP 载荷前面拼一段非 QUIC 的数据,或者修改 Version 字段绕过规则。遇到这种情况,可以在解析前先做一次载荷偏移探测,自动跳过未知的前缀。这个功能实现起来不复杂,但能显著提升在对抗环境下的识别鲁棒性。
5.3 误杀场景:CDN、共享 IP 与域名跳转
IP 上经常是成千上万个域名共存的,尤其在 CDN 场景。如果策略只看域名命中,不看 IP 归属,很容易因为一个子域名命中策略就误杀整台服务器上的所有域名。确认前一定要做 DNS 关联和证书 SAN 校验。
域名跳转也很常见,一个主页域名跳转到另一个内容域名,策略要跟着主文档域名走,不能只拦初始域名就算完事。这类误杀在测试阶段很难暴露,通常都是上线后业务方来投诉。所以架构里要预留白名单优先通道:匹配顺序上白名单先于黑名单,重要业务域名走专门白名单规则,不参与通配匹配。
5.4 后续可以往哪些方向扩展
QUIC 协议还在演进,后续可以做的方向包括:支持更多 QUIC 版本的解析(v2 已经出现,GREASE 版本号也要兼容);引入用户态协议栈做更深入的连接状态管理;把 DPDK 收包和策略执行模块做成独立服务,用 gRPC 下发规则,方便跟已有安全平台对接。
还有一个容易被低估的方向:机器学习辅助的流量画像。但前提是先把前面这些规则引擎和数据链路做扎实,否则模型再准也没有可靠的特征输入。数据质量决定了模型效果,这个顺序不能反。
我自己的体会是,QUIC 域名管控这件事,难点从来不在"写一个能解析 SNI 的函数",而在于"当 SNI 拿不到的时候,系统能不能不慌、不误伤、还能继续工作"。当初在测试环境里跑通提取逻辑还挺兴奋,等放到真实流量里才发现,各种非标准实现、分片、ECH、奇怪的 UDP 端口把场景搅得很乱。如果你也要做这套系统,先把可观测性做起来,把每个决策的来源记录下来,先看清自己,再谈优化。规则引擎可以后续迭代,但架构一旦定下来,改起来就费劲了。