在前两篇文章里,我们已经从整体上理解了 tunnelto 的两件事:
第一,执行:
tunnelto --port 8000
之后,外部用户访问公网 URL,请求会先到达 tunnelto 的公网服务端,再通过客户端与服务端之间的 WebSocket 控制通道,反向转发到本地的 localhost:8000。
第二,tunnelto 是一个 Rust workspace,主要分成三块:
tunnelto_lib 共享协议库
tunnelto 本地命令行客户端
tunnelto_server 公网服务端
这一篇开始,我们正式进入客户端源码。
客户端是用户真正执行的程序。它看起来只是一个命令行工具,但启动过程里其实做了很多准备工作:
解析命令行参数
读取或保存鉴权 key
解析本地转发地址
拼接控制服务器 WebSocket URL
启动本地请求观察面板
连接公网 wormhole 控制服务器
发送 ClientHello
等待 ServerHello
进入数据转发循环
这篇文章就以客户端启动流程为主线,拆解从命令行执行到连接控制服务器之间发生了什么。
一、客户端启动的核心入口
tunnelto 客户端的主入口在:
tunnelto/src/main.rs
它的启动流程可以概括成:
main()
↓
Config::get()
↓
setup_panic!()
↓
update::check()
↓
start_introspect_web_dashboard()
↓
loop
↓
run_wormhole()
↓
connect_to_wormhole()
从这个流程可以看出,客户端并不是一启动就直接转发本地端口,而是先完成配置、检查、面板、连接控制服务器等一系列准备动作。
如果把 tunnelto 客户端想象成一台"小型代理机",那么它启动时需要先回答四个问题:
我要把请求转发到本地哪里?
我要连接哪个公网控制服务器?
我有没有认证身份?
连接失败后该不该重试?
这些问题主要由 config.rs 和 main.rs 一起解决。
二、命令行参数:StructOpt 如何描述 CLI
客户端配置定义在:
tunnelto/src/config.rs
它使用 StructOpt 描述命令行参数。常见参数包括:
--port / -p
--host
--use-tls / -t
--subdomain / -s
--key / -k
--dashboard-port
--verbose / -v
其中最常用的是:
tunnelto --port 8000
这表示把公网请求转发到本地 8000 端口。
如果不指定 --host,默认 host 是:
localhost
如果不指定 --port,默认端口是:
8000
所以这两条命令在默认情况下效果接近:
tunnelto
tunnelto --host localhost --port 8000
这也是 tunnelto 对新手比较友好的地方。很多本地开发服务,比如前端 dev server、简单 HTTP server、Webhook 测试服务,都经常运行在 3000、5000、8000、8080 等端口。用户只需要改一个 --port 参数即可。
例如:
tunnelto --port 3000
表示目标本地服务是:
localhost:3000
再比如:
tunnelto --host 127.0.0.1 --port 8080
表示目标本地服务是:
127.0.0.1:8080
这里要特别注意:--host 和 tunnelto 分配给你的公网域名不是一回事。
--host 指的是本地目标服务地址,例如:
localhost
127.0.0.1
0.0.0.0
192.168.1.10
公网访问域名则是服务端返回的 hostname,例如:
abc123.tunnelto.dev
二者处在完全不同的方向上。
三、Config 结构:客户端运行时需要哪些信息
配置解析完成后,源码会生成一个 Config 对象。
这个对象基本包含了客户端运行所需的所有关键字段:
client_id
control_url
use_tls
host
local_host
local_port
local_addr
sub_domain
secret_key
control_tls_off
first_run
dashboard_port
verbose
这些字段可以分成几类。
第一类是本地转发相关:
local_host
local_port
local_addr
use_tls
它们决定客户端最终要连接哪个本地服务。
第二类是公网控制服务器相关:
control_url
host
control_tls_off
它们决定客户端要连接哪个 tunnelto 服务端。
第三类是身份和域名相关:
client_id
sub_domain
secret_key
它们决定客户端是否带认证 key,以及是否请求指定子域名。
第四类是本地体验相关:
dashboard_port
verbose
first_run
它们主要影响日志、调试面板和首次运行表现。
可以这样理解:
Config = tunnelto 客户端启动时的运行说明书
之后的 main.rs、run_wormhole()、connect_to_wormhole()、local.rs 都会围绕这个 Config 工作。
四、本地地址解析:从 localhost:8000 到 SocketAddr
用户输入的是:
tunnelto --host localhost --port 8000
但网络连接真正需要的是可以用于 TcpStream::connect() 的地址。
所以源码会把:
localhost + 8000
转换成:
SocketAddr
也就是类似:
127.0.0.1:8000
这一步看起来不起眼,但非常关键。
因为如果用户输入了一个无效地址,例如 host 无法解析,或者端口不合法,客户端就不应该继续启动。否则后面就算连接上了公网服务端,真正有请求进来时也无法转发到本地服务。
因此,配置阶段会提前检查本地地址是否有效。
这一步的意义是:
在连接公网服务端之前,先确认本地目标地址至少能被解析成有效 SocketAddr。
不过要注意,解析成功不等于本地服务已经启动。
例如:
tunnelto --port 8000
即使 localhost:8000 可以解析成功,也不代表本地 8000 端口上一定有服务正在监听。
真正连接本地服务是在远端请求到来之后,由 local.rs 按需执行:
TcpStream::connect(config.local_addr)
也就是说,客户端启动阶段只验证地址格式和解析;本地服务是否真的可连接,要等到有请求进来时才知道。
五、鉴权 Key:命令行传入与本地保存
tunnelto 支持认证 key。
用户可以直接通过命令行传入:
tunnelto --key your_token_here --port 8000
也可以使用子命令把 key 保存到本地:
tunnelto set-auth --key your_token_here
保存后,下次再运行 tunnelto 时,就不需要每次都传 --key。
源码里的逻辑大致是:
如果是 set-auth 子命令:
把 key 写入用户 home 目录下的 .tunnelto/key.token
输出保存成功
退出程序
如果是普通启动:
优先使用命令行 --key
如果命令行没有 key:
尝试读取 ~/.tunnelto/key.token
如果仍然没有:
使用匿名模式
这套设计比较符合 CLI 工具的常见习惯:
临时使用:--key
长期使用:set-auth 保存到本地
无 key 使用:匿名 tunnel
从用户体验看,它降低了长期使用门槛。
从源码结构看,它把"认证身份"抽象成了 SecretKey:
Option<SecretKey>
也就是说,客户端天然支持两种状态:
Some(secret_key) -> 认证用户
None -> 匿名用户
这会直接影响后面的 ClientHello 生成逻辑。
六、子域名参数:--subdomain
除了认证 key,客户端还支持指定子域名:
tunnelto --subdomain my-demo --port 8000
这个参数会被保存到:
config.sub_domain
后续客户端发送 ClientHello 时,会把这个子域名请求一起发给服务端。
但这里有一个重要点:客户端只是"提出请求",最终能不能使用这个子域名,要由服务端决定。
服务端可能返回:
Success
SubDomainInUse
InvalidSubDomain
AuthFailed
Error
也就是说,--subdomain my-demo 并不保证一定成功。
原因很简单:
这个子域名可能已经被占用
这个子域名可能格式不合法
这个子域名可能属于认证用户保留域名
当前 key 可能没有权限使用它
所以,客户端启动时只是把 sub_domain 放进配置;真正的校验发生在服务端握手阶段。
七、控制服务器地址:control_url 是怎么拼出来的
tunnelto 客户端要主动连接公网控制服务器。
这个控制服务器地址最终会被拼成类似:
wss://wormhole.tunnelto.dev:10001/wormhole
源码里有几个默认值:
DEFAULT_HOST = tunnelto.dev
DEFAULT_CONTROL_HOST = wormhole.tunnelto.dev
DEFAULT_CONTROL_PORT = 10001
同时,它也允许通过环境变量覆盖:
CTRL_HOST
CTRL_PORT
CTRL_TLS_OFF
这里的设计很有意思。
默认情况下,客户端使用安全 WebSocket:
wss
如果设置了 CTRL_TLS_OFF,则使用普通 WebSocket:
ws
于是控制通道 URL 的组成逻辑可以理解成:
scheme = CTRL_TLS_OFF 存在 ? "ws" : "wss"
host = CTRL_HOST 或默认 wormhole.tunnelto.dev
port = CTRL_PORT 或默认 10001
control_url = scheme://host:port/wormhole
这对自托管和本地开发非常重要。
例如你自己部署 tunnelto server,或者在本机调试服务端,就可能需要把控制服务器改成:
CTRL_HOST=127.0.0.1
CTRL_PORT=10001
CTRL_TLS_OFF=1
tunnelto --port 8000
此时客户端连接的就不再是官方公网服务,而是你指定的控制服务器。
这说明 tunnelto 客户端并不是硬编码只能连接官方服务,它在源码层面已经预留了自托管和调试入口。
八、forward_url() 与 ws_forward_url()
Config 里还有两个很有用的方法:
forward_url()
ws_forward_url()
它们分别生成本地 HTTP/HTTPS 转发地址和本地 WebSocket 转发地址。
如果没有启用 --use-tls,那么:
forward_url() -> http://localhost:8000
ws_forward_url() -> ws://localhost:8000
如果启用了:
tunnelto --use-tls --port 8000
那么:
forward_url() -> https://localhost:8000
ws_forward_url() -> wss://localhost:8000
这说明 tunnelto 客户端不仅考虑普通 HTTP 请求,也考虑了 WebSocket 请求和本地 HTTPS 服务。
不过 --use-tls 指的是客户端连接本地服务时是否使用 TLS,不是公网访问地址是否使用 HTTPS。
公网访问是否 HTTPS,取决于 tunnelto 服务端如何暴露外部域名;本地转发是否 HTTPS,取决于你的本地服务是否提供 TLS。
九、主函数:main() 启动了什么
配置准备好之后,客户端进入 main() 的后续流程。
简化一下,主函数大致做了这些事:
读取配置
设置 panic 处理
检查更新
启动 introspection dashboard
循环运行 wormhole
连接失败时按错误类型重试或退出
其中最值得关注的是这个循环:
loop {
run_wormhole(...)
如果是临时网络错误:
等待后重试
如果是认证失败:
提示用户检查 key
退出
如果是其他不可恢复错误:
输出错误并退出
}
这个设计说明 tunnelto 客户端把错误分成两类。
第一类是可恢复错误,例如:
WebSocketError
NoResponseFromServer
Timeout
这些可能是网络抖动、服务端临时不可用、连接断开造成的。客户端会等待一段时间后重试。
第二类是不可恢复错误,例如:
AuthenticationFailed
InvalidSubDomain
SubDomainInUse
ServerError
这些通常不是简单重试能解决的,需要用户修改 key、子域名或配置。
这种错误分类很合理。
因为内网穿透工具通常需要长时间运行,网络短暂断开很常见。如果每次 WebSocket 断开都直接退出,体验会很差。
十、为什么要启动 introspection dashboard
客户端启动时还会调用:
start_introspect_web_dashboard()
它会启动一个本地请求观察面板。
这个面板的作用不是完成内网穿透本身,而是帮助开发者调试通过 tunnel 进来的请求。
例如你在调试 Webhook:
Stripe 回调
GitHub Webhook
支付平台通知
第三方登录回调
如果没有观察面板,你只能在本地服务日志里看请求。
而 introspection dashboard 可以记录请求和响应,让你更直观地查看:
请求路径
请求头
请求体
响应状态
响应内容
耗时
在工程分工上,这个功能放在客户端非常合理。
因为真正需要观察这些请求的人是本地开发者,而不是公网服务端运维者。
十一、run_wormhole():正式进入隧道连接流程
客户端配置完成、面板启动之后,就会进入:
run_wormhole()
这个函数可以理解成 tunnelto 客户端的核心运行循环。
它做的第一件关键事情是:
connect_to_wormhole(&config)
也就是连接公网控制服务器。
连接成功之后,服务端会返回当前 tunnel 的:
sub_domain
hostname
然后客户端 CLI 界面会显示连接成功信息,告诉用户公网访问地址是什么。
接下来,客户端会把 WebSocket 拆成两个方向:
ws_sink 写方向,客户端向服务端发送数据
ws_stream 读方向,客户端从服务端接收数据
然后启动一个异步任务,专门负责把本地服务返回的数据写入 WebSocket。
主循环则负责不断读取服务端发来的控制消息。
所以 run_wormhole() 的角色可以概括成:
建立 tunnel
展示连接结果
拆分 WebSocket 读写
把本地数据发给服务端
把服务端数据交给本地转发逻辑
十二、connect_to_wormhole():WebSocket 连接与业务握手
真正连接控制服务器的是:
connect_to_wormhole()
它首先调用 WebSocket 客户端连接:
connect_async(config.control_url)
这一步只是建立底层 WebSocket 连接。
但 tunnelto 还需要业务层握手。
WebSocket 连上之后,客户端会生成 ClientHello。
这里有三种情况。
第一种:用户提供了认证 key。
ClientHello::generate(
sub_domain,
ClientType::Auth { key }
)
第二种:用户没有 key,但本地保存了 reconnect token。
ClientHello::reconnect(reconnect_token)
第三种:用户没有 key,也没有 reconnect token。
ClientHello::generate(
sub_domain,
ClientType::Anonymous
)
这三种情况对应三类客户端身份:
认证用户
匿名重连用户
普通匿名用户
为什么匿名用户还需要 reconnect token?
因为匿名 tunnel 没有固定账号身份。如果连接断开后想恢复之前的 tunnel,就需要服务端给一个短期 token,让客户端在重连时证明"我是刚才那个连接"。
所以 tunnelto 的握手逻辑不仅处理认证,还处理断线恢复。
十三、ClientHello:客户端发出的第一条业务消息
ClientHello 是客户端发给服务端的第一条业务消息。
它大致表达的是:
我是一个客户端
我想建立 tunnel
这是我请求的 subdomain
这是我的身份类型
如果我是重连,这是我的 reconnect token
从字段上看,它包含:
id
sub_domain
client_type
reconnect_token
其中 client_type 可以是:
Auth { key }
Anonymous
客户端会把 ClientHello 序列化成 JSON,然后作为二进制 WebSocket 消息发送给服务端。
这一步很重要,因为 tunnelto 的控制通道并不是一连接上就直接传 ControlPacket。
它有一个明确的阶段划分:
阶段一:WebSocket 连接
阶段二:ClientHello / ServerHello 握手
阶段三:ControlPacket 数据转发
只有前两个阶段成功,才会进入第三阶段。
十四、ServerHello:服务端返回的连接结果
客户端发送 ClientHello 后,会等待服务端返回 ServerHello。
成功时,服务端返回:
ServerHello::Success {
sub_domain,
hostname,
client_id,
}
客户端拿到这些信息后,就知道:
服务端接受了连接
最终使用的 subdomain 是什么
公网 hostname 是什么
我的 client_id 是什么
然后 CLI 界面就可以把公网访问地址显示给用户。
失败时,服务端可能返回:
AuthFailed
InvalidSubDomain
SubDomainInUse
Error
客户端会把这些结果转换成自己的错误类型。
例如认证失败时,客户端会提示用户使用 --key 或去 dashboard 检查 access key。
这说明 tunnelto 的客户端体验并不是简单抛出异常,而是根据错误类型给出更具体的提示。
十五、连接成功后,客户端还没有连接本地服务
这一点非常容易误解。
当 connect_to_wormhole() 成功后,说明:
客户端已经连接上公网控制服务器
服务端已经接受了 ClientHello
公网 hostname 已经分配好
但此时客户端通常还没有连接到:
localhost:8000
为什么?
因为本地连接是按需创建的。
只有当外部用户真的访问公网 URL,服务端发来某个 StreamId 的 ControlPacket::Data 时,客户端才会检查本地是否已有对应 stream。
如果没有,才调用:
local::setup_new_stream()
然后在 local.rs 中执行:
TcpStream::connect(config.local_addr)
这意味着客户端启动成功,不等于本地服务一定可用。
例如你执行:
tunnelto --port 8000
即使本地 8000 端口没有任何服务,客户端仍然可能成功连上控制服务器,并显示公网 URL。
但当外部请求真的进来时,客户端连接本地服务失败,就会返回拒绝信息。
所以排查问题时要区分两个阶段:
阶段一:客户端能否连接 tunnelto 控制服务器?
阶段二:客户端能否连接本地 localhost:8000?
这两个问题完全不同。
十六、控制消息处理:启动后的主循环
连接成功后,客户端会开始处理服务端发来的控制消息。
主要消息类型包括:
Init
Data
Ping
End
Refused
这些消息定义在 tunnelto_lib 的 ControlPacket 中。
客户端收到不同消息时,会做不同处理。
1. Ping
服务端发送 Ping,客户端收到后会回复 Ping(None)。
如果 Ping 里带了 reconnect token,客户端会保存起来,供后续断线重连使用。
2. Init
表示一个新的远端 stream 开始。
客户端会记录日志,但真正建立本地连接通常发生在收到数据时。
3. Data
表示某个远端请求有数据要转发到本地服务。
客户端会检查 ACTIVE_STREAMS 里是否已经有对应 StreamId。
如果没有,就创建新的本地 TCP 连接。
然后把数据写入本地连接。
4. End
表示某个远端 stream 结束。
客户端会找到对应本地 stream,关闭并清理。
5. Refused
客户端一般不期待从服务端收到这个消息。如果收到,会把它当成异常控制包处理。
这些逻辑说明客户端启动之后并不是单线程阻塞转发,而是一个基于 Tokio 和 channel 的异步消息系统。
十七、ACTIVE_STREAMS:客户端如何管理并发请求
浏览器访问一个页面时,可能同时发出多个请求。
例如:
GET /index.html
GET /style.css
GET /app.js
GET /favicon.ico
GET /api/user
每个请求在 tunnelto 中都会对应一个 StreamId。
客户端用一个全局 ACTIVE_STREAMS 保存:
StreamId -> 本地 stream 的发送通道
当服务端发来数据时,客户端根据 StreamId 找到对应本地连接。
如果找不到,就新建本地连接。
这个机制让一条 WebSocket 控制通道可以同时承载多个远端请求。
可以理解成:
WebSocket 控制通道
├── stream_A -> localhost:8000
├── stream_B -> localhost:8000
├── stream_C -> localhost:8000
└── stream_D -> localhost:8000
每个 stream 都有自己的生命周期。
这也是 tunnelto 客户端能处理并发访问的基础。
十八、启动流程完整串起来
现在我们把客户端启动流程完整串起来。
用户执行:
tunnelto --port 8000
第一步,解析命令行参数:
port = 8000
host = localhost
use_tls = false
subdomain = None
key = None 或命令行传入值
第二步,读取本地保存的认证 key:
~/.tunnelto/key.token
如果命令行没有 key,则尝试读取这个文件。
第三步,解析本地地址:
localhost:8000 -> SocketAddr
第四步,拼接控制服务器地址:
wss://wormhole.tunnelto.dev:10001/wormhole
第五步,启动本地 introspection dashboard。
第六步,连接控制服务器 WebSocket。
第七步,生成并发送 ClientHello:
有 key -> Auth client
无 key -> Anonymous client
有 token -> Reconnect
第八步,等待服务端返回 ServerHello。
第九步,如果成功,显示公网地址。
第十步,进入控制消息循环,等待服务端转发外部请求。
第十一步,当外部请求到来时,才连接本地服务:
TcpStream::connect(local_addr)
第十二步,在本地服务和公网服务端之间双向转发数据。
十九、从启动流程看 tunnelto 的设计思想
从这一套启动流程可以看出 tunnelto 的几个设计特点。
1. 配置集中化
所有关键运行参数都先归入 Config。
这让后续模块不需要反复解析命令行,也不需要直接读取环境变量。
2. 控制通道优先
客户端启动后,第一目标不是连接本地服务,而是连接公网控制服务器。
这是内网穿透工具的典型模式:
先建立反向控制通道
再等待公网请求进入
最后按需连接本地服务
3. 支持匿名与认证两种模式
通过 Option<SecretKey> 和 ClientType,客户端可以同时支持匿名 tunnel 和认证 tunnel。
这让产品既能满足快速试用,也能支持正式账号能力。
4. 支持断线重连
ReconnectToken 的存在说明客户端不是一次性连接,而是考虑了长连接场景下的网络抖动。
5. 本地连接按需创建
客户端不会在启动时就创建所有本地连接,而是在收到远端 stream 数据时再连接本地服务。
这让启动过程更轻,也符合"每个远端请求一个 stream"的模型。
二十、排查客户端启动问题的思路
理解源码后,排查 tunnelto 客户端问题就有了更清晰的顺序。
1. 命令行参数是否正确?
例如:
tunnelto --port 8000
本地服务是否真的运行在 8000 端口?
如果本地服务其实在 3000,就应该使用:
tunnelto --port 3000
2. 本地地址是否能解析?
如果指定了特殊 host:
tunnelto --host my-local-service --port 8000
要确认这个 host 能被系统解析。
3. 控制服务器能否连接?
如果连接控制服务器失败,问题可能在:
网络访问
DNS
CTRL_HOST
CTRL_PORT
CTRL_TLS_OFF
服务端是否启动
WebSocket 是否可达
4. 鉴权 key 是否有效?
如果返回 AuthenticationFailed,要检查:
--key 是否正确
~/.tunnelto/key.token 是否过期或错误
账号状态是否正常
5. 子域名是否可用?
如果返回 SubDomainInUse 或 InvalidSubDomain,说明问题不在本地端口,而在服务端对子域名的校验。
6. 本地服务是否真的监听?
如果公网 URL 能打开,但请求失败,可能是客户端连接本地服务失败。
这时要检查:
curl http://localhost:8000
如果本地 curl 都失败,tunnelto 也无法成功转发。
二十一、这一篇的核心结论
tunnelto --port 8000 的客户端启动流程,可以总结成一句话:
客户端先把命令行参数、认证 key、本地地址和控制服务器地址整理成 Config,
再连接公网 wormhole WebSocket,
通过 ClientHello / ServerHello 完成业务握手,
最后进入 ControlPacket 消息循环,等待远端请求到来后再按需连接 localhost:8000。
它的关键不是"启动时直接连本地服务",而是:
先连公网控制服务器
再等待远端请求
最后按需连本地端口
这正是内网穿透客户端的核心工作方式。
二十二、下一篇预告
这一篇我们重点分析了客户端启动流程,包括:
配置解析
认证 key
本地地址
控制服务器 URL
WebSocket 连接
ClientHello
ServerHello
重试逻辑
下一篇可以继续深入控制通道本身:
Tunnelto 源码解析 #4:Wormhole 控制通道:WebSocket 如何建立一条"隧道控制线"
下一篇会重点分析:
run_wormhole()
connect_to_wormhole()
WebSocket split
tunnel_tx / tunnel_rx
ControlPacket 读写循环
Ping / reconnect token
也就是 tunnelto 客户端和服务端之间那条真正承载请求转发的"隧道控制线"。