当前位置: 首页 > news >正文

Tunnelto 源码解析 #2:Rust Workspace 架构拆解:CLI、协议库与服务端如何分工

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

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,看看客户端从命令行启动到连接控制服务器之前,到底做了哪些准备工作。

http://www.rkmt.cn/news/1423899.html

相关文章:

  • Proxmark3GUI:让RFID技术变得简单直观的图形界面工具
  • GIS数据进游戏引擎?手把手教你用FME把大批量OSGB模型转成FBX,保留目录结构
  • 分布式系统弹性模式:构建高可用的分布式系统
  • 百考通AI:让毕业论文写作告别焦虑,对于不同学历层次的学生,多元分析
  • 从“建起来“到“用起来“:高校大数据实验室建设的系统性解法
  • 什么是 Vibe Coding?为什么企业不能只停留在快速原型 | 星云PLUS
  • 2026甄选:成都/自贡/攀枝花/泸州二手冷库冻库回收服务公司评估与选择 - 品牌企业推荐师(官方)
  • 中电金信:不说概念,看投入:银行数智化到底在卷什么
  • 新手避坑指南:在Ubuntu 20.04上从零配置ROS Melodic激光雷达仿真环境(含RViz可视化)
  • AI资讯简报高效管理指南:从信息过载到精准获取
  • AI自动化在医疗领域的应用有哪些?
  • 2026夏护腰带选购指南:谁更靠谱?
  • ADC抗体药物偶联物:肿瘤精准治疗生物导弹
  • 大型工业部件的AR检测:从可行性到实施效果
  • 别再乱卸载补丁了!Win10/11打印机共享报错0x0000011b,试试这个注册表一键修复法
  • Windows下FinalShell 3.9.8安装指南:从下载WinPcap到配置SSH密钥连接的全流程避坑
  • 改进PSO算法下带导叶离心泵性能优化与非定常流动分析【附数据】
  • 从数据隔离到全链路分发:短视频矩阵系统的防关联底层逻辑与提效实践
  • 告别Vitis笨重编辑器:手把手教你用VSCode高效开发ZYNQ应用程序(附配置详解)
  • 数字化转型下,企业新媒体矩阵系统的底层架构与选型实践
  • 终极免费文档下载指南:如何使用Kill-Doc脚本轻松获取30+平台资源
  • 为什么你的SWOT输给Claude的五力推演?:揭秘LLM实时竞对扫描、替代品预警与买方议价力量化引擎
  • 别再只盯着协同过滤了!用Python和NumPy手撸一个超市购物篮分析(附完整代码)
  • 基于可见/近红外光谱的梨树叶片氮含量无损诊断解析方案【附代码】
  • Visual C++运行库AIO安装包:终极解决方案,一劳永逸解决Windows软件启动问题
  • AI通识教育:从技术认知到人机协作的全民素养构建
  • 2026指南:室内/室外/折叠/移动式国标双人乒乓球桌专业厂家与品牌解析 - 品牌企业推荐师(官方)
  • 2026全国轻工工艺品研发设计赋能平台优选服务商:从“同质化泥潭”到“趋势引领”,谁在改写行业规则? - 资讯纵览
  • 告别CentOS 8.5安装焦虑:手把手教你从ISO下载到分区配置的保姆级避坑指南
  • 终极指南:如何使用R3nzSkin国服版免费体验所有英雄联盟皮肤