GoSuda

gosuda.org/chromenet: Chromium's Network Stack for Go, Built Reproducibly from a Pinned Release

By Lemon Mint views ...

gosuda.org/chromenet is a Go http.RoundTripper that sends requests through Chromium's own network stack. The stack is //net from an official Chromium release: its HTTP/1.1, HTTP/2, and QUIC/HTTP/3 implementations, BoringSSL, Chrome's TLS configuration, and the Chrome Root Store. It runs in a separate worker process, chrome_net_server, and the Go side stays pure Go (CGO_ENABLED=0), talking to the worker over HQQ shared-memory RPC.

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

Why Run Chromium's Network Stack

A Go program that talks to the web with net/http presents a Go client to the server. That is the right default for service-to-service traffic. It is the wrong one when the server's behavior depends on what kind of client it thinks it is talking to: browser-gated endpoints, anti-bot middleware, CDNs that serve different protocol versions to different TLS and HTTP/2 fingerprints, and tests that must reproduce what a real browser experiences.

Go's standard library cannot give you a browser's wire behavior. crypto/tls has its own cipher-suite order and extension set, and net/http has its own HTTP/2 settings. Libraries that imitate a browser by copying its ClientHello bytes keep copying, release after release. chromenet takes the other route: it does not imitate the Chromium network stack, it runs it.

Measured on the darwin/arm64 engine against a public fingerprint service (tls.peet.ws), the default configuration reports:

PropertyObserved value
Negotiated protocolh2
JA4t13d1516h2_8daaf6152771_806a8c22fdea
HTTP/2 fingerprint (Akamai format)1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p
User-Agentdesktop Chrome of the engine's major version, e.g. Chrome/157.0.0.0

With ForceHTTP3, requests to cloudflare-quic.com and www.google.com were answered over HTTP/3. These numbers come from one platform on one day. The engine is the stack, so they change when the pinned Chromium release changes.

Using It

NewTransport returns an http.RoundTripper. Use it wherever you would use 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}

On the first request, the module writes the embedded worker binary for your platform to the user cache directory, verifies its SHA-256 digest with gosuda.org/hqq/secureexec, starts it with a parent lifeline (--parent-stdin, so it exits when your process exits), and opens a session pool. Nothing needs to be installed.

A few behaviors follow http.Transport so that http.Client works unchanged:

  • The Transport does not follow redirects and keeps no cookies. http.Client does both, as it does with http.Transport.
  • Responses compressed with gzip, deflate, Brotli, or zstd arrive decoded, and resp.Uncompressed is true.
  • A request holds one engine session until its response body is closed. Close bodies.
  • Cancelling the request context cancels the request inside Chromium.
  • Network failures are *client.NetError values carrying the Chromium error code and name:
1var ne *client.NetError
2if errors.As(err, &ne) {
3	fmt.Println(ne.Code, ne.Name) // -105 ERR_NAME_NOT_RESOLVED
4}

Options

FieldEffect
UserAgentReplaces the default Chrome User-Agent. A User-Agent request header still wins.
AcceptLanguageReplaces en-US,en;q=0.9.
ProxyChromium proxy rules such as http://127.0.0.1:8080 or socks5://127.0.0.1:1080. Empty means direct.
DisableHTTP2, DisableQUICTurn the protocols off.
ForceHTTP3Use QUIC for every origin without waiting for an Alt-Svc advertisement.
InsecureSkipVerifyAccept any server certificate.
PoolEngine sessions to use. Nil uses client.DefaultPool(), which allows 64 concurrent requests.

Transports with equal options share one Chromium URLRequestContext, so connection pools, HTTP/2 sessions, and QUIC sessions are reused across requests. Setting HQQ_CHROME_NET_SERVER=/path/to/chrome_net_server replaces the embedded worker, for example with a local build.

How It Works

LayerWhat runs there
Your programhttp.Client and chromenet.Transport, pure Go.
Process boundaryHQQ shared-memory RPC. The interface is chrome_net.idl, compiled by hqq-bindgen into the Go client and the C server glue.
chrome_net_serverA small C++ layer on top of Chromium: one network thread that owns every //net object, and session threads that block on a request until the network thread answers.
Chromium //netURLRequestContext, URLRequest, HTTP/2 and QUIC sessions, BoringSSL.

