上一篇文章中,我们从一条最常见的命令开始:

tunnelto --port 8000

梳理了 tunnelto 的完整内网穿透链路:本地客户端主动连接公网控制服务器,公网服务器接收外部请求,再通过 WebSocket 控制通道把请求转发给本地客户端,最后由客户端连接 localhost:8000 并把响应原路传回。

这一篇我们换一个角度,不急着分析具体的网络转发细节,而是先看 tunnelto 的工程结构。

如果你想真正读懂一个 Rust 项目,第一步不是直接打开 main.rs 从头看到尾,而是先看:

Cargo.toml
workspace 成员
crate 之间的依赖关系
每个 crate 的职责边界

因为一个项目如何拆分 crate,通常就已经暴露了作者对系统边界的设计思路。

在 tunnelto 这个项目中,源码并不是一个单独的 Rust crate,而是一个 Rust workspace。它主要由三部分组成:

tunnelto_lib
tunnelto
tunnelto_server

这三个 crate 分别对应:

共享协议库
本地命令行客户端
公网服务端

理解这三者的分工,是继续阅读后续源码的基础。


一、为什么先看 Workspace?

Rust 的 workspace 适合管理多个相关 crate。对于 tunnelto 这种项目来说,它天然就不是一个单端程序,而是一个典型的“客户端 + 服务端 + 共享协议”的架构。

如果把所有代码都塞进一个 crate,短期看起来简单,但后期会出现几个问题:

第一,客户端和服务端都需要使用同一套协议结构,例如 ClientHelloServerHelloControlPacketStreamId。如果没有独立协议库,就很容易出现两边定义不一致的问题。

第二,客户端和服务端依赖不同。客户端需要处理命令行参数、本地 TCP 连接、WebSocket 客户端、请求观察面板;服务端需要处理公网 TCP 监听、WebSocket 服务端、鉴权、连接表、路由分发、可观测性等。两边的依赖混在一起,会让项目越来越重。

第三,后续扩展不方便。例如你想单独测试协议序列化,或者想实现另一个语言版本的客户端,都需要一个清晰的协议边界。

所以,tunnelto 的 workspace 拆分可以理解为:

tunnelto_lib      负责“说什么”
tunnelto          负责“本地怎么连”
tunnelto_server   负责“公网怎么收、怎么转”

这就是整个项目的骨架。


二、根目录 Cargo.toml:三块核心成员

先看根目录的 workspace 结构,可以抽象成这样:

[workspace]
members = [
  "tunnelto_lib",
  "tunnelto",
  "tunnelto_server",
]

这个结构非常清晰:

tunnelto_lib
  ↓
被客户端 tunnelto 使用
  ↓
被服务端 tunnelto_server 使用

也就是说,tunnelto_lib 是底层共享模块,tunneltotunnelto_server 是两个上层可执行程序。

可以画成这样:

                   ┌──────────────────┐
                   │  tunnelto_lib     │
                   │  共享协议与类型    │
                   └────────▲─────────┘
                            │
              ┌─────────────┴─────────────┐
              │                           │
┌─────────────┴─────────────┐ ┌───────────┴──────────────┐
│ tunnelto                  │ │ tunnelto_server           │
│ 本地 CLI 客户端            │ │ 公网服务端                 │
└───────────────────────────┘ └──────────────────────────┘

从这个结构就能看出:tunnelto 的核心不是“单机工具”,而是“一套分布式通信协议 + 两端程序”。


三、tunnelto_lib:最底层的共享协议库

我们先从 tunnelto_lib 看起。

它是三个 crate 里面最基础的一层。它本身不负责启动客户端,也不负责监听公网端口,而是定义客户端和服务端通信时共同使用的类型。

你可以把它理解成 tunnelto 内部协议的“字典”。

里面最重要的对象包括:

SecretKey
ReconnectToken
ClientHello
ServerHello
ClientType
ClientId
StreamId
ControlPacket

这些类型构成了 tunnelto 客户端和服务端之间交流的基础。


四、ClientHello:客户端怎么介绍自己

客户端连接服务端时,不能一上来就开始传 HTTP 数据。它首先需要告诉服务端:

我是谁?
我有没有认证 key?
我想使用哪个子域名?
我是新连接,还是断线重连?

这些信息就通过 ClientHello 表达。

可以把 ClientHello 理解成客户端发给服务端的第一封“自我介绍信”。

它包含几个关键信息:

