前面几篇文章,我们主要分析 tunnelto 的源码运行机制:
客户端如何启动
WebSocket 控制通道如何建立
ControlPacket 如何封装
远端请求如何转发到 localhost
服务端如何根据 Host 分发请求
StreamId 如何支持多路复用
鉴权和重连机制如何设计
本地调试面板如何记录请求
这一篇开始,我们从"源码阅读"转向"部署实战"。
也就是:
如果不使用官方 tunnelto.dev 服务,
自己部署一套 tunnelto_server,
需要注意哪些端口?
需要配置哪些环境变量?
Dockerfile 是怎么设计的?
客户端如何连接自托管服务端?
单实例部署有什么限制?
这篇文章的标题是:
Tunnelto 源码解析 #13:自托管部署:Docker、环境变量、端口规划与单实例限制
重点不是教你搭一个完整商业化内网穿透平台,而是从源码角度看清楚 tunnelto 自托管部署的关键点。
一、Tunnelto 自托管部署的整体模型
默认情况下,用户执行:
tunnelto --port 8000
客户端会连接官方控制服务器。
默认控制地址类似:
wss://wormhole.tunnelto.dev:10001/wormhole
如果要自托管,核心就是把这条控制连接改成你自己的服务端。
整体结构变成:
外部浏览器
↓
你的公网服务器 remote port
↓
tunnelto_server
↓
WebSocket 控制通道
↓
本地 tunnelto 客户端
↓
localhost:8000
所以你需要准备两端:
服务端:
运行 tunnelto_server
暴露公网访问端口
暴露客户端控制端口
客户端:
运行 tunnelto
通过 CTRL_HOST / CTRL_PORT / CTRL_TLS_OFF 指向你的服务端
自托管的本质不是改客户端转发逻辑,而是把客户端连接的控制服务器换掉。
二、README 里的自托管说明
项目 README 中的 Host it yourself 部分给了非常简洁的自托管思路。
大致分为三步:
1. 编译 musl target 的 tunnelto_server
2. 参考 Dockerfile 构建 Alpine 镜像
3. 把镜像部署到你的服务器
也就是说,官方自托管思路偏向:
Rust 静态编译
↓
复制单个 tunnelto_server 二进制
↓
放进 Alpine 镜像运行
这是一种比较常见的 Rust 服务部署方式。
好处是镜像简单,运行时依赖少。
三、musl_build.sh:用 Docker 编译静态二进制
仓库里有一个:
musl_build.sh
它的作用是用 clux/muslrust 镜像编译服务端:
docker run \
-v "cargo-cache:$HOME/.cargo/" \
-v "$PWD:/volume" \
--rm -it \
clux/muslrust:stable \
cargo build --bin tunnelto_server --release
它编译的是:
tunnelto_server
而不是客户端 tunnelto。
构建产物路径大致是:
target/x86_64-unknown-linux-musl/release/tunnelto_server
后面的 Dockerfile 就会复制这个二进制。
这说明 tunnelto 自托管主要部署的是服务端,不是把客户端也打到服务器里。
客户端仍然运行在开发者本机。
四、Dockerfile:极简 Alpine 镜像
项目根目录的 Dockerfile 非常简单。
核心逻辑是:
FROM alpine:latest
COPY ./target/x86_64-unknown-linux-musl/release/tunnelto_server /tunnelto_server
EXPOSE 8080
EXPOSE 5000
EXPOSE 10002
ENTRYPOINT ["/tunnelto_server"]
从这个 Dockerfile 可以看出几个点。
第一,它不在 Dockerfile 里编译 Rust。
也就是说,你需要先在外部把 tunnelto_server 编译好。
第二,镜像里只放服务端二进制。
这符合 Rust 静态编译后的部署风格。
第三,它暴露了三个端口:
8080 远端公网请求入口
5000 客户端控制 WebSocket 入口
10002 内部网络服务入口
不过这里要特别注意:源码配置里的默认内部网络端口是 NET_PORT,默认值是 6000。而 Dockerfile 暴露的是 10002。如果你真的要使用内部网络服务,需要显式确认容器暴露端口、环境变量和实际监听端口是否一致。
单实例部署时,内部网络服务不是最关键的;真正必须关注的是:
remote port
control port
五、服务端有三类端口
从 tunnelto_server/src/config.rs 和 main.rs 可以看出,服务端主要有三类端口。
1. Remote Port:远端公网请求端口
环境变量:
PORT
默认值:
8080
作用:
外部浏览器访问 tunnel 域名时,请求进入这个端口。
例如本地测试时:
curl -H 'abc.localhost' http://localhost:8080/some_path
这个 8080 就是 remote port。
它对应源码中的:
CONFIG.remote_port
服务端会用 TcpListener 监听这个端口。
2. Control Port:客户端控制通道端口
环境变量:
CTRL_PORT
默认值:
5000
作用:
tunnelto 客户端连接 /wormhole WebSocket 的端口。
例如客户端自托管测试时:
CTRL_HOST=localhost CTRL_PORT=5000 CTRL_TLS_OFF=1 tunnelto -p 8000
客户端连接的是:
ws://localhost:5000/wormhole
这个端口对应服务端的控制服务器,也就是前面分析过的:
control_server.rs
3. Internal Network Port:实例间通信端口
环境变量:
NET_PORT
默认值:
6000
作用:
多实例部署时,服务端实例之间查询某个 host 由哪个实例负责。
这个端口对应:
network::spawn()
它是为了多实例 gossip / 内部发现服务准备的。
如果你只是单实例部署,可以先不用重点关注它。
但如果你部署多个 tunnelto_server 实例,它就会变得重要。
六、三个端口不要混淆
初次部署 tunnelto_server 时,最容易混淆的就是端口。
可以记住:
PORT:
给外部浏览器访问 tunnel 用
CTRL_PORT:
给 tunnelto 客户端建立 WebSocket 控制通道用
NET_PORT:
给多个 tunnelto_server 实例之间通信用
用一张图表示:
外部浏览器
↓
PORT=8080
↓
tunnelto_server
本地 tunnelto 客户端
↓
CTRL_PORT=5000 /wormhole
↓
tunnelto_server
其他 tunnelto_server 实例
↓
NET_PORT=6000
↓
当前 tunnelto_server
如果你只有一个服务端实例,最核心的是:
PORT
CTRL_PORT
七、本地测试命令如何理解?
README 里给了一个本地测试流程。
服务端启动:
ALLOWED_HOSTS="localhost" cargo run --bin tunnelto_server
客户端连接本地服务端:
CTRL_HOST="localhost" CTRL_PORT=5000 CTRL_TLS_OFF=1 cargo run --bin tunnelto -- -p 8000
测试远端请求:
curl -H '.localhost' "http://localhost:8080/some_path?with=somequery"
这三步分别对应:
第一步:
启动自托管服务端
第二步:
启动客户端,并让客户端连接本地控制服务器
第三步:
模拟外部用户访问 remote port
这里最关键的是:
CTRL_TLS_OFF=1
因为本地测试没有配置 TLS,所以客户端要使用:
ws://localhost:5000/wormhole
而不是:
wss://localhost:5000/wormhole
如果不设置 CTRL_TLS_OFF=1,客户端会尝试使用安全 WebSocket,连接本地非 TLS 服务就会失败。
八、ALLOWED_HOSTS:允许哪些根域名建立 tunnel
服务端配置里有:
ALLOWED_HOSTS
它表示允许作为 tunnel 根域名的 host。
例如:
ALLOWED_HOSTS="localhost"
就表示允许:
xxx.localhost
这样的 tunnel host。
如果你部署到自己的域名,比如:
tunnel.example.com
可能会配置:
ALLOWED_HOSTS="tunnel.example.com"
TUNNEL_HOST="tunnel.example.com"
这样服务端才能从 Host Header 中识别:
abc.tunnel.example.com
并提取:
abc
作为子域名。
如果 ALLOWED_HOSTS 没配对,远端请求可能会被认为是非法 Host。
九、TUNNEL_HOST:服务端生成公网域名时使用
服务端还有一个配置:
TUNNEL_HOST
默认值:
tunnelto.dev
它用于生成返回给客户端的完整公网 hostname。
比如客户端请求子域名:
demo
如果:
TUNNEL_HOST="tunnel.example.com"
那么服务端返回的 hostname 可能就是:
demo.tunnel.example.com
所以:
ALLOWED_HOSTS 影响服务端接收哪些 Host
TUNNEL_HOST 影响服务端告诉客户端哪个公网域名
这两个值通常应该保持一致或至少逻辑一致。
否则可能出现:
客户端看到的 hostname 是 demo.xxx
但远端入口校验的 allowed host 是 yyy
导致访问失败。
十、BLOCKED_SUB_DOMAINS:保留子域名
服务端配置中还有:
BLOCKED_SUB_DOMAINS
它是一个逗号分隔列表。
例如:
BLOCKED_SUB_DOMAINS="www,api,admin,dashboard,wormhole"
这些子域名不能被普通用户占用。
原因很简单:它们通常有系统用途。
例如:
www.tunnel.example.com
api.tunnel.example.com
admin.tunnel.example.com
wormhole.tunnel.example.com
如果允许普通 tunnel 用户申请这些名字,就可能和系统入口冲突。
在服务端鉴权阶段,如果用户请求的 subdomain 命中这个列表,会被视为不可用。
十一、MASTER_SIG_KEY:签名密钥
服务端配置里有:
MASTER_SIG_KEY
它用于生成和验证 ReconnectToken。
如果没有设置,服务端会生成临时签名密钥。
这会带来一个问题:
服务端重启后,旧 token 全部失效。
如果你只是本地测试,可以不配置。
如果是生产部署,建议配置稳定的 MASTER_SIG_KEY。
它应该是符合源码要求的 hex 字符串,并且长度正确。
可以理解为:
MASTER_SIG_KEY 是服务端签发短期恢复凭证的根密钥。
注意,不要把它提交到公开仓库。
十二、BLOCKED_IPS:屏蔽客户端 IP
服务端还支持:
BLOCKED_IPS
它也是逗号分隔。
作用是阻止某些 IP 连接控制服务器。
例如:
BLOCKED_IPS="1.2.3.4,5.6.7.8"
控制服务器在处理 /wormhole 连接时,会读取客户端 IP,并检查是否在 blocked list 中。
如果在,就关闭连接。
这属于比较基础的访问控制能力。
十三、HONEYCOMB_API_KEY:可观测性配置
服务端配置里还有:
HONEYCOMB_API_KEY
如果设置了这个值,服务端会配置 Honeycomb tracing。
如果没有设置,也可以正常运行,只是没有 Honeycomb 上报。
这说明可观测性是可选能力,不是自托管运行的必要条件。
本地部署或个人使用可以先忽略它。
十四、FLY_APP_NAME 与多实例内部发现
服务端配置中还有:
FLY_APP_NAME
FLY_ALLOC_ID
如果设置了 FLY_APP_NAME,源码会生成:
global.{app_name}.internal
作为内部 gossip DNS host。
这是为了 Fly.io 的私有网络设计的。
如果没有 FLY_APP_NAME,network 模块会提示 gossip mode disabled。
也就是说,默认自托管单实例时,这部分不会真正工作。
这也解释了为什么 README 里说官方托管版本是基于 Fly.io Private Networking 做了分布式系统,而简单自托管版本不支持多个服务器的集中协调。
十五、客户端如何连接自托管服务端?
客户端配置在:
tunnelto/src/config.rs
它支持三个控制服务器相关环境变量:
CTRL_HOST
CTRL_PORT
CTRL_TLS_OFF
默认情况下:
CTRL_HOST 默认指向 wormhole.tunnelto.dev
CTRL_PORT 默认是 10001
没有 CTRL_TLS_OFF 时使用 wss
自托管时,你需要覆盖它们。
本地测试:
CTRL_HOST=localhost \
CTRL_PORT=5000 \
CTRL_TLS_OFF=1 \
tunnelto --port 8000
如果你的服务端部署在公网服务器:
CTRL_HOST=your-server.example.com \
CTRL_PORT=5000 \
CTRL_TLS_OFF=1 \
tunnelto --port 8000
如果你给控制端口配置了 TLS 和反向代理,则可以不设置 CTRL_TLS_OFF,让客户端使用:
wss://your-server.example.com:5000/wormhole
但如果没有 TLS,就必须设置:
CTRL_TLS_OFF=1
否则客户端会尝试 wss,连接可能失败。
十六、客户端连接控制端口,浏览器访问远端端口
这里再强调一次,因为很容易搞错。
客户端连的是:
CTRL_HOST:CTRL_PORT
外部浏览器访问的是:
你的域名:PORT
例如:
CTRL_PORT=5000
PORT=8080
那么:
tunnelto 客户端连接:
ws://your-server:5000/wormhole
外部请求访问:
http://abc.your-domain:8080
如果你前面接了 Nginx 或 Caddy,可以把外部 80/443 转发到容器的 8080,把控制通道域名转发到 5000。
一种常见规划是:
公网 80/443:
处理 *.tunnel.example.com
反代到 tunnelto_server:8080
控制通道:
wormhole.tunnel.example.com 或单独端口
反代到 tunnelto_server:5000
不过具体反代配置要结合你的域名和 TLS 方案。
十七、单实例 Docker 部署示例
假设你已经编译好了:
target/x86_64-unknown-linux-musl/release/tunnelto_server
可以先构建镜像:
docker build -t my-tunnelto-server .
然后运行:
docker run -d \
--name tunnelto-server \
-p 8080:8080 \
-p 5000:5000 \
-e ALLOWED_HOSTS="localhost" \
-e TUNNEL_HOST="localhost" \
-e CTRL_PORT="5000" \
-e PORT="8080" \
-e BLOCKED_SUB_DOMAINS="www,api,admin,wormhole" \
my-tunnelto-server
本地客户端连接:
CTRL_HOST=localhost \
CTRL_PORT=5000 \
CTRL_TLS_OFF=1 \
tunnelto --port 8000
然后你可以用 curl 模拟访问:
curl -H 'abc.localhost' http://localhost:8080/
这里的核心是:
客户端连接 5000
远端请求访问 8080
Host Header 中包含子域名
十八、为什么测试时要手动传 Host Header?
本地测试时,你可能没有真的配置 DNS。
例如:
abc.localhost
未必能自动解析到你的服务端。
所以 README 里的测试方式是:
curl -H '.localhost' "http://localhost:8080/some_path?with=somequery"
本质上是:
TCP 连接到 localhost:8080
但 HTTP Host Header 伪装成某个 tunnel host
因为服务端分发请求依赖的是:
Host Header
而不是 TCP 连接目标地址本身。
这也是第 #7 篇讲过的内容:
Host Header -> subdomain -> ConnectedClient
本地测试时,只要 Host Header 正确,服务端就可以走同样的分发逻辑。
十九、域名部署时需要通配符 DNS
如果你想真正公网使用,一般需要配置通配符 DNS。
例如你想使用:
*.tunnel.example.com
那么 DNS 里需要配置:
*.tunnel.example.com -> 你的服务器 IP
这样:
abc.tunnel.example.com
demo.tunnel.example.com
test.tunnel.example.com
都会解析到你的 tunnelto_server。
然后服务端根据 Host Header 提取:
abc
demo
test
再分发给不同客户端。
如果没有通配符 DNS,用户访问随机子域名时,DNS 层就可能已经失败,根本到不了 tunnelto_server。
二十、反向代理与 TLS
tunnelto_server 自身的 remote TCP listener 是一个 TCP 入口。
如果你希望用户通过:
https://abc.tunnel.example.com
访问,通常需要在前面放一个支持 TLS 的反向代理或负载均衡器。
例如:
Caddy / Nginx / Traefik / 云负载均衡
↓
tunnelto_server:8080
同时控制通道也可以通过反向代理暴露为:
wss://wormhole.tunnel.example.com/wormhole
然后客户端就可以不设置 CTRL_TLS_OFF。
但如果你只是本地或内网测试,没有 TLS,则使用:
CTRL_TLS_OFF=1
这会让客户端使用:
ws://...
二十一、单实例限制:README 里的关键提醒
README 明确提醒:简单自托管实现不支持多个运行中的服务端实例之间的集中协调。
这句话非常重要。
原因是 tunnelto 的运行依赖两张内存表:
Connections
ACTIVE_STREAMS
它们都在当前服务端实例内存中。
客户端连接到实例 A 后:
实例 A:
abc -> client_A
如果外部请求却被负载均衡分发到实例 B:
实例 B:
没有 abc -> client_A
实例 B 就找不到这个 tunnel。
于是请求可能返回:
Tunnel Not Found
所以单实例部署时没有这个问题。
但多实例部署时,必须保证:
客户端连接的实例
和远端请求进入的实例
能够找到彼此
要么请求始终打到同一个实例,要么实现跨实例发现与代理。
二十二、为什么多实例会复杂?
多实例难点在于:客户端长连接是有状态的。
假设:
client_A 连接到了实例 A
那么实例 A 内存里有:
host -> client_A
client_id -> client_A
WebSocket tx
如果请求进入实例 B,实例 B 本地没有这条 WebSocket。
它只能做两件事:
1. 返回 Tunnel Not Found
2. 找到实例 A,并把 TCP 流代理过去
tunnelto 源码里确实有 network 模块用于实例发现和代理。
但 README 也提示,官方托管版本依赖 Fly.io Private Networking 做分布式协调。
对于普通自托管用户来说,最稳妥的是先部署单实例。
二十三、network 模块的作用
network/mod.rs 中有一个函数:
instance_for_host(host)
它会尝试查找哪个实例服务某个 host。
大致逻辑是:
1. 根据 FLY_APP_NAME 生成 gossip DNS host
2. 通过 DNS 查找所有实例 IP
3. 向每个实例的 internal network port 发请求
4. 询问它是否服务某个 host
5. 找到后返回该实例 IP 和 client_id
如果没有配置 FLY_APP_NAME,源码会提示:
gossip mode disabled
这意味着普通环境默认不会启用多实例发现。
这也是为什么自托管单实例更简单。
二十四、单实例部署的实际建议
如果你只是自己用,或者初期做 MVP,我建议这样部署:
1 台 VPS
1 个 tunnelto_server 实例
1 个通配符域名
1 个反向代理处理 TLS
remote port 走 80/443 反代到 8080
control port 走 wss 反代到 5000
例如:
*.tunnel.example.com -> VPS IP
wormhole.tunnel.example.com -> VPS IP
然后:
Caddy/Nginx:
*.tunnel.example.com -> tunnelto_server:8080
wormhole.tunnel.example.com -> tunnelto_server:5000
客户端:
CTRL_HOST=wormhole.tunnel.example.com tunnelto --port 8000
如果没有 TLS:
CTRL_HOST=你的服务器IP CTRL_PORT=5000 CTRL_TLS_OFF=1 tunnelto --port 8000
二十五、自托管时的鉴权问题
当前源码默认使用:
AuthDbService
它会连接 DynamoDB 进行 key、账号、域名、订阅校验。
如果你只是自托管测试,可能不想接 DynamoDB。
源码中在 main.rs 里有注释:
To disable all authentication:
pub static ref AUTH_DB_SERVICE: crate::auth::NoAuth = crate::auth::NoAuth;
也就是说,可以把真实鉴权服务换成:
NoAuth
NoAuth 会直接返回:
AuthResult::Available
这样本地测试就不需要 DynamoDB。
不过,如果你准备正式开放给别人使用,还是应该设计自己的鉴权系统。
否则任何人只要能连接你的控制服务器,就可能创建 tunnel。
二十六、部署时容易踩的坑
1. CTRL_PORT 和 PORT 搞反
客户端应该连:
CTRL_PORT
浏览器应该访问:
PORT
搞反就会连不上。
2. 忘记设置 CTRL_TLS_OFF
本地没有 TLS 时,客户端默认可能尝试 wss。
要设置:
CTRL_TLS_OFF=1
3. ALLOWED_HOSTS 没配
如果 ALLOWED_HOSTS 没包含你的根域名,远端请求可能被判定为 invalid host。
4. DNS 没有通配符
随机子域名访问不到服务器。
5. TUNNEL_HOST 和实际域名不一致
客户端看到的公网 URL 和服务端实际可访问域名不一致。
6. MASTER_SIG_KEY 未固定
如果你依赖 ReconnectToken,服务重启后旧 token 会全部失效。
7. 多实例负载均衡没有粘性或内部发现
客户端连接实例 A,请求进入实例 B,导致 Tunnel Not Found。
8. Dockerfile 暴露端口和源码默认端口不一致
Dockerfile 暴露了 internal network 端口 10002,但源码默认 NET_PORT 是 6000。
如果你使用内部网络服务,要显式统一。
二十七、一个简化版 docker-compose 示例
仓库里没有现成的 docker-compose 文件,但可以根据 Dockerfile 和环境变量自己写一个简化版本。
例如:
version: "3.8"
services:
tunnelto-server:
image: my-tunnelto-server:latest
container_name: tunnelto-server
restart: unless-stopped
ports:
- "8080:8080"
- "5000:5000"
environment:
ALLOWED_HOSTS: "localhost"
TUNNEL_HOST: "localhost"
PORT: "8080"
CTRL_PORT: "5000"
NET_PORT: "6000"
BLOCKED_SUB_DOMAINS: "www,api,admin,wormhole"
MASTER_SIG_KEY: "替换成你自己的hex密钥"
本地测试客户端:
CTRL_HOST=localhost \
CTRL_PORT=5000 \
CTRL_TLS_OFF=1 \
tunnelto --port 8000
注意,这只是单实例测试配置。
生产环境还需要考虑:
TLS
域名
通配符 DNS
反向代理
鉴权
日志
监控
安全
二十八、从源码角度看自托管部署链路
现在把自托管完整链路串起来。
服务端启动
Config::from_env()
↓
读取 ALLOWED_HOSTS / TUNNEL_HOST / PORT / CTRL_PORT / NET_PORT
↓
control_server::spawn(CTRL_PORT)
↓
network::spawn(NET_PORT)
↓
TcpListener::bind(PORT)
↓
等待远端请求
客户端启动
Config::get()
↓
读取 CTRL_HOST / CTRL_PORT / CTRL_TLS_OFF
↓
拼接 control_url
↓
ws://CTRL_HOST:CTRL_PORT/wormhole
↓
发送 ClientHello
请求转发
浏览器访问 abc.TUNNEL_HOST
↓
请求进入 PORT
↓
remote.rs 解析 Host
↓
Connections::find_by_host("abc")
↓
通过客户端 WebSocket 转发
↓
客户端写入 localhost:8000
所以自托管时要保证三件事同时正确:
客户端能连上控制端口
浏览器能访问远端端口
Host Header 能匹配 ALLOWED_HOSTS / TUNNEL_HOST
二十九、这一篇的核心结论
tunnelto 自托管部署的核心可以总结成一句话:
服务端运行 tunnelto_server,
通过 PORT 暴露远端公网请求入口,
通过 CTRL_PORT 暴露客户端 WebSocket 控制入口,
通过 ALLOWED_HOSTS 和 TUNNEL_HOST 决定可用域名,
客户端再用 CTRL_HOST、CTRL_PORT、CTRL_TLS_OFF 指向这个自托管控制服务器。
更简单地说:
PORT 是给浏览器访问的
CTRL_PORT 是给 tunnelto 客户端连接的
NET_PORT 是给多实例内部通信准备的
ALLOWED_HOSTS 决定哪些 Host 可以被路由
TUNNEL_HOST 决定返回给客户端的公网域名
自托管初期,建议先做单实例。
因为 tunnelto 的简单自托管模式不提供完整的多实例集中协调。
如果你直接上多个实例,又没有粘性路由或内部发现,请求很容易进入没有对应客户端连接的实例,最终出现:
Tunnel Not Found
所以最稳妥的路径是:
单实例跑通
↓
配置域名和 TLS
↓
接入鉴权
↓
再考虑多实例和内部发现
三十、下一篇预告
下一篇我们继续分析多实例扩展:
Tunnelto 源码解析 #14:多实例扩展:Fly.io Private Networking、内部发现与跨实例代理
下一篇会重点研究:
network/mod.rs
network/server.rs
network/proxy.rs
FLY_APP_NAME
global.{app}.internal
instance_for_host()
serves_host()
proxy_stream()
为什么官方托管版本可以多实例运行
为什么普通自托管需要谨慎处理多实例
如果说本篇讲的是"单实例如何部署",下一篇讲的就是"多个服务端实例之间如何互相发现并转发请求"。