The IDL has six operations that matter: create an engine from key=value options, start a request with its head (METHOD URL plus header lines) and body length, write request body chunks, wait for the response head, read the decoded body, and destroy the handles. A request handle declares @cancel, so an abandoned call interrupts the request inside Chromium instead of leaving it to run to completion.

Isolating the stack in a worker process is a safety property as much as a packaging one. Chromium's network code parses hostile input from the open internet. If it crashes, your Go service does not crash with it: the call fails with an error, the session is discarded, and the client starts a new worker the next time it needs one.

Built Reproducibly, Pinned by SHA-256

A 25 MB binary that contains Chromium is only trustworthy if you can say exactly what went into it. chromenet follows Chromium Stable the way gosuda.org/sqlite3 follows SQLite: the source is never vendored, only pinned.

PinWhere
Chromium release and its official -lite source archivethird_party/chromium/VERSION
SHA-256 of the archive and of every toolchain that release pins: clang, Rust, GN, ninja, the Windows clang runtime, xwinthird_party/chromium/SHA256
MSVC CRT and Windows SDK versions fetched through xwinthird_party/chromium/WINSDK
The sandbox runtime (rungv) release, the guest image digest, the Debian snapshot datescripts/engine/BUILDENV
The HQQ C runtimego.mod and go.sum

The current pin is Chromium 157.0.8103.38. A download that does not match its pin stops the build. The release applies exactly one patch to Chromium: on Linux, chrome_net_server is built without NSS and verifies server certificates against the Chrome Root Store only, so the binary needs nothing beyond libc.

The Linux and Windows engines are built inside gvx sandboxes: unprivileged processes with a pinned guest image and no host daemon. The Windows engines are cross-compiled on Linux with Chromium's clang-cl and lld-link against the Windows SDK that xwin fetches from Microsoft. The macOS engines build natively on a macOS runner, because Chromium's macOS build needs Xcode.

Rebuilding the same commit produced byte-identical binaries across separate CI runs. The release binaries are byte-identical to the ones an earlier CI run built for the commit that landed the engine in the monorepo, on all six platforms.

PlatformBinary size
linux/amd6427.9 MB
linux/arm6427.4 MB
darwin/amd6426.2 MB
darwin/arm6423.9 MB
windows/amd6427.3 MB
windows/arm6425.8 MB

The Linux engines depend only on libc, libm, libdl, libpthread, and libgcc_s, and need glibc 2.31 or newer.

Tracking Chromium and Releases

A scheduled workflow checks Chromium Stable every six hours. For a new release it rewrites the pins, builds and tests all six engines on a chromium/<version> branch, attests the binaries, and commits the pins together with the embedded binaries, their digests, and the license texts to main in one commit. In the monorepo, per-file OWNERS grant the workflow's bot exactly those files, so the update lands without a human step, while changes to the build recipe itself (the Windows SDK pin and the patch) still need an owner.

Every release binary carries a build provenance attestation from the workflow that built it:

1gh attestation verify chrome_net_server_linux_amd64 --repo gosuda/chromenet

The license texts of what each binary contains (Chromium, BoringSSL, QUICHE, and the rest of the build graph) are generated by Chromium's own tools/licenses/licenses.py and shipped with the release as NOTICE_<os>_<arch>.txt.

Limits

  • DialWebSocket is a pure-Go RFC 6455 client and does not use the Chromium stack.
  • DialWebTransport is an in-process implementation. It does not reach a server.
  • The default JA4 and HTTP/2 fingerprints are those of the pinned Chromium release. Chromium changes them from release to release, and chromenet follows.
  • The macOS binaries are ad-hoc signed and not notarized.
  • A worker process costs memory. Each engine binary is 24 to 28 MB on disk, and Chromium's runtime starts a thread pool and a network thread.
  • An engine session is held until the response body is closed. A program that leaks response bodies will exhaust the pool.

Source

The repository is github.com/gosuda/chromenet, mirrored from //net/chromenet in the gosuda monorepo. The build recipe lives in scripts/engine, the engine glue in engine/, and the interface in chrome_net.idl.