GoSuda

gosuda.org/chromenet: 固定されたリリースから再現可能にビルドされた、Go向けのChromiumネットワークスタック

By Lemon Mint views ...

gosuda.org/chromenet は、Chromium 自身のネットワークスタックを通じてリクエストを送信する Go の http.RoundTripper である。このスタックは公式の 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 を用いて Web と通信する 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}

最初のリクエストの際、このモジュールは埋め込まれたワーカーバイナリをお使いのプラットフォーム向けに選択し、ユーザーキャッシュディレクトリに書き出す。その SHA-256 ダイジェストを gosuda.org/hqq/secureexec で検証し、親プロセスとのライフライン(--parent-stdin、これによりお使いのプロセスが終了するとワーカーも終了する)を付けて起動した後、セッションプールを開く。いかなるものもインストールする必要はない。

http.Client が変更なしで動作するよう、いくつかの振る舞いは http.Transport に準拠している。

  • Transport はリダイレクトに従わず、Cookie も保持しない。http.Client は、http.Transport の場合と同様にこれらを行う。
  • gzip、deflate、Brotli、zstd で圧縮されたレスポンスは復号された状態で届き、resp.Uncompressed は true となる。
  • リクエストは、そのレスポンスボディが閉じられるまで一つのエンジンセッションを保持する。ボディは必ず閉じること。
  • リクエストのコンテキストをキャンセルすると、Chromium 内部のリクエストもキャンセルされる。
  • ネットワークの失敗は、Chromium のエラーコードおよび名称を保持する *client.NetError の値として返される。
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 は引き続き優先される。
AcceptLanguageen-US,en;q=0.9 を置き換える。
Proxyhttp://127.0.0.1:8080 や socks5://127.0.0.1:1080 などの Chromium のプロキシルール。空の場合は直接接続となる。
DisableHTTP2, DisableQUIC各プロトコルを無効にする。
ForceHTTP3Alt-Svc の通知を待たず、すべてのオリジンに対して QUIC を使用する。
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_serverChromium の上に構築された小規模な C++ レイヤー。すべての //net オブジェクトを所有する一つのネットワークスレッドと、リクエストへの応答がネットワークスレッドから返されるまでブロックするセッションスレッドから成る。
Chromium //netURLRequestContext、URLRequest、HTTP/2 および QUIC のセッション、BoringSSL。

IDL には重要な操作が六つ存在する。key=value 形式のオプションからエンジンを作成する操作、ヘッダ(METHOD URL とヘッダ行)とボディ長でリクエストを開始する操作、リクエストボディのチャンクを書き込む操作、レスポンスヘッダの到着を待機する操作、復号されたボディを読み取る操作、そしてハンドルを破棄する操作である。リクエストハンドルには @cancel が宣言されているため、中断された呼び出しは、完了まで実行され続けるのではなく、Chromium 内部のリクエストを中断する。

ネットワークスタックをワーカープロセス内に隔離することは、パッケージング上の利点であると同時に安全性の確保でもある。Chromium のネットワークコードは、インターネットから届く悪意のある入力を解析する。仮にそれがクラッシュしても、Go のサービスは一緒にクラッシュしない。呼び出しはエラーで失敗し、セッションは破棄され、次に必要となった際にクライアントが新しいワーカーを起動する。

再現可能なビルドと SHA-256 による固定

Chromium を含む 25 MB のバイナリは、その中に何が組み込まれているかを正確に説明できる場合にのみ信頼に足る。chromenet は、gosuda.org/sqlite3 が SQLite に従うのと同様に Chromium Stable に従う。ソースはベンダリングされることはなく、固定(ピン留め)されるのみである。

固定対象場所
Chromium のリリースおよびその公式 -lite ソースアーカイブthird_party/chromium/VERSION
アーカイブ、およびそのリリースが固定するすべてのツールチェーン(clang、Rust、GN、ninja、Windows 向け clang ランタイム、xwin)の SHA-256third_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 のエンジンは、Chromium の clang-cl と lld-link を用いて Linux 上でクロスコンパイルされ、xwin が Microsoft から取得する Windows SDK に対してリンクされる。macOS のエンジンは macOS ランナー上でネイティブにビルドされる。これは、Chromium の macOS ビルドに Xcode が必要となるためである。

同一のコミットを再ビルドした場合、別々の CI 実行においてもバイト単位で同一のバイナリが生成された。リリースバイナリは、エンジンをモノレポに取り込んだコミットに対して以前の CI 実行がビルドしたものと、六つすべてのプラットフォームにおいてバイト単位で同一である。

プラットフォームバイナリサイズ
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 とリリースの追跡

スケジュールされたワークフローが six 時間ごとに Chromium Stable を確認する。新しいリリースが見つかると、固定値を書き換え、chromium/<version> ブランチ上で六つのエンジンすべてをビルドおよびテストし、バイナリに対する証明を付与し、固定値を埋め込まれたバイナリ、そのダイジェスト、およびライセンス文書とともに、一つのコミットとして main にコミットする。モノレポにおいては、ファイルごとの OWNERS によって、ワークフローのボットにこれらのファイルのみが付与されるため、人の手を介さずに更新が反映される。ただし、ビルドレシピそのものの変更(Windows SDK の固定値やパッチ)については、引き続きオーナーの対応が必要である。

リリースされたすべてのバイナリには、それをビルドしたワークフローによるビルド来歴の証明(attestation)が付与される。

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 のバイナリはアドホック署名されており、公証(notarization)は行われていない。
  • ワーカープロセスはメモリを消費する。エンジンのバイナリはディスク上で 24〜28 MB あり、Chromium のランタイムはスレッドプールとネットワークスレッドを起動する。
  • エンジンセッションは、レスポンスボディが閉じられるまで保持される。レスポンスボディをリークするプログラムは、プールを枯渇させる。

ソース

リポジトリは github.com/gosuda/chromenet であり、gosuda モノレポの //net/chromenet からミラーされている。ビルドレシピは scripts/engine に、エンジンのグルーコードは engine/ に、インターフェースは chrome_net.idl にある。