client id
sub_domain
client_type
reconnect_token

其中 client_type 又可以分成:

Authenticated client
Anonymous client

也就是说,tunnelto 在协议层面已经区分了认证用户和匿名用户。

这个设计很重要。因为公网服务端需要根据客户端身份决定:

是否允许连接
是否允许使用指定子域名
是否允许复用保留域名
是否允许恢复之前的连接

如果没有 ClientHello 这一层,服务端就无法在连接建立时进行统一判断。


五、ServerHello:服务端怎么回复客户端

客户端发送 ClientHello 之后,服务端会返回 ServerHello

ServerHello 可以理解成服务端对客户端握手请求的裁决结果。

成功时,它会返回:

sub_domain
hostname
client_id

失败时,则可能返回:

SubDomainInUse
InvalidSubDomain
AuthFailed
Error

这说明 tunnelto 的连接建立过程并不是简单的 WebSocket 连接成功就算成功。真正的 tunnel 建立,需要经过一层业务握手:

WebSocket 连接成功
        ↓
客户端发送 ClientHello
        ↓
服务端鉴权、检查子域名、分配 hostname
        ↓
服务端返回 ServerHello
        ↓
客户端开始进入正式转发阶段

这就是为什么 tunnelto_lib 需要存在。因为客户端和服务端都必须严格理解同一套握手结果。


六、StreamId:多路请求复用的关键

内网穿透工具最核心的问题之一是:一条客户端到服务端的连接,如何同时处理多个外部请求?

例如浏览器访问一个页面时,并不只是请求一个 HTML 文件,还可能请求:

/index.html
/style.css
/main.js
/logo.png
/api/user

这些请求可能几乎同时发生。

如果所有数据都通过一条 WebSocket 通道传输,就必须有办法区分:

这段数据属于哪个请求?
这个响应应该返回给哪个浏览器连接?
哪个请求已经结束?
哪个请求被本地服务拒绝?

tunnelto_lib 中的 StreamId 就是为了解决这个问题。

每个远端连接会被分配一个 StreamId。之后,服务端和客户端传输数据时,都会把这个 StreamId 带上。

这样一来,一条 WebSocket 连接就可以承载多个逻辑 stream:

WebSocket tunnel
  ├── stream_A -> /index.html
  ├── stream_B -> /style.css
  ├── stream_C -> /main.js
  └── stream_D -> /api/user

这就是 tunnelto 能支持并发请求的基础。


七、ControlPacket:客户端和服务端之间真正传输的消息

如果说 ClientHelloServerHello 负责“建立关系”,那么 ControlPacket 就负责“正式干活”。

ControlPacket 包括几种核心类型:

Init
Data
Refused
End
Ping

它们分别表示:

Init      新的 stream 开始
Data      某个 stream 上有数据
Refused   本地连接失败或请求被拒绝
End       某个 stream 结束
Ping      保活或携带 reconnect token

从这里可以看出,tunnelto 并不是直接把 HTTP 请求作为一个高级对象来处理,而是把请求和响应都看成 TCP 字节流。ControlPacket::Data 传输的是字节数据,HTTP 只是这些字节之上的应用层协议。

这也是内网穿透工具常见的设计方式:底层只关心流,至于流里面是 HTTP、WebSocket,还是其他文本协议,则由上层服务自己处理。


八、tunnelto:本地命令行客户端

接下来再看 tunnelto 这个 crate。

它是用户真正安装和执行的命令行程序。也就是我们运行的:

tunnelto --port 8000

这个 crate 的职责主要有五个:

1. 解析命令行参数
2. 读取认证 key 和配置
3. 连接公网控制服务器
4. 接收服务端发来的 ControlPacket
5. 连接本地服务并转发数据

它可以理解成“本地代理”。


九、客户端配置模块:config.rs

客户端首先需要知道要把流量转发到哪里。

例如:

tunnelto --port 8000

对应的本地目标就是:

localhost:8000

如果指定:

tunnelto --host 127.0.0.1 --port 3000

那么目标就是:

127.0.0.1:3000

配置模块负责解析这些参数,并生成客户端运行所需的配置对象。

其中比较关键的配置包括:

local_host
local_port
local_addr
control_url
sub_domain
secret_key
use_tls
dashboard_port

这里要注意两个不同方向的地址。

第一个是本地服务地址:

localhost:8000

第二个是控制服务器地址:

wss://wormhole.tunnelto.dev:10001/wormhole

