注入层

注入层要回答一个问题:一条 h2 流,怎么变成 nginx 的一条普通 HTTP 请求? 答案是造一条完整的虚拟连接:连接槽、连接池、fd、事件、生命周期全部交给 nginx 原生机制, 只把三个数据面函数指针换成模块自己的实现,然后用 ngx_http_init_connection() 把请求字节 喂进 nginx 的 HTTP 解析器。

为什么能这么做 ​

nginx 的 HTTP 处理链只依赖 ngx_connection_t 上的若干字段与几个回调(recv / send / send_chain)。只要这条连接在 nginx 眼里是「刚 accept 进来的合法客户端连接」,请求就会走完整 的原生链:

nginx 会用到的虚拟连接给的
c->recv / c->send / c->send_chain模块的三个函数(inj_recv / inj_send / inj_send_chain)—— 数据从这里进出,不经过 fd
c->read / c->write连接池里真实分配的 ngx_event_t(ready 由模块与 h2 数据面共同驱动)
c->fdsocketpair(AF_UNIX) 的真实 fd(sv[0]),只作「连接身份」的载体
c->poolngx_create_pool(listener->pool_size) 的真实连接池,cleanup 里挂两个回收动作
c->listening / c->local_sockaddr / local_socklen借监听器的真实本地地址(于是 $server_addr / $server_port 是真的)
c->sockaddr / socklen / addr_text可配的注入来源地址(默认 127.0.0.1,见 inject_source_addr)
c->log池里拷一份私有 ngx_log_t,清理掉 connection / handler 等回调,避免递归回指
c->number / c->start_time / c->type与 ngx_event_accept() 一样赋值(连接编号自增、起始时间、SOCK_STREAM)

连接的身份:fd 是真的(早期那套 fd = -1 已经删了) ​

早期(阶段 4.6 之前)走的是「伪造连接」:c->fd = -1,靠「fd 不合法」让一切碰 fd 的路径退化。 那条路能跑,但绕过了 nginx 的关闭/回收语义,还留下两条 alert(写过滤器的 the http output chain is empty、zero size buf in writer)。现在一行 -1 旁路都没有:

text
socketpair(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK, 0, sv)
   ├─ sv[0] → ngx_get_connection() → c->fd          (真实 fd,连接身份)
   └─ sv[1] → 永不读写的「对端占位」,挂在 c->pool 的 cleanup 上关闭

于是关闭与回收全部是 nginx 原生路径:

text
ngx_close_connection(c) → ngx_free_connection(c)(归还空闲链表)→ close(sv[0])
ngx_destroy_pool(c->pool) → cleanup: close(sv[1])

代码里能看到 c->fd = (ngx_socket_t) -1 的地方只有失败路径(连接池分配失败、槽位越界、 cleanup 分配失败、请求合成失败),那是照抄 ngx_event_accept() 的失败写法:先 ngx_free_connection(c) 归还槽位、把 fd 置成 -1 防止误关,再关两个 socketpair fd。

因为 fd 是 AF_UNIX socketpair,代码还做了两处与 ngx_event_accept() 对 AF_UNIX 连接相同的处理:

字段值为什么
c->tcp_nodelayNGX_TCP_NODELAY_DISABLED预置「已处理」,否则 ngx_tcp_nodelay() 会对 AF_UNIX fd 做 setsockopt(TCP_NODELAY) 必然失败,而有些调用点把返回值当致命错误
c->sendfile0静态文件不要走 sendfile 的文件缓冲路径(这条连接没有真实 socket)

别碰 socketpair 的对端(L4 不变式) ​

sv[1] 必须保持打开、永不读写。nginx 读侧有一条「对端提前关闭」的漏网路径:它对 rev->fd 做 MSG_PEEK,读到 0 就认为客户端关了连接,于是把挂位中的请求 terminate。 我们的 fd 是 socketpair,sv[1] 既没有写入者、也没有关闭者 ⇒ 那次 MSG_PEEK 恒为 EAGAIN、 「对端已经关了」永远不成立 —— 这条路径就是靠这个不变式挡住的。

所以:一旦为了「叫醒读侧」去 close() / shutdown() / 写 sv[1],这条路径立刻变成真漏网, 挂位中的请求会被当成客户端断开。要唤醒读侧请走事件与回调(inj_pump() / c->read->handler(c->read)),别碰 sv[1]。

数据面的三个函数 ​

