GoSuda

gosuda.org/chromenet:Chromium 面向 Go 的网络栈,基于固定版本发布构建且可复现

By Lemon Mint views ...

gosuda.org/chromenet 是一个 Go 语言的 http.RoundTripper,它通过 Chromium 自身的网络栈发送请求。该网络栈来自官方 Chromium 发行版中的 //net:包括其 HTTP/1.1、HTTP/2 和 QUIC/HTTP/3 实现、BoringSSL、Chrome 的 TLS 配置以及 Chrome Root Store。它在独立的工作进程 chrome_net_server 中运行,Go 一侧保持纯 Go(CGO_ENABLED=0),并通过 HQQ 共享内存 RPC 与工作进程通信。

1go get gosuda.org/chromenet@v0.1.10157081033800

为何运行 Chromium 的网络栈

使用 net/http 与网络交互的 Go 程序,会向服务器呈现为 Go 客户端。对于服务间通信,这是正确的默认选择。但当服务器的行为取决于其认为的客户端类型时,这种做法就不再合适:例如仅限浏览器访问的端点、反机器人中间件、针对不同 TLS 和 HTTP/2 指纹提供不同协议版本的 CDN,以及必须复现真实浏览器体验的测试。

Go 标准库无法提供浏览器的线路层行为。crypto/tls 有其自身的密码套件顺序和扩展集合,net/http 也有其自身的 HTTP/2 设置。通过复制浏览器 ClientHello 字节来模仿浏览器的库,必须在每个版本中持续复制。chromenet 采取了另一条路径:它不模仿 Chromium 网络栈,而是直接运行它。

在 darwin/arm64 引擎上,针对公共指纹服务(tls.peet.ws)进行测量,默认配置报告如下:

属性观测值
协商协议h2
JA4t13d1516h2_8daaf6152771_806a8c22fdea
HTTP/2 指纹(Akamai 格式)1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p
User-Agent与引擎主版本一致的桌面 Chrome,例如 Chrome/157.0.0.0

启用 ForceHTTP3 后,对 cloudflare-quic.com 和 www.google.com 的请求通过 HTTP/3 得到响应。这些数值来自某一天的某一平台。引擎即是该网络栈本身,因此当锁定的 Chromium 版本变更时,这些数值也会随之变化。

使用方法

NewTransport 返回一个 http.RoundTripper。凡是可以使用 http.Transport 的地方均可使用它:

 1package main
 2
 3import (
 4	"fmt"
 5	"io"
 6	"net/http"
 7
 8	"gosuda.org/chromenet"
 9)
10
11func main() {
12	client := &http.Client{Transport: chromenet.NewTransport(chromenet.Options{})}
13
14	resp, err := client.Get("https://example.com/")
15	if err != nil {
16		panic(err)
17	}
18	defer resp.Body.Close()
19
20	body, _ := io.ReadAll(resp.Body)
21	fmt.Println(resp.Proto, resp.StatusCode, len(body))
22}

首次请求时,模块会将适用于当前平台的嵌入式工作进程二进制文件写入用户缓存目录,使用 gosuda.org/hqq/secureexec 验证其 SHA-256 摘要,以父进程生命线启动它(--parent-stdin,使其随您的进程退出而退出),并打开一个会话池。无需安装任何内容。

为使 http.Client 无需修改即可工作,部分行为与 http.Transport 保持一致:

  • Transport 不跟随重定向,也不保存 Cookie。与 http.Transport 一样,http.Client 会执行这两项操作。
  • 使用 gzip、deflate、Brotli 或 zstd 压缩的响应会被解码后返回,且 resp.Uncompressed 为 true。
  • 在响应正文关闭之前,一个请求会占用一个引擎会话。请务必关闭正文。
  • 取消请求上下文会在 Chromium 内部取消该请求。
  • 网络故障以 *client.NetError 值的形式返回,其中包含 Chromium 错误码和名称:
1var ne *client.NetError
2if errors.As(err, &ne) {
3	fmt.Println(ne.Code, ne.Name) // -105 ERR_NAME_NOT_RESOLVED
4}

选项

字段作用
UserAgent替换默认的 Chrome User-Agent。请求头中的 User-Agent 仍然优先生效。
AcceptLanguage替换 en-US,en;q=0.9。
ProxyChromium 代理规则,例如 http://127.0.0.1:8080 或 socks5://127.0.0.1:1080。为空则直连。
DisableHTTP2, DisableQUIC关闭相应协议。
ForceHTTP3对所有源使用 QUIC,无需等待 Alt-Svc 通告。
InsecureSkipVerify接受任何服务器证书。
Pool要使用的引擎会话。为 nil 时使用 client.DefaultPool(),其允许 64 个并发请求。

选项相同的 Transport 共享同一个 Chromium URLRequestContext,因此连接池、HTTP/2 会话和 QUIC 会话可在请求之间复用。设置 HQQ_CHROME_NET_SERVER=/path/to/chrome_net_server 可替换嵌入的工作进程,例如使用本地构建版本。

工作原理

