给 Drogon 提第一个 PR:从一行 curl 到 255 行合并
昨天下午,drogonframework/drogon 的 master 分支多了一行提交:
1 | fix(plugin): support ipv6 in RealIpResolver (#2598) |
11 天前我还只是个用 Drogon 写秒杀项目的使用者,那篇 XFF 勘误写的是我自己取错请求头的事。这篇把后面这段补上:一次真实的上游贡献,从头到尾。
先把结论摆在最前面,因为它是这篇唯一有分量的部分:
「读源码读出来的 bug」和「能进上游的 patch」之间,差的不是代码能力,是取证纪律。
我读了源码、推出了两条缺陷、写了个 issue——然后在 issue 里明说了「我没有跑复现,请把具体报错字符串当作推导结果而非观测结果」。这句话看着像示弱,实际是唯一能让自己不翻车的写法。最后 3 个文件、255 增 / 36 删,被合并。
一、这个 PR 到底改了什么
先把账摆清楚,后面所有复盘都建立在这张表上:
| 项 | 值 |
|---|---|
| PR | drogonframework/drogon#2598 |
| 标题 | fix(plugin): support ipv6 in RealIpResolver |
| 关联 issue | #2596(Closes #2596) |
| 分支 | Hespethorn:fix/realipresolver-ipv6 → drogonframework:master |
| 体量 | 3 files changed, +255 / −36 |
| 提交数 | 3 commits,合并方式为 squash |
| 开出 | 2026-09-18 02:44 UTC |
| 合并 | 2026-09-28 15:07 UTC(北京时间 23:07),by an-tao(项目作者) |
| Review | 0 条 —— 没有 review、没有行内评论、没有 reviewer 指派 |
三个文件:
| 文件 | 改动 | 内容 |
|---|---|---|
lib/inc/drogon/plugins/RealIpResolver.h |
+19 / −5 | CIDR 结构体换存储表示;头注释从「只支持 ipv4」改成「both ipv4 and ipv6」 |
lib/src/RealIpResolver.cc |
+109 / −30 | CIDR 构造、matchCidr、新增 comparePrefix、重写 parseAddress |
lib/tests/RealIpResolverTest.cc |
+127 / −1 | 测试用例 5 → 14,断言 56 条 |
最后一行是这个 PR 能被合掉的关键。改动量最大的文件是测试——比源码改动还多。等会儿说为什么。
二、起点是一条评论,不是一次调研
11 天前那篇信任边界的长文是回应一条读者评论的:那位读者主张「直接在 proxy 上 strip 掉非可信 header,把复杂性丢给 proxy」。
我写完那篇,顺手把结论里「Drogon 有官方插件能干这事」的插件源码读了一遍。读完发现,它只支持 IPv4。
这不是我特意去找的 bug。是写文章时为了说清「边界 strip 和后端解析是同一个算法」,把源码当证据引用,顺手读到的。
flowchart LR
A[读者评论<br/>「strip 掉就行」] --> B[写长文回应<br/>引 nginx/Envoy 源码]
B --> C[顺手读<br/>Drogon RealIpResolver]
C --> D[发现:只支持 IPv4]
D --> E[issue #2596]
E --> F[PR #2598]
F --> G[merged ✅]
这条链的起点是「为了写清楚而读源码」,不是「为了提 issue 而读源码」。 后者容易带着目的去找证据,前者不。这个区别后来救了我——因为在 issue 里我不得不承认「我没跑复现」,这是带着目的找证据时最说不出口的一句话。
三、缺陷本体:两条静默失效
插件做的是那个两步解析:先验直连方是否可信代理,再从右往左扫 XFF 取第一个不可信地址。问题出在「只认 IPv4」上,而且分两条路。
缺陷一:trust_ips 里填 IPv6,报的是「地址格式错误」
原实现:
1 | trantor::InetAddress addr(ipv4, 0); // 第三个参数 ipv6 默认为 false |
看着挺周全:专门的 IPv6 分支、专门的报错文案。但那个分支永远进不去。因为 trantor 的字符串构造函数不自动判断地址族——isIpV6_ 直接取自参数:
1 | // trantor/net/InetAddress.cc |
参数默认 ipv6 = false → isIpV6() 恒为 false → 拿 "2001:db8::1" 去跑 inet_pton(AF_INET, ...) 失败 → 返回 isUnspecified() == true。
于是填 "trust_ips": ["2001:db8::1"] 的人,收到的是:
1 | Bad ipv4 address: 2001:db8::1 |
这句话在说「你的地址格式不对」。而这是个完全合法的 IPv6 地址。 用户会去反复检查配置里的拼写,而正确答案是「这个插件压根不支持 v6」——一句已经写好了、却因为一个默认参数永远抛不出来的话。
flowchart TD
A["InetAddress addr(ipv4, 0);<br/>ipv6 参数默认 false"] --> B{"if (addr.isIpV6())"}
B -->|"isIpV6_ 取自参数<br/>恒为 false"| C["✗ 永远走不到<br/>专门的报错文案抛不出来"]
B -->|false| D{"if (addr.isUnspecified())"}
D -->|"inet_pton(AF_INET) 失败<br/>2001:db8::1 解析不了"| E["✓ 实际从这里抛出<br/>Bad ipv4 address"]
E --> F["把「不支持」说成了「写错了」<br/>误导性报错比不报错更难查"]
缺陷二:XFF 里的 IPv6 被静默丢弃
parseAddress() 用 find(':') 拆 host 和 port:
1 | auto pos = addr.find(':'); // "2001:db8::1" → pos == 4 |
"2001" 过不了 inet_pton(AF_INET, ...),isUnspecified() 为真。然后在遍历循环里:
1 | while (!(ip = parser.getNext()).empty()) |
每一条 IPv6 都落到最后那行,插件存下的是 TCP 对端地址,也就是反代自己的地址。
这条的后果形状,和「trust_ips 填窄」一模一样:反代后面所有 IPv6 客户端收缩成同一个 key。任何建在 GetRealAddr() 上的按 IP 限流——注册频控、登录锁定——会把整个 IPv6 人群当成一个客户端。
而日志里一切正常。
flowchart TD
A[XFF: 2001:dba::1, 2001:db8::99] --> B[parseAddress 逐个解析]
B --> C{find(':') 拆 host/port}
C -->|IPv4 1.2.3.4| D[解析成功]
C -->|IPv6 2001:dba::1| E[stoi 抛异常<br/>→ InetAddress 2001,0]
E --> F[isUnspecified = true]
F --> G[continue 跳过]
G --> H[整链扫完无命中]
H --> I[回退 peerAddr = 反代地址]
D --> J[返回真实客户端 ✅]
I --> K[所有 IPv6 客户端<br/>collapse 成同一个 key ❌]
还有第三处,是推导出来的
matchCidr() 也是纯 IPv4——addr.ipNetEndian() 返回 32 位 in_addr_t。所以就算解析修好了,v6 对端也永远匹配不上任何可信 CIDR。这一处在 issue 里列了,因为它决定了修法的形状:不能只修 parseAddress。
四、issue 里最要紧的一句话
issue 正文末尾我写了这么一段:
Everything above comes from reading
lib/src/RealIpResolver.ccandtrantor/net/InetAddress.{h,cc}. I have not run a reproduction, so please treat the exact error strings as derived rather than observed. Happy to attach a failing test if that would help.
现在回头看,这段话是这个 PR 能成的前提。理由很实在:
- 本机没有能编译 Drogon 的环境,硬造一个「复现输出」就是编。而开源维护者最反感的就是编。
- 我确实在写的时候也没打算跑:脏数据、测试、描述全都能从代码推出来,但推导结果的风险是——它是一个模型,模型可能错,而错的地方你恰恰知道得最少。
- 所以主动把边界划出来,把「我确定的部分」(代码路径、参数默认值、算子语义)和「我推的部分」(具体报错字符串)分开。维护者拿到的是一个可分级的证据包,而不是一堆混在一起、真假难辨的断言。
反过来说:如果我在 issue 里用笃定的语气写「实测报错为 Bad ipv4 address」,然后 an-tao 一跑发现文案对不上,那这条 issue 的公信力会连同后面整个 PR 一起崩掉。在没法跑的地方主动降级措辞,不是谦虚,是止损。
五、修法的核心:一个结构体换掉两个字段
原实现把 CIDR 存成「32 位地址 + 32 位掩码」:
1 | struct CIDR |
这个表示从类型上就排除了 v6。改法是换存储:
1 | struct CIDR |
这里有个设计上值得说一下的点,注释里也写了:字节串长度本身就是地址族标识。4 字节 vs 16 字节,长度不同直接返回 false,不需要任何 if (isIpV6) 分支。地址族这个维度,用「数据结构」表达比用「代码分支」表达更省事、也更难写错。
比对逻辑从「掩码与」换成「前缀字节比较」:
1 | static bool comparePrefix(const std::string &addr, |
remainingBits 那几行是必须的。前缀长度不一定是 8 的倍数(/33、/12),最后一个字节得按位掩一下。这一处漏了,测试用例 12 和 13 就会挂——后面讲。
parseAddress 重写成按形态分派:
| XFF 条目 | 结果 | 判定依据 |
|---|---|---|
1.2.3.4 |
ipv4,无端口 | 没有冒号 |
1.2.3.4:5678 |
ipv4 + 端口 | 恰好一个冒号 |
[2001:db8::1]:5678 |
带括号 ipv6 + 端口 | 以 [ 开头,]: 之后是端口 |
[2001:db8::1] |
带括号 ipv6,无端口 | 以 [ 开头,无 ]: |
2001:db8::1 |
裸 ipv6,无端口 | 多个冒号、无括号 |
「恰好一个冒号 → 必是 ipv4:port」这条判据我觉得很干净:一个合法的 IPv6 地址至少有两个冒号。所以 firstColon == rfind(':') 这个条件本身就完成了消歧,不用去猜、不用去试解析。
解析失败一律返回 unspecified 地址,而调用方的循环本来就会 continue 跳过——畸形输入的既有行为没变。这种「新逻辑不改变旧边界行为」的处理,是让 diff 能被快速信任的关键。
XForwardedForParser 一行没动。它只按空格和逗号切分,v6 地址天然完整通过。改得少的地方,通常正是原设计对的地方。
六、255 行里那 127 行测试
测试从 5 个用例扩到 14 个,断言 56 条。最有价值的两条是验证方向相反的:
1 | // 12. 前缀长度不是 8 的倍数:2001:dbd::/33 保留了第 33 位 |
这两条是一对。 只写 12 号,一个把末尾字节整个忽略的实现也能过;只写 13 号同理。两条一起,才把「第 33 位」这一个 bit 钉死——不管实现往哪个方向错,必挂一条。
这是我从这次贡献里学到的最实在的一招:边界类的改动,测试要成对写,一个「该在内」一个「该在外」,夹住那条边界。 单侧的测试只能证明你对,双侧的测试能证明别人错。
其他用例覆盖:裸 v6、[v6]:port、[v6]、v6 落在可信 CIDR 内被跳过、全链可信回退对端、v4/v6 混合、ipv4-mapped v6(::ffff:4.4.4.4)。既有 5 条 v4 用例一条没改。
七、最难写的是那句「向后兼容」
向后兼容这一节,我最后给的是这个说法:
The ipv4 path is bit-identical to the previous implementation for every prefix from 1 to 32, so no existing
trust_ipsconfiguration changes behaviour.Verified by translating both the old masked comparison and the new byte-prefix comparison into a script and comparing them per prefix over large random samples: zero disagreements for prefixes 1-32.
这句话分三层,每层都有对应动作:
- 声明「bit-identical」——不是「行为大致相同」,是逐位相同。这是能被人拿去验证的强断言。
- 说明验证方式——把新旧两套比较逻辑各翻成一份脚本,逐前缀跑随机样本对比。因为本机没有 Drogon 编译环境,跑不了真实测试,那就退一步验证「算法等价性」。这不等于跑过单测,但它能证明一件具体的事。
- 给出结论——prefixes 1-32 零分歧。
顺带还发现一处顺手修掉的:旧实现靠 0xffffffffu << 32 表示零前缀,这在 C++ 里是未定义行为(移位量等于宽度)。新实现把零前缀显式处理了,不再依赖它。这条在 issue 里没提,是改的时候撞见的——上游贡献的常见副产品:你要动的那块代码,总会顺手暴露旁边的问题。
还有一条兼容性约束写在 PR 描述里:
friend class HodorusesCIDR(const std::string &), theCIDRstype andRealIpResolver::matchCidr. All three public signatures are unchanged, soHodorcompiles without modification.
Hodor 是另一个内部插件,friend 关系让它直接用了这三个东西。改结构体不能只想着插件自己——先 grep 谁在用,再决定签名动不动。三个签名保住不变,Hodor 零改动。
flowchart LR
A[改 CIDR 内部表示] --> B{grep 谁在用它}
B --> C[RealIpResolver 自己]
B --> D[Hodor: friend class]
D --> E[用到三个公开签名<br/>CIDR 构造 / CIDRs / matchCidr]
E --> F[三个签名保持不变]
F --> G[Hodor 编译零改动 ✅]
八、PR 描述的组织方式
3000 多字的描述,结构是这样的:
1 | Closes #2596. |
几个刻意的选择:
- 把「需要维护者决策」的部分留在 issue 里,没塞进 PR。issue 结尾我问了两条:「这个方向可接受吗,我可以准备 PR」以及「还是你更希望换个走法,比如彻底不用
in_addr_t改用InetAddress?」——给维护者留选择权,而不是把已经做好的方案砸过去。 后来 an-tao 直接合了,说明这个方向他是认的。 - 强调这是 feature gap 不是 regression:
#1321(2022-07 合并)以来这块代码没变过,v1.9.10 和 master 在这块完全一致。这个定性很重要——它决定了维护者是用「你新引入的 bug」还是「一直存在的缺口」的视角读你的 PR。 - 主动说明「没有重复劳动」:查过没有别的 issue/PR 覆盖这个插件。避免维护者花时间去合重复的东西。
九、时间线:11 天,0 条 review
timeline
title #2596 → #2598 的 11 天
09-17 : 提 issue #2596<br/>声明未跑复现
09-18 : 自己回帖「Opened #2598」<br/>同日开出 PR
09-18 ~ 09-27 : 静默期<br/>无 review 无评论
09-28 : an-tao 直接 squash 合并<br/>0 review / 0 行内评论
几个诚实的观察:
- issue 和 PR 是同一天连着的。 09-17 提 issue,09-18 就开 PR——中间没有等回复。我在 issue 里问了「方向可接受吗」,但没等答案就把 patch 写完了。这不算失礼(patch 本身就是对问题最清楚的说明),但严格说违背了自己在 issue 里说的话。如果那次方向被否,255 行就白写了。要不要等回复,是个真实的取舍:等,节奏慢但省力;不等,快但可能白干。
- 0 条 review 不等于没人看。 合并者是项目作者 an-tao 本人,squash 合并会保留 PR 号(
(#2598))。没有 review 记录,只说明他选择了直接合而不是走 review 流程——在小体量、边界清晰的补丁上,这是常见的处理方式,不代表跳过了审查。 - 从开出到合并 10 天。 对一个个人主导的开源项目,这个响应速度没什么可抱怨的。
十、对比表与小结
| 我做了什么 | 效果 | 如果反着做会怎样 |
|---|---|---|
| issue 里主动声明「未跑复现,报错字符串是推导」 | 证据可分级,维护者知道该验什么 | 文案对不上,整条 issue 公信力崩掉 |
| 说清是 feature gap 而非 regression | 定性准确,不被当成新引入的坑 | 被要求先证明「以前是好的」 |
| 把方向选择权留给维护者 | an-tao 认可方向,直接合 | 方案被否,255 行白写 |
| 测试 5 → 14,含一对反向用例 | 第 33 位那个 bit 被钉死 | 单侧测试,实现错向仍能过 |
| 声明 v4 路径 bit-identical + 给验证方法 | 零配置回归风险,可被复核 | 「应该没影响」这种没法验的话 |
grep Hodor 保住三个公开签名 |
上游零改动编译 | 顺手破坏另一个插件 |
顺手修 << 32 的 UB |
顺带清掉一处隐患 | 留着,下次再踩 |
小结:
- 这次贡献的起点是「为了把文章写清楚去读源码」,不是「为了提 issue 去找代码」。 后者容易带着结论找证据,前者不会——而且顺手读到的东西,往往比你专门去找的更真。
- 本机没有编译环境,就不要编造复现输出。 在 issue 里明确标出「哪部分是从代码推的、哪部分是实测的」,比写一个漂亮的假输出有价值得多。推导是允许的,把推导说成观测不行。
- 「不支持」和「写错了」是两回事。 一个默认参数
ipv6 = false,让一句写好的Ipv6 is not supported永远抛不出来,用户拿到的是「地址格式错误」。误导性报错比不报错更难查,因为它把人引向错误的方向。 - 地址族用数据结构表达,别用 if 分支。 4 字节 vs 16 字节,长度不同直接 false,比在每个函数里判
isIpV6()干净得多。 - 边界改动,测试要成对写。 一个「该在内」一个「该在外」,两条夹住那条边界。单侧只是自我确认。
- 换个表示法要保留公开签名。 先 grep
friend和外部调用,再决定动不动接口——Hodor就是这么保住的。 - 向后兼容的话要能被人验。「bit-identical for prefixes 1-32」+「怎么验的」+「零分歧」,三段式。说不出口的兼容性,等于没有。
- 最后一条跟开源无关:这次真正起作用的能力,是能把「我确定的」和「我推的」分开说。 代码是其次——
parseAddress那五个分支谁都能写。🐾
相关阅读
- 3.11 X-Forwarded-For 取首段的陷阱:一次真实的注册频控绕过
- 信任边界放哪一层:XFF 的「边界 strip」与后端兜底之辩
- 3.10 注册安全加固:同 IP 注册频控 + 反代真实客户端 IP 解析
配套源码
触发这次贡献的项目:Hespethorn/seckill-cpp
上游 PR:drogonframework/drogon#2598(已合并)
上游 issue:drogonframework/drogon#2596(已关闭)