客户端的任务就是连接控制服务器,然后在需要时再连接本地服务。

所以它同时扮演两个角色:

对服务端来说:它是 WebSocket 客户端
对本地服务来说:它是 TCP 客户端

这个“双重客户端”身份,是理解 tunnelto 客户端源码的关键。


十、客户端入口:main.rs

客户端的主入口负责整体调度。

它的主流程可以概括成:

读取 Config
初始化 panic 处理和日志
检查更新
启动本地 introspection dashboard
循环连接 wormhole 控制服务器
如果断开,根据错误类型决定是否重试

这里的关键词是:

run_wormhole
connect_to_wormhole
process_control_flow_message
ACTIVE_STREAMS
RECONNECT_TOKEN

其中 run_wormhole 是客户端连接服务端并处理控制消息的核心流程。

它会把 WebSocket 拆成读写两部分:

写方向:把本地服务返回的数据发送给服务端
读方向:接收服务端发来的 Init、Data、Ping、End

当客户端收到 ControlPacket::Data 时,会根据 StreamId 查找本地 stream。如果这个 stream 还不存在,就创建一个新的本地 TCP 连接,连接到用户指定的 localhost:8000

也就是说,客户端并不是启动时就连接本地服务,而是在远端真的有请求进来时,才按需建立本地连接。


十一、客户端本地转发:local.rs

local.rs 是客户端真正和本地服务打交道的地方。

它的职责可以分成两条线:

第一条线:服务端请求数据进入本地服务。

服务端发来 ControlPacket::Data
        ↓
客户端找到对应 StreamId
        ↓
写入本地 TcpStream
        ↓
localhost:8000 收到请求

第二条线:本地服务响应数据返回服务端。

localhost:8000 返回响应
        ↓
客户端读取本地 TcpStream
        ↓
封装成 ControlPacket::Data
        ↓
通过 WebSocket 发回服务端

这样,客户端就把公网请求和本地服务连接在了一起。

local.rs 还有一个细节:如果启用了 --use-tls,它会把本地连接升级为 TLS 连接。也就是说,客户端不仅支持转发到本地 HTTP 服务,也可以转发到本地 HTTPS 服务。


十二、客户端调试面板:introspect

tunnelto 客户端中还有一个很实用的模块:introspect

它的作用是记录和查看通过 tunnel 的请求与响应。

这个模块会收集:

请求方法
请求路径
请求头
请求体
响应状态码
响应头
响应体
请求耗时

并通过一个本地 dashboard 展示出来。

更有意思的是,它还支持 replay 请求。也就是说,你可以把之前捕获到的请求重新发送到本地服务,这对调试 webhook、支付回调、第三方平台通知这类场景非常有用。

从工程分工上看,introspect 放在客户端 crate 中是合理的。因为它关注的是“本地开发者如何观察请求”,不是服务端的核心转发逻辑,也不是协议库应该关心的内容。


十三、tunnelto_server:公网服务端

再来看 tunnelto_server

如果说 tunnelto 是用户电脑上的本地代理,那么 tunnelto_server 就是部署在公网的中心节点。

它的职责更复杂,主要包括:

1. 启动 WebSocket 控制服务
2. 接收客户端握手
3. 鉴权和子域名校验
4. 保存客户端连接表
5. 监听公网远端端口
6. 根据 Host 找到对应客户端
7. 创建 ActiveStream
8. 在公网 socket 和客户端 tunnel 之间转发数据
9. 处理多实例网络发现
10. 输出日志和可观测性数据

服务端本质上是 tunnelto 的“公网入口”和“流量调度中心”。


十四、服务端入口:main.rs

服务端入口主要做三件事。

第一,初始化可观测性。比如 tracing、日志、Honeycomb 相关配置。

第二,启动控制服务器:

control_server::spawn(...)

这个控制服务器负责接收客户端 WebSocket 连接,也就是 /wormhole

第三,监听远端公网端口:

TcpListener::bind(...)

外部浏览器访问 tunnel 地址时,请求会到达这个远端监听端口。服务端再把它交给 remote::accept_connection 处理。

所以服务端实际启动了两类入口:

控制入口:给 tunnelto 客户端连接
远端入口:给外部用户访问

可以画成这样:

tunnelto 客户端
      ↓ WebSocket
control_server /wormhole


外部浏览器
      ↓ TCP/HTTP
remote listener

这两个入口最终会在服务端内部汇合:远端入口收到请求后,会找到控制入口中已经注册的客户端,然后把数据发过去。


