ngrok本地调试原理及Telegram mini app cookie path 问题
隧道是本地 agent 主动拨出去的,不是公网打进来。顺着这条链路核一遍 ngrok v3 改掉的命令和配置、免费版反转了的域名策略,以及 Telegram Mini App 调试时那个「Visit Site」拦截页到底挡住了谁。
页面在 ngrok 的域名下打开,白屏,控制台一行 Unexpected token '<'。把那个接口的响应体原样打出来,收到的不是 JSON,是一整页 HTML,中间一个「Visit Site」按钮。
这是用 ngrok 调 Telegram Mini App 时最常撞上的一幕。它和证书无关,也和隧道稳不稳定无关 —— 要说清它是什么,得先说清 ngrok 这条链路上的连接是朝哪个方向建立的。
隧道是本机拨出去的,不是公网打进来的
内网穿透听起来像是「让外面能连进来」,实际发生的事正好相反。
ngrok agent 一启动,就主动向 ngrok 的服务端建立一条长连接 —— 目标是 connect.ngrok-agent.com:443,TLS,并且靠心跳一直保持着。官方文档里的原话是:agent 建立到 ngrok 服务的长连接,在这条连接上创建 endpoint,并从 ngrok 的云服务接收连接。
于是公网请求的路径是这样:请求先打到 ngrok 的边缘节点,边缘节点顺着那条已经存在的连接把它送下来,本机的 agent 再转发到 localhost:3000,响应原路回去。
方向反过来这一点,就是内网穿透之所以成立的全部原因:你不需要公网 IP,不需要在路由器上做端口映射,也不需要在防火墙上开洞 —— NAT 和防火墙拦的是入站连接,而 agent 走的是出站。而且所有流量都在 443 上,连「哪个端口被封了」这种问题都不存在。
v3 的命令和配置:照着旧文章敲会起不来
ngrok http 3000 这句本身没变,仍然是最常用的写法。变的是它周围的一圈东西。
默认只开 HTTPS。 v2 会同时给你一个 HTTP 和一个 HTTPS 端点,v3 的 HTTP 隧道默认只开 HTTPS 那一个。
指定域名的标志换了两代。 v2 的 -subdomain / -hostname 先是被 --domain 取代,现在 --domain 自己也标成了 deprecated,官方推荐 --url。CLI 参考里 --domain、--hostname、--subdomain、--scheme 后面都跟着 deprecated 字样。另外 v3 不再接受单横杠的老式写法。
配置文件挪了位置,schema 也有自己的版本号。 这里是最容易混的地方 —— agent 是 v3,配置文件的 schema 也刚从 2 升到 3,两个「3」不是一回事。
| 位置 | |
|---|---|
| macOS | ~/Library/Application Support/ngrok/ngrok.yml |
| Linux | ~/.config/ngrok/ngrok.yml |
| Windows | %LocalAppData%\ngrok\ngrok.yml |
老文件用 ngrok config upgrade --relocate 搬过来。schema v3 相对 v2 的改动:顶层的 agent 选项收进了 agent: 底下,tunnels: 让位给 endpoints:,server_addr 改叫 connect_url,root_cas 改叫 connect_cas。常用的几个子命令是 ngrok config add-authtoken <token>、ngrok config check、ngrok config edit。
auto_reconnect: true 不是真的配置项。 这篇的旧版本抄过这一行,网上也到处都是。它在 v2 和 v3 两份 schema 的属性表里都不存在。重连本来就是 agent 内置的行为,能调的只有心跳:heartbeat_interval(默认 10 秒)和 heartbeat_tolerance(默认 15 秒)。
更要紧的是后果变了:v3 遇到不认识的配置项会直接报错,不再默默忽略。 所以照抄这一行的结果不是「配了没生效」,是 agent 根本起不来 —— 而报错信息指向配置文件,不会告诉你是哪篇教程害的。
免费版的域名策略,和你记忆里的反过来了
旧文章里那个 https://xxxx.ngrok.io 现在拿不到了。2023 年 4 月 6 日起,新注册的免费账号只发 ngrok-free.app / ngrok-free.dev 这两个基础域名,老账号继续保留 ngrok.io。
真正反转的是另一件事。「ngrok 每次重启 URL 都变」曾经是它最被吐槽的一点,2023 年 8 月之后不成立了:每个账号(包括免费账号)都有一个固定的 dev domain。而随机 URL(--url 'https://' 这种写法)反倒成了付费功能,免费计划只能用你那个固定域名。自定义域名、保留域名也依然是付费的。
对 Telegram 调试来说这是好消息 —— BotFather 里的地址配一次就行,不用每次重启都回去改。
HTTPS 是真证书,浏览器不会说它不安全
这篇的旧版本写过「ngrok 生成的 HTTPS URL 可能会被浏览器标记为不安全,解决办法是买付费版」。这条是错的,整段作废。
ngrok 会从 Let’s Encrypt 这类 ACME 兼容的 CA 自动签发并续期证书;ngrok 托管的域名上如果你没挂自己的证书,它就用自己那张。两种情况都是浏览器信任的公共证书,不会有警告。
唯一一种真会看到证书警告的情况很窄:你自带一个自定义域名,在证书还在签发的那几分钟里,ngrok 先拿一张通配符证书顶着,主机名对不上,浏览器就报错 —— 官方明说这是预期行为,签好之后自动消失。
反过来有个真限制值得记住:.app 和 .dev 都在 HSTS preload 名单里,所以 ngrok-free.app / ngrok-free.dev 这些域名在浏览器里根本走不了明文 HTTP,你会拿到 ngrok 的 3200 错误。curl 这类非浏览器客户端不受这条约束。
那个「Visit Site」页面叫 interstitial,它挡的不只是人
它的官方名字是 interstitial page,2022 年 6 月上线,目的是治理平台滥用。它只对免费计划生效,任意付费计划都没有。
文档说它挡的是「HTML 浏览器流量」,不影响以编程方式访问 endpoint 的请求。关键在于**「以编程方式」是按请求长什么样判定的,不是按你的意图**。所以从浏览器里发出去的 fetch / XHR 完全可能吃到这一页:服务端返回 200,body 却是拦截页的 HTML,前端 res.json() 一解析就炸 —— 开头那行 Unexpected token '<' 就是这么来的。
先确认一下你收到的到底是什么:
# 不带头:如果免费版拦截页生效,这里返回的是 HTMLcurl -s https://<你的域名>.ngrok-free.app/api/ping | head -c 200
# 带上跳过头:应该直接拿到你自己接口的响应curl -s -H 'ngrok-skip-browser-warning: 1' https://<你的域名>.ngrok-free.app/api/ping三种去掉它的办法,按推荐顺序:
- 给请求加上
ngrok-skip-browser-warning头,值随便填。这是官方给的正解,文档里有 axios、fetch、superagent、jQuery 的示例。前端所有走 XHR 的请求都该带上。 - 换一个非标准的
User-Agent(比如MyApp/0.0.1),也能绕过。适合 SDK 和服务端调用。 - 升级到任意付费计划,整页消失。
至于人工点过的那次:点了 Visit 之后会种一个 cookie,同一个域名 7 天内不再显示拦截页。
关于 cookie 的 path,我没能证实原来的说法
这篇的旧版本给出的解释是:首次访问的路径如果不是根路径 /,ngrok 会把 cookie 种在那个路径下,于是根路径下的其它资源读不到它,页面里的资源就被换成了拦截页。
这个解释我没有找到依据。 ngrok 的文档只说「点 Visit 之后一个 cookie 会让这个域名 7 天内不再出现拦截页」,既没写这个 cookie 叫什么,也没写它带哪些属性。而在几个公开 issue 的请求抓包里能看到,这个 cookie 名叫 abuse_interstitial、值是主机名本身,并且在同一主机的各种路径上都被带着发出去 —— 那更像 Path=/ 的行为,不像按路径隔离。
所以因果多半不在 path 上,而在上一节那条:没带上 cookie、也没带跳过头的请求,拿到的就是拦截页。 好在不管机制是哪一种,处理办法都一样 —— 给 XHR 加 ngrok-skip-browser-warning,别把正确性押在那个 cookie 上。
Telegram Mini App 的地址在哪儿配
Mini App 的 URL 必须是 HTTPS:Bot API 文档里 WebAppInfo.url 那一栏写的就是 “An HTTPS URL”。唯一的例外是 Telegram 的测试环境,那里允许用不带 TLS 的 HTTP 链接。ngrok v3 默认只开 HTTPS,正好对上。
配置入口有好几个,取决于你想让用户从哪儿进:
- 菜单按钮 —— BotFather 的
/setmenubutton,或者 Bot Settings > Menu Button。 - Main Mini App —— 在 BotFather 里设好之后,bot 的资料页会出现 Launch app 按钮,还能拿到
t.me/<bot>?startapp这样的深链。 - 直接链接 ——
https://t.me/<bot>/<appname>。 - 想改加载页那些设置,官方给的路径是
/mybots→ 选 bot → Bot Settings > Configure Mini App > Enable Mini App。
有一个命令要单独拎出来说:/setdomain 不是干这个的。 它属于 Login Widget,用来把一个网站域名和 bot 配对,跟 Mini App 的地址是两回事。拿它去配 Mini App,配不上。
隧道通了之后真正卡人的是 initData 校验
前端能从 Telegram 拿到两样东西:initData 是原始的 query string,initDataUnsafe 是解析好的对象。文档对后者的措辞很直白 —— “Data from this field should not be trusted”。带 unsafe 的那个只能拿来渲染,凡是涉及身份的判断,都得把 initData 原样发给后端验一遍。
校验用 HMAC-SHA256,只有一个地方容易写反,但写反了在本地一样跑得通(因为两边都是你自己):
import crypto from 'node:crypto';
export function checkInitData(initData, botToken, maxAgeSec = 86400) { const params = new URLSearchParams(initData); const hash = params.get('hash'); params.delete('hash');
// 除 hash 外的字段按 key 字母序排,写成 key=value,用 \n 连起来 const dataCheckString = [...params.entries()] .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) .map(([k, v]) => `${k}=${v}`) .join('\n');
// 注意顺序:bot token 是消息,字符串 WebAppData 是密钥。反过来写也能算出一个值,只是永远对不上 const secret = crypto.createHmac('sha256', 'WebAppData').update(botToken).digest(); const expected = crypto.createHmac('sha256', secret).update(dataCheckString).digest('hex');
if (expected !== hash) return false;
// auth_date 必须查,否则一份旧的 initData 可以一直用下去 return Date.now() / 1000 - Number(params.get('auth_date')) < maxAgeSec;}高亮的两处就是全部的要害。上面那处是排序和拼接的规则,少一个 \n 或者排序不对,算出来的东西就全盘对不上;下面那一行是密钥派生的方向 —— secret_key = HMAC_SHA256(<bot_token>, "WebAppData"),bot token 在消息位,字面量 WebAppData 在密钥位。上线前记得把 !== 换成 crypto.timingSafeEqual。
还有一条不需要 bot token 的校验路子,是给第三方服务准备的:验 signature 字段,它是 data-check-string 的 Ed25519 签名,base64url 编码,用 Telegram 公布的公钥验。它的 data-check-string 拼法和上面那套不一样 —— 前面要先拼上 <bot_id>:WebAppData\n,字段里要同时排除 hash 和 signature。自己的后端有 bot token,走 HMAC 那条就够了。
这篇归在构建与工程化下。同一主题里最近的另外两篇是 Sentry SDK 架构解析和 Webpack 5 打包原理详解。