Tunnelto 源码解析 #1:从 tunnelto --port 8000 看内网穿透的完整链路

在本系列的第一篇,我们先不急着钻进每一个函数,而是从最常见的一条命令开始:

复制代码
tunnelto --port 8000

这条命令看起来很简单:本地有一个 Web 服务运行在 localhost:8000,执行之后,tunnelto 会给你一个公网可访问的 URL。外部用户访问这个 URL,请求最终会被转发到你的本地服务。README 里也明确说明,tunnelto --port 8000 会打开一个 tunnel,并把流量转发到 localhost:8000。(GitHub)

但问题是:一个公网请求,为什么能穿过 NAT、防火墙,最终到达你本机的 8000 端口?

这篇文章就从源码角度,把这条完整链路拆开。


一、Tunnelto 是什么?

tunnelto 是一个用 Rust 编写的内网穿透工具,它的核心目标是:把本地正在运行的 Web 服务暴露成一个公网 URL 。官方 README 中介绍,它基于 Tokio 的异步 I/O 构建。(GitHub)

从仓库结构看,它不是一个单独的 Rust crate,而是一个 workspace。根目录 Cargo.toml 中可以看到三个主要成员:

复制代码
[workspace]
members = [
  "tunnelto_lib",
  "tunnelto",
  "tunnelto_server",
]

也就是说,项目被拆成了三块:客户端 CLI、共享协议库、服务端。(GitHub)

可以先这样理解:

复制代码
tunnelto             -> 本地命令行客户端
tunnelto_lib         -> 客户端和服务端共用的协议定义
tunnelto_server      -> 公网服务器,负责接收外部请求并转发

所以,当我们执行:

复制代码
tunnelto --port 8000

真正参与工作的并不只是本地命令行程序,而是:

复制代码
浏览器 / 外部用户
        ↓
公网 tunnelto_server
        ↓
WebSocket 控制通道
        ↓
本地 tunnelto 客户端
        ↓
localhost:8000

这就是 tunnelto 的完整内网穿透链路。


二、第一步:客户端解析命令参数

当你执行:

复制代码
tunnelto --port 8000

客户端首先会解析命令行参数。源码中的 Config 负责读取 --port--host--subdomain--key--use-tls 等配置。--host 默认是 localhost--port 默认是 8000,因此即使你只执行 tunnelto,默认目标也会指向本地 8000 端口。(GitHub)

相关配置可以概括成这样:

复制代码
local_host: localhost
local_port: 8000
local_addr: localhost:8000
control_url: wss://wormhole.tunnelto.dev:10001/wormhole

这里有两个地址一定要分清:

复制代码
localhost:8000

这是你的本地 Web 服务地址。

复制代码
wss://wormhole.tunnelto.dev:10001/wormhole

这是 tunnelto 客户端要连接的控制服务器地址。源码中会根据 CTRL_HOSTCTRL_PORTCTRL_TLS_OFF 等环境变量拼出控制服务器 URL。默认情况下,它会使用 WebSocket Secure,也就是 wss。(GitHub)

所以,第一步并不是"开始转发数据",而是先确定:

复制代码
我本地要转发到哪里?
我应该连接哪个公网控制服务器?
我有没有指定 subdomain?
我有没有认证 key?

三、第二步:客户端连接 Wormhole 控制通道

配置解析完成后,客户端会进入 main.rs 的主流程。源码中 main 会调用 Config::get() 获取配置,然后启动本地 introspection dashboard,接着不断尝试运行 run_wormhole。如果 WebSocket 断开、服务端无响应或超时,客户端会等待后重试。(GitHub)

这个 run_wormhole 很关键,它代表客户端和服务端之间的"控制通道"。

在源码里,客户端通过:

复制代码
tokio_tungstenite::connect_async(&config.control_url)

连接服务端的 /wormhole WebSocket 地址。连接建立后,客户端会发送一个 ClientHello,告诉服务端自己是谁、想使用哪个子域名、是否带认证 key、是否使用 reconnect token。(GitHub)

可以把这一步想象成:

复制代码
客户端:你好,我想开一个 tunnel。
服务端:你是谁?你要哪个子域名?有没有 key?
客户端:这是我的 ClientHello。
服务端:通过,这是你的公网 hostname。

服务端返回的是 ServerHello。如果成功,里面会包含:

复制代码
sub_domain
hostname
client_id

如果失败,则可能返回认证失败、子域名非法、子域名已被占用等错误。共享协议库 tunnelto_lib 中定义了 ServerHello::SuccessAuthFailedInvalidSubDomainSubDomainInUse 等结果。(GitHub)


四、第三步:服务端登记这个客户端

服务端的控制通道入口在 tunnelto_server/src/control_server.rs 中。服务端会启动一个 Warp WebSocket 路由 /wormhole,当客户端连接时,会执行握手逻辑。握手成功后,服务端创建 ConnectedClient,并把这个客户端加入连接表。(GitHub)

可以把服务端此时的状态理解成:

复制代码
subdomain abc123  ->  client_xxx

也就是说,服务端知道:

