上一篇文章中我们从一条最常见的命令开始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 分别对应共享协议库 本地命令行客户端 公网服务端理解这三者的分工是继续阅读后续源码的基础。一、为什么先看 WorkspaceRust 的 workspace 适合管理多个相关 crate。对于 tunnelto 这种项目来说它天然就不是一个单端程序而是一个典型的“客户端 服务端 共享协议”的架构。如果把所有代码都塞进一个 crate短期看起来简单但后期会出现几个问题第一客户端和服务端都需要使用同一套协议结构例如ClientHello、ServerHello、ControlPacket、StreamId。如果没有独立协议库就很容易出现两边定义不一致的问题。第二客户端和服务端依赖不同。客户端需要处理命令行参数、本地 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是底层共享模块tunnelto和tunnelto_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 连接就可以承载多个逻辑 streamWebSocket tunnel ├── stream_A - /index.html ├── stream_B - /style.css ├── stream_C - /main.js └── stream_D - /api/user这就是 tunnelto 能支持并发请求的基础。七、ControlPacket客户端和服务端之间真正传输的消息如果说ClientHello和ServerHello负责“建立关系”那么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.rslocal.rs是客户端真正和本地服务打交道的地方。它的职责可以分成两条线第一条线服务端请求数据进入本地服务。服务端发来 ControlPacket::Data ↓ 客户端找到对应 StreamId ↓ 写入本地 TcpStream ↓ localhost:8000 收到请求第二条线本地服务响应数据返回服务端。localhost:8000 返回响应 ↓ 客户端读取本地 TcpStream ↓ 封装成 ControlPacket::Data ↓ 通过 WebSocket 发回服务端这样客户端就把公网请求和本地服务连接在了一起。local.rs还有一个细节如果启用了--use-tls它会把本地连接升级为 TLS 连接。也就是说客户端不仅支持转发到本地 HTTP 服务也可以转发到本地 HTTPS 服务。十二、客户端调试面板introspecttunnelto客户端中还有一个很实用的模块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.rscontrol_server.rs负责处理客户端连接。它的核心流程可以概括为客户端连接 /wormhole ↓ 读取 ClientHello ↓ 执行鉴权和子域名校验 ↓ 返回 ServerHello ↓ 创建 ConnectedClient ↓ 加入 CONNECTIONS ↓ 启动客户端消息处理任务 ↓ 启动 ping 保活任务服务端需要保存哪些客户端在线以及每个客户端对应哪个 host。所以它会维护类似这样的映射subdomain - ConnectedClient client_id - ConnectedClient这个映射后面会被远端请求使用。当浏览器访问abc.tunnelto.dev服务端会解析出abc然后查找abc 对应哪个 ConnectedClient找到之后才能把请求转发给正确的客户端。十六、服务端连接表connected_clients.rsconnected_clients.rs的作用非常直接管理当前已连接的客户端。它里面的核心结构可以理解成ConnectedClient { id, host, is_anonymous, tx }其中最重要的是tx它是服务端给客户端发送控制包的通道。当远端请求进来时服务端需要通过这个tx把ControlPacket::Init和ControlPacket::Data发给对应客户端。所以ConnectedClient不是一个单纯的元数据对象而是服务端“联系某个客户端”的句柄。从架构上看服务端之所以能把公网请求送回本地客户端正是因为它在握手成功后保存了这个发送通道。十七、服务端 ActiveStream并发请求的服务端表示服务端还需要维护ActiveStream。它表示一个正在处理中的远端连接。可以理解为一个浏览器连接 一个 ActiveStream 一个 ActiveStream 一个 StreamId 一个目标客户端 一个响应通道当外部请求进来时服务端创建一个ActiveStream然后给客户端发送ControlPacket::Init(stream_id)之后这个请求的所有数据都通过同一个stream_id传输。当客户端返回数据时服务端根据stream_id找回对应的远端 socket并把数据写回浏览器。所以ActiveStream是服务端处理并发请求的核心数据结构之一。十八、服务端远端入口remote.rsremote.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二者中间靠ControlPacket和StreamId连接起来。十九、服务端鉴权模块authtunnelto_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.rs和tunnelto/src/main.rs看看客户端从命令行启动到连接控制服务器之前到底做了哪些准备工作。