自建 ngrok 内网穿透 Mac 客户端修复简记

发表于 6 小时前  22 次阅读


文章目录

自建了一套 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 8080ngrok -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。但"老服务 + 新工具链"的组合会长期存在,这四个坑算是把组合拳踩了个遍。

本站文章基于国际协议BY-NA-SA 4.0协议共享;
如未特殊说明,本站文章皆为原创文章,请规范转载。

0

scanz个人博客