在前几篇文章里,我们已经分析了 tunnelto 的整体架构、客户端启动流程、Wormhole 控制通道,以及 ClientHello、ServerHello、ControlPacket 这些协议对象。
到这里,我们终于进入 tunnelto 最核心的问题:
远端 HTTP 请求到底是如何被转发到本地 localhost 的?
例如你在本地启动了一个 Web 服务:
python -m http.server 8000
然后执行:
tunnelto --port 8000
tunnelto 给你分配了一个公网地址:
https://abc123.tunnelto.dev
当外部用户访问这个地址时,请求最终会进入你的本地服务:
localhost:8000
这篇文章就围绕这条链路展开:
浏览器
↓
tunnelto_server
↓
ControlPacket::Data
↓
WebSocket 控制通道
↓
tunnelto 客户端
↓
TcpStream::connect(localhost:8000)
↓
本地 Web 服务
重点分析客户端和服务端如何配合,把远端 HTTP 请求转成本地 TCP 流,再把本地响应反向传回浏览器。
一、数据转发不是"HTTP 转发",而是"字节流转发"
先明确一个关键点:tunnelto 并不真正理解你的 HTTP 业务。
它不会关心:
请求方法是 GET 还是 POST
路径是 /api/user 还是 /index.html
响应状态码是 200 还是 500
Content-Type 是 text/html 还是 application/json
这些信息对 tunnelto 来说,本质上只是 TCP 字节流中的内容。
tunnelto 真正关心的是:
这段字节属于哪个连接?
应该转发给哪个客户端?
应该写入哪个本地 TCP 连接?
响应回来后应该写回哪个远端 socket?
因此,它的转发模型不是:
HTTP Request -> HTTP Response
而是:
远端 TCP 字节流 -> ControlPacket::Data -> 本地 TCP 字节流
这是理解 tunnelto 转发核心的第一步。
二、完整数据链路先看一遍
一次远端请求进入本地服务,大致会经历这几个阶段:
1. 外部用户访问 abc123.tunnelto.dev
2. 请求到达 tunnelto_server 的远端 TCP listener
3. 服务端解析 HTTP Host,找到 abc123 对应的客户端
4. 服务端为这个连接创建 ActiveStream 和 StreamId
5. 服务端发送 ControlPacket::Init(stream_id)
6. 服务端读取远端 socket 的请求字节
7. 服务端发送 ControlPacket::Data(stream_id, request_bytes)
8. 客户端收到 Data,检查本地是否已有这个 stream
9. 如果没有,客户端连接 localhost:8000
10. 客户端把 request_bytes 写入本地 TcpStream
11. 本地服务返回 response_bytes
12. 客户端发送 ControlPacket::Data(stream_id, response_bytes)
13. 服务端收到响应数据,写回远端 socket
14. 浏览器收到响应
15. 连接结束后双方发送或处理 End,并清理 stream
如果画成图,就是:
浏览器
│
│ HTTP 请求字节
▼
tunnelto_server remote.rs
│
│ ControlPacket::Data(stream_id, request_bytes)
▼
WebSocket 控制通道
│
▼
tunnelto client main.rs
│
│ StreamMessage::Data(request_bytes)
▼
local.rs
│
│ TcpStream::connect(localhost:8000)
▼
本地 Web 服务
│
│ HTTP 响应字节
▼
local.rs
│
│ ControlPacket::Data(stream_id, response_bytes)
▼
tunnelto_server
│
│ 写回远端 socket
▼
浏览器
这个链路的核心就是两个方向的字节传输。
三、服务端第一步:接收远端连接
远端请求首先进入服务端的 remote.rs。
服务端会接收一个 TCP 连接,然后调用:
accept_connection(socket)
这个函数是公网流量进入 tunnelto 的入口。
它首先需要知道:这个请求访问的是哪个 tunnel?
因为同一个 tunnelto_server 可能同时服务很多用户:
abc123.tunnelto.dev -> client_A
demo.tunnelto.dev -> client_B
test.tunnelto.dev -> client_C
所以服务端必须先从 HTTP 请求头里解析 Host。
例如:
GET / HTTP/1.1
Host: abc123.tunnelto.dev
服务端通过 Host 头提取出:
abc123
然后查找:
abc123 对应哪个 ConnectedClient?
如果找不到,就返回:
Error: Tunnel Not Found
如果找到了,说明这个子域名当前有一个客户端在线,后续就可以把请求转发给它。
四、服务端为什么要先 peek HTTP Header?
remote.rs 中不是直接读取并消费整个请求,而是先 peek 一部分 HTTP 头。
原因是它需要读取 Host,但又不能把这些字节丢掉。
如果服务端读取了请求头并消费掉,后面转发给本地服务时,本地服务收到的请求就不完整了。
所以它使用类似"偷看"的方式:
peek 前 4KB 数据
解析 Host
保留 socket 中的数据不被消费
这样既能知道请求应该转发给哪个客户端,又能保证后面完整请求仍然可以被转发。
这一步非常关键。
因为 tunnelto 要做的是透明转发:
浏览器发了什么,本地服务就应该收到什么。
五、服务端创建 ActiveStream
找到对应客户端后,服务端会为当前远端连接创建一个 ActiveStream。
可以把它理解成:
一个远端 TCP 连接在服务端侧的抽象
每个 ActiveStream 都有自己的:
StreamId
ConnectedClient
tx channel
其中最重要的是 StreamId。
因为一条 WebSocket 控制通道可能同时转发多个请求,必须用 StreamId 区分它们。
例如浏览器访问首页时,可能同时请求:
GET /index.html
GET /style.css
GET /main.js
GET /favicon.ico
服务端会给它们分别创建不同的 stream:
stream_A -> /index.html
stream_B -> /style.css
stream_C -> /main.js
stream_D -> /favicon.ico
这些 stream 都会通过同一条客户端 WebSocket 转发,但每个数据包都带有自己的 StreamId。
六、服务端把一个 socket 拆成两个方向
创建 ActiveStream 后,服务端会把远端 TCP socket 拆成读写两个方向:
stream 读取浏览器发来的请求数据
sink 向浏览器写回本地服务的响应数据
然后启动两个异步任务。
第一个任务:
process_tcp_stream()
负责:
从远端 socket 读取数据
封装成 ControlPacket::Data
发送给客户端
第二个任务:
tunnel_to_stream()
负责:
从客户端返回队列读取数据
写回远端 socket
也就是说,服务端侧的数据转发天然分成两个方向:
浏览器 -> 客户端
客户端 -> 浏览器
这种读写拆分,是异步网络代理很常见的结构。
七、服务端发送 Init
在真正转发数据之前,服务端会先发送:
ControlPacket::Init(stream_id)
它的含义是:
一个新的远端 stream 开始了。
这一步相当于告诉客户端:
准备好,接下来会有一个新的连接数据过来。
不过,客户端收到 Init 后不一定马上连接本地服务。
在 tunnelto 的实现中,真正创建本地 TCP 连接通常发生在收到 Data 包时。
Init 更像是 stream 生命周期的起点通知。
八、服务端读取远端请求并发送 Data
接着,process_tcp_stream() 开始从远端 socket 读取数据。
读取到数据后,它会封装成:
ControlPacket::Data(stream_id, data)
然后通过当前客户端的发送通道发给客户端。
这里的 data 就是远端浏览器发来的原始字节。
它可能包含:
HTTP 请求行
HTTP Header
请求体
WebSocket upgrade 请求
其他基于 TCP 的内容
tunnelto 不解析这些业务内容,只是把它们作为字节转发。
如果远端 socket 读到 0 字节,说明连接结束。服务端会发送:
ControlPacket::End(stream_id)
通知客户端这个 stream 结束。
九、客户端收到 Data 后进入本地转发
客户端的 WebSocket 读取循环在 main.rs 中。
当客户端收到服务端发来的二进制消息后,会调用:
process_control_flow_message()
这个函数会先把 payload 反序列化成:
ControlPacket
如果是:
ControlPacket::Data(stream_id, data)
客户端会做两件事。
第一,检查当前 stream_id 是否已经存在于 ACTIVE_STREAMS。
第二,如果不存在,就调用:
local::setup_new_stream()
这说明本地 TCP 连接是按需创建的。
客户端启动时,并不会预先连接 localhost:8000。只有当远端请求真正进来时,才会为这个 StreamId 创建对应的本地连接。
十、ACTIVE_STREAMS:客户端侧的 stream 路由表
客户端全局维护了一个:
ACTIVE_STREAMS
它可以理解成:
StreamId -> 本地连接的发送通道
当远端数据到来时,客户端根据 StreamId 找到对应的本地连接。
如果没有,就新建。
如果有,就直接把数据发给这个本地连接。
这张表是客户端处理并发请求的关键。
例如:
stream_A -> local connection A
stream_B -> local connection B
stream_C -> local connection C
每个远端请求都有自己独立的本地 TCP 连接。
这保证了并发请求之间不会互相串数据。
十一、setup_new_stream():真正连接本地 localhost
客户端连接本地服务的核心函数在 local.rs:
setup_new_stream()
它首先做的事情就是:
TcpStream::connect(config.local_addr)
如果你执行的是:
tunnelto --port 8000
那么这里的 config.local_addr 就对应:
localhost:8000
如果本地服务没有启动,连接会失败。
失败后,客户端会做两件事:
1. 记录 connect_failed
2. 向服务端发送 ControlPacket::Refused(stream_id)
也就是说,客户端不会假装转发成功。
它会明确告诉服务端:
这个 stream 无法连接本地服务。
服务端收到后,可以向远端浏览器返回类似:
Tunnel says: connection refused.
这就是为什么有时候 tunnel URL 能打开,但会提示本地连接失败:控制通道是成功的,但本地服务没有监听对应端口。
十二、本地 TLS:--use-tls 做了什么
setup_new_stream() 中还有一个分支:
if config.use_tls
如果用户启动时指定:
tunnelto --use-tls --port 8000
客户端连接本地 TCP 后,还会尝试用 TLS 包装这个连接。
也就是说,普通模式下:
tunnelto client -> TCP -> localhost:8000
启用 TLS 后:
tunnelto client -> TLS over TCP -> localhost:8000
这里要注意,--use-tls 指的是客户端连接本地服务时使用 TLS。
它不是指公网访问地址是否 HTTPS。
公网 HTTPS 是服务端暴露域名时的事情;本地 TLS 是客户端和本地服务之间的事情。
如果你的本地服务只是普通 HTTP,但你加了 --use-tls,本地 TLS 握手会失败,客户端也会发送 Refused。
十三、客户端把本地连接拆成读写两半
成功连接本地服务后,客户端会把这个本地 TCP stream 拆成两部分:
stream 从本地服务读取响应
sink 向本地服务写入请求
然后启动两个异步任务。
第一个任务:
process_local_tcp()
负责:
从本地服务读取响应数据
封装成 ControlPacket::Data
发回服务端
第二个任务:
forward_to_local_tcp()
负责:
从客户端本地队列读取远端请求数据
写入本地服务
这正好和服务端的两个任务形成镜像关系。
服务端:
process_tcp_stream() 浏览器 -> 客户端
tunnel_to_stream() 客户端 -> 浏览器
客户端:
forward_to_local_tcp() 服务端 -> 本地服务
process_local_tcp() 本地服务 -> 服务端
十四、forward_to_local_tcp():把远端请求写入本地服务
当客户端收到服务端发来的 Data 包后,最终会把字节发送到本地 stream 的队列里:
StreamMessage::Data(data)
forward_to_local_tcp() 会从这个队列中不断读取数据。
如果读到的是:
StreamMessage::Data(data)
就执行:
sink.write_all(&data)
把远端请求字节写入本地服务。
也就是说,外部浏览器发出的 HTTP 请求,最终在这里进入:
localhost:8000
如果队列关闭,或者收到:
StreamMessage::Close
客户端就会关闭本地写方向。
十五、process_local_tcp():把本地响应发回服务端
本地服务处理请求后,会返回响应。
客户端通过 process_local_tcp() 从本地连接读取响应数据。
它每次读取一段字节,然后封装成:
ControlPacket::Data(stream_id, data)
再通过 tunnel_tx 发回 WebSocket 写入任务。
WebSocket 写入任务会执行:
packet.serialize()
然后通过 WebSocket 发给服务端。
这就是响应回传路径:
localhost:8000
↓
process_local_tcp()
↓
ControlPacket::Data(stream_id, response_bytes)
↓
WebSocket
↓
tunnelto_server
如果本地服务关闭连接,读取到 0 字节,客户端会从 ACTIVE_STREAMS 中移除这个 stream_id。
这一步是资源清理,避免 stream 表无限增长。
十六、服务端把本地响应写回浏览器
服务端收到客户端发回的:
ControlPacket::Data(stream_id, response_bytes)
后,会根据 stream_id 找到对应的 ActiveStream。
然后把数据放入这个 stream 的队列。
前面启动的 tunnel_to_stream() 任务会从队列中读取数据,并执行:
sink.write_all(&data)
把本地服务响应写回浏览器。
因此,浏览器最终收到的响应,其实是本地服务返回的原始字节。
tunnelto 在中间只是搬运它。
十七、Refused:本地连接失败时如何处理
如果客户端无法连接本地服务,例如:
localhost:8000 没有服务
端口写错了
本地服务崩了
TLS 握手失败
客户端会发送:
ControlPacket::Refused(stream_id)
服务端收到后,会把这个 stream 标记为被拒绝。
然后远端浏览器会收到一个错误响应:
Tunnel says: connection refused.
这类错误经常出现在使用内网穿透时。
很多人看到公网地址打不开,就以为是公网服务端问题。
但源码告诉我们,要区分两个阶段:
控制通道是否建立成功?
本地服务是否能连接成功?
如果 CLI 已经显示公网 URL,说明控制通道通常已经建立成功。
如果访问时报 connection refused,更可能是:
本地服务没有启动
端口指定错了
--use-tls 和本地服务协议不匹配
十八、End:一次远端连接如何结束
当远端 socket 结束时,服务端会向客户端发送:
ControlPacket::End(stream_id)
客户端收到后,会找到对应本地 stream,并在短暂延迟后发送关闭消息:
StreamMessage::Close
然后从 ACTIVE_STREAMS 中移除。
为什么需要 End?
因为一条 WebSocket 控制通道上同时承载多个 stream。
某一个 HTTP 请求结束,不代表整个 WebSocket tunnel 结束。
所以必须用 End(stream_id) 明确告诉对方:
这个 stream 可以收尾了,但 tunnel 还要继续运行。
这就是 stream 生命周期管理。
十九、为什么每个远端连接都要新建本地连接?
从源码看,服务端每接受一个远端 TCP 连接,就创建一个新的 StreamId。
客户端收到这个 stream 的数据后,也会创建一个新的本地 TCP 连接。
这意味着:
一个远端 TCP 连接
对应一个 StreamId
对应一个本地 TCP 连接
这种设计很直观,也符合 TCP 转发的语义。
浏览器和服务端之间有一条连接,本地客户端和本地服务之间也创建一条对应连接。
中间用 StreamId 把这两条连接关联起来:
remote socket <-> StreamId <-> local socket
这样可以保持连接隔离,避免多个请求共享同一个本地 TCP 连接导致协议错乱。
二十、Introspection:顺手记录请求和响应
local.rs 里还有一个和转发相关的辅助功能:introspection。
当客户端创建本地 stream 时,会创建两条记录通道:
introspect_request
introspect_response
当远端请求写入本地服务时,会把请求数据送入 request 记录通道。
当本地服务响应返回时,会把响应数据送入 response 记录通道。
后续 introspection 模块会解析 HTTP 请求和响应,记录路径、方法、头部、响应状态、请求体、响应体等信息。
这就是 tunnelto 本地调试面板能够展示请求记录的原因。
它不是在服务端抓包,而是在客户端本地转发时顺手记录。
这对调试 Webhook 特别有用。
二十一、完整转发流程源码级串联
现在我们把整个流程按函数串起来。
服务端侧
accept_connection(socket)
↓
peek_http_request_host(socket)
↓
validate_host_prefix(host)
↓
Connections::find_by_host(host)
↓
ActiveStream::new(client)
↓
ACTIVE_STREAMS.insert(stream_id, active_stream)
↓
spawn process_tcp_stream()
↓
spawn tunnel_to_stream()
process_tcp_stream() 负责:
send_client_stream_init()
读取远端 socket
发送 ControlPacket::Data 给客户端
远端结束时发送 ControlPacket::End
tunnel_to_stream() 负责:
等待客户端返回的数据
写回远端 socket
处理 TunnelRefused / NoClientTunnel
清理 ActiveStream
客户端侧
run_wormhole()
↓
读取 WebSocket message
↓
process_control_flow_message()
↓
ControlPacket::deserialize()
↓
如果是 Data:
检查 ACTIVE_STREAMS
如果没有,调用 setup_new_stream()
把 data 发送给本地 stream
setup_new_stream() 负责:
TcpStream::connect(config.local_addr)
如果 use_tls,包装成 TLS stream
split 本地连接
spawn process_local_tcp()
spawn forward_to_local_tcp()
ACTIVE_STREAMS.insert(stream_id, tx)
forward_to_local_tcp() 负责:
读取 StreamMessage::Data
写入本地 TcpStream
process_local_tcp() 负责:
读取本地服务响应
发送 ControlPacket::Data 回服务端
本地连接结束时清理 ACTIVE_STREAMS
这就是远端 HTTP 请求进入本地 localhost 的完整源码路径。
二十二、用一个例子理解
假设外部用户访问:
https://abc123.tunnelto.dev/api/hello
浏览器发出的请求大致是:
GET /api/hello HTTP/1.1
Host: abc123.tunnelto.dev
服务端收到后:
解析 Host -> abc123
找到 client -> client_A
创建 StreamId -> stream_X
发送 Init(stream_X)
发送 Data(stream_X, request_bytes)
客户端收到后:
发现 stream_X 不存在
连接 localhost:8000
创建本地 stream
把 request_bytes 写入 localhost:8000
本地服务处理后返回:
HTTP/1.1 200 OK
Content-Type: application/json
{"message":"hello"}
客户端读取响应:
Data(stream_X, response_bytes)
发给服务端。
服务端收到后:
根据 stream_X 找到远端 socket
把 response_bytes 写回浏览器
浏览器最终看到:
{"message":"hello"}
整个过程中,tunnelto 并不理解 /api/hello 的业务含义,它只是根据 StreamId 搬运字节。
二十三、这一篇的核心结论
远端 HTTP 请求被转发到本地 localhost,靠的是服务端和客户端两侧的协作。
服务端负责:
接收公网 TCP 连接
解析 Host 找到客户端
创建 StreamId 和 ActiveStream
把远端请求字节封装成 ControlPacket::Data
通过 WebSocket 发给客户端
把客户端返回的数据写回浏览器
客户端负责:
接收服务端的 ControlPacket::Data
根据 StreamId 管理本地 stream
按需连接 localhost:8000
把远端请求写入本地服务
读取本地响应
封装成 ControlPacket::Data 发回服务端
一句话总结:
tunnelto 的数据转发核心,就是把一个远端 TCP 连接映射成一个 StreamId,
再在客户端创建对应的本地 TCP 连接,
通过 ControlPacket::Data 在 WebSocket 控制通道中双向搬运字节。
所以,tunnelto --port 8000 背后的本质不是神秘的"打洞",而是:
公网服务端负责收请求
本地客户端负责连 localhost
中间用 WebSocket + StreamId + ControlPacket 做反向转发
二十四、下一篇预告
下一篇我们继续分析服务端入口:
Tunnelto 源码解析 #7:服务端入口:Host Header、子域名匹配与公网请求分发
下一篇会重点研究:
remote.rs
peek_http_request_host()
validate_host_prefix()
Connections::find_by_host()
network::instance_for_host()
HTTP_NOT_FOUND_RESPONSE
HTTP_INVALID_HOST_RESPONSE
也就是公网请求进入 tunnelto_server 后,服务端如何根据 Host Header 判断这个请求应该交给哪个客户端。