在本系列的第一篇,我们先不急着钻进每一个函数,而是从最常见的一条命令开始:
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_HOST、CTRL_PORT、CTRL_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::Success、AuthFailed、InvalidSubDomain、SubDomainInUse 等结果。(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 分发到对应的本地连接
共享协议库中定义了 StreamId 和 ControlPacket。ControlPacket 包括:
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::generate 和 ClientHello::reconnect。(GitHub)
2. ServerHello
服务端返回给客户端的握手结果。成功时返回 sub_domain、hostname 和 client_id;失败时可能返回认证失败、子域名非法、子域名已占用等状态。(GitHub)
3. StreamId
每个远端请求都会分配一个 StreamId。它的作用是让多条请求可以复用同一条 WebSocket tunnel,而不会互相混淆。(GitHub)
4. ControlPacket
客户端和服务端之间的数据包协议。它包含 Init、Data、Refused、End、Ping 等类型,是 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 结构对一个网络工具项目有什么好处。