在前几篇文章中,我们已经理解了 tunnelto 的整体架构:
tunnelto_lib 共享协议库
tunnelto 本地命令行客户端
tunnelto_server 公网服务端
也分析了客户端启动流程:解析命令行参数、读取鉴权 Key、生成本地转发地址、拼接控制服务器地址,然后连接公网控制服务器。
这一篇,我们继续往下看一个非常关键的概念:Wormhole 控制通道。
在 tunnelto 里,客户端和服务端之间并不是每来一个外部请求就重新建立一条连接,而是先建立一条长期存在的 WebSocket 连接。之后,外部请求、本地响应、stream 初始化、stream 结束、ping 保活、重连 token 等控制消息,都会通过这条连接传输。
这条 WebSocket 连接,就是 tunnelto 的"隧道控制线"。
一、为什么需要 Wormhole 控制通道?
内网穿透的本质难点是:公网用户无法直接访问你的本机。
例如你本地有一个服务:
localhost:8000
它只能在你的电脑上访问。外部用户没有办法直接连接这个地址。
tunnelto 的思路不是让公网用户直接连你的电脑,而是让你的本地客户端主动连接公网服务器:
tunnelto client ---> tunnelto server
这条连接是从内网主动发起到公网的,所以通常可以穿过 NAT 和防火墙。
一旦这条连接建立,公网服务器就可以把外部用户的请求"反向"推给本地客户端:
外部用户
↓
tunnelto server
↓
WebSocket 控制通道
↓
tunnelto client
↓
localhost:8000
所以,Wormhole 控制通道的价值在于:
客户端主动连服务端
服务端借这条连接反向下发请求
客户端再把请求转给本地服务
本地响应通过同一条连接返回服务端
这就是内网穿透工具最核心的通信模式。
二、Wormhole 不只是 WebSocket 连接
很多人一看到 WebSocket,可能会以为 tunnelto 只是建立了一条普通 WebSocket,然后把数据往里面塞。
但从源码看,事情分成三个阶段:
阶段一:建立 WebSocket 连接
阶段二:ClientHello / ServerHello 业务握手
阶段三:ControlPacket 控制包循环
也就是说,WebSocket 只是底层传输通道。
真正让 tunnelto 工作起来的是它在 WebSocket 之上定义的一套协议:
ClientHello
ServerHello
ControlPacket
StreamId
ReconnectToken
如果只建立 WebSocket,但没有握手、没有 stream 管理、没有控制包协议,那么客户端和服务端仍然不知道应该如何协作。
所以,本篇说的 Wormhole 控制通道,准确地说是:
WebSocket 长连接 + 握手协议 + ControlPacket 二进制控制协议
三、客户端入口:run_wormhole()
客户端建立控制通道的核心函数是:
run_wormhole()
它的大致职责可以概括成:
启动 CLI 界面
连接 wormhole 控制服务器
拿到服务端返回的公网 hostname
拆分 WebSocket 读写方向
启动写入任务
进入读取循环
处理服务端发来的 ControlPacket
简化后的流程可以写成:
run_wormhole()
↓
connect_to_wormhole()
↓
interface.did_connect()
↓
websocket.split()
↓
spawn websocket writer task
↓
loop read websocket message
↓
process_control_flow_message()
这里有一个非常关键的点:run_wormhole() 连接成功之后,会把 WebSocket 拆成两个部分:
ws_sink 写方向
ws_stream 读方向
这意味着客户端之后可以同时做两件事:
从 WebSocket 读取服务端发来的控制包
向 WebSocket 写入本地服务返回的数据包
这正是异步网络程序的常见结构。
四、第一步:连接控制服务器
真正建立 WebSocket 连接的是:
connect_to_wormhole()
这个函数会使用配置里的:
config.control_url
连接控制服务器。
默认情况下,这个地址类似:
wss://wormhole.tunnelto.dev:10001/wormhole
其中:
wss 表示安全 WebSocket
wormhole 表示控制通道路由
10001 表示控制服务端口
这一步完成后,客户端和服务端之间已经有了底层 WebSocket 连接。
但注意:这时候 tunnel 还不算真正建立成功。
因为服务端还不知道:
这个客户端是谁?
它有没有认证 Key?
它想使用哪个子域名?
它是不是断线重连?
它是否有权限建立 tunnel?
所以还需要第二步:业务握手。
五、第二步:发送 ClientHello
WebSocket 连接建立之后,客户端会构造一个 ClientHello。
它相当于客户端发给服务端的第一条正式消息。
ClientHello 主要表达几件事:
我是一个 tunnelto 客户端
我想建立一条 tunnel
这是我请求的 subdomain
这是我的身份类型
如果我是重连,这是我的 reconnect token
根据不同情况,客户端会生成不同类型的 ClientHello。
1. 认证用户
如果配置里有 secret_key,客户端会生成认证类型的 hello:
ClientType::Auth { key }
这种情况下,服务端会根据 key 判断客户端身份,以及它是否有权限使用指定子域名。
2. 匿名用户
如果没有 key,客户端会以匿名身份连接:
ClientType::Anonymous
匿名模式适合快速试用,但通常不适合长期固定域名或正式服务。
3. 匿名重连
如果客户端没有 key,但本地保存了 reconnect token,那么它会尝试用:
ClientHello::reconnect(reconnect_token)
这表示:我不是一个全新的匿名连接,我想恢复刚才断开的匿名 tunnel。
这个设计很实用。因为网络抖动时,如果匿名用户每次重连都拿到一个新子域名,体验会很差。Reconnect token 可以让服务端在短时间内识别"这是同一个客户端回来重连"。
六、第三步:等待 ServerHello
客户端发送 ClientHello 后,会等待服务端返回 ServerHello。
成功时,服务端返回:
ServerHello::Success {
sub_domain,
hostname,
client_id,
}
这三个字段很关键。
sub_domain 最终分配给客户端的子域名
hostname 最终公网访问域名
client_id 服务端识别客户端的 ID
客户端拿到这些信息后,才会在命令行界面里提示用户:
你的公网 tunnel 已经建立
可以通过某个 hostname 访问本地服务
失败时,服务端可能返回:
AuthFailed
InvalidSubDomain
SubDomainInUse
Error
这几种错误分别代表:
AuthFailed 鉴权失败
InvalidSubDomain 子域名格式非法
SubDomainInUse 子域名已被占用
Error 服务端内部错误或其他业务错误
这说明 tunnelto 的控制通道不是"连上就能用",而是有明确的握手结果。
只有拿到 ServerHello::Success,客户端才进入正式转发阶段。
七、服务端如何处理 /wormhole
客户端连接的是 /wormhole,服务端对应的代码在控制服务器模块里。
服务端启动时会注册一个 WebSocket 路由:
/wormhole
当客户端连接到这个路由后,服务端会执行:
handle_new_connection()
这个函数大致做几件事:
检查客户端 IP 是否被封禁
执行客户端握手
返回 ServerHello
创建 ConnectedClient
加入 Connections
拆分 WebSocket
启动发送任务 tunnel_client
启动接收任务 process_client_messages
启动 ping 保活任务
这里的核心是:握手成功后,服务端会把客户端登记为一个 ConnectedClient。
可以理解成:
host/subdomain -> ConnectedClient
client_id -> ConnectedClient
之后外部用户访问某个子域名时,服务端才能根据 Host 找到对应的客户端。
八、ConnectedClient:服务端保存的客户端句柄
服务端登记客户端时,会创建一个 ConnectedClient。
它可以简化理解成:
ConnectedClient {
id,
host,
is_anonymous,
tx
}
这里最重要的是 tx。
它是服务端向这个客户端发送 ControlPacket 的通道。
当外部请求进来时,服务端需要向客户端发送:
ControlPacket::Init(stream_id)
ControlPacket::Data(stream_id, data)
这些消息最终都会通过这个 tx 进入发送队列,再由 WebSocket 写入任务发给客户端。
所以 ConnectedClient 不只是记录客户端信息,它还是服务端"联系这个客户端"的句柄。
没有它,服务端即使收到了外部请求,也不知道该把请求发给谁。
九、WebSocket 为什么要拆成读写两半?
无论客户端还是服务端,建立 WebSocket 后都会把它拆成读写两部分。
客户端这边:
ws_sink 负责写入 WebSocket
ws_stream 负责读取 WebSocket
服务端这边也类似:
sink 负责向客户端发送控制包
stream 负责接收客户端返回的数据
为什么要这样拆?
因为控制通道是双向的。
服务端会向客户端发送:
Init
Data
Ping
End
客户端也会向服务端发送:
Data
Refused
Ping
End
如果读写都放在一个同步流程里,很容易互相阻塞。
拆分之后,可以形成两个独立循环:
读取循环:持续接收对方消息
写入循环:持续从本地队列取消息并发送
这让整个 tunnel 可以同时处理:
远端请求进入
本地响应返回
保活 ping
stream 关闭
异常拒绝
这就是异步 I/O 在 tunnelto 里的重要作用。
十、客户端写入任务:把本地数据发回服务端
客户端进入 run_wormhole() 后,会创建一个 tunnel channel:
tunnel_tx
tunnel_rx
其中:
tunnel_tx 给其他模块发送 ControlPacket
tunnel_rx WebSocket 写入任务从这里接收 ControlPacket
客户端会启动一个异步任务:
loop {
packet = tunnel_rx.next()
ws_sink.send(packet.serialize())
}
它的职责非常单纯:
从本地队列拿到 ControlPacket
序列化成二进制
通过 WebSocket 发给服务端
比如本地服务返回了一段 HTTP 响应数据,客户端会把它封装成:
ControlPacket::Data(stream_id, response_bytes)
然后通过 tunnel_tx 发送出去。
WebSocket 写入任务拿到这个包后,执行:
packet.serialize()
最终写入控制通道。
所以,客户端发回服务端的数据不是直接写 WebSocket,而是先进入一个 channel。
这带来一个好处:本地转发模块不需要直接持有 WebSocket,它只需要拿到 tunnel_tx,就可以把数据交给控制通道发送。
十一、客户端读取循环:处理服务端控制包
WebSocket 读方向则在 run_wormhole() 的主循环里。
它不断读取服务端发来的消息:
ws_stream.next()
如果收到 close message,说明服务端关闭了连接,客户端准备重启 wormhole。
如果收到普通消息,则把消息体交给:
process_control_flow_message()
这个函数是客户端处理控制包的核心。
它先反序列化:
ControlPacket::deserialize(payload)
然后根据控制包类型执行不同逻辑。
十二、ControlPacket:控制通道里的统一消息格式
tunnelto_lib 中定义了 ControlPacket。
它包含几种类型:
Init(StreamId)
Data(StreamId, Vec<u8>)
Refused(StreamId)
End(StreamId)
Ping(Option<ReconnectToken>)
它们分别表示:
Init 一个新的远端 stream 开始
Data 某个 stream 上有数据
Refused 某个 stream 被拒绝
End 某个 stream 结束
Ping 保活,可能携带 reconnect token
这套协议非常简洁。
每个包都有明确用途:
创建 stream
传输数据
拒绝连接
关闭 stream
保持连接活跃
其中,StreamId 是多路复用的关键。
因为一条 WebSocket 控制通道可能同时承载多个外部请求。每个请求都需要一个自己的 StreamId,这样客户端和服务端才知道每段数据属于哪个连接。
可以理解成:
WebSocket 控制通道
├── stream_1: GET /index.html
├── stream_2: GET /style.css
├── stream_3: GET /app.js
└── stream_4: GET /api/user
所有 stream 都走同一条 WebSocket,但靠 StreamId 区分。
十三、客户端如何处理 Init
当客户端收到:
ControlPacket::Init(stream_id)
它只会记录这个 stream 初始化事件。
Init 表示服务端告诉客户端:
有一个新的远端连接要开始了。
不过客户端不一定在 Init 时立刻连接本地服务。
真正的数据通常会在后续的 Data 包中到来。
也就是说,Init 更像是一个控制信号,告诉客户端某个 stream 的生命周期开始了。
十四、客户端如何处理 Data
Data 是最重要的控制包。
当客户端收到:
ControlPacket::Data(stream_id, data)
它会先检查:
ACTIVE_STREAMS 里是否已有这个 stream_id?
如果没有,说明这是这个远端连接第一次有数据到达。客户端会调用本地转发逻辑,创建一个新的本地连接:
localhost:8000
创建成功后,客户端会把 stream_id 和本地连接对应起来。
之后,每次再收到同一个 stream_id 的数据,就直接写入对应本地连接。
流程可以概括为:
收到 Data
↓
检查 ACTIVE_STREAMS
↓
如果没有,创建本地 TcpStream
↓
把远端数据写入本地服务
这一步就是 tunnelto 把公网请求送到本地服务的关键。
十五、客户端如何处理 End
当客户端收到:
ControlPacket::End(stream_id)
表示某个远端连接结束。
客户端会找到对应的本地 stream,然后发送关闭信号,并从 ACTIVE_STREAMS 中移除这个 stream。
这一步很重要。
如果 stream 结束后不清理,客户端就会不断积累无用连接,最终造成资源泄漏。
所以 End 不只是一个通知,它代表一次请求生命周期的收尾。
十六、客户端如何处理 Ping
服务端会定期发送:
ControlPacket::Ping(...)
客户端收到后,会回复:
ControlPacket::Ping(None)
这就是控制通道的 ping/pong 保活机制。
如果服务端发送的 ping 里面带了 reconnect token,客户端会把它保存起来。
这个 token 主要用于匿名客户端的断线恢复。
可以这样理解:
服务端:你还活着吗?顺便给你一个短期重连凭证。
客户端:我还活着,我把凭证保存起来。
如果后面 WebSocket 因网络原因断开,客户端重连时可以使用这个 token,尝试恢复原来的匿名 tunnel。
这也是 tunnelto 在用户体验上的一个细节优化。
十七、服务端的 ping 保活任务
服务端在握手成功后,会启动一个循环任务,定期给客户端发送 Ping。
对于认证客户端,ping 只是普通保活。
对于匿名客户端,服务端还会生成一个短期 reconnect token,并把它放进 Ping 里发给客户端。
这个设计解决了两个问题:
1. 判断客户端连接是否还活着
2. 给匿名客户端提供短期断线恢复能力
如果发送 ping 失败,说明客户端连接可能已经断开,服务端就会把这个客户端从连接表中移除。
这可以避免服务端继续保留已经失效的客户端记录。
十八、服务端如何接收客户端返回的数据
客户端收到远端请求后,会连接本地服务。本地服务返回响应时,客户端会把响应数据封装成:
ControlPacket::Data(stream_id, response_bytes)
然后通过 WebSocket 发回服务端。
服务端的接收任务会处理客户端发来的控制包。
如果收到:
ControlPacket::Data(stream_id, data)
服务端会根据 stream_id 找到对应的远端连接,然后把数据写回外部用户。
如果收到:
ControlPacket::Refused(stream_id)
说明客户端没能连接本地服务,服务端就可以把这个 stream 标记为被拒绝。
如果收到:
ControlPacket::Ping(_)
服务端把它当作 pong,说明客户端仍然在线。
所以服务端的控制消息处理逻辑和客户端是镜像关系:
服务端发请求数据给客户端
客户端发响应数据给服务端
双方用 StreamId 找到对应连接
十九、异常断开后如何重启 Wormhole
客户端主函数里有一个外层循环。
如果 run_wormhole() 返回可恢复错误,比如:
WebSocketError
NoResponseFromServer
Timeout
客户端会等待一段时间,然后重新连接。
这说明 tunnelto 客户端默认把 Wormhole 控制通道看成一条可能断开的长连接。
它不会假设网络永远稳定,而是把断线重连作为正常流程的一部分。
不过,如果错误是:
AuthenticationFailed
InvalidSubDomain
SubDomainInUse
这类问题就不是重试能解决的。
例如:
Key 错了
子域名非法
子域名已经被别人占用
客户端会直接提示用户,而不是无限重试。
这个错误分类非常重要。否则用户可能会看到程序一直重连,却不知道真正原因是鉴权失败。
二十、Wormhole 控制通道的完整生命周期
现在我们把控制通道生命周期串起来:
客户端读取配置
↓
连接 wss://.../wormhole
↓
发送 ClientHello
↓
服务端校验身份、子域名和重连 token
↓
服务端返回 ServerHello
↓
服务端登记 ConnectedClient
↓
双方拆分 WebSocket 读写方向
↓
服务端启动 ping 保活
↓
客户端进入 ControlPacket 读取循环
↓
外部请求进来时,服务端发送 Init/Data
↓
客户端连接本地服务并转发数据
↓
本地响应通过 Data 包返回服务端
↓
请求结束后发送 End 并清理 stream
↓
连接异常时客户端尝试重连
这就是 Wormhole 控制通道从建立到运行再到重连的完整过程。
二十一、从设计角度看 Wormhole
Wormhole 控制通道的设计可以总结成四个关键词。
1. 长连接
客户端和服务端之间维持一条长期存在的 WebSocket。
这避免了每个请求都重新建立客户端到服务端的连接。
2. 反向通道
连接由内网客户端主动发起,但服务端可以通过这条连接把公网请求反向送给客户端。
这正是内网穿透的关键。
3. 多路复用
一条 WebSocket 可以同时承载多个 stream。
StreamId 负责区分不同请求。
4. 控制包协议
所有操作都通过 ControlPacket 表达:
Init
Data
Refused
End
Ping
这让控制通道既能传数据,也能管理连接生命周期。
二十二、这一篇的核心结论
Wormhole 控制通道不是一个简单的 WebSocket。
它是 tunnelto 客户端和服务端之间的核心通信骨架。
可以用一句话总结:
tunnelto 客户端先主动连接服务端的 /wormhole WebSocket,
通过 ClientHello / ServerHello 完成业务握手,
之后双方使用 ControlPacket 在同一条长连接上传输 stream 初始化、数据、结束、拒绝和 ping 保活消息,
从而把公网请求稳定地反向转发到本地服务。
如果说 tunnelto_server 是公网入口,localhost:8000 是本地终点,那么 Wormhole 控制通道就是连接两者的那条"隧道控制线"。
理解了这条线,后面再分析协议设计、数据转发、多路复用和重连机制,就会清晰很多。
二十三、下一篇预告
下一篇我们继续深入共享协议库:
Tunnelto 源码解析 #5:协议设计:ClientHello、ServerHello 与 ControlPacket 的二进制封装
下一篇会重点分析:
tunnelto_lib/src/lib.rs
ClientHello
ServerHello
ClientType
StreamId
ControlPacket::serialize()
ControlPacket::deserialize()
Ping 与 ReconnectToken 的编码方式
也就是 tunnelto 客户端和服务端之间到底"说什么话",以及这些控制消息如何被编码成 WebSocket 二进制消息。