gosuda.org/chromenet: 고정된 릴리스에서 재현 가능하게 빌드된, Go를 위한 Chromium 네트워크 스택

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로 웹과 통신하는 Go 프로그램은 서버에 Go 클라이언트로 인식된다. 이는 서비스 간 트래픽에 적합한 기본값이다. 그러나 서버의 동작이 자신이 어떤 종류의 클라이언트와 통신하고 있다고 판단하는지에 따라 달라지는 경우에는 적합하지 않다. 예를 들어 브라우저 전용 엔드포인트, 봇 탐지 미들웨어, TLS 및 HTTP/2 핑거프린트에 따라 서로 다른 프로토콜 버전을 제공하는 CDN, 실제 브라우저의 경험을 재현해야 하는 테스트가 이에 해당한다.
Go 표준 라이브러리로는 브라우저의 와이어 동작을 재현할 수 없다. crypto/tls는 자체적인 cipher suite 순서와 확장 집합을 가지며, net/http는 자체적인 HTTP/2 설정을 가진다. ClientHello 바이트를 복사하여 브라우저를 모방하는 라이브러리는 릴리스마다 이를 계속 복사해야 한다. chromenet은 다른 방식을 택한다. Chromium 네트워크 스택을 모방하지 않고 그 스택을 직접 실행한다.
darwin/arm64 엔진에서 공개 핑거프린트 서비스(tls.peet.ws)를 대상으로 측정한 결과, 기본 구성은 다음 값을 보고한다.
| 속성 | 관측값 |
|---|---|
| 협상된 프로토콜 | h2 |
| JA4 | t13d1516h2_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는 리다이렉트를 따르지 않으며 쿠키를 보관하지 않는다.
http.Transport와 마찬가지로 이 두 가지는http.Client가 수행한다. - 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는 여전히 우선한다. |
AcceptLanguage | en-US,en;q=0.9를 대체한다. |
Proxy | http://127.0.0.1:8080 또는 socks5://127.0.0.1:1080과 같은 Chromium 프록시 규칙. 비어 있으면 직접 연결이다. |
DisableHTTP2, DisableQUIC | 해당 프로토콜을 비활성화한다. |
ForceHTTP3 | Alt-Svc 광고를 기다리지 않고 모든 오리진에 QUIC을 사용한다. |
InsecureSkipVerify | 모든 서버 인증서를 수락한다. |
Pool | 사용할 엔진 세션. nil이면 동시에 64개의 요청을 허용하는 client.DefaultPool()을 사용한다. |
동일한 옵션을 가진 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 서버 glue 코드로 컴파일한다. |
chrome_net_server | Chromium 위에 구축된 작은 C++ 계층. 모든 //net 객체를 소유하는 하나의 네트워크 스레드와, 네트워크 스레드가 응답할 때까지 요청에서 블록되는 세션 스레드들로 구성된다. |
Chromium //net | URLRequestContext, 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을 따른다. 소스는 벤더링되지 않고 고정(pin)만 된다.
| 고정 항목 | 위치 |
|---|---|
Chromium 릴리스 및 공식 -lite 소스 아카이브 | third_party/chromium/VERSION |
| 아카이브 및 해당 릴리스가 고정하는 모든 툴체인(clang, Rust, GN, ninja, Windows clang 런타임, xwin)의 SHA-256 | third_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 엔진은 Chromium의 macOS 빌드에 Xcode가 필요하므로 macOS 러너에서 네이티브로 빌드된다.
같은 커밋을 재빌드하면 별도의 CI 실행 간에 바이트 단위로 동일한 바이너리가 생성된다. 릴리스 바이너리는 모놀리포에 엔진을 반영한 커밋을 위해 이전 CI 실행이 빌드한 바이너리와 여섯 개 플랫폼 모두에서 바이트 단위로 동일하다.
| 플랫폼 | 바이너리 크기 |
|---|---|
linux/amd64 | 27.9 MB |
linux/arm64 | 27.4 MB |
darwin/amd64 | 26.2 MB |
darwin/arm64 | 23.9 MB |
windows/amd64 | 27.3 MB |
windows/arm64 | 25.8 MB |
Linux 엔진은 libc, libm, libdl, libpthread, libgcc_s에만 의존하며, glibc 2.31 이상이 필요하다.
Chromium 및 릴리스 추적
예약된 워크플로는 6시간마다 Chromium Stable을 확인한다. 새 릴리스가 있으면 고정 값을 다시 작성하고, chromium/<version> 브랜치에서 여섯 개 엔진을 모두 빌드하고 테스트하며, 바이너리에 증명(attestation)을 부여하고, 고정 값을 내장 바이너리, 다이제스트, 라이선스 텍스트와 함께 하나의 커밋으로 main에 커밋한다. 모놀리포에서는 파일별 OWNERS가 워크플로 봇에게 정확히 이 파일들에 대한 권한만 부여하므로, 사람의 개입 없이 업데이트가 반영된다. 다만 빌드 레시피 자체의 변경(Windows SDK 고정 값과 패치)에는 여전히 소유자의 승인이 필요하다.
모든 릴리스 바이너리는 이를 빌드한 워크플로로부터 빌드 출처 증명(build provenance 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 바이너리는 ad-hoc 서명되어 있으며 공증(notarization)을 받지 않았다.
- 워커 프로세스는 메모리를 소비한다. 각 엔진 바이너리는 디스크에서 24~28 MB이며, Chromium 런타임은 스레드 풀과 네트워크 스레드를 시작한다.
- 응답 본문이 닫힐 때까지 엔진 세션이 유지된다. 응답 본문을 누수시키는 프로그램은 풀을 고갈시킨다.
출처
저장소는 github.com/gosuda/chromenet이며, gosuda 모놀리포의 //net/chromenet에서 미러링된다. 빌드 레시피는 scripts/engine에, 엔진 glue 코드는 engine/에, 인터페이스는 chrome_net.idl에 있다.