tunnelto 是用 Rust + tokio 写的内网穿透工具,一个二进制同时塞下了客户端和服务端,定位本身很简洁。但当你真的想脱离托管版、把它架到自己的域名和 VPS 上时,会撞上一堵很奇怪的墙——网上的教程跑不通,AI Chat 给出的配置也跑不通,每份资料都信誓旦旦地告诉你「ALLOWED_HOSTS=*.tunnel.example.com 就行」「客户端不传 --key 也能匿名连接」「Cloudflare 橙云直接代理就好」,但真把这套抄下来,你看到的会是 AuthFailed / 400 Invalid Hostname / SSL handshake failed 三个轮流出现的报错。
我之前在《tunnelto 实战指南》里也写过一份入门教程,老实说,那篇里有几条在生产环境会直接卡死。这篇文是源码视角的纠偏:把仓库的 tunnelto_server/src/ 翻一遍后,三处被所有教程漏掉的约束就清楚了。
不要先信教程,先看 fork 来源
第一件事其实不在源码里,而在仓库本身。tunnelto 这个名字下面其实存在两条主线:
agrinman/tunnelto:原作者仓库,也是 README 里那条「Host it yourself」指南指向的源码。tunneltodev/tunnelto:托管服务的镜像/分发仓库,README 看上去差不多。
很多教程在「自托管」的章节里贴的是 tunneltodev 的 release URL,下载下来的是为托管服务编译的产物,里面写死了 DynamoDB 鉴权和官方域名。你照着配 ALLOWED_HOSTS 是不会有效的,因为它根本不读你这个变量的预期路径。
如果你打算自托管,请只看 agrinman/tunnelto,并且自己 cargo build,不要用任何二进制 release。这是接下来三个坑都成立的前提。
坑一:默认二进制强依赖 DynamoDB,必须改 NoAuth 重编
打开 tunnelto_server/src/main.rs,最关键的一段写在 lazy_static! 里:
lazy_static! {
pub static ref AUTH_DB_SERVICE: AuthDbService =
AuthDbService::new().expect("failed to init auth-service");
pub static ref CONFIG: Config = Config::from_env();
// To disable all authentication:
// pub static ref AUTH_DB_SERVICE: crate::auth::NoAuth = crate::auth::NoAuth;
}
注意被注释掉的那一行——这是整个仓库里唯一明示「自托管时如何关掉鉴权」的地方,但 README 完全没提。默认产物里 AUTH_DB_SERVICE 类型是 AuthDbService,它的实现在 tunnelto_server/src/auth/auth_db.rs:
mod domain_db { pub const TABLE_NAME: &str = "tunnelto_domains"; ... }
mod key_db { pub const TABLE_NAME: &str = "tunnelto_auth"; ... }
mod record_db { pub const TABLE_NAME: &str = "tunnelto_record"; ... }
也就是说,默认编译出来的服务端会去 AWS DynamoDB 的 us-east-1 区域查这三张表:你的 auth key 哈希、你认领的子域、你的订阅状态。即使你只是想给自己一个人用,启动时没有 AWS 凭据,所有 auth_sub_domain 调用都会落到 PaymentRequired 分支:
if is_pro_account {
Ok(AuthResult::Available)
} else {
Ok(AuthResult::PaymentRequired)
}
客户端那边收到 AuthFailed 后直接断开。所有「我跑起来了 tunnelto_server,但客户端一连就 AuthFailed」的提问,根因都是这个。
更糟的是,匿名分支也被作者主动堵死了。看 auth/client_auth.rs:
ClientType::Anonymous => {
let data = serde_json::to_vec(&ServerHello::AuthFailed).unwrap_or_default();
let _ = websocket.send(Message::binary(data)).await;
return None;
// ...原来的匿名子域分配逻辑被整段注释掉了
}
所以在自托管场景下,默认编译产物根本没有可用路径:要么进 DynamoDB(你没这张表),要么进匿名(直接拒绝)。网上很多教程让你「不传 --key 试试看」,那是基于早期版本的描述,现在的 master 上跑不通。
实际可行的修法
只有两步,但作者从没把它写进 README:
- 把
main.rs里lazy_static!那行换成被注释的NoAuth版本。 auth/mod.rs里NoAuth实现的type AuthKey = ();与client_auth.rs的调用签名不一致,会编译失败。最小补丁是把NoAuth的type AuthKey = ();改成type AuthKey = String;,方法体不动(反正它返回的就是Available)。
#[async_trait]
impl AuthService for NoAuth {
type Error = ();
type AuthKey = String; // 原来是 ()
async fn auth_sub_domain(
&self,
_auth_key: &Self::AuthKey,
_subdomain: &str,
) -> Result<AuthResult, Self::Error> {
Ok(AuthResult::Available)
}
}
之后 cargo build --release --bin tunnelto_server,你会得到一个任何非空 --key 都接受的服务端。客户端那条 tunnelto set-auth --key whatever 仍然要写,因为协议层 ClientHello.client_type 必须是 Auth { key },匿名分支被禁了。
坑二:ALLOWED_HOSTS 的真实匹配规则——不是通配符,是「去掉首段后的剩余字符串」
这是网上资料错得最离谱的一处。几乎所有中文教程都写:
ALLOWED_HOSTS=*.tunnel.example.com
我让几个 AI Chat 验证这个配置,它们也都点头说对。但翻 tunnelto_server/src/remote.rs 的 validate_host_prefix:
fn validate_host_prefix(host: &str) -> Option<String> {
// 解析 Host 头并标准化...
let domain_segments = host.split(".").collect::<Vec<&str>>();
let prefix = &domain_segments[0];
let remaining = &domain_segments[1..].join(".");
if CONFIG.allowed_hosts.contains(remaining) {
Some(prefix.to_string())
} else {
None
}
}
请对着这段代码逐字读:
- 它把进来的
Host头按.切,第一段当 subdomain 前缀,剩下的拼回去当作要匹配的字符串。 - 然后用
CONFIG.allowed_hosts.contains(remaining)——这是Vec<String>::contains,纯字符串相等比较,根本没有通配符语义。
这意味着:
ALLOWED_HOSTS 的值 |
访问 demo.tunnel.example.com 的结果 |
|---|---|
*.tunnel.example.com |
❌ 因为 remaining = "tunnel.example.com" ≠ *.tunnel.example.com |
tunnel.example.com |
✅ remaining 正好相等 |
example.com |
❌ remaining 不等;而且如果你硬这么写,www.example.com 也会被当成隧道路由进去——这就是为什么有人配错了主站 |
正确写法是 ALLOWED_HOSTS=tunnel.example.com,不带星号、不带前缀。这条规则我在任何一份社区文档里都没见到正确陈述,全是抄星号那一版。
TUNNEL_HOST 是另一个变量,仅用于服务端在握手时给客户端打印「你的公开 URL 是 <sub>.<TUNNEL_HOST>」(control_server.rs:147),它不参与路由判定,但建议和 ALLOWED_HOSTS 保持一致以免 CLI 显示混乱。
坑三:Cloudflare Free 的 Universal SSL 不覆盖多级通配,必须走 DNS-only + Let's Encrypt DNS-01
第三个坑发生在网络层。前两篇教程包括我自己那篇都默认你「证书随便上一张就行」,但具体到 Cloudflare Free + 多级子域的组合,事情没那么简单。
事实是:
- Cloudflare Free 的 Universal SSL 只覆盖 root 与一级通配,也就是
example.com和*.example.com。 *.tunnel.example.com属于多级通配(嵌套通配),Universal SSL 不签。要让橙云在边缘正确终结这级 TLS,得开 Advanced Certificate Manager,每月 10 美元。- Cloudflare Origin Certificate(免费那张)可以手动签
*.tunnel.example.com,但它只在源站和 Cloudflare 边缘之间生效——边缘自己向客户端那一段还是用 Universal SSL,依然不覆盖嵌套通配。橙云方案在 Free 计划下整体不可行。
很多 AI Chat 在被问到这个问题时会自信地回答「Cloudflare 自动给你签好了 *.tunnel.example.com」,这是错的。要么自己花钱,要么走下面这条路。
唯一免费可行解:tunnel 子树灰云 + Let's Encrypt DNS-01
把 tunnel.example.com 整棵子树从 Cloudflare 代理里摘出来,DNS 设为 DNS only(灰云),Nginx 自己终结 TLS,证书用 Let's Encrypt 通过 Cloudflare API 做 DNS-01 验证签出来:
sudo apt-get install -y certbot python3-certbot-dns-cloudflare
sudo install -m 600 /dev/null /etc/letsencrypt/cloudflare.ini
echo 'dns_cloudflare_api_token = <你的 Edit zone DNS token>' \
| sudo tee /etc/letsencrypt/cloudflare.ini
sudo certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
-d 'tunnel.example.com' \
-d '*.tunnel.example.com' \
--agree-tos -m [email protected] --non-interactive
主站 example.com / www.example.com 维持现有的橙云 + Origin Certificate 不变,完全互不影响。这样整套方案在 Free 计划下零费用,代价是 tunnel 流量不过 Cloudflare 边缘——但隧道流量没必要过 WAF,相反,过了反而会因为 WebSocket / 长连接超时引入新麻烦。
把三件事拼起来:能跑通的最小配置
走完上面三步,剩下的 Nginx 与 systemd 部分就是常规活了。关键是 server_name 必须按 tunnelto 的协议要求严格分流:
# 控制平面:精确域名,反代 5000,处理 WebSocket
server {
listen 443 ssl http2;
server_name tunnel.example.com;
# ssl_certificate ... letsencrypt 那张通配证书
location /wormhole {
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s;
}
location /health_check { proxy_pass http://127.0.0.1:5000; }
}
# 数据平面:正则匹配任意子域,反代 8080
server {
listen 443 ssl http2;
server_name ~^(?<sub>[^.]+)\.tunnel\.example\.com$;
# 同一张通配证书
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host; # 关键:保留原 Host 头
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
}
}
服务端环境文件:
ALLOWED_HOSTS=tunnel.example.com
TUNNEL_HOST=tunnel.example.com
PORT=8080
CTRL_PORT=5000
NET_PORT=6000
MASTER_SIG_KEY=<openssl rand -hex 32>
MASTER_SIG_KEY 别留空。reconnect_token.rs 用它给客户端签 reconnect 凭证,留空就走默认随机生成,每次服务端重启所有客户端的 reconnect token 立刻失效,要重新走完整握手——长跑的 webhook 调试会非常痛苦。
客户端:
tunnelto set-auth --key any-non-empty-string
CTRL_HOST=tunnel.example.com CTRL_PORT=443 \
tunnelto --port 5173 --subdomain demo
https://demo.tunnel.example.com 直通本地 5173。
为什么这些坑都没人写
回头看,三个问题的共同点是它们都不在 README 里,也都不在错误信息里——AuthFailed 不会告诉你「请去改 lazy_static! 然后重编」,400 Invalid Hostname 不会告诉你「Vec::contains 是字符串相等比较」,Cloudflare 的 SSL 错误也只会显示 ERR_CERT_COMMON_NAME_INVALID 不会提示你升级 ACM。
所以中文圈、英文博客、Stack Overflow、AI Chat 上充斥的都是互相抄写的错误:第一个写错的人没被指出来,后面所有人就一起错下去。AI Chat 更糟,它把这些错误当成「主流共识」回答得理直气壮,因为训练语料里这种写法占多数。
我自己以前那篇入门也是这样翻车的:当时只验证了「能连上」,没验证「重启后能继续连上」「Cloudflare 续期不会断」「换一个 subdomain 申请会不会被拒」这些边界。看起来跑通的方案,往往是因为还没踩到那个隐藏的状态机分支。
总结
agrinman/tunnelto的默认编译产物自带 DynamoDB 鉴权依赖,自托管必须把lazy_static!切到NoAuth并修一行类型签名才能编过、跑通。ALLOWED_HOSTS是字符串相等列表,不是通配符。请填tunnel.example.com,不要填*.tunnel.example.com。- Cloudflare Free 的 Universal SSL 不覆盖
*.tunnel.example.com。免费可行解是 tunnel 子树走灰云 DNS-only,Nginx 自己用 Let's Encrypt DNS-01 终结 TLS。
更大的教训是:在自托管这种边缘场景里,少信教程,少信 AI Chat,多花半小时看源码。这个仓库一共也就一千多行 Rust,三个文件——config.rs、remote.rs、auth/client_auth.rs——读完所有问题都会暴露在你面前。AI 工具适合帮你列搜索词、解释陌生 API,但不适合替你做这种「权威解读」型决策。一旦它把错的资料学进去了,它就只会更自信地把错的告诉你。
评论