自建了一套 ngrok 1.x 内网穿透服务(服务端 ngrokd 跑在公网服务器上),Mac 客户端死活跑不起来。本文记录这次排查踩过的四个坑:二进制格式不对、老 Go 编译的二进制在新 macOS 上崩溃、新版 Go 拒绝无 SAN 的自签证书、服务端端口被别的会话占用,最后重新编译、打补丁、跑通全链路。
一、先验格式:file 是排障第一刀
现象:客户端一执行就报 exec format error,连版本号都打不出来。
$ file darwin_ngrok
darwin_ngrok: ELF 64-bit LSB executable, x86-64, dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2
ELF 是 Linux 的可执行格式,macOS 只认 Mach-O。文件名叫 darwin_ngrok 不代表它就是 darwin 产物——大概率是编译时用了 GOOS=linux(很多教程前半段就是错误示范),或者直接把服务器上编的产物拷了下来。
教训:跨平台二进制拿到手先 file 验格式,看到 Mach-O 64-bit executable x86_64 才是 Mac 能跑的。
二、老 Go 编译的二进制在新 macOS 上直接崩
换了一个 Mach-O 格式的旧客户端(2024 年编译的),一运行就崩:
failed MSpanList_Insert 0x888000 0x548c00eaae17 0x0
fatal error: MSpanList_Insert
这是 Go 运行时层面的崩溃。这个二进制是当年按教程用 Go 1.14.1 编译的——老 Go 的 runtime 和新版 macOS 不兼容,启动阶段初始化内存分配器就挂了。
解决:装新 Go 重编。官网下载 pkg 安装包(或者 brew install go),装完确认:
$ go version
go version go1.26.4 darwin/amd64
示例机器是 Intel Mac,所以 GOARCH=amd64;Apple Silicon 用 arm64。
三、重新编译(GOPATH 模式)
ngrok 1.x 源码是 GOPATH 时代的项目(没有 go.mod),源码树本身就是个 GOPATH:src/ 放 ngrok 主代码和全部依赖,assets/ 里是证书资源。客户端编译:
cd ~/ngrok-authtoken
GO111MODULE=off GOPATH=$PWD GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 \
go build -tags=release -o bin/ngrok-darwin ngrok/main/ngrok
几个要点:
GO111MODULE=off走 GOPATH 模式,依赖全在源码树里,不用联网拉包,也就不受国内网络影响-tags=release:嵌入正式资源(包括内置 CA 证书),走正式构建分支(不带 release 的调试构建会直接跳过证书校验)CGO_ENABLED=0纯静态编译,也方便以后交叉编译- 客户端务必用和服务端同一份源码树编译,1.x 认证握手时会比对协议版本,版本不一致直接被服务端踢
四、坑:新版 Go 拒绝无 SAN 的自签证书
编译完一跑,又报新错:
tls: failed to verify certificate: x509: certificate relies on legacy Common Name field, use SANs instead
原因:自建 ngrok 服务端一般用 openssl 自签证书,只有 CN(Common Name)没有 SAN(Subject Alternative Name)。Go 从 1.15 起弱化了 CN 回退,新版本干脆直接拒绝无 SAN 证书——哪怕这张证书是自签名的、而且你已经把它嵌进了客户端的信任池。当年 Go 1.14 能跑通,靠的正是当时还存在的 CN 回退,属于"能用但过时"的组合。
处理有两条路:
- 正路:重签一张带 SAN 的证书(
-addext "subjectAltName=DNS:xxx.com,DNS:*.xxx.com"),服务端换证书重启,客户端换内置 CA 重编; - 快路:改客户端源码,把 Go 1.14 时代的校验行为加回来——证书链校验照做(对内置 CA 验签),主机名校验允许在 SAN 之外回退到 CN。
源码在手,选了路 2,改两个文件。
src/ngrok/client/tls.go 增加一个自校验回调:
// cnFallbackVerifier 返回一个 VerifyPeerCertificate:完整自做证书校验——
// 证书链对给定 roots 验签,主机名按 SAN 匹配、再回退到老 Go 1.15 之前的
// CommonName 匹配。新版 Go 在自己的校验阶段就会拒绝无 SAN 证书
// ("legacy Common Name field" 错误),根本轮不到这个回调,所以必须配合
// InsecureSkipVerify=true,把内置校验整个关掉、由这个回调全权接管。
func cnFallbackVerifier(hostname string, roots *x509.CertPool) func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
return func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
if len(rawCerts) == 0 {
return errors.New("tls: no server certificate presented")
}
leaf, err := x509.ParseCertificate(rawCerts[0])
if err != nil {
return err
}
intermediates := x509.NewCertPool()
for _, raw := range rawCerts[1:] {
if c, err := x509.ParseCertificate(raw); err == nil {
intermediates.AddCert(c)
}
}
// 1. 证书链校验:对内置 CA 验签(这里不做主机名)
if _, err := leaf.Verify(x509.VerifyOptions{
Roots: roots,
Intermediates: intermediates,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}); err != nil {
return err
}
// 2. 主机名:先 SAN,再 CommonName 兜底
for _, alt := range leaf.DNSNames {
if dnsNameMatches(alt, hostname) {
return nil
}
}
if leaf.Subject.CommonName != "" &&
dnsNameMatches(leaf.Subject.CommonName, hostname) {
return nil
}
return fmt.Errorf("x509: certificate is not valid for name %q", hostname)
}
}
// dnsNameMatches 支持精确匹配和单级通配符
func dnsNameMatches(pattern, name string) bool {
pattern = strings.TrimSuffix(strings.ToLower(pattern), ".")
name = strings.TrimSuffix(strings.ToLower(name), ".")
if pattern == name {
return true
}
if strings.HasPrefix(pattern, "*.") {
suffix := pattern[2:]
if strings.HasSuffix(name, "."+suffix) {
label := strings.TrimSuffix(name, "."+suffix)
if label != "" && !strings.Contains(label, ".") {
return true
}
}
}
return false
}
src/ngrok/client/model.go 的 TLS 配置段改成:
// configure TLS SNI
m.tlsConfig.ServerName = serverName(m.serverAddr)
if config.TrustHostRootCerts {
m.tlsConfig.InsecureSkipVerify = useInsecureSkipVerify()
} else {
// 自建服务端常用只有 CN 的老证书,新版 Go 会在回调之前就拒绝。
// 关闭内置校验,链校验 + 主机名全部由回调完成
m.tlsConfig.InsecureSkipVerify = true
m.tlsConfig.VerifyPeerCertificate = cnFallbackVerifier(m.tlsConfig.ServerName, m.tlsConfig.RootCAs)
}
这里有个实测出来的关键细节:**只挂 VerifyPeerCertificate 是不够的**。新版 Go 在调用回调之前就自己完成了主机名校验并直接报错,回调根本没机会执行。必须同时 InsecureSkipVerify = true 把内置校验整个关掉,在回调里完整地做一遍。这样安全性没有倒退:依然要求服务端持有那张 CA 的私钥(链验签过不了就断),只是主机名匹配恢复了老行为。改完重编,证书报错消失。
五、客户端配置与用法(1.x 语法和 2.x 完全不同)
配置文件是**单文件 ~/.ngrok**(1.x 不是 ~/.ngrok2/ngrok.yml):
server_addr: "ngrok.example.com:93"
auth_token: "你的token"
trust_host_root_certs: false
tunnels:
mstsc:
subdomain: "mstsc"
remote_port: 19838
proto:
tcp: "127.0.0.1:3389"
t18789:
remote_port: 19840
proto:
tcp: "127.0.0.1:18789"
两个注意点:
- 服务端 authtokens.txt 按相对路径从 ngrokd 的工作目录读取,客户端 auth_token 必须和服务端文件里某一行完全一致
- YAML 坑:token 这类值如果含
*等特殊字符必须加引号,否则会被当成 YAML 别名节点,直接解析报错
1.x 的用法和 2.x 差异很大,别搞混:
| 操作 | 2.x 语法 | 1.x 语法 |
|---|---|---|
| 起一个 http 隧道 | ngrok http 8080 | ngrok -proto=http 8080 |
| 启动配置里的命名隧道 | 不支持 | ngrok start <名字> |
| 启动全部命名隧道 | 不支持 | ngrok start-all |
| flag 位置 | 随意 | 必须写在位置参数之前 |
本地监控界面固定在 http://127.0.0.1:4040,可以看隧道状态和请求记录。
六、坑:服务端端口被别的会话占用
隧道注册时又报:
Server failed to allocate tunnel: Error binding TCP listener: listen tcp 0.0.0.0:19838: bind: address already in use
这不是客户端问题——ngrokd 会为每个 TCP 隧道在服务端绑定 remote_port,而服务器上还有另一个 ngrok 客户端会话(可能是另一台机器上挂着的旧客户端)占着这个端口。到服务器上 ss -tnp | grep 19838 找到连接来源,把旧会话断掉,或者干脆换个空闲端口。
七、验证
换一个空闲端口做端到端验证:
- 客户端日志出现
Tunnel established at tcp://ngrok.example.com:19840 - 本地目标端口起个回显服务,从公网
nc连服务端的 19840 端口发数据,本地收到、原路返回,全链路通
需要常驻的话:
nohup ./bin/ngrok-darwin -log=stdout -log-level=INFO start t18789 >> ngrok.log 2>&1 &
简记
这次修复横跨三层:二进制格式(file 一查便知)、运行时兼容(老 Go 二进制在新 macOS 上启动即崩)、TLS 证书策略演进(Go 1.15 移除 CN 回退,新版本直接拒绝无 SAN 证书)。ngrok 1.x 停更多年,官方 README 自己都写着不建议生产使用,临时穿透够用;要长期跑建议换 2.x 自建或 frp。但"老服务 + 新工具链"的组合会长期存在,这四个坑算是把组合拳踩了个遍。






COMMENTS | NOTHING