层级运行内容
您的程序http.Client 与 chromenet.Transport,纯 Go 实现。
进程边界HQQ 共享内存 RPC。接口为 chrome_net.idl,由 hqq-bindgen 编译为 Go 客户端和 C 服务端胶水代码。
chrome_net_server构建于 Chromium 之上的小型 C++ 层:一个拥有全部 //net 对象的网络线程,以及在请求上阻塞直至网络线程应答的会话线程。
Chromium //netURLRequestContext、URLRequest、HTTP/2 和 QUIC 会话、BoringSSL。

IDL 包含六个关键操作:从 key=value 选项创建引擎;以请求头(METHOD URL 加上头部行)和正文长度开始请求;写入请求正文分块;等待响应头;读取解码后的响应正文;以及销毁句柄。请求句柄声明了 @cancel,因此被放弃的调用会在 Chromium 内部中断请求,而不是任其运行至完成。

将网络栈隔离在工作进程中,既是一种打包方式,也是一项安全属性。Chromium 的网络代码需要解析来自开放互联网的恶意输入。若它崩溃,您的 Go 服务不会随之崩溃:调用会返回错误,会话被丢弃,客户端在下次需要时启动新的工作进程。

可复现构建,通过 SHA-256 锁定

包含 Chromium 的 25 MB 二进制文件,只有在您能够准确说明其构成内容时才值得信赖。chromenet 跟踪 Chromium Stable 的方式,与 gosuda.org/sqlite3 跟踪 SQLite 的方式相同:源代码从不纳入仓库,只进行锁定。

锁定项位置
Chromium 发行版及其官方 -lite 源码压缩包third_party/chromium/VERSION
压缩包以及该发行版锁定的每个工具链的 SHA-256:clang、Rust、GN、ninja、Windows clang 运行时、xwinthird_party/chromium/SHA256
通过 xwin 获取的 MSVC CRT 与 Windows SDK 版本third_party/chromium/WINSDK
沙箱运行时(rungv)发行版、客户机镜像摘要、Debian 快照日期scripts/engine/BUILDENV
HQQ C 运行时go.mod 与 go.sum

当前锁定的版本为 Chromium 157.0.8103.38。若下载内容与锁定值不符,构建将终止。该发行版对 Chromium 仅应用一个补丁:在 Linux 上,chrome_net_server 构建时不使用 NSS,并且仅依据 Chrome Root Store 验证服务器证书,因此该二进制文件除 libc 外无需任何其他依赖。

Linux 和 Windows 引擎构建于 gvx 沙箱中:这些是无特权进程,使用锁定的客户机镜像,且没有宿主守护进程。Windows 引擎在 Linux 上使用 Chromium 的 clang-cl 和 lld-link 进行交叉编译,并针对 xwin 从 Microsoft 获取的 Windows SDK 进行链接。macOS 引擎则在 macOS 运行器上原生构建,因为 Chromium 的 macOS 构建需要 Xcode。

对同一提交进行重新构建,在不同的 CI 运行中生成了逐字节相同的二进制文件。在全部六个平台上,发布的二进制文件与早前 CI 运行为将引擎引入 monorepo 的提交所构建的二进制文件逐字节相同。

平台二进制大小
linux/amd6427.9 MB
linux/arm6427.4 MB
darwin/amd6426.2 MB
darwin/arm6423.9 MB
windows/amd6427.3 MB
windows/arm6425.8 MB

Linux 引擎仅依赖 libc、libm、libdl、libpthread 和 libgcc_s,并需要 glibc 2.31 或更高版本。

跟踪 Chromium 与发布

一个定时工作流每六小时检查一次 Chromium Stable。若出现新版本,它会重写锁定信息,在 chromium/<version> 分支上构建并测试全部六个引擎,为二进制文件生成证明,并将锁定信息连同嵌入的二进制文件、其摘要以及许可证文本,在一次提交中推送到 main。在 monorepo 中,各文件的 OWNERS 仅授予该工作流的机器人对这些文件的权限,因此更新无需人工介入即可合入;而对构建方案本身的更改(Windows SDK 锁定和补丁)仍需负责人审批。

每个发布的二进制文件都带有构建该文件的工作流所生成的构建来源证明:

1gh attestation verify chrome_net_server_linux_amd64 --repo gosuda/chromenet

每个二进制文件所包含内容(Chromium、BoringSSL、QUICHE 以及构建图中的其他组件)的许可证文本,均由 Chromium 自带的 tools/licenses/licenses.py 生成,并随发布版本以 NOTICE_<os>_<arch>.txt 的形式提供。

限制

  • DialWebSocket 是一个纯 Go 实现的 RFC 6455 客户端,不使用 Chromium 网络栈。
  • DialWebTransport 是进程内实现,无法连接服务器。
  • 默认的 JA4 和 HTTP/2 指纹取决于所锁定的 Chromium 版本。Chromium 会在各版本之间更改这些指纹,而 chromenet 会随之跟进。
  • macOS 二进制文件采用临时签名(ad-hoc),未经过公证。
  • 工作进程会占用内存。每个引擎二进制文件在磁盘上占 24 至 28 MB,且 Chromium 运行时会启动线程池和网络线程。
  • 引擎会话在响应正文关闭之前一直被占用。若程序泄漏响应正文,将耗尽连接池。

源代码

代码仓库为 github.com/gosuda/chromenet,镜像自 gosuda monorepo 中的 //net/chromenet。构建方案位于 scripts/engine,引擎胶水代码位于 engine/,接口定义位于 chrome_net.idl。