十五、服务端控制模块:control_server.rs

control_server.rs 负责处理客户端连接。

它的核心流程可以概括为:

客户端连接 /wormhole
        ↓
读取 ClientHello
        ↓
执行鉴权和子域名校验
        ↓
返回 ServerHello
        ↓
创建 ConnectedClient
        ↓
加入 CONNECTIONS
        ↓
启动客户端消息处理任务
        ↓
启动 ping 保活任务

服务端需要保存哪些客户端在线,以及每个客户端对应哪个 host。

所以它会维护类似这样的映射:

subdomain -> ConnectedClient
client_id -> ConnectedClient

这个映射后面会被远端请求使用。

当浏览器访问:

abc.tunnelto.dev

服务端会解析出:

abc

然后查找:

abc 对应哪个 ConnectedClient?

找到之后,才能把请求转发给正确的客户端。


十六、服务端连接表:connected_clients.rs

connected_clients.rs 的作用非常直接:管理当前已连接的客户端。

它里面的核心结构可以理解成:

ConnectedClient {
    id,
    host,
    is_anonymous,
    tx
}

其中最重要的是 tx,它是服务端给客户端发送控制包的通道。

当远端请求进来时,服务端需要通过这个 txControlPacket::InitControlPacket::Data 发给对应客户端。

所以 ConnectedClient 不是一个单纯的元数据对象,而是服务端“联系某个客户端”的句柄。

从架构上看,服务端之所以能把公网请求送回本地客户端,正是因为它在握手成功后保存了这个发送通道。


十七、服务端 ActiveStream:并发请求的服务端表示

服务端还需要维护 ActiveStream

它表示一个正在处理中的远端连接。

可以理解为:

一个浏览器连接 = 一个 ActiveStream
一个 ActiveStream = 一个 StreamId + 一个目标客户端 + 一个响应通道

当外部请求进来时,服务端创建一个 ActiveStream,然后给客户端发送:

ControlPacket::Init(stream_id)

之后,这个请求的所有数据都通过同一个 stream_id 传输。

当客户端返回数据时,服务端根据 stream_id 找回对应的远端 socket,并把数据写回浏览器。

所以 ActiveStream 是服务端处理并发请求的核心数据结构之一。


十八、服务端远端入口:remote.rs

remote.rs 是公网流量进入 tunnelto 的地方。

它负责处理外部浏览器或第三方系统发来的请求。

核心逻辑可以概括成:

接收 TCP 连接
        ↓
读取 HTTP 头部
        ↓
解析 Host
        ↓
提取 subdomain
        ↓
查找 ConnectedClient
        ↓
创建 ActiveStream
        ↓
发送 Init 给客户端
        ↓
把远端数据转成 ControlPacket::Data
        ↓
等待客户端返回数据
        ↓
写回远端 socket

这部分代码和客户端的 local.rs 是镜像关系。

客户端 local.rs 连接的是本地服务:

tunnelto client -> localhost:8000

服务端 remote.rs 连接的是外部访问者:

browser -> tunnelto_server

二者中间靠 ControlPacketStreamId 连接起来。


十九、服务端鉴权模块:auth

tunnelto_server 里还有一个重要部分:auth

这个模块负责处理客户端握手时的认证和子域名校验。

它需要回答几个问题:

这个 key 是否有效?
这个 subdomain 是否合法?
这个 subdomain 是否被别人占用?
这个 subdomain 是否是保留域名?
匿名连接是否允许?
是否可以用 reconnect token 恢复?

这说明 tunnelto 不是一个只关注 TCP 转发的 demo 项目,它还包含了面向真实服务的账号、域名、订阅、重连等业务逻辑。

从源码阅读顺序上,建议不要一开始就钻进 auth,因为它会涉及数据库和业务规则。更好的阅读路径是:

先理解 tunnelto_lib 协议
再理解客户端 run_wormhole
再理解服务端 control_server
再理解 remote 转发
最后再看 auth 和多实例网络

这样不会被业务逻辑打断主线。


二十、三个 crate 的分工总结

现在我们可以把三个 crate 的职责总结成一张表。

tunnelto_lib
  - 定义共享协议
  - 定义 ClientHello / ServerHello
  - 定义 ControlPacket
  - 定义 StreamId / ClientId / SecretKey
  - 不关心客户端 UI
  - 不关心服务端监听
  - 不关心本地转发