复制代码
如果以后有人访问 abc123.tunnelto.dev,
这个请求应该交给 client_xxx 处理。

服务端还会启动一个 ping 循环,定期给客户端发送 ControlPacket::Ping。如果是匿名客户端,服务端还可能生成一个短期 reconnect token,用于断线后的会话恢复。(GitHub)

所以,WebSocket 不只是"建立连接"这么简单,它同时承担了三个职责:

复制代码
1. 客户端注册
2. 连接保活
3. 后续转发远端请求的数据包

这也是 tunnelto 的核心设计之一:客户端主动连服务端,后续所有外部请求都通过这条已建立的连接反向送回本地。


五、第四步:外部用户访问公网 URL

现在假设服务端分配给你的公网地址是:

复制代码
https://abc123.tunnelto.dev

外部用户访问这个地址时,请求首先到达公网的 tunnelto_server

服务端的远端入口在 remote.rs。它会接收 TCP 连接,然后先 peek HTTP 请求头,从请求头里解析 Host。源码中可以看到,它会读取请求前 4KB 的头部内容,并通过 httparse 查找 Host header。(GitHub)

也就是说,服务端首先关心的是:

复制代码
Host: abc123.tunnelto.dev

然后它会从 Host 中提取子域名前缀:

复制代码
abc123

接着服务端查找:

复制代码
abc123 对应哪个已连接客户端?

如果找不到对应 tunnel,就返回:

复制代码
Error: Tunnel Not Found

源码里也定义了 HTTP_NOT_FOUND_RESPONSE,用于没有找到 tunnel 的情况。(GitHub)


六、第五步:服务端为这个请求创建 StreamId

找到客户端之后,服务端不会直接把 TCP socket 原封不动地"塞进"WebSocket。它会先为这个远端请求创建一个新的 ActiveStream,并生成一个 StreamId。(GitHub)

为什么需要 StreamId

因为一条 tunnel 连接可能同时承载多个请求。例如:

复制代码
请求 A:GET /index.html
请求 B:GET /style.css
请求 C:GET /main.js
请求 D:WebSocket /api/live

它们都要通过同一条客户端控制连接转发。如果没有 StreamId,客户端就不知道某段数据属于哪个请求。

所以 tunnelto 的做法是:

复制代码
每个远端连接 = 一个 StreamId
每个数据包都带上 StreamId
客户端根据 StreamId 分发到对应的本地连接

共享协议库中定义了 StreamIdControlPacketControlPacket 包括:

复制代码
Init(StreamId)
Data(StreamId, Vec<u8>)
Refused(StreamId)
End(StreamId)
Ping(Option<ReconnectToken>)

这些控制包就是客户端和服务端之间传输数据的基本协议。(GitHub)


七、第六步:服务端把远端请求打包发给客户端

当公网服务器收到外部请求的数据后,它会把数据包装成:

复制代码
ControlPacket::Data(stream_id, data)

然后通过 WebSocket 发给客户端。

remote.rs 中,服务端会先给客户端发送 Init,表示新请求开始;随后不断从远端 TCP 连接读取数据,并把读取到的字节封装成 ControlPacket::Data 发给对应客户端。(GitHub)

这个过程可以简化成:

复制代码
外部浏览器请求
    ↓
tunnelto_server 读取 TCP 数据
    ↓
封装成 ControlPacket::Data
    ↓
通过 WebSocket 发给 tunnelto 客户端

注意,这里转发的不是"HTTP 对象",而是更底层的字节数据。HTTP 请求头、请求体、本地服务响应,本质上都是 TCP 流里的字节。


八、第七步:客户端连接本地 localhost:8000

客户端收到服务端发来的 ControlPacket::Data 后,会先检查这个 StreamId 是否已经有对应的本地连接。

如果没有,客户端会调用 local::setup_new_stream,连接本地服务:

复制代码
TcpStream::connect(config.local_addr).await

也就是连接:

复制代码
localhost:8000

如果连接失败,客户端会向服务端发送 ControlPacket::Refused,服务端再向外部请求返回错误。源码中 setup_new_stream 连接失败时,会调用 introspect::connect_failed(),并发送 ControlPacket::Refused(stream_id)。(GitHub)

如果连接成功,客户端就建立了这样一条本地链路:

复制代码
WebSocket 控制通道
        ↓
tunnelto 客户端
        ↓
TcpStream localhost:8000

然后它会把远端数据写入这个本地 TCP 连接。

这一步就是"穿透"的关键:外部用户不能直接访问你的 localhost:8000,但 tunnelto 客户端可以。因为 tunnelto 客户端运行在你的电脑上,它主动连接本地服务没有任何问题。


九、第八步:本地服务响应再反向传回公网

本地服务收到请求后,会像处理普通请求一样返回响应。例如你的本地服务返回:

复制代码
HTTP/1.1 200 OK
Content-Type: text/html

<html>...</html>

客户端从本地 TCP 连接读取响应数据,再把这些字节封装成:

复制代码
ControlPacket::Data(stream_id, response_bytes)

