下拉查看上一篇

读了 tunnelto 源码我才发现:自托管教程几乎都漏了这三件事


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:

  1. 把 main.rs 里 lazy_static! 那行换成被注释的 NoAuth 版本。
  2. 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,但不适合替你做这种「权威解读」型决策。一旦它把错的资料学进去了,它就只会更自信地把错的告诉你。

评论

上滑查看下一篇