tunnelto
  - 命令行客户端
  - 解析参数和认证 key
  - 连接 wormhole 控制服务器
  - 接收服务端转发数据
  - 连接本地 localhost 服务
  - 提供 introspection dashboard
  - 管理本地 ActiveStreams

tunnelto_server
  - 公网服务端
  - 启动 WebSocket 控制入口
  - 启动远端 TCP 监听入口
  - 处理客户端鉴权
  - 保存 ConnectedClient
  - 根据 Host 分发请求
  - 管理服务端 ActiveStreams
  - 处理可观测性和多实例网络

一句话概括:

tunnelto_lib 定协议
tunnelto 连本地
tunnelto_server 连公网

二十一、这种架构有什么好处?

这种 workspace 拆分有几个明显优势。

1. 协议复用

客户端和服务端共用 tunnelto_lib,可以避免协议结构重复定义。

如果未来要修改 ControlPacket 格式,只需要修改共享协议库,然后客户端和服务端同时升级即可。

2. 编译边界清晰

客户端不需要服务端的数据库鉴权依赖,服务端也不需要客户端的命令行 UI 和本地 dashboard 逻辑。

这能让每个 crate 的依赖相对可控。

3. 方便测试

共享协议库可以单独测试序列化和反序列化逻辑。

例如:

ControlPacket::Data -> serialize -> deserialize -> ControlPacket::Data

这种测试不需要启动真实服务端,也不需要连接本地端口。

4. 方便扩展

未来如果要写一个新的客户端,比如 GUI 客户端、移动端客户端、或者另一个语言实现的客户端,都可以参考 tunnelto_lib 的协议设计。

同样,如果要改造服务端,比如增加限流、计费、独立域名绑定、多租户管理,也可以主要在 tunnelto_server 中展开,而不影响客户端主流程。


二十二、源码阅读建议

如果你准备继续深入 tunnelto 源码,我建议按下面顺序阅读:

1. 根目录 Cargo.toml
2. tunnelto_lib/src/lib.rs
3. tunnelto/src/config.rs
4. tunnelto/src/main.rs
5. tunnelto/src/local.rs
6. tunnelto/src/introspect/mod.rs
7. tunnelto_server/src/main.rs
8. tunnelto_server/src/control_server.rs
9. tunnelto_server/src/connected_clients.rs
10. tunnelto_server/src/active_stream.rs
11. tunnelto_server/src/remote.rs
12. tunnelto_server/src/auth/*
13. tunnelto_server/src/network/*

为什么这样排?

因为你先看协议,就能知道双方到底在传什么。

再看客户端,就能知道本地服务如何被连接。

然后看服务端,就能知道公网请求如何被分发。

最后看鉴权和多实例,才不会被业务逻辑和部署细节干扰。


二十三、从架构角度重新理解 tunnelto

现在我们再回头看 tunnelto 的整体结构。

它不是简单的“把请求转发一下”,而是由三个层次组成:

协议层:tunnelto_lib
客户端层:tunnelto
服务端层:tunnelto_server

协议层解决的是:

双方怎么握手?
怎么表示一个 stream?
怎么传输数据?
怎么表示结束、拒绝和 ping?

客户端层解决的是:

怎么读取用户配置?
怎么连接公网控制服务器?
怎么连接本地 localhost 服务?
怎么把本地响应传回去?
怎么给开发者展示请求记录?

服务端层解决的是:

怎么接收公网请求?
怎么找到正确客户端?
怎么管理在线客户端?
怎么处理多个并发连接?
怎么鉴权?
怎么支持部署和可观测性?

这就是 tunnelto 作为一个内网穿透系统的工程架构。


二十四、结语

这一篇我们没有深入某一个函数,而是从 Rust workspace 的角度拆解了 tunnelto 的整体工程结构。

如果只看 tunnelto --port 8000,它像是一个简单命令。

但从源码结构看,它其实是一个清晰的三层系统:

tunnelto_lib      负责协议
tunnelto          负责本地客户端
tunnelto_server   负责公网服务端

这种拆分让 tunnelto 的源码阅读变得非常有层次。

下一篇我们可以继续深入客户端部分:

Tunnelto 源码解析 #3:客户端启动流程:配置解析、鉴权 Key、本地地址与控制服务器连接

下一篇会重点分析 tunnelto/src/config.rstunnelto/src/main.rs,看看客户端从命令行启动到连接控制服务器之前,到底做了哪些准备工作。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