函数干什么
inj_recv先吐合成好的请求头块;再按需吐请求体(定长 / 未知长度的 chunked 帧由模块补);边缘已重置时如实给 EOF
inj_send / inj_send_chain把 nginx 的响应字节写进 h2 流;写不进去返回 NGX_AGAIN,流已死返回 NGX_ERROR
唤醒inj_pump() / inj_wake_read() / inj_wake_write():只在事件层面驱动,不产生任何 fd 系统调用

c → inj 的映射不是哈希:c 一定来自 ngx_cycle->connections 数组,槽位下标 c - ngx_cycle->connections 就是稳定编号,模块按槽位下标的指针数组 O(1) 查表。连接关闭时 先清槽位再交付响应 —— 那时连接已经被塞回空闲链表,下一次注入可能立刻拿走同一个槽位。

一次性连接:绝不复用 ​

虚拟连接是「一条 h2 流 = 一次请求」的一次性连接。头过滤器用 c->recv == inj_recv 认出它, 强制 r->keepalive = 0:合成请求里写 Connection: keep-alive 是为了对齐 cloudflared 发给源站的 字节,真正的复用决定在这里显式否决。

注入目标怎么选 ​

text
inject_listen 写了地址+端口 ──▶ 直接用
inject_listen 只写端口     ──▶ 在该端口的监听器里找唯一的地址;多个 ⇒ 歧义 ⇒ 跳过这条隧道
inject_listen 没写         ──▶ 本地没有远程配置:自动挑一个地址族/地址合适的监听器
                              开了 remote_config:由命中规则的 service 端口决定

选中监听器的标志位在注入期间被临时改动(让 nginx 把它当成普通连接),这个窗口是同步的, 所以多条隧道并发注入同一个 listen 是安全的。

请求头怎么合成 ​

cfd_hdr_ser.c 负责把 h2 的伪头与普通头拼回 HTTP/1.1 的请求行 + 头块:

输入(h2)输出(喂给 nginx 的 h1 字节)
:method / :path / :authority请求行 + Host
cf-connecting-ip 等普通头原样(名字小写,值逐字节)
cf-cloudflared-proxy-*丢弃(模块自己的元数据,不进源站)
升级请求(Upgrade: websocket)合成 Connection: upgrade + 保留 Upgrade
请求 trailer合成 Transfer-Encoding: chunked 后按 trailer 写

合成出来的字节逐字节可对账:phase2 的用例会拿假边缘发出的请求与源站实际收到的请求做比对。

Host 与 X-Forwarded-Host ​

场景HostX-Forwarded-Host
默认访客请求的 hostname不写
host_override app.internalapp.internal访客原来的 hostname
远程规则 originRequest.httpHostHeader该值(本地没写 host_override 时)同上

与 cloudflared 的 ingress/origin_proxy.go 行为逐字对齐。

远程规则匹配(0.12.0) ​

cfd_rcfg.c 把边缘下发的 ingress 表变成可匹配的结构:

text
hostname 精确匹配 → path 规则(字面量 / 两端 ^$ / .* / .+)→ service
未命中 hostname → 最后一条(catch-all)
命中 http_status:NNN → 模块直接应答,不进 nginx
命中 http(s)://host:port → 注入端口 = 该端口(本地 inject_listen 优先)
命中不支持的 service → 502 + ERR(本地没写 inject_listen 时)

规则表换代是原子替换,旧世代整块释放;每条规则的注入目标惰性解析并缓存一次。

响应的回程 ​

text
nginx 产出响应头/体(含 chunked 分块)
   ↓ 模块的 filter / write 回调
h2 DATA / HEADERS(含 trailer)
   ↓ 背压:待写字节 > CFD_RESP_HWM(256 KiB) 就暂停读源站
边缘

写路径有一条硬约束:先把出缓冲排空、才允许再造新字节。原因是 nginx 没设 SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER,OpenSSL 要求 WANT_WRITE 之后的重试同指针同长度 —— 顺序错了会以 bad write retry 打死整条会话。

生命周期与出错 ​

时刻做什么
流到达分配请求上下文、选注入目标、造虚拟连接、ngx_http_init_connection()
请求体推进分片入队,cfd_inject_resume_read() 唤醒 nginx 读侧
响应结束写回 END_STREAM,释放流,记账归还
隧道断开给每条在途请求置错并摇醒写侧(否则请求永不收尾)
裸 TCP 流立刻 RST_STREAM(REFUSED_STREAM),不进 nginx

模块文档 · 对齐依据是 cloudflared 的源码与实测行为