通过 WebSocket 发回服务端。local.rs 中的 process_local_tcp 会不断读取本地服务的数据,并把数据封装成 ControlPacket::Data 发送到 tunnel。(GitHub)

服务端收到客户端返回的数据后,再根据 StreamId 找到原来的外部 TCP 连接,把响应写回给浏览器。remote.rs 中的 tunnel_to_stream 就负责从队列里取出客户端返回的数据,并写入远端 socket。(GitHub)

完整闭环如下:

复制代码
浏览器
  ↓ HTTP Request
tunnelto_server
  ↓ ControlPacket::Data
WebSocket 控制通道
  ↓
tunnelto 客户端
  ↓ TCP
localhost:8000
  ↓ HTTP Response
tunnelto 客户端
  ↓ ControlPacket::Data
WebSocket 控制通道
  ↓
tunnelto_server
  ↓ HTTP Response
浏览器

这就是 tunnelto --port 8000 背后的完整数据流。


十、为什么 tunnelto 不需要你配置路由器端口映射?

传统端口映射需要你在路由器上配置:

复制代码
公网 IP:某端口 -> 内网机器:8000

但 tunnelto 不依赖这个模式。

它的关键是:本地客户端主动向公网服务器发起 WebSocket 连接

大多数 NAT 和防火墙会允许内网机器主动访问公网服务。一旦这条出站连接建立,服务端就可以沿着这条已经存在的连接,把外部请求"反向"送给客户端。

所以 tunnelto 的思路不是:

复制代码
公网直接连你电脑

而是:

复制代码
你的电脑主动连公网服务器
公网服务器把请求通过这条连接转给你

这也是很多内网穿透工具的共同核心。


十一、这条链路里的几个关键对象

为了后面继续读源码,我们可以先记住几个核心对象。

1. ClientHello

客户端发给服务端的握手消息,用于声明客户端类型、认证 key、子域名、重连 token 等。tunnelto_lib 中定义了 ClientHello::generateClientHello::reconnect。(GitHub)

2. ServerHello

服务端返回给客户端的握手结果。成功时返回 sub_domainhostnameclient_id;失败时可能返回认证失败、子域名非法、子域名已占用等状态。(GitHub)

3. StreamId

每个远端请求都会分配一个 StreamId。它的作用是让多条请求可以复用同一条 WebSocket tunnel,而不会互相混淆。(GitHub)

4. ControlPacket

客户端和服务端之间的数据包协议。它包含 InitDataRefusedEndPing 等类型,是 tunnelto 内部通信的基础。(GitHub)

5. ActiveStreams

客户端和服务端都会维护活跃 stream。服务端用它把客户端返回的数据写回正确的远端 socket;客户端用它把服务端发来的数据写入正确的本地 TCP 连接。客户端源码中定义了 ACTIVE_STREAMS,用于保存 StreamId 到本地流发送通道的映射。(GitHub)


十二、用一句话总结完整链路

Tunnelto --port 8000 的本质是:

本地客户端主动连接公网控制服务器,注册一个子域名;公网服务器收到该子域名的请求后,为每个远端连接创建 StreamId,通过 WebSocket 把请求数据转给本地客户端;客户端再连接 localhost:8000,把响应数据原路返回给公网服务器,最终返回给外部访问者。

这套设计的精髓在于:

复制代码
公网入口在服务端
本地访问能力在客户端
二者之间靠 WebSocket 控制通道连接
多请求并发靠 StreamId 区分
数据转发靠 ControlPacket 封装

十三、下一篇预告

这一篇我们先从宏观上看懂了:

复制代码
tunnelto --port 8000

背后的完整链路。

下一篇继续深入:

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

下一篇会重点分析:

复制代码
tunnelto/
tunnelto_lib/
tunnelto_server/

这三个 crate 为什么要拆开,它们之间如何依赖,以及这种 workspace 结构对一个网络工具项目有什么好处。

相关推荐
啄缘之间1 小时前
8.【学习】工业级详细接口约束&覆盖率
开发语言·笔记·学习·uvm·sv
Chase_______1 小时前
【Java基础核心知识点全解·09】Java 内存布局与垃圾回收详解:栈、堆、栈帧、GC Roots 与对象回收
java·开发语言
江南十四行2 小时前
并发编程(四)
开发语言·python
葱卤山猪2 小时前
C++17 联合体
开发语言·c++
折哥的程序人生 · 物流技术专研2 小时前
Java 23 种设计模式:从踩坑到精通 | 抽象工厂 —— 支付/收款如何成套创建?跨平台 UI 如何一键换肤?
java·开发语言·后端·设计模式
方也_arkling2 小时前
【Java-Day11】抽象类和抽象方法
java·开发语言
Ulyanov2 小时前
深入QML-Python通信 构建响应式交互界面的桥梁设计:QML+PySide6现代开发入门(五)
开发语言·python·算法·交互·qml·系统仿真
就叫_这个吧2 小时前
JavaScript中常用事件示例展示附源码
开发语言·javascript·html
不会C语言的男孩2 小时前
C++ Primer Plus 第9章:内存模型和名称空间
开发语言·c++