Tunnelto 源码解析 #4:Wormhole 控制通道:WebSocket 如何建立一条“隧道控制线”

在前几篇文章中,我们已经理解了 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:协议设计:ClientHelloServerHelloControlPacket 的二进制封装

下一篇会重点分析:

复制代码
tunnelto_lib/src/lib.rs
ClientHello
ServerHello
ClientType
StreamId
ControlPacket::serialize()
ControlPacket::deserialize()
Ping 与 ReconnectToken 的编码方式

也就是 tunnelto 客户端和服务端之间到底"说什么话",以及这些控制消息如何被编码成 WebSocket 二进制消息。

相关推荐
xiaofeichaichai1 小时前
网络与跨域
前端·网络
Latticy1 小时前
内网渗透-Windows RDP凭证的抓取和密码破解
网络·安全·网络安全·内网渗透·内网
Forget_85502 小时前
HCIA——计算机网络诞生与发展
服务器·网络·计算机网络
志栋智能3 小时前
超自动化巡检:降低运维总成本(TCO)的有效路径
大数据·运维·网络·人工智能·自动化
古道青阳3 小时前
深入密码学内核:对称/非对称原理、PKI体系及C语言实现
网络协议·https·ssl
Yang96113 小时前
一站式网络检测 鼎讯信通网络综合测试仪科普
运维·服务器·网络·能源
郑洁文4 小时前
基于Python的网络入侵检测系统
网络·python·php
爱吃土豆的马铃薯ㅤㅤㅤㅤㅤㅤㅤㅤㅤ4 小时前
nginx部署教程
运维·网络·nginx
安全小白wula4 小时前
RCE远程代码/命令执行基础讲解
网络·网络安全·渗透测试·rce·web渗透