前几篇文章里,我们已经分析了 tunnelto 的核心链路:
浏览器
↓
tunnelto_server
↓
WebSocket 控制通道
↓
tunnelto 客户端
↓
localhost:8000
也讲过服务端如何通过 Host Header 找到客户端,如何创建 StreamId 和 ActiveStream,以及如何在同一条 WebSocket 控制通道上复用多个并发请求。
这一篇我们继续深入服务端控制层:
control_server.rs
重点分析:
Warp 如何提供 /wormhole WebSocket 路由
客户端连接后如何握手
服务端如何登记 ConnectedClient
WebSocket 为什么要拆成读写两个任务
服务端如何处理客户端返回的数据
Ping/Pong 如何维持连接活跃
匿名客户端如何通过 ReconnectToken 实现短期重连
客户端断开后服务端如何清理连接
如果说 remote.rs 是 tunnelto_server 的"公网请求入口",那么 control_server.rs 就是 tunnelto_server 的"客户端控制入口"。
它负责维持所有 tunnel 客户端和服务端之间的长期连接。
一、控制服务器在整体架构中的位置
tunnelto_server 启动后,至少有两个重要入口。
第一个是远端入口:
remote TCP listener
它接收外部浏览器访问,例如:
https://abc123.tunnelto.dev
第二个是控制入口:
/wormhole WebSocket
它接收本地 tunnelto 客户端连接,例如:
tunnelto --port 8000
客户端并不是直接暴露本地端口,而是主动连接服务端的 /wormhole。
所以服务端整体上有两个方向:
外部浏览器
↓
remote.rs
tunnelto 客户端
↓
control_server.rs
这两个入口最终通过 Connections 和 ActiveStreams 连接起来。
可以这样理解:
control_server.rs 负责"谁在线"
remote.rs 负责"请求来了发给谁"
如果没有控制服务器,服务端就不知道有哪些客户端在线,也无法把公网请求反向发送给本地客户端。
二、服务端启动时如何启动 control server
在 tunnelto_server/src/main.rs 中,服务端启动时会调用:
control_server::spawn(([0, 0, 0, 0], CONFIG.control_port))
也就是说,控制服务器监听的是配置里的 control_port。
客户端默认连接的控制地址类似:
wss://wormhole.tunnelto.dev:10001/wormhole
这里的 /wormhole 就是控制服务器暴露的 WebSocket 路由。
从架构上看:
main.rs
↓
control_server::spawn()
↓
Warp routes
↓
/wormhole WebSocket
这说明 control_server.rs 不负责启动整个服务端,它只负责启动"客户端控制通道服务"。
真正的总入口还是 main.rs。
三、为什么使用 Warp?
control_server.rs 使用 Warp 来定义 HTTP/WebSocket 路由。
从源码结构看,它注册了两个路由:
/wormhole
/health_check
/health_check 很简单,用于返回:
ok
这类接口一般用于负载均衡器或部署平台检查服务是否存活。
真正重要的是:
/wormhole
这个路由接收 WebSocket 升级请求。
可以把它理解成:
客户端:我要升级成 WebSocket
服务端:可以,进入 handle_new_connection()
Warp 在这里负责完成 HTTP 到 WebSocket 的升级,而 tunnelto 自己负责升级后的业务逻辑。
四、/wormhole:客户端控制连接入口
客户端执行:
tunnelto --port 8000
之后,会连接控制服务器的:
/wormhole
服务端对应逻辑大致是:
warp::path("wormhole")
↓
读取客户端 IP
↓
warp::ws()
↓
ws.on_upgrade(...)
↓
handle_new_connection(client_ip, websocket)
也就是说,一旦 WebSocket 升级成功,连接就交给:
handle_new_connection()
这个函数是控制服务器处理新客户端连接的核心入口。
五、客户端 IP 如何获取?
control_server.rs 中有一个辅助函数:
client_ip()
它会尝试从几个地方获取客户端 IP:
Fly-Client-IP
X-Forwarded-For
remote addr
这说明 tunnelto 的服务端可能部署在代理或平台后面。
例如 Fly.io、反向代理、负载均衡器都会给请求添加不同的客户端 IP 头。
服务端获取客户端 IP 的目的主要有两个:
1. 日志记录
2. 屏蔽 blocked_ips 中的 IP
进入 handle_new_connection() 后,第一步就是检查:
CONFIG.blocked_ips.contains(&client_ip)
如果客户端 IP 在黑名单里,服务端会直接关闭 WebSocket。
这说明控制通道入口本身也有基本的访问控制。
六、handle_new_connection() 的整体流程
handle_new_connection() 可以说是控制服务器最重要的函数。
它处理一个客户端从连接到正式在线的全过程。
简化后的流程如下:
handle_new_connection(client_ip, websocket)
↓
检查 blocked_ips
↓
try_client_handshake(websocket)
↓
创建 channel: tx / rx
↓
构造 ConnectedClient
↓
Connections::add(client)
↓
websocket.split()
↓
spawn tunnel_client()
↓
spawn process_client_messages()
↓
spawn ping loop
这几个步骤分别对应:
握手
登记
读写拆分
消息发送
消息接收
连接保活
后面我们逐个分析。
七、第一步:客户端握手
客户端连接 WebSocket 后,服务端不会马上认为 tunnel 建立成功。
它会先执行:
try_client_handshake(websocket)
这个函数内部会调用:
client_auth::auth_client_handshake(websocket)
也就是说,握手和鉴权逻辑被放在 client_auth 模块中。
握手阶段需要完成几件事:
读取客户端发来的 ClientHello
判断是认证用户还是匿名用户
校验 key
校验 subdomain
处理 reconnect token
决定最终 subdomain
返回 ClientHandshake
如果握手失败,返回 None,服务端直接结束当前连接。
如果握手成功,服务端会发送:
ServerHello::Success
里面包含:
sub_domain
hostname
client_id
这一步完成后,客户端才知道:
我的 tunnel 已经建立成功
我的公网访问域名是什么
服务端给我的 client_id 是什么
所以 WebSocket 连接成功只是底层连接成功,ServerHello::Success 才是真正的 tunnel 建立成功。
八、ServerHello::Success 的意义
try_client_handshake() 成功后,会向客户端发送:
ServerHello::Success {
sub_domain,
hostname,
client_id,
}
这个响应非常关键。
例如客户端请求:
tunnelto --subdomain demo --port 8000
服务端如果接受,就可能返回:
sub_domain = demo
hostname = demo.tunnelto.dev
client_id = ...
客户端收到后,会把公网 URL 展示给用户。
如果服务端不接受,则可能在握手阶段返回认证失败、子域名非法、子域名被占用等错误。
这说明 tunnelto 的控制服务器设计中有一个明确边界:
WebSocket 层:负责建立长连接
业务握手层:负责判断能不能开 tunnel
只有两层都成功,后续才进入 ControlPacket 转发阶段。
九、第二步:创建服务端发送通道
握手成功后,服务端会创建一个 channel:
let (tx, rx) = unbounded()
这里的 tx 和 rx 非常关键。
可以这样理解:
tx:服务端其他模块用它给客户端发送 ControlPacket
rx:tunnel_client() 从它读取 ControlPacket 并写入 WebSocket
为什么需要 channel?
因为服务端很多地方都可能需要给客户端发消息。
例如:
remote.rs 收到外部请求后,要发送 Init/Data
ping loop 要发送 Ping
连接异常时可能要触发清理
这些模块不应该直接持有 WebSocket sink。
更好的方式是:统一通过 tx 发控制包。
然后由一个专门的任务 tunnel_client() 负责从 rx 中取出控制包并写入 WebSocket。
这就是典型的异步消息队列设计。
十、第三步:构造 ConnectedClient
创建 channel 后,服务端会构造:
ConnectedClient
它大致包含:
id
host
is_anonymous
tx
这几个字段分别表示:
id 客户端 ID
host 分配给客户端的子域名
is_anonymous 是否匿名客户端
tx 服务端向客户端发送 ControlPacket 的通道
其中 tx 是最重要的字段。
因为后续远端请求进来时,服务端要通过这个 tx 给客户端发送:
ControlPacket::Init(stream_id)
ControlPacket::Data(stream_id, data)
所以 ConnectedClient 不只是一个"在线客户端记录",它还是服务端联系客户端的发送句柄。
十一、第四步:登记客户端到 Connections
构造 ConnectedClient 后,服务端调用:
Connections::add(client.clone())
这一步会把客户端加入连接表。
Connections 内部维护了两张映射:
client_id -> ConnectedClient
host -> ConnectedClient
第一张表可以根据客户端 ID 找客户端。
第二张表可以根据子域名找客户端。
远端请求分发主要依赖第二张表。
例如:
abc123 -> client_A
demo -> client_B
test -> client_C
当浏览器访问:
abc123.tunnelto.dev
remote.rs 会解析 Host,提取出 abc123,然后调用:
Connections::find_by_host("abc123")
找到对应的 ConnectedClient。
所以,control_server.rs 负责"把客户端登记进去",remote.rs 负责"根据 Host 查出来使用"。
这两个模块通过 Connections 解耦。
十二、第五步:拆分 WebSocket
客户端登记完成后,服务端会把 WebSocket 拆成两半:
let (sink, stream) = websocket.split()
可以理解成:
sink 服务端写给客户端
stream 服务端读取客户端消息
为什么要拆?
因为控制通道是双向的。
服务端需要向客户端发送:
Init
Data
Ping
客户端也需要向服务端发送:
Data
Refused
Ping
如果读写都放在一个同步流程里,很容易互相阻塞。
拆分后,服务端可以启动两个独立任务:
tunnel_client() 负责写 WebSocket
process_client_messages() 负责读 WebSocket
这让控制通道可以双向并发运行。
十三、tunnel_client():服务端到客户端的发送任务
tunnel_client() 负责把服务端侧的控制包写入客户端 WebSocket。
它接收三个参数:
client
sink
queue
其中:
client 当前客户端
sink WebSocket 写方向
queue 前面创建的 rx
它的循环逻辑很简单:
从 queue 中读取 ControlPacket
↓
packet.serialize()
↓
Message::binary(...)
↓
sink.send(...)
也就是说,服务端发给客户端的所有控制包,最终都经过这个任务。
例如远端请求进来后,remote.rs 调用 client.tx.send(...),消息会进入队列,然后由 tunnel_client() 写入 WebSocket。
如果写 WebSocket 失败,说明客户端可能已经断开。
这时服务端会调用:
Connections::remove(&client)
把客户端从连接表中移除。
十四、process_client_messages():客户端到服务端的接收任务
另一个任务是:
process_client_messages()
它负责读取客户端发回来的 WebSocket 消息。
客户端会发回几类消息:
ControlPacket::Data
ControlPacket::Refused
ControlPacket::Ping
服务端收到后,会先调用:
ControlPacket::deserialize(&message)
把二进制消息解析成控制包。
然后根据类型处理。
十五、服务端如何处理客户端 Data
当客户端本地服务返回响应时,客户端会发送:
ControlPacket::Data(stream_id, data)
服务端收到后,会把它转换成:
StreamMessage::Data(data)
接着根据 stream_id 查找:
ACTIVE_STREAMS.get(&stream_id)
如果找到对应 stream,就把数据发送到这个 stream 的队列里。
后续 tunnel_to_stream() 会从队列读取这段数据,并写回浏览器 socket。
所以 process_client_messages() 在这里扮演的是"响应路由器":
客户端响应数据
↓
ControlPacket::Data(stream_id, data)
↓
ACTIVE_STREAMS[stream_id]
↓
远端浏览器 socket
没有它,客户端返回的数据就无法回到正确的浏览器连接。
十六、服务端如何处理客户端 Refused
如果客户端连接本地服务失败,例如:
localhost:8000 没有服务
本地端口写错
--use-tls 和本地服务协议不匹配
客户端会发送:
ControlPacket::Refused(stream_id)
服务端收到后,会把它转换成:
StreamMessage::TunnelRefused
然后同样根据 stream_id 找到 ActiveStream,把拒绝消息发送过去。
远端 socket 对应的任务收到后,就可以向浏览器返回类似:
Tunnel says: connection refused.
注意,这是 stream 级别的失败,不是整个客户端连接失败。
一个请求被拒绝,不代表整个 WebSocket tunnel 断开。
十七、服务端如何处理客户端 Ping
当服务端收到:
ControlPacket::Ping(_)
它会把它当作客户端的 pong。
源码里的处理逻辑很简单:
tracing::trace!("pong")
Connections::add(client.clone())
continue
也就是说,客户端回 ping,服务端认为这个客户端仍然在线,并重新加入或刷新连接表。
这是一种轻量的心跳确认。
服务端主动发送 Ping,客户端收到后再返回 Ping。
从语义上看就是:
服务端:你还在线吗?
客户端:我还在线。
十八、为什么服务端认为 Init 和 End 是非法客户端消息?
在 process_client_messages() 里,如果服务端收到客户端发来的:
ControlPacket::Init(_)
ControlPacket::End(_)
它会认为这是非法协议消息。
原因是,在 tunnelto 的设计里:
Init 通常由服务端发送给客户端
End 也主要由服务端根据远端 socket 状态发送给客户端
客户端主要负责:
返回 Data
返回 Refused
回复 Ping
这体现了协议方向的设计。
服务端是远端请求入口,它负责创建 stream,所以它发送 Init。
客户端是本地转发端,它负责把响应返回,所以它发送 Data 或 Refused。
这种方向约束可以避免协议状态混乱。
十九、Ping 保活任务:控制通道是否还活着?
handle_new_connection() 在最后会启动一个 ping loop。
这个 loop 会周期性向客户端发送:
ControlPacket::Ping(...)
间隔由 PING_INTERVAL 控制。
在 tunnelto_lib 中:
PING_INTERVAL = 30
也就是大约每 30 秒发送一次 ping。
为什么需要 ping?
因为 WebSocket 是长连接。
长连接可能因为很多原因断开:
客户端网络变化
NAT 映射过期
代理关闭空闲连接
服务端重启
客户端进程退出
中间网络抖动
如果没有心跳,服务端可能长时间不知道客户端已经离线。
Ping loop 的作用就是定期确认:
这个客户端还活着吗?
这条控制通道还能发消息吗?
如果发送 ping 失败,说明服务端已经无法联系这个客户端。
这时服务端会移除客户端连接。
二十、匿名客户端的 ReconnectToken
Ping loop 里还有一个很重要的逻辑:如果当前客户端是匿名客户端,服务端会创建一个 reconnect token。
这个 token 包含:
sub_domain
client_id
expires
其中 expires 是当前时间加上大约 2 分钟。
然后服务端把它放进:
ControlPacket::Ping(Some(reconnect_token))
发给客户端。
客户端收到后会保存 token。
如果后面 WebSocket 断开,客户端重新连接时,可以通过:
ClientHello::reconnect(reconnect_token)
尝试恢复之前的匿名 tunnel。
这解决了匿名用户的一个体验问题。
如果没有 reconnect token:
匿名客户端断线后,可能重新分配一个新子域名
有了 reconnect token:
匿名客户端短时间断线后,可以尝试恢复原来的子域名
所以 Ping 不只是保活,有时还承担"下发短期重连凭证"的职责。
二十一、发送 Ping 失败时如何处理?
Ping loop 发送消息使用的是:
client.tx.send(ControlPacket::Ping(...))
如果发送成功,说明消息已经进入客户端发送队列。
如果发送失败,说明这个客户端对应的 channel 可能已经关闭。
这通常意味着客户端连接已经断开,或者 tunnel_client() 已经结束。
此时服务端会执行:
Connections::remove(&client)
并退出 ping loop。
这一步很关键。
因为如果客户端已经断开,但服务端仍然保留:
host -> ConnectedClient
那么后续外部用户访问这个 host 时,服务端会误以为客户端仍然在线。
清理连接后,后续请求会返回:
Tunnel Not Found
或者在多实例场景中尝试查找其他实例。
二十二、Connections::remove() 做了什么?
连接移除逻辑在 connected_clients.rs 中。
Connections::remove(client) 会做几件事:
关闭 client.tx channel
如果当前 host 仍然对应这个 client,则移除 host 映射
移除 client_id 映射
输出日志
其中有一个细节:
只有当 hosts 表中当前 host 的 client id 等于要移除的 client id 时,才移除 host 映射。
为什么要这样?
因为有可能同一个 host 后来被新的客户端连接占用了。
如果旧客户端断开时不检查 id,直接删除 host,就可能误删新连接。
这个判断可以避免旧连接清理影响新连接。
也就是说,Connections::remove() 不只是简单删除,它还考虑了并发连接更新的问题。
二十三、控制服务器和远端入口如何配合?
现在把 control_server.rs 和 remote.rs 放在一起看。
客户端连接 /wormhole 后:
control_server.rs
↓
握手成功
↓
ConnectedClient
↓
Connections::add()
外部请求进入后:
remote.rs
↓
解析 Host
↓
Connections::find_by_host()
↓
找到 ConnectedClient
↓
client.tx.send(ControlPacket::Init/Data)
客户端返回数据后:
control_server.rs
↓
process_client_messages()
↓
ControlPacket::Data(stream_id, data)
↓
ACTIVE_STREAMS.get(stream_id)
↓
写回远端 socket
所以服务端的两个入口是这样协作的:
control_server.rs 维护客户端连接
remote.rs 使用客户端连接转发公网请求
Connections 是它们之间的桥梁。
ACTIVE_STREAMS 则是客户端响应数据回到远端浏览器的桥梁。
二十四、控制服务器的完整生命周期
现在我们把一个客户端控制连接的生命周期完整串起来。
1. 客户端连接 /wormhole
tunnelto client -> /wormhole WebSocket
2. 服务端检查 IP
blocked_ips.contains(client_ip)
如果被封禁,关闭连接。
3. 服务端执行握手
读取 ClientHello
鉴权
校验 subdomain
处理 reconnect token
生成 ClientHandshake
4. 服务端返回 ServerHello::Success
sub_domain
hostname
client_id
5. 服务端创建 ConnectedClient
id
host
is_anonymous
tx
6. 服务端登记连接
Connections::add(client)
7. 服务端拆分 WebSocket
sink -> tunnel_client()
stream -> process_client_messages()
8. 服务端启动 Ping loop
每 30 秒发送 Ping
匿名客户端附带 reconnect token
9. 远端请求进入
remote.rs 找到 ConnectedClient
发送 Init/Data 给客户端
10. 客户端返回响应
process_client_messages()
↓
ACTIVE_STREAMS
↓
写回浏览器
11. 客户端断开
可能由以下原因触发:
WebSocket close
读取失败
写入失败
Ping 发送失败
channel 关闭
服务端执行:
Connections::remove(client)
这个客户端生命周期结束。
二十五、为什么控制服务器要分三个任务?
一个客户端连接成功后,服务端会启动三个异步任务:
1. tunnel_client()
2. process_client_messages()
3. ping loop
这不是随意拆分,而是职责分离。
1. tunnel_client()
负责:
服务端 -> 客户端
例如发送:
Init
Data
Ping
2. process_client_messages()
负责:
客户端 -> 服务端
例如处理:
Data
Refused
Ping
3. ping loop
负责:
周期性保活
生成 reconnect token
检测发送通道是否失效
这样拆分后,每个任务都非常清楚。
如果把它们塞在一个循环里,读写、保活、转发会互相影响,逻辑也会复杂很多。
二十六、从源码看控制服务器设计的优点
1. 路由清晰
/wormhole 专门处理客户端控制连接,/health_check 专门处理健康检查。
2. 握手和转发分层
先 ClientHello / ServerHello,再进入 ControlPacket 阶段。
连接建立和数据转发边界清楚。
3. 连接登记集中
所有在线客户端都登记在 Connections 中。
远端入口只需要根据 Host 查找客户端即可。
4. WebSocket 读写分离
tunnel_client() 负责写,process_client_messages() 负责读,互不阻塞。
5. Ping 保活简单有效
每 30 秒发送一次 Ping。
发送失败就清理客户端,避免连接表残留无效客户端。
6. 匿名重连体验更好
匿名客户端通过 Ping 获取短期 reconnect token,断线后可以尝试恢复原 tunnel。
二十七、这套设计的局限和改进方向
虽然 tunnelto 的控制服务器设计很清晰,但如果要把它改造成更完整的商业化内网穿透平台,还可以继续扩展。
1. 更详细的连接状态
目前连接状态主要体现在 Connections 是否存在。
如果要做管理后台,可能需要记录:
连接时间
最后 ping 时间
流量统计
当前活跃 stream 数
客户端版本
客户端 IP
2. 更丰富的心跳协议
现在 Ping 比较简单。
未来可以扩展成:
Ping(seq)
Pong(seq)
延迟统计
客户端负载上报
这样可以更准确地判断连接质量。
3. 更清晰的协议方向
服务端目前对客户端发来的 Init / End 视为非法。
如果后续协议更复杂,可以在协议层加入方向约束或版本字段,避免兼容问题。
4. 更完善的断线清理
当前连接移除主要依赖 WebSocket 读写失败、channel 关闭和 Ping 失败。
如果有更多实例、多节点、持久化状态,可能需要额外的租约机制或分布式心跳。
5. 更明确的重连策略
匿名重连 token 有过期时间。
如果要支持更稳定的免费用户体验,可以设计更完整的 session 恢复机制。
二十八、这一篇的核心结论
tunnelto_server 的控制服务器可以总结成一句话:
control_server.rs 使用 Warp 暴露 /wormhole WebSocket,
客户端连接后先完成 ClientHello / ServerHello 握手,
服务端再创建 ConnectedClient 并登记到 Connections,
随后将 WebSocket 拆成发送任务和接收任务,
通过 ControlPacket 在服务端和客户端之间传输 Init、Data、Refused、Ping 等消息,
并通过周期性 Ping 维持连接活跃、为匿名客户端下发 ReconnectToken、在连接失效时清理客户端映射。
更简洁地说:
control_server.rs 负责维护 tunnel 客户端的"在线状态"和"控制通道"。
它不是直接处理公网浏览器请求的地方。
它真正负责的是:
谁连上来了?
这个客户端对应哪个子域名?
如何向这个客户端发送控制包?
客户端返回的数据应该怎么处理?
这条连接是否还活着?
断开后如何清理?
理解了控制服务器,tunnelto 的服务端架构就清晰很多:
control_server.rs 负责客户端长连接
remote.rs 负责公网请求入口
Connections 连接两者
ACTIVE_STREAMS 连接客户端响应和远端 socket
二十九、下一篇预告
下一篇我们继续进入鉴权系统:
Tunnelto 源码解析 #10:鉴权系统:SecretKey、保留子域名与 DynamoDB 账号校验
下一篇会重点分析:
client_auth.rs
auth_db.rs
SecretKey
ClientType::Auth
匿名用户和认证用户的差异
subdomain 是否可用
保留域名校验
DynamoDB 中的 auth/domain/record 关系
也就是服务端在握手阶段,究竟如何判断一个客户端是否有权限建立 tunnel,以及是否能使用某个指定子域名。