diff --git a/README.md b/README.md index 097f316..cef0d31 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ AutoCAR 的身份验证、`autocar/2` 协议、TCP/UDP framing、速率协商、 熔断回退和资源边界均由 AutoCAR 实现;自有协议不提供第三方代理协议兼容模式。 Native 模式继续使用上游 `github.com/quic-go/quic-go`;web H3 则透明依赖 `github.com/apernet/quic-go` fork,并精确锁定到 -`v0.61.1-0.20260806010916-184d081eef3e`,用于客户端 Chrome QUIC 握手画像。 +`v0.63.1-0.20261004180939-a10df75c260c`,用于客户端 Chrome QUIC 握手画像。 项目不依赖外部代理应用模块,依赖边界由自动检查验证。 ## 设计目标与实现边界 @@ -44,7 +44,7 @@ Native 模式继续使用上游 `github.com/quic-go/quic-go`;web H3 则透明 | 公开设计目标 | AutoCAR 的独立实现 | 不作出的承诺 | | --- | --- | --- | | 长连接、多流和标准 DATAGRAM | native 模式使用 TLS 1.3 QUIC 热连接、自有 `ACDG` UDP 分片/重组和真实 TCP/TLS fallback | 不提供第三方代理协议兼容模式或 Fast Open | -| 正常网站兼容与 UDP 受阻时的连续服务 | web 模式在同一数字端口提供 H1/H2/TCP 与 H3/UDP cover;web H3 默认使用固定 `chrome-2026-08` 客户端握手画像与零长度源 CID;`web-auto` 在 H3 传输失败后使用标准 H2/TCP | 固定画像只覆盖客户端握手层;正常 HTTP 和共享实现都不能单独证明流量不可识别 | +| 正常网站兼容与 UDP 受阻时的连续服务 | web 模式在同一数字端口提供 H1/H2/TCP 与 H3/UDP cover;web H3 默认使用固定 `chrome-2026-10` 客户端握手画像与零长度源 CID;`web-auto` 在 H3 传输失败后使用标准 H2/TCP | 固定画像只覆盖客户端握手层;正常 HTTP 和握手画像都不能单独证明流量不可识别 | | BBR 的带宽/RTT 模型思想 | `adaptive` 在应用发送层观察 quic-go 的累计发送、丢失、min RTT 和 smoothed RTT,以有界的近似 delivery-rate 窗口和 pacing gain 调节写入 | 不是 Linux BBR,也不替换 quic-go 的拥塞窗口、ACK、重传或底层 Reno 控制器 | | 有损链路上的持续传输 | 双端独立 sender pacing、热连接状态复用、有界流式回压、QUIC 标准丢失恢复 | 不复制任何专有预测算法,不做内核透明代理、FEC、抢先重传或包复制 | | 已知容量链路的固定发送 | 双方通过 AutoCAR v2 协商显式上限,使用有界 token bucket | 不是底层拥塞控制器或不可绕过的流量 policer,也不保证对其他流公平 | @@ -227,7 +227,7 @@ H3。`--cover-root` 和 `--cover-upstream` 必须且只能选一个: --ca server.crt \ --token-file token \ --transport web-auto \ - --h3-fingerprint chrome-2026-08 + --h3-fingerprint chrome-2026-10 ``` 也可用 `--cover-upstream https://www.example.com` 反向代理一个固定、已获授权的 @@ -237,12 +237,18 @@ CONNECT-UDP,`h2` 不支持 UDP。Web 模式要求 TCP/UDP 使用相同数字 `--disable-tcp-fallback`,也不能在两端配置 mTLS。详细能力、边界和安全的验证方法见 [Web-cover 模式](docs/WEB_COVER.md)。 -`--h3-fingerprint=chrome-2026-08` 是默认值,固定使用上述依赖版本提供的完整客户端 +`--h3-fingerprint=chrome-2026-10` 是默认值,固定使用上述依赖版本提供的完整客户端 QUIC/TLS 握手画像,并固定为与画像中版本参数一致的 QUIC v1。 `--h3-fingerprint=native` 是互操作与故障回滚选项:它关闭该 fork 的 ChromeParrot 行为,但仍属于 web H3,不会切换成 `autocar/2` 或第三方代理 协议,也不会把依赖替换为 native 模式使用的上游模块。 +升级前请检查客户端配置:显式的 `chrome-2026-08` 已被拒绝,不会静默映射到新画像; +请有意识地改为 `chrome-2026-10` 或 `native`。未填写 `h3-fingerprint` 的旧配置会采用 +新默认值,因此也会改变客户端握手。新生成的 web `init` 客户端配置会明确固定 +`chrome-2026-10`;仅运行服务端不需要迁移客户端画像选项。此更新不启用 H3 Chrome +画像的 TLS 会话恢复或 0-RTT,也不构成隐蔽性保证;旧采集结果仍只属于其冻结版本。 + 在启动本地代理前,可用同一组隧道参数做一次真实端到端探测: ```bash @@ -442,7 +448,8 @@ iptables 下的 UDP `sendmsg` 直接返回 `EPERM`。有损阶段只硬验证协 `github.com/apernet/quic-go` 版本,拒绝已知外部代理应用模块、local replace 和 vendored/copied 外部源码目录,并扫描已跟踪及未跟踪的 Go 源; - `docker-integration.sh` 在隔离容器网络中验证 QUIC、TLS、自动回退、`doctor`、错误令牌拒绝和非 root 只读运行; -- `govulncheck.sh` 安装固定版本的扫描器并检查可达漏洞; +- `govulncheck.sh` 安装固定版本的扫描器并检查可达漏洞;上游 quic-go 公告不会自动 + 覆盖改名 fork,仍需逐项核对其补丁,详见 [依赖安全边界](SECURITY.md#dependency-boundary); - `netem-integration.sh` 创建 Linux network namespace、延迟/丢包链路并保存诊断工件。 更多文档:[Web-cover 模式](docs/WEB_COVER.md)、[隔离小规模 pilot](docs/STEALTH-PILOT.md)、 diff --git a/SECURITY.md b/SECURITY.md index 5c28227..fabb665 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -38,12 +38,14 @@ use of QUIC/TLS. Web mode serves a real configured H1/H2/H3 origin and routes unauthenticated requests through that cover, but normal HTTP semantics do not make all observable behavior identical to a browser. The H2 client uses a fixed Chrome 133 uTLS ClientHello reference; that is not a claim about the complete -TLS/H2 fingerprint. Web H3 defaults to the fixed `chrome-2026-08` client +TLS/H2 fingerprint. Web H3 defaults to the fixed `chrome-2026-10` client handshake profile from the exactly pinned `github.com/apernet/quic-go` fork and uses a zero-length source CID. That client-only profile does not reproduce the -relay, H3 SETTINGS, CONNECT traffic, packet sizes, reuse, or timing. Sharing the -implementation with a comparator is not proof of a classification advantage. -AutoCAR makes no undetectability guarantee. +relay, H3 SETTINGS, CONNECT traffic, packet sizes, reuse, or timing. This profile +still disables TLS session resumption: a replacement connection performs a full +handshake even with a configured session cache. Neither the dependency update +nor the profile is proof of a classification advantage. AutoCAR makes no +undetectability guarantee. The relay sees destination metadata and any destination protocol that lacks its own encryption. A client holding the shared token can distinguish authenticated @@ -240,7 +242,7 @@ parties. Native AutoCAR builds on official upstream quic-go. Web H3 uses `github.com/apernet/quic-go` pinned exactly to -`v0.61.1-0.20260806010916-184d081eef3e`; H2 uses x/net HTTP/2 and uTLS for its +`v0.63.1-0.20261004180939-a10df75c260c`; H2 uses x/net HTTP/2 and uTLS for its fixed ClientHello reference. A targeted CI boundary allows only that exact web QUIC fork version, rejects the known external proxy application module, local replacements, and copied/vendored external-source directories, and scans tracked @@ -249,3 +251,16 @@ That regression check is not a universal provenance or license detector. CI also runs tests, race detection, vet, CodeQL, a reachable vulnerability scan and privileged namespace integration tests. These checks reduce risk but are not a formal audit. + +`govulncheck` matches Go vulnerability records by module and package path. An +advisory for `github.com/quic-go/quic-go` does not automatically cover the renamed +`github.com/apernet/quic-go` fork; also scanning the official module does not +close that gap. Upstream advisories need a separate applicability and patch +review against the exact pinned fork. A clean scan is not proof that the fork +has no vulnerabilities. + +The current client profile is versioned as `chrome-2026-10`; the retired +`chrome-2026-08` name is rejected, not aliased. Existing client configurations +that omit the profile select the new default on upgrade. Review that handshake +change before deployment; new web `init` client files pin the profile explicitly. +Server-only deployments have no client-profile setting to migrate. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 56f912c..dd401f6 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -34,7 +34,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -## `github.com/apernet/quic-go` `v0.61.1-0.20260806010916-184d081eef3e` +## `github.com/apernet/quic-go` `v0.63.1-0.20261004180939-a10df75c260c` ### `LICENSE` diff --git a/cmd/autocar/common.go b/cmd/autocar/common.go index bd28780..50d10a1 100644 --- a/cmd/autocar/common.go +++ b/cmd/autocar/common.go @@ -36,7 +36,7 @@ type tunnelFlags struct { openTimeout time.Duration h2WriteTimeout time.Duration fallbackTTL time.Duration - h3Fingerprint string + h3Fingerprint h3FingerprintFlag pacing string pacingProfile string uploadMbps uint64 @@ -44,6 +44,28 @@ type tunnelFlags struct { eventHandler tunnel.ClientEventHandler } +// h3FingerprintFlag rejects the retired wire image at parse time, including +// commands that would not otherwise construct an H3 client. Other values retain +// their existing transport-specific validation behavior. +type h3FingerprintFlag string + +func (value *h3FingerprintFlag) String() string { + if value == nil { + return "" + } + return string(*value) +} + +func (value *h3FingerprintFlag) Get() any { return value.String() } + +func (value *h3FingerprintFlag) Set(profile string) error { + *value = h3FingerprintFlag(profile) + if tunnel.H3FingerprintProfile(profile) == tunnel.H3FingerprintChrome202608 { + return tunnel.ErrH3FingerprintProfileRetired + } + return nil +} + func addTunnelFlags(fs *flag.FlagSet, flags *tunnelFlags) { fs.StringVar(&flags.server, "server", "", "relay host:port (required)") fs.StringVar(&flags.fallback, "fallback-server", "", "TCP/TLS relay host:port; defaults to --server") @@ -59,7 +81,8 @@ func addTunnelFlags(fs *flag.FlagSet, flags *tunnelFlags) { fs.DurationVar(&flags.openTimeout, "open-timeout", 15*time.Second, "overall remote stream open timeout") fs.DurationVar(&flags.h2WriteTimeout, "h2-write-timeout", 30*time.Second, "shared H2 connection write timeout (h2/web-auto only; 0 uses 30s; not an idle or per-stream timeout)") fs.DurationVar(&flags.fallbackTTL, "fallback-cooldown", 30*time.Second, "base time to prefer the TCP fallback after a UDP path failure (each retry is jittered +/-20%)") - fs.StringVar(&flags.h3Fingerprint, "h3-fingerprint", string(tunnel.H3FingerprintChrome202608), "web H3 wire profile: chrome-2026-08 or native") + flags.h3Fingerprint = h3FingerprintFlag(tunnel.H3FingerprintChrome202610) + fs.Var(&flags.h3Fingerprint, "h3-fingerprint", "web H3 wire profile: chrome-2026-10 or native; chrome-2026-08 is retired") fs.StringVar(&flags.pacing, "pacing", "adaptive", "QUIC application pacing: adaptive, reno, or fixed-rate") fs.StringVar(&flags.pacingProfile, "pacing-profile", "balanced", "adaptive pacing profile: conservative, balanced, or aggressive") fs.Uint64Var(&flags.uploadMbps, "upload-mbps", 0, "client-to-relay fixed pacing rate in Mbit/s") @@ -72,6 +95,9 @@ type closeDialer interface { } func buildTunnelDialer(flags tunnelFlags) (closeDialer, error) { + if tunnel.H3FingerprintProfile(flags.h3Fingerprint) == tunnel.H3FingerprintChrome202608 { + return nil, tunnel.ErrH3FingerprintProfileRetired + } if flags.server == "" { return nil, errors.New("--server is required") } diff --git a/cmd/autocar/config_file.go b/cmd/autocar/config_file.go index fe5c203..b0f5d8e 100644 --- a/cmd/autocar/config_file.go +++ b/cmd/autocar/config_file.go @@ -12,6 +12,8 @@ import ( "sort" "strconv" "time" + + "github.com/cppla/autocar/internal/tunnel" ) const maxCommandConfigSize = 64 << 10 @@ -59,6 +61,11 @@ func parseFlagsWithConfig(fs *flag.FlagSet, args []string) error { // Validate even overridden entries so stale/invalid config never goes // unnoticed. Do not include a possibly sensitive value in diagnostics. if err := fs.Set(name, value); err != nil { + if errors.Is(err, tunnel.ErrH3FingerprintProfileRetired) { + // This sentinel contains only fixed profile names and migration + // instructions; arbitrary option values remain redacted below. + return tunnel.ErrH3FingerprintProfileRetired + } return fmt.Errorf("invalid config option %q; see command help for its type and format", name) } if cliValue, ok := explicit[name]; ok { diff --git a/cmd/autocar/doctor.go b/cmd/autocar/doctor.go index b9f057e..c18f22c 100644 --- a/cmd/autocar/doctor.go +++ b/cmd/autocar/doctor.go @@ -75,6 +75,11 @@ func runDoctorWith(parent context.Context, args []string, stdout, stderr io.Writ parseErr := parseFlagsWithConfig(fs, args) effectiveJSON = effectiveJSON || *jsonOutput if err := parseErr; err != nil { + // flag.Parse does not preserve a Value.Set error's identity. Recognize + // only this exact retired identifier, never arbitrary error text. + if tunnel.H3FingerprintProfile(tf.h3Fingerprint) == tunnel.H3FingerprintChrome202608 { + err = tunnel.ErrH3FingerprintProfileRetired + } if errors.Is(err, flag.ErrHelp) { fmt.Fprintln(stderr, "Usage of doctor:") fs.SetOutput(stderr) @@ -263,6 +268,9 @@ func doctorFail(stdout io.Writer, jsonOutput bool, exitCode int, code string, er // doctorFailureGuidance returns only closed, static text. Typed causes can // narrow the next step; unstructured messages must not be parsed or echoed. func doctorFailureGuidance(code string, err error) (string, string) { + if errors.Is(err, tunnel.ErrH3FingerprintProfileRetired) { + return "h3_fingerprint_retired", "The chrome-2026-08 H3 profile is retired. Explicitly set --h3-fingerprint=chrome-2026-10 for the new handshake profile, or --h3-fingerprint=native for the ordinary Go handshake; update the same option in any JSON configuration." + } switch code { case "invalid_arguments": if errors.Is(err, os.ErrNotExist) { diff --git a/cmd/autocar/doctor_guidance_test.go b/cmd/autocar/doctor_guidance_test.go index c79f4a7..f2a21de 100644 --- a/cmd/autocar/doctor_guidance_test.go +++ b/cmd/autocar/doctor_guidance_test.go @@ -38,6 +38,8 @@ func TestDoctorFailureGuidanceUsesTypedCausesAndRedactsDetails(t *testing.T) { {"missing file", "configuration_failed", &os.PathError{Op: "open", Path: private, Err: os.ErrNotExist}, "file_missing", "paths"}, {"file permission", "configuration_failed", &os.PathError{Op: "open", Path: private, Err: os.ErrPermission}, "file_permission", "world-readable"}, {"untyped configuration", "configuration_failed", errors.New(private), "configuration", "--token-file"}, + {"retired H3 flag", "invalid_arguments", tunnel.ErrH3FingerprintProfileRetired, "h3_fingerprint_retired", "--h3-fingerprint=chrome-2026-10"}, + {"retired H3 configuration", "configuration_failed", tunnel.ErrH3FingerprintProfileRetired, "h3_fingerprint_retired", "--h3-fingerprint=native"}, {"cancel", "probe_failed", context.Canceled, "canceled", "canceled"}, {"certificate trust", "probe_failed", &tls.CertificateVerificationError{Err: x509.UnknownAuthorityError{Cert: certificate}}, "certificate_trust", "--ca"}, {"certificate hostname", "probe_failed", x509.HostnameError{Certificate: certificate, Host: private}, "certificate_name", "--server-name"}, diff --git a/cmd/autocar/h3_profile_migration_test.go b/cmd/autocar/h3_profile_migration_test.go new file mode 100644 index 0000000..45ac2e8 --- /dev/null +++ b/cmd/autocar/h3_profile_migration_test.go @@ -0,0 +1,144 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "flag" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/cppla/autocar/internal/tunnel" +) + +func TestRetiredH3FingerprintCLIRejectsBeforeNetwork(t *testing.T) { + clearPreflightEnvironment(t) + dnsCalls := denyPreflightDNS(t) + for _, mode := range []string{"web-auto", "h3", "h2", "auto", "quic", "tls", "direct"} { + args := []string{"--transport", mode, "--h3-fingerprint", "chrome-2026-08"} + for _, command := range []string{"client", "doctor", "bench-client"} { + t.Run(command+"/"+mode, func(t *testing.T) { + var err error + switch command { + case "client": + err = runClient(context.Background(), args) + case "bench-client": + err = runBenchClient(context.Background(), args) + case "doctor": + for _, jsonOutput := range []bool{false, true} { + var stdout, stderr bytes.Buffer + doctorArgs := append([]string(nil), args...) + if jsonOutput { + doctorArgs = append(doctorArgs, "--json") + } + err = runDoctorWith(context.Background(), doctorArgs, &stdout, &stderr, func(tunnelFlags) (closeDialer, error) { + t.Fatal("retired profile reached dialer construction") + return nil, nil + }) + if err == nil || commandExitCode(err) != doctorExitUsageFailure { + t.Fatalf("doctor did not reject retired profile as usage error: %v", err) + } + assertRetiredH3MigrationMessage(t, stdout.String()+err.Error()) + if jsonOutput { + var failure doctorFailure + if decodeErr := json.Unmarshal(stdout.Bytes(), &failure); decodeErr != nil || failure.Diagnosis != "h3_fingerprint_retired" { + t.Fatalf("doctor diagnosis=%q decode error=%v", failure.Diagnosis, decodeErr) + } + } + } + return + } + if err == nil { + t.Fatal("retired H3 profile accepted") + } + assertRetiredH3MigrationMessage(t, err.Error()) + }) + } + } + if calls := dnsCalls.Load(); calls != 0 { + t.Fatalf("retired profile performed %d DNS calls", calls) + } +} + +func TestRetiredH3FingerprintConfigRejectsEvenWithCLIOverride(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "client.json") + if err := os.WriteFile(configPath, []byte(`{"h3-fingerprint":"chrome-2026-08"}`), 0o600); err != nil { + t.Fatal(err) + } + for _, override := range []string{"", "chrome-2026-10", "native"} { + t.Run(override, func(t *testing.T) { + fs := flag.NewFlagSet("client", flag.ContinueOnError) + fs.SetOutput(io.Discard) + var tf tunnelFlags + addTunnelFlags(fs, &tf) + args := []string{"--config", configPath} + if override != "" { + args = append(args, "--h3-fingerprint", override) + } + err := parseFlagsWithConfig(fs, args) + if !errors.Is(err, tunnel.ErrH3FingerprintProfileRetired) { + t.Fatalf("stale config was not rejected: %v", err) + } + assertRetiredH3MigrationMessage(t, err.Error()) + }) + } +} + +func TestRetiredH3FingerprintConfigCommandsStayOffline(t *testing.T) { + clearPreflightEnvironment(t) + dnsCalls := denyPreflightDNS(t) + configPath := filepath.Join(t.TempDir(), "client.json") + if err := os.WriteFile(configPath, []byte(`{"h3-fingerprint":"chrome-2026-08"}`), 0o600); err != nil { + t.Fatal(err) + } + args := []string{"--config", configPath} + if err := runClient(context.Background(), args); !errors.Is(err, tunnel.ErrH3FingerprintProfileRetired) { + t.Fatalf("client stale configuration error = %v", err) + } + for _, jsonOutput := range []bool{false, true} { + var stdout, stderr bytes.Buffer + doctorArgs := append([]string(nil), args...) + if jsonOutput { + doctorArgs = append(doctorArgs, "--json") + } + err := runDoctorWith(context.Background(), doctorArgs, &stdout, &stderr, func(tunnelFlags) (closeDialer, error) { + t.Fatal("retired configuration reached dialer construction") + return nil, nil + }) + if err == nil || commandExitCode(err) != doctorExitUsageFailure { + t.Fatalf("doctor stale configuration error = %v", err) + } + assertRetiredH3MigrationMessage(t, stdout.String()+err.Error()) + } + if calls := dnsCalls.Load(); calls != 0 { + t.Fatalf("retired configuration performed %d DNS calls", calls) + } +} + +func TestH3FingerprintFlagPreservesNonRetiredValues(t *testing.T) { + for _, profile := range []string{"", "chrome-2026-10", "native", "unused-by-native"} { + fs := flag.NewFlagSet("native-client", flag.ContinueOnError) + fs.SetOutput(io.Discard) + var tf tunnelFlags + addTunnelFlags(fs, &tf) + if err := fs.Parse([]string{"--transport", "quic", "--h3-fingerprint", profile}); err != nil { + t.Fatalf("non-retired profile %q rejected at parse time: %v", profile, err) + } + if string(tf.h3Fingerprint) != profile { + t.Fatalf("profile %q was silently changed to %q", profile, tf.h3Fingerprint) + } + } +} + +func assertRetiredH3MigrationMessage(t *testing.T, text string) { + t.Helper() + for _, part := range []string{"chrome-2026-08", "retired", "--h3-fingerprint=chrome-2026-10", "--h3-fingerprint=native"} { + if !strings.Contains(text, part) { + t.Fatalf("retired profile message lacks %q: %s", part, text) + } + } +} diff --git a/cmd/autocar/init.go b/cmd/autocar/init.go index fc6fdf8..85d6dac 100644 --- a/cmd/autocar/init.go +++ b/cmd/autocar/init.go @@ -17,6 +17,7 @@ import ( "time" "github.com/cppla/autocar/internal/security" + "github.com/cppla/autocar/internal/tunnel" ) func runInit(args []string) error { @@ -81,10 +82,14 @@ func runInitWith(args []string, output io.Writer) error { if err != nil { return err } - clientConfig, err := json.MarshalIndent(map[string]any{ + clientOptions := map[string]any{ "server": net.JoinHostPort(host, port), "server-name": name, "ca": "server.crt", "token-file": "relay-token", "transport": transportMode, - }, "", " ") + } + if protocolMode == "web" { + clientOptions["h3-fingerprint"] = string(tunnel.H3FingerprintChrome202610) + } + clientConfig, err := json.MarshalIndent(clientOptions, "", " ") if err != nil { return err } diff --git a/cmd/autocar/init_test.go b/cmd/autocar/init_test.go index b3d5b92..1dd9904 100644 --- a/cmd/autocar/init_test.go +++ b/cmd/autocar/init_test.go @@ -22,6 +22,7 @@ import ( "github.com/cppla/autocar/internal/config" "github.com/cppla/autocar/internal/security" + "github.com/cppla/autocar/internal/tunnel" ) func TestInitCreatesPrivatePortableMatchingBundle(t *testing.T) { @@ -128,6 +129,17 @@ func TestInitCreatesPrivatePortableMatchingBundle(t *testing.T) { if tf.mode != test.transport || tf.systemRoots || tf.server != test.server { t.Fatal("generated client changed transport, server or trust defaults") } + clientOptions, err := readCommandConfig(filepath.Join(dir, "client", "client.json")) + if err != nil { + t.Fatal(err) + } + if wantProtocol == "web" { + if clientOptions["h3-fingerprint"] != string(tunnel.H3FingerprintChrome202610) || tf.h3Fingerprint != h3FingerprintFlag(tunnel.H3FingerprintChrome202610) { + t.Fatal("web bundle must explicitly pin the current H3 fingerprint") + } + } else if _, ok := clientOptions["h3-fingerprint"]; ok { + t.Fatal("native bundle unexpectedly contains an H3 fingerprint option") + } if err := cert.VerifyHostname(tf.serverName); err != nil { t.Fatalf("certificate does not match generated client server name: %v", err) } diff --git a/cmd/autocar/web_cli_test.go b/cmd/autocar/web_cli_test.go index 7fd8077..b28e32e 100644 --- a/cmd/autocar/web_cli_test.go +++ b/cmd/autocar/web_cli_test.go @@ -16,6 +16,7 @@ import ( "github.com/cppla/autocar/internal/accel" "github.com/cppla/autocar/internal/security" "github.com/cppla/autocar/internal/transport" + "github.com/cppla/autocar/internal/tunnel" ) func TestValidateServerProtocolOptions(t *testing.T) { @@ -155,7 +156,7 @@ func TestTunnelHelpListsWebTransports(t *testing.T) { t.Errorf("tunnel help omitted %q: %s", mode, output.String()) } } - if !strings.Contains(output.String(), "h3-fingerprint") || !strings.Contains(output.String(), "chrome-2026-08") { + if flags.h3Fingerprint != h3FingerprintFlag(tunnel.H3FingerprintChrome202610) || !strings.Contains(output.String(), "h3-fingerprint") || !strings.Contains(output.String(), "chrome-2026-10") { t.Errorf("tunnel help omitted versioned H3 fingerprint profile: %s", output.String()) } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6a69393..ca4c28e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -37,7 +37,7 @@ flowchart LR The native transport uses upstream `github.com/quic-go/quic-go` v0.63.0. Web H3 uses the `github.com/apernet/quic-go` fork, pinned exactly to -`v0.61.1-0.20260806010916-184d081eef3e`, because its client-only ChromeParrot +`v0.63.1-0.20261004180939-a10df75c260c`, because its client-only ChromeParrot profile controls the TLS ClientHello, QUIC transport parameters, connection IDs and Initial packetization as one coherent handshake. AutoCAR explicitly supplies a zero-length client source connection ID for that profile. @@ -197,7 +197,7 @@ TLS 1.3. The handler rejects TLS 1.2 from the tunnel path before ticket verification, strips any credential, and delegates the request to the cover. H3 remains TLS 1.3-only. -The default web H3 client profile is the immutable `chrome-2026-08` profile. It +The default web H3 client profile is the immutable `chrome-2026-10` profile. It enables the pinned fork's full client handshake image and a zero-length source CID, and locks the outer packet version to QUIC v1 so it agrees with the fixed version-information transport parameter. `--h3-fingerprint=native` disables ChromeParrot within that same web-H3 @@ -207,6 +207,14 @@ QUIC/H3 server, and H3 SETTINGS, CONNECT requests, application traffic and timin are outside the handshake profile. H2 independently uses a fixed Chrome 133 ClientHello but retains the Go HTTP/2 stack. +This version replaces, rather than aliases, the retired `chrome-2026-08` name. +Explicit old names fail validation; omitted client profiles use the new default. +New web `init` client configs pin the current name. Server-only deployments do +not configure a client profile. The Chrome profile still does not resume TLS; +new physical connections complete a full handshake and fresh proxy authentication, +with 0-RTT disabled. Historical captures remain tied to their original dependency +and profile and do not validate this new handshake image. + ## Native UDP association SOCKS5 UDP ASSOCIATE asks the tunnel client for a `transport.PacketConn`. The diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 805035f..809ab97 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -263,7 +263,7 @@ This example records it explicitly so that the chosen protocol stays clear: --system-roots \ --token-file /etc/autocar/relay-token \ --transport web-auto \ - --h3-fingerprint chrome-2026-08 \ + --h3-fingerprint chrome-2026-10 \ --quic-attempt-timeout 5s \ --open-timeout 15s \ --fallback-cooldown 30s @@ -276,7 +276,7 @@ TCP flows use H2 without repeating the UDP wait. The configured cooldown is a base with independent +/-20% jitter; after it, exactly one caller probes H3. Use `--transport=h3` or `--transport=h2` to require one path during diagnosis. -`chrome-2026-08` is the default H3 fingerprint. It uses the exactly pinned +`chrome-2026-10` is the default H3 fingerprint. It uses the exactly pinned `github.com/apernet/quic-go` fork for the complete client QUIC/TLS handshake profile and a zero-length source CID. Use `--h3-fingerprint=native` only as an interoperability or rollback diagnostic; it @@ -284,6 +284,17 @@ disables ChromeParrot within the same web-H3 fork and does not select native `autocar/2`. The profile is client-only and does not claim to reproduce server behavior, H3 SETTINGS, CONNECT traffic or timing. +Before upgrading a client, review its effective profile. An explicit +`chrome-2026-08` is rejected with a migration hint; choose `chrome-2026-10` or +`native` deliberately. The old name is not an alias for the new handshake. +An older config that omits `h3-fingerprint` adopts `chrome-2026-10` automatically, +so omission does not preserve its prior wire behavior. Newly generated web +`init` client files explicitly pin `h3-fingerprint`. Run `client --check` with +the intended config before deployment. Server-only deployments need no +client-profile migration. The Chrome profile still uses full TLS handshakes on +replacement H3 connections; this update enables neither resumption nor 0-RTT +and is not evidence of improved passive indistinguishability. + H3 and H2 use the same `--server` address, so `--fallback-server` is not valid with web transports. Web transports reject `--client-cert/--client-key` and `--pacing=fixed-rate`; native pacing metadata is reported as @@ -739,7 +750,7 @@ endpoint and fingerprint frozen in the preregistration. See and [RELEASING.md](RELEASING.md) for ordinary release quality checks. `autocar/2` and web-cover do not provide third-party proxy or AutoCAR v1 compatibility. -The shared web-H3 QUIC implementation does not change that protocol boundary. +The web-H3 QUIC dependency does not change that protocol boundary. For a rolling breaking upgrade, start the new relay on a second UDP/TCP port, move clients, verify each selected transport independently, and then retire the old endpoint. There is no silent protocol-family downgrade. diff --git a/docs/STEALTH-BENCHMARK.md b/docs/STEALTH-BENCHMARK.md index d5a80db..9e68275 100644 --- a/docs/STEALTH-BENCHMARK.md +++ b/docs/STEALTH-BENCHMARK.md @@ -82,13 +82,17 @@ documents reverse-proxy masquerading and `listenHTTPS`; its [client configuration](https://v2.hysteria.network/docs/advanced/Full-Client-Config/) documents that Chrome QUIC parroting is enabled by default. -AutoCAR's default `chrome-2026-08` web-H3 profile deliberately uses the same -external QUIC fork revision, pinned as -`github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e`, and adds a -zero-length client source CID. This shared implementation is part of the test -provenance, not evidence that AutoCAR is baseline-wire-compatible or already -less distinguishable. Both effective configurations and binaries remain pinned -and hashed independently. +The earlier `chrome-2026-08` AutoCAR web-H3 profile used the same external QUIC +fork revision as the frozen baseline, +`github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e`, with a +zero-length client source CID. That is historical provenance, not a statement +about the current implementation. New AutoCAR captures use `chrome-2026-10` +and `github.com/apernet/quic-go v0.63.1-0.20261004180939-a10df75c260c`; the +baseline remains unchanged. Both effective configurations and binaries must +still be pinned and hashed independently. Preserve frozen preregistrations and +results as recorded; do not relabel old captures or transfer their conclusions +to the new profile. Neither revision sharing nor upgrading proves wire +compatibility or a passive-identification advantage. The baseline's `Gecko` mode is also exercised as a healthy tunnel by the Docker run. It is recorded separately because the source documentation states that @@ -471,8 +475,9 @@ current for the exact release commit: The pinned ChromeParrot profile addresses known stock ClientHello, client transport-parameter, CID and Initial-packetization differences, but it is a -client-only handshake profile shared with the comparator. Passing a functional -H3 test—or merely enabling that shared implementation—is not evidence of an -end-to-end classification advantage. Until the passive gate passes with fresh +client-only handshake profile. The current revision differs from the frozen +comparison baseline. Passing a functional H3 test—or merely enabling a maintained +handshake implementation—is not evidence of an end-to-end classification +advantage. Until the passive gate passes with fresh captures, documentation must not claim that AutoCAR is undetectable or universally superior. diff --git a/docs/STEALTH-PILOT.md b/docs/STEALTH-PILOT.md index 7648b51..b9137d5 100644 --- a/docs/STEALTH-PILOT.md +++ b/docs/STEALTH-PILOT.md @@ -43,12 +43,21 @@ The default pilot records two independent samples for each product in three - `parallel_20`: issue twenty concurrent 1 KiB requests through one client process, allowing each proxy to multiplex streams over its H3 connection. -AutoCAR runs with `--transport h3` and `--h3-fingerprint chrome-2026-08`. +New AutoCAR runs use `--transport h3` and `--h3-fingerprint chrome-2026-10`, +with `github.com/apernet/quic-go` pinned to +`v0.63.1-0.20261004180939-a10df75c260c`. The baseline runs the pinned standard profile with Chrome QUIC parroting enabled, its `Gecko` mode disabled, and a real reverse-proxy masquerade. Both proxies fetch the same private deterministic HTTP origin. The H3 control fetches the equivalent payload directly from its private H3 fixture server. +The baseline stays frozen; it no longer shares AutoCAR's exact QUIC revision. +New AutoCAR variant labels and effective descriptors record the current profile. +Do not rewrite old preregistrations or result labels: earlier captures remain +evidence only for their recorded source, dependency and profile. This update +does not enable TLS resumption or 0-RTT for the Chrome H3 client and does not +establish a passive-identification advantage. + ## Running The runner intentionally refuses to download prerequisites. Confirm the local diff --git a/docs/WEB_COVER.md b/docs/WEB_COVER.md index a600def..afc4086 100644 --- a/docs/WEB_COVER.md +++ b/docs/WEB_COVER.md @@ -387,7 +387,7 @@ Recommended configuration: --system-roots \ --token-file /etc/autocar/relay-token \ --transport web-auto \ - --h3-fingerprint chrome-2026-08 \ + --h3-fingerprint chrome-2026-10 \ --quic-attempt-timeout 5s \ --open-timeout 15s \ --fallback-cooldown 30s @@ -401,7 +401,7 @@ The three web transport choices are: | `h3` | Require HTTP/3 over UDP for TCP CONNECT and CONNECT-UDP; no H2 fallback | | `h2` | Require HTTPS/HTTP/2 over TCP CONNECT; no H3 attempt and no UDP support | -`--h3-fingerprint=chrome-2026-08` is the default for `web-auto` and `h3`. It +`--h3-fingerprint=chrome-2026-10` is the default for `web-auto` and `h3`. It enables the fixed full client QUIC/TLS handshake profile and zero-length source CID supplied by the pinned `github.com/apernet/quic-go` fork. The profile is locked to QUIC v1 because its fixed version-information transport parameter is @@ -410,6 +410,14 @@ part of that v1 handshake image. explicit interoperability and rollback choice. Here `native` names only the H3 fingerprint fallback; it does not select AutoCAR's native `autocar/2` protocol. +The retired `chrome-2026-08` name is rejected, not mapped to the new image. To +upgrade an explicitly pinned client config, choose `chrome-2026-10` or `native` +and validate it with `client --check`. Old configs that omit the profile adopt +the new default and therefore change their handshake after upgrade. New web +`init` client files record the current profile explicitly. Review this change +before deploying clients; server-only configurations have no client-profile +migration. + For `web-auto`, `0 < --quic-attempt-timeout < --open-timeout` is required. When the relay name resolves to both address families, H3 interleaves IPv6 and IPv4 candidates and starts them with a short stagger; each candidate retains @@ -601,9 +609,9 @@ clocks must be synchronized closely enough to satisfy the acceptance window. ## Fingerprint boundary -The H3 client uses the fixed `chrome-2026-08` profile by default. AutoCAR obtains +The H3 client uses the fixed `chrome-2026-10` profile by default. AutoCAR obtains that profile from the `github.com/apernet/quic-go` fork, -pinned to `v0.61.1-0.20260806010916-184d081eef3e`. It applies the fork's +pinned to `v0.63.1-0.20261004180939-a10df75c260c`. It applies the fork's ChromeParrot client handshake image—including ClientHello, client transport parameters and Initial packetization—and uses a zero-length client source CID. ChromeParrot is client-only: it does not turn the AutoCAR relay into a particular @@ -663,11 +671,13 @@ libraries' default `User-Agent` and automatic `Accept-Encoding: gzip` values. AutoCAR does not invent browser headers until the complete request-header set and ordering have been measured and implemented as one coherent profile. -Using the same maintained handshake implementation as the comparator removes -some known stock-QUIC differences, but it is not independent evidence of a -classification advantage and does not make the two application protocols wire -compatible. Therefore the project does not claim that web-cover traffic is -indistinguishable from a browser. A defensible result requires a versioned, +The new dependency and profile are distinct from the implementation frozen in +the earlier comparison baseline. Historical shared-revision observations must +not be described as current implementation equivalence. A maintained handshake +profile is not independent evidence of a classification advantage and does not +make different application protocols wire compatible. Therefore the project +does not claim that web-cover traffic is indistinguishable from a browser. +A defensible result requires a versioned, reproducible capture and a classifier evaluated on held-out runs. Until such a gate passes, passive-fingerprint superiority is unproven. diff --git a/go.mod b/go.mod index 5ef27ee..3d46b8d 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,8 @@ module github.com/cppla/autocar go 1.26.8 require ( - github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e + github.com/apernet/quic-go v0.63.1-0.20261004180939-a10df75c260c + github.com/quic-go/qpack v0.6.0 github.com/quic-go/quic-go v0.63.0 github.com/refraction-networking/utls v1.8.2 golang.org/x/net v0.59.0 @@ -12,7 +13,6 @@ require ( require ( github.com/andybalholm/brotli v1.0.6 // indirect github.com/klauspost/compress v1.17.4 // indirect - github.com/quic-go/qpack v0.6.0 // indirect golang.org/x/crypto v0.57.0 // indirect golang.org/x/sys v0.48.0 // indirect golang.org/x/text v0.42.0 // indirect diff --git a/go.sum b/go.sum index d1d9c2d..e60fe4c 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= -github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e h1:5mgtR5gwIgBKMiGI1QdXldZZ+SNor06Nbu1wCBulQBg= -github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e/go.mod h1:x7qxEvX6MCVtDuBKHj3E+88+BtrbEMuAL5qGUKItjW8= +github.com/apernet/quic-go v0.63.1-0.20261004180939-a10df75c260c h1:cxK8qTA0YCsj68A7zLYDCfPgMTOyt3ePkHh+oXEmYU0= +github.com/apernet/quic-go v0.63.1-0.20261004180939-a10df75c260c/go.mod h1:J7UZVOMF2M1LiE3iiUZWPuy8YCPAcLJOwVURrfJCvc0= github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= diff --git a/internal/tunnel/web_client.go b/internal/tunnel/web_client.go index a7eb6ca..baea043 100644 --- a/internal/tunnel/web_client.go +++ b/internal/tunnel/web_client.go @@ -27,7 +27,7 @@ type WebClientConfig struct { Token string TLSConfig *tls.Config QUICConfig *quic.Config - // H3FingerprintProfile defaults to the fixed chrome-2026-08 QUIC profile. + // H3FingerprintProfile defaults to the fixed chrome-2026-10 QUIC profile. // Native is an explicit interoperability and rollback path. H3FingerprintProfile H3FingerprintProfile diff --git a/internal/tunnel/web_fingerprint.go b/internal/tunnel/web_fingerprint.go index a75048b..d227edb 100644 --- a/internal/tunnel/web_fingerprint.go +++ b/internal/tunnel/web_fingerprint.go @@ -46,10 +46,15 @@ func normalizeFingerprintProfile(profile FingerprintProfile) (FingerprintProfile type H3FingerprintProfile string const ( - // H3FingerprintChrome202608 is the fixed Chrome QUIC profile provided by - // the audited github.com/apernet/quic-go revision dated 2026-08-06. The + // H3FingerprintChrome202610 is the fixed Chrome QUIC profile provided by + // the audited github.com/apernet/quic-go revision dated 2026-10-04. The // version in the name is immutable; it never means "whatever Chrome does // today". It is the default for web-cover HTTP/3 connections. + H3FingerprintChrome202610 H3FingerprintProfile = "chrome-2026-10" + + // H3FingerprintChrome202608 identifies the retired August 2026 profile. + // Deprecated: this profile is rejected, not aliased to a different wire + // image. Explicitly migrate to H3FingerprintChrome202610 or native. H3FingerprintChrome202608 H3FingerprintProfile = "chrome-2026-08" // H3FingerprintNative uses the QUIC library's ordinary Go handshake. It is @@ -57,14 +62,20 @@ const ( H3FingerprintNative H3FingerprintProfile = "native" ) +// ErrH3FingerprintProfileRetired reports an explicitly selected historical +// profile whose immutable wire image is no longer provided by the dependency. +var ErrH3FingerprintProfileRetired = errors.New("tunnel: HTTP/3 fingerprint profile chrome-2026-08 is retired; explicitly set --h3-fingerprint=chrome-2026-10 (new handshake profile) or --h3-fingerprint=native (ordinary Go handshake)") + func normalizeH3FingerprintProfile(profile H3FingerprintProfile) (H3FingerprintProfile, error) { switch profile { - case "", H3FingerprintChrome202608: - return H3FingerprintChrome202608, nil + case "", H3FingerprintChrome202610: + return H3FingerprintChrome202610, nil + case H3FingerprintChrome202608: + return "", ErrH3FingerprintProfileRetired case H3FingerprintNative: return H3FingerprintNative, nil default: - return "", fmt.Errorf("tunnel: unsupported web-cover HTTP/3 fingerprint profile %q; supported profiles are %q and %q", profile, H3FingerprintChrome202608, H3FingerprintNative) + return "", fmt.Errorf("tunnel: unsupported web-cover HTTP/3 fingerprint profile %q; supported profiles are %q and %q", profile, H3FingerprintChrome202610, H3FingerprintNative) } } diff --git a/internal/tunnel/web_fingerprint_test.go b/internal/tunnel/web_fingerprint_test.go index ac454fd..b85dfdf 100644 --- a/internal/tunnel/web_fingerprint_test.go +++ b/internal/tunnel/web_fingerprint_test.go @@ -33,13 +33,13 @@ func TestFingerprintProfileValidation(t *testing.T) { } func TestH3FingerprintProfileValidation(t *testing.T) { - for _, input := range []H3FingerprintProfile{"", H3FingerprintChrome202608} { + for _, input := range []H3FingerprintProfile{"", H3FingerprintChrome202610} { got, err := normalizeH3FingerprintProfile(input) if err != nil { t.Fatal(err) } - if got != H3FingerprintChrome202608 { - t.Fatalf("normalizeH3FingerprintProfile(%q) = %q, want %q", input, got, H3FingerprintChrome202608) + if got != H3FingerprintChrome202610 { + t.Fatalf("normalizeH3FingerprintProfile(%q) = %q, want %q", input, got, H3FingerprintChrome202610) } } if got, err := normalizeH3FingerprintProfile(H3FingerprintNative); err != nil || got != H3FingerprintNative { @@ -48,6 +48,32 @@ func TestH3FingerprintProfileValidation(t *testing.T) { if _, err := normalizeH3FingerprintProfile("chrome-current"); err == nil || !strings.Contains(err.Error(), "unsupported") { t.Fatalf("unversioned H3 profile error = %v", err) } + if got, err := normalizeH3FingerprintProfile(H3FingerprintChrome202608); got != "" || !errors.Is(err, ErrH3FingerprintProfileRetired) { + t.Fatalf("retired H3 profile = %q, %v; must not alias to the current profile", got, err) + } +} + +func TestRetiredH3FingerprintRejectedByConstructors(t *testing.T) { + _, clientTLS := testTLSConfigs(t) + config := WebH3ClientConfig{ + ServerAddress: "relay.invalid:443", Token: webTestToken, + TLSConfig: clientTLS, FingerprintProfile: H3FingerprintChrome202608, + } + if client, err := NewWebH3Client(config); !errors.Is(err, ErrH3FingerprintProfileRetired) || client != nil { + if client != nil { + _ = client.Close() + } + t.Fatalf("retired H3 constructor: client=%v error=%v", client != nil, err) + } + if client, err := NewWebClient(WebClientConfig{ + ServerAddress: config.ServerAddress, Token: config.Token, + TLSConfig: clientTLS, H3FingerprintProfile: H3FingerprintChrome202608, + }); !errors.Is(err, ErrH3FingerprintProfileRetired) || client != nil { + if client != nil { + _ = client.Close() + } + t.Fatalf("retired web-auto constructor: client=%v error=%v", client != nil, err) + } } func TestChrome133ClientHelloHasVersionedBrowserShape(t *testing.T) { diff --git a/internal/tunnel/web_h3_client.go b/internal/tunnel/web_h3_client.go index cb9348a..a17af87 100644 --- a/internal/tunnel/web_h3_client.go +++ b/internal/tunnel/web_h3_client.go @@ -25,7 +25,7 @@ type WebH3ClientConfig struct { Token string TLSConfig *tls.Config QUICConfig *quic.Config - // FingerprintProfile defaults to chrome-2026-08, a fixed full QUIC + // FingerprintProfile defaults to chrome-2026-10, a fixed full QUIC // handshake profile. Native is retained for interoperability and rollback. FingerprintProfile H3FingerprintProfile DialTimeout time.Duration @@ -208,14 +208,14 @@ func NewWebH3Client(config WebH3ClientConfig) (*WebH3Client, error) { } func validateWebH3FingerprintTLSConfig(config *tls.Config, profile H3FingerprintProfile) error { - if profile != H3FingerprintChrome202608 { + if profile != H3FingerprintChrome202610 { return nil } if config.VerifyConnection != nil { - return errors.New("tunnel: chrome-2026-08 HTTP/3 fingerprint profile does not support crypto/tls VerifyConnection; use VerifyPeerCertificate or the native rollback profile") + return errors.New("tunnel: chrome-2026-10 HTTP/3 fingerprint profile does not support crypto/tls VerifyConnection; use VerifyPeerCertificate or the native rollback profile") } if config.GetConfigForClient != nil || config.GetCertificate != nil || len(config.Certificates) > 0 { - return errors.New("tunnel: chrome-2026-08 HTTP/3 fingerprint profile does not support server-side TLS fields or static client certificates") + return errors.New("tunnel: chrome-2026-10 HTTP/3 fingerprint profile does not support server-side TLS fields or static client certificates") } return nil } @@ -661,7 +661,7 @@ func (c *WebH3Client) dialSessionAddress(ctx context.Context, address net.IPAddr return nil, fmt.Errorf("listen %s for %s: %w", network, address.String(), err) } quicTransport := &quic.Transport{Conn: packet} - if c.fingerprint == H3FingerprintChrome202608 { + if c.fingerprint == H3FingerprintChrome202610 { quicTransport.ConnectionIDGenerator = quic.ZeroLengthConnectionIDGenerator{} } remote := &net.UDPAddr{IP: address.IP, Port: port, Zone: address.Zone} diff --git a/internal/tunnel/web_h3_config.go b/internal/tunnel/web_h3_config.go index 2bf3a8b..4bb99b2 100644 --- a/internal/tunnel/web_h3_config.go +++ b/internal/tunnel/web_h3_config.go @@ -79,7 +79,7 @@ func hardenedWebH3ClientConfig(input *quic.Config, handshakeTimeout time.Duratio cfg.MaxIncomingUniStreams = webH3RequiredIncomingUniStreams cfg.Allow0RTT = false cfg.EnableDatagrams = true - cfg.ChromeParrot = profile == H3FingerprintChrome202608 + cfg.ChromeParrot = profile == H3FingerprintChrome202610 cfg.HandshakeIdleTimeout = boundedPositiveDuration(cfg.HandshakeIdleTimeout, handshakeTimeout) cfg.MaxIdleTimeout = boundedPositiveDuration(cfg.MaxIdleTimeout, webH3MaxIdleTimeout) // A deterministic keepalive interval is a strong long-lived-flow marker. diff --git a/internal/tunnel/web_h3_config_test.go b/internal/tunnel/web_h3_config_test.go index 16ebdbb..63c6576 100644 --- a/internal/tunnel/web_h3_config_test.go +++ b/internal/tunnel/web_h3_config_test.go @@ -54,14 +54,14 @@ func TestWebH3QUICConfigHardeningCannotBeBypassed(t *testing.T) { t.Fatalf("native rollback versions = %v, want caller's QUIC v2", client.Versions) } - defaults := hardenedWebH3ClientConfig(nil, 5*time.Second, H3FingerprintChrome202608) + defaults := hardenedWebH3ClientConfig(nil, 5*time.Second, H3FingerprintChrome202610) if !defaults.ChromeParrot || defaults.KeepAlivePeriod != 0 { t.Fatalf("default client profile = ChromeParrot %v keepalive %s", defaults.ChromeParrot, defaults.KeepAlivePeriod) } if len(defaults.Versions) != 1 || defaults.Versions[0] != quic.Version1 { t.Fatalf("ChromeParrot versions = %v, want QUIC v1 only", defaults.Versions) } - chromeFromV2 := hardenedWebH3ClientConfig(input, 5*time.Second, H3FingerprintChrome202608) + chromeFromV2 := hardenedWebH3ClientConfig(input, 5*time.Second, H3FingerprintChrome202610) if len(chromeFromV2.Versions) != 1 || chromeFromV2.Versions[0] != quic.Version1 { t.Fatalf("ChromeParrot retained incompatible caller versions %v", chromeFromV2.Versions) } diff --git a/internal/tunnel/web_h3_resumption_test.go b/internal/tunnel/web_h3_resumption_test.go index ab64cfc..92a80c6 100644 --- a/internal/tunnel/web_h3_resumption_test.go +++ b/internal/tunnel/web_h3_resumption_test.go @@ -123,7 +123,7 @@ func TestWebH3ResumptionReconnectContract(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { _ = client.Close() }) - if test.profile == "" && client.fingerprint != H3FingerprintChrome202608 { + if test.profile == "" && client.fingerprint != H3FingerprintChrome202610 { t.Fatal("default H3 profile changed") } entropy := &webH2AuthEntropyCounter{} diff --git a/internal/tunnel/web_h3_test.go b/internal/tunnel/web_h3_test.go index db321ec..b2abad3 100644 --- a/internal/tunnel/web_h3_test.go +++ b/internal/tunnel/web_h3_test.go @@ -93,7 +93,7 @@ func TestWebH3CoverAndAuthenticatedConnect(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { _ = client.Close() }) - if client.fingerprint != H3FingerprintChrome202608 || !client.quicConfig.ChromeParrot || client.quicConfig.KeepAlivePeriod != 0 { + if client.fingerprint != H3FingerprintChrome202610 || !client.quicConfig.ChromeParrot || client.quicConfig.KeepAlivePeriod != 0 { t.Fatalf("default H3 profile = %q ChromeParrot=%v keepalive=%s", client.fingerprint, client.quicConfig.ChromeParrot, client.quicConfig.KeepAlivePeriod) } conn, err := client.DialContext(context.Background(), "tcp", target) diff --git a/internal/tunnel/web_h3_upgrade_test.go b/internal/tunnel/web_h3_upgrade_test.go new file mode 100644 index 0000000..03ff2bb --- /dev/null +++ b/internal/tunnel/web_h3_upgrade_test.go @@ -0,0 +1,296 @@ +package tunnel + +import ( + "bytes" + "context" + "errors" + "io" + "net" + "net/http" + "net/netip" + "sync/atomic" + "testing" + "time" + + "github.com/apernet/quic-go" + "github.com/apernet/quic-go/http3" + "github.com/apernet/quic-go/quicvarint" + "github.com/cppla/autocar/internal/transport" + "github.com/quic-go/qpack" +) + +// The dependency's v0.63 HTTP/3 parser now follows net/http request URL +// semantics. Observe its actual wire decoder, not a hand-built Request: cover +// middleware and authentication must not rely on URL.Host or URL.Scheme being +// populated for ordinary requests or Extended CONNECT. +type webH3UpgradeRequest struct { + method, protocol, host, scheme, urlHost, path, rawQuery, requestURI string + credentialBytes int + connection *quic.Conn +} + +func snapshotWebH3UpgradeRequest(r *http.Request) webH3UpgradeRequest { + connection, _ := r.Context().Value(webH3ConnectionContextKey{}).(*quic.Conn) + return webH3UpgradeRequest{ + method: r.Method, protocol: r.Proto, host: r.Host, + scheme: r.URL.Scheme, urlHost: r.URL.Host, path: r.URL.EscapedPath(), + rawQuery: r.URL.RawQuery, requestURI: r.RequestURI, + credentialBytes: len(r.Header.Get("Proxy-Authorization")), connection: connection, + } +} + +func receiveWebH3UpgradeRequest(t *testing.T, observed <-chan webH3UpgradeRequest) webH3UpgradeRequest { + t.Helper() + select { + case request := <-observed: + return request + case <-time.After(3 * time.Second): + t.Fatal("HTTP/3 handler did not observe the decoded request") + return webH3UpgradeRequest{} + } +} + +func TestWebH3UpgradeCoverRequestURLSemantics(t *testing.T) { + serverTLS, clientTLS := testTLSConfigs(t) + observed := make(chan webH3UpgradeRequest, 1) + var dials atomic.Int32 + server, err := ListenWebH3(WebH3ServerConfig{ + Address: "127.0.0.1:0", Token: webTestToken, TLSConfig: serverTLS, + Dialer: countingDialer{dials: &dials}, + Cover: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + observed <- snapshotWebH3UpgradeRequest(r) + w.WriteHeader(http.StatusNoContent) + }), + }) + if err != nil { + t.Fatal(err) + } + serveWebH3ForTest(t, server) + roundTripper := &http3.Transport{TLSClientConfig: clientTLS} + t.Cleanup(func() { _ = roundTripper.Close() }) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + const path = "/news/a%2Fb" + const query = "source=reader%2Fdaily&next=%2F" + request := mustWebRequest(t, http.MethodGet, "https://"+server.Addr().String()+path+"?"+query, nil).WithContext(ctx) + request.Header.Set("Proxy-Authorization", "not-a-valid-tunnel-credential") + response, err := roundTripper.RoundTrip(request) + if err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + if response.StatusCode != http.StatusNoContent { + t.Fatalf("cover status = %d, want 204", response.StatusCode) + } + got := receiveWebH3UpgradeRequest(t, observed) + if got.method != http.MethodGet || got.protocol != "HTTP/3.0" || + got.host != server.Addr().String() || got.scheme != "" || got.urlHost != "" || + got.path != path || got.rawQuery != query || got.requestURI != path+"?"+query { + t.Fatalf("decoded cover request = %+v", got) + } + if got.credentialBytes != 0 { + t.Fatal("cover handler received a tunnel credential") + } + if got.connection == nil || dials.Load() != 0 { + t.Fatalf("cover QUIC connection present = %v, destination dials = %d", got.connection != nil, dials.Load()) + } +} + +func TestWebH3UpgradeConnectRequestURLAndAuthSemantics(t *testing.T) { + serverTLS, clientTLS := testTLSConfigs(t) + tcpTarget, closeTarget := startHalfCloseTarget(t) + t.Cleanup(closeTarget) + udpTarget := startWebUDPEcho(t) + resolver := newWebUDPTestResolver(map[string]netip.AddrPort{udpTarget.String(): udpTarget}) + var dials, coverCalls atomic.Int32 + server, err := ListenWebH3(WebH3ServerConfig{ + Address: "127.0.0.1:0", Token: webTestToken, TLSConfig: serverTLS, + Dialer: countingDialer{dials: &dials}, UDPResolver: resolver, + Cover: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + coverCalls.Add(1) + http.NotFound(w, r) + }), + }) + if err != nil { + t.Fatal(err) + } + observed := make(chan webH3UpgradeRequest, 2) + handler := server.server.Handler + server.server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + observed <- snapshotWebH3UpgradeRequest(r) + handler.ServeHTTP(w, r) + }) + serveWebH3ForTest(t, server) + client, err := NewWebH3Client(WebH3ClientConfig{ + ServerAddress: server.Addr().String(), Token: webTestToken, TLSConfig: clientTLS, + DialTimeout: time.Second, HandshakeTimeout: 2 * time.Second, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + entropy := &webH2AuthEntropyCounter{} + client.signer = newWebAuthSigner(mustWebAuthKey(t, webTestToken), nil, entropy) + if err := exchange(client, tcpTarget, "upgraded H3 TCP authentication"); err != nil { + t.Fatal(err) + } + tcp := receiveWebH3UpgradeRequest(t, observed) + if tcp.method != http.MethodConnect || tcp.protocol != "HTTP/3.0" || + tcp.host != tcpTarget || tcp.scheme != "" || tcp.urlHost != tcpTarget || + tcp.path != "" || tcp.rawQuery != "" || tcp.requestURI != tcpTarget { + t.Fatalf("decoded TCP CONNECT = %+v", tcp) + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + packet, err := client.DialPacket(ctx) + if err != nil { + t.Fatal(err) + } + defer packet.Close() + assertWebUDPEcho(t, packet, []byte("upgraded H3 UDP authentication"), udpTarget.String()) + udp := receiveWebH3UpgradeRequest(t, observed) + path, _, err := connectUDPPath(udpTarget.String()) + if err != nil { + t.Fatal(err) + } + if udp.method != http.MethodConnect || udp.protocol != webConnectUDPProtocol || + udp.host != server.Addr().String() || udp.scheme != "" || udp.urlHost != "" || + udp.path != path || udp.rawQuery != "" || udp.requestURI != path { + t.Fatalf("decoded CONNECT-UDP = %+v", udp) + } + if tcp.connection == nil || udp.connection != tcp.connection || entropy.nonceReads.Load() != 1 { + t.Fatal("TCP and UDP did not retain one authenticated QUIC connection and one bootstrap") + } + if udp.credentialBytes == 0 || udp.credentialBytes >= tcp.credentialBytes { + t.Fatal("expected a full bootstrap credential followed by a shorter continuation credential") + } + if dials.Load() != 1 || resolver.count(udpTarget.String()) != 1 || coverCalls.Load() != 0 { + t.Fatalf("TCP dials / UDP resolutions / cover requests = %d / %d / %d, want 1 / 1 / 0", + dials.Load(), resolver.count(udpTarget.String()), coverCalls.Load()) + } +} + +// A malformed header block must be rejected by HTTP/3 before AutoCAR sees it, +// even when it carries a valid credential for the intended CONNECT target. +// Public QPACK and QUIC APIs preserve the invalid fields that net/http would +// otherwise normalize before transmission. +func TestWebH3UpgradeRejectsMalformedHeadersBeforeHandler(t *testing.T) { + const target = "127.0.0.1:9" + for _, test := range []struct { + name string + fields []qpack.HeaderField + }{ + {"duplicate_empty_authority", []qpack.HeaderField{ + {Name: ":method", Value: "CONNECT"}, {Name: ":authority", Value: ""}, {Name: ":authority", Value: target}, + }}, + {"duplicate_method", []qpack.HeaderField{ + {Name: ":method", Value: "CONNECT"}, {Name: ":method", Value: "CONNECT"}, {Name: ":authority", Value: target}, + }}, + {"authority_host_conflict", []qpack.HeaderField{ + {Name: ":method", Value: "CONNECT"}, {Name: ":authority", Value: target}, {Name: "host", Value: "other.invalid:9"}, + }}, + {"connect_empty_scheme", []qpack.HeaderField{ + {Name: ":method", Value: "CONNECT"}, {Name: ":authority", Value: target}, {Name: ":scheme", Value: ""}, + }}, + {"connect_host_without_authority", []qpack.HeaderField{ + {Name: ":method", Value: "CONNECT"}, {Name: "host", Value: target}, + }}, + {"pseudo_after_regular", []qpack.HeaderField{ + {Name: ":method", Value: "CONNECT"}, {Name: "user-agent", Value: "test"}, {Name: ":authority", Value: target}, + }}, + {"absolute_request_path", []qpack.HeaderField{ + {Name: ":method", Value: "GET"}, {Name: ":scheme", Value: "https"}, + {Name: ":authority", Value: target}, {Name: ":path", Value: "https://other.invalid/news"}, + }}, + } { + t.Run(test.name, func(t *testing.T) { + serverTLS, clientTLS := testTLSConfigs(t) + var handlerCalls, dials, coverCalls atomic.Int32 + server, err := ListenWebH3(WebH3ServerConfig{ + Address: "127.0.0.1:0", Token: webTestToken, TLSConfig: serverTLS, + Dialer: transport.DialFunc(func(context.Context, string, string) (net.Conn, error) { + dials.Add(1) + return nil, errors.New("destination must not be dialed") + }), + Cover: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + coverCalls.Add(1) + w.WriteHeader(http.StatusNoContent) + }), + }) + if err != nil { + t.Fatal(err) + } + handler := server.server.Handler + server.server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handlerCalls.Add(1) + handler.ServeHTTP(w, r) + }) + serveWebH3ForTest(t, server) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + clientTLS.NextProtos = []string{http3.NextProtoH3} + connection, err := quic.DialAddr(ctx, server.Addr().String(), clientTLS, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = connection.CloseWithError(0, "") }) + h3Transport := &http3.Transport{} + t.Cleanup(func() { _ = h3Transport.Close() }) + h3Client := h3Transport.NewClientConn(connection) + stream, err := connection.OpenStreamSync(ctx) + if err != nil { + t.Fatal(err) + } + _ = stream.SetDeadline(time.Now().Add(3 * time.Second)) + defer stream.CancelRead(quic.StreamErrorCode(http3.ErrCodeRequestCanceled)) + defer stream.CancelWrite(quic.StreamErrorCode(http3.ErrCodeRequestCanceled)) + binding := webAuthBinding{transport: webAuthTransportH3, method: http.MethodConnect, authority: target} + credential, err := newWebAuthSigner(mustWebAuthKey(t, webTestToken), nil, nil).bearer(binding, webAuthClaims{}) + if err != nil { + t.Fatal(err) + } + var headers bytes.Buffer + encoder := qpack.NewEncoder(&headers) + for _, field := range test.fields { + if err := encoder.WriteField(field); err != nil { + t.Fatal(err) + } + } + if err := encoder.WriteField(qpack.HeaderField{Name: "proxy-authorization", Value: credential}); err != nil { + t.Fatal(err) + } + if err := encoder.Close(); err != nil { + t.Fatal(err) + } + frame := quicvarint.Append(nil, 1) // HEADERS frame, RFC 9114 section 7.2.2. + frame = quicvarint.Append(frame, uint64(headers.Len())) + frame = append(frame, headers.Bytes()...) + if _, err := stream.Write(frame); err != nil { + t.Fatal(err) + } + _ = stream.Close() + _, err = stream.Read(make([]byte, 1)) + var streamErr *quic.StreamError + if !errors.As(err, &streamErr) || !streamErr.Remote || streamErr.ErrorCode != quic.StreamErrorCode(http3.ErrCodeMessageError) { + t.Fatalf("malformed request error = %v, want remote H3_MESSAGE_ERROR", err) + } + if handlerCalls.Load() != 0 || coverCalls.Load() != 0 || dials.Load() != 0 { + t.Fatalf("malformed request reached handler / cover / destination = %d / %d / %d", + handlerCalls.Load(), coverCalls.Load(), dials.Load()) + } + // A stream-local rejection must leave the same physical connection + // usable for an ordinary cover request, not silently poison it. + request := mustWebRequest(t, http.MethodGet, "https://"+server.Addr().String()+"/healthy", nil).WithContext(ctx) + response, err := h3Client.RoundTrip(request) + if err != nil { + t.Fatalf("cover request after rejected stream: %v", err) + } + _, readErr := io.Copy(io.Discard, response.Body) + _ = response.Body.Close() + if readErr != nil || response.StatusCode != http.StatusNoContent || handlerCalls.Load() != 1 || coverCalls.Load() != 1 || dials.Load() != 0 { + t.Fatalf("healthy follow-up status / read error / handler / cover / dials = %d / %v / %d / %d / %d", + response.StatusCode, readErr, handlerCalls.Load(), coverCalls.Load(), dials.Load()) + } + }) + } +} diff --git a/scripts/check-dependency-boundary.sh b/scripts/check-dependency-boundary.sh index 604d8fc..72ad292 100755 --- a/scripts/check-dependency-boundary.sh +++ b/scripts/check-dependency-boundary.sh @@ -2,7 +2,7 @@ set -Eeuo pipefail readonly ALLOWED_WEB_QUIC_MODULE='github.com/apernet/quic-go' -readonly ALLOWED_WEB_QUIC_VERSION='v0.61.1-0.20260806010916-184d081eef3e' +readonly ALLOWED_WEB_QUIC_VERSION='v0.63.1-0.20261004180939-a10df75c260c' readonly FORBIDDEN_HYSTERIA_PATTERN='github\.com/apernet/hysteria(/|[[:space:]"`]|$)' readonly FORBIDDEN_HYSTERIA_GO_PATTERN='["`]github\.com/apernet/hysteria(/[^"`[:space:]]*)?["`]' status=0 diff --git a/scripts/stealth-full-config.py b/scripts/stealth-full-config.py index 3fc928f..663a974 100755 --- a/scripts/stealth-full-config.py +++ b/scripts/stealth-full-config.py @@ -276,7 +276,7 @@ def generate(args: argparse.Namespace) -> None: "implementation_version": args.autocar_version, "binary_sha256": sha256(files["autocar_binary"]), "transport": "h3", - "h3_fingerprint": "chrome-2026-08", + "h3_fingerprint": "chrome-2026-10", "relay": f"{endpoints['autocar']}:8443", "origin": f"http://{endpoints['origin']}:8080", "server_name": "cover.test", @@ -343,9 +343,9 @@ def generate(args: argparse.Namespace) -> None: ), ]}, "autocar": {"variants": [{ - "name": "autocar-v101-web-h3", - "client_implementation": "AutoCAR v1.0.1 WebH3Client", - "server_implementation": "AutoCAR v1.0.1 web H3 server", + "name": "autocar-chrome-2026-10-h3", + "client_implementation": "AutoCAR worktree WebH3Client (chrome-2026-10)", + "server_implementation": "AutoCAR worktree web H3 server", "implementation_version": args.autocar_version, "real_browser": False, "runner_image": images["runner_image"], @@ -1848,6 +1848,16 @@ def self_test() -> None: finally: globals()["verify_numeric_sans"] = original config = read_json(inputs / "campaign.json") + effective = read_json(inputs / "autocar-effective.json") + if effective["h3_fingerprint"] != "chrome-2026-10": + fail("self-test found an incorrect AutoCAR H3 profile") + autocar = config["products"]["autocar"]["variants"][0] + if ( + autocar["name"] != "autocar-chrome-2026-10-h3" + or autocar["client_implementation"] != "AutoCAR worktree WebH3Client (chrome-2026-10)" + or autocar["implementation_version"] != args.autocar_version + ): + fail("self-test found stale AutoCAR capture labels") campaign_module = runpy.run_path(str(Path(__file__).with_name("stealth-campaign.py"))) campaign_module["validate_config"](config, inputs) if set(config["products"]) != set(PRODUCTS): diff --git a/scripts/stealth-pilot-config.py b/scripts/stealth-pilot-config.py index 5134b10..9403a57 100755 --- a/scripts/stealth-pilot-config.py +++ b/scripts/stealth-pilot-config.py @@ -109,7 +109,7 @@ def prepare(args: argparse.Namespace) -> None: "implementation_version": args.autocar_version, "binary_sha256": sha256(files["autocar_binary"]), "transport": "h3", - "h3_fingerprint": "chrome-2026-08", + "h3_fingerprint": "chrome-2026-10", "relay": f"{endpoints['autocar']}:8443", "origin": f"http://{endpoints['origin']}:8080", "server_name": "cover.test", @@ -189,9 +189,9 @@ def prepare(args: argparse.Namespace) -> None: "environment": {}, "mounts": common_mounts, "user": args.user, }]}, "autocar": {"variants": [{ - "name": "autocar-v101-worktree-h3", - "client_implementation": "AutoCAR v1.0.1 worktree WebH3Client", - "server_implementation": "AutoCAR v1.0.1 worktree web H3 server", + "name": "autocar-chrome-2026-10-h3", + "client_implementation": "AutoCAR worktree WebH3Client (chrome-2026-10)", + "server_implementation": "AutoCAR worktree web H3 server", "implementation_version": args.autocar_version, "real_browser": False, "runner_image": args.runner_image, @@ -465,6 +465,16 @@ def self_test() -> None: if secret in retained: fail("self-test found a secret in generated retained inputs") generated = read_json(root / "campaign.json") + effective = read_json(root / "autocar-effective.json") + if effective["h3_fingerprint"] != "chrome-2026-10": + fail("self-test found an incorrect AutoCAR H3 profile") + autocar = generated["products"]["autocar"]["variants"][0] + if ( + autocar["name"] != "autocar-chrome-2026-10-h3" + or autocar["client_implementation"] != "AutoCAR worktree WebH3Client (chrome-2026-10)" + or autocar["implementation_version"] != args.autocar_version + ): + fail("self-test found stale AutoCAR capture labels") cover = generated["products"]["cover"]["variants"][0] if cover["real_browser"] is not False or "not a browser" not in cover["client_implementation"]: fail("self-test found a misleading cover-control label") diff --git a/scripts/stealth-pilot/main.go b/scripts/stealth-pilot/main.go index e3059e6..cb7bcad 100644 --- a/scripts/stealth-pilot/main.go +++ b/scripts/stealth-pilot/main.go @@ -468,13 +468,7 @@ func runProxyWorkload(ctx context.Context, args []string) error { if *serverName == "" || *caFile == "" || *tokenFile == "" { return errors.New("AutoCAR requires --server-name, --ca, and --token-file") } - commandArgs = []string{ - "client", "--server", *server, "--transport", "h3", - "--h3-fingerprint", "chrome-2026-08", "--server-name", *serverName, - "--ca", *caFile, "--token-file", *tokenFile, "--socks", "", - "--http", *proxyListen, "--dial-timeout", "4s", "--open-timeout", "12s", - "--idle-timeout", "20s", "--max-connections", "128", - } + commandArgs = autocarClientCommandArgs(*server, *serverName, *caFile, *tokenFile, *proxyListen) case "hysteria2": if *clientBin != "/hysteria" || *clientConfig != "/pilot/hysteria-client.yaml" { return errors.New("the Hysteria pilot requires fixed /hysteria and /pilot/hysteria-client.yaml paths") @@ -510,6 +504,16 @@ func runProxyWorkload(ctx context.Context, args []string) error { return executeWorkload(ctx, client, *origin, *workload, *seed, defaultIdle) } +func autocarClientCommandArgs(server, serverName, caFile, tokenFile, proxyListen string) []string { + return []string{ + "client", "--server", server, "--transport", "h3", + "--h3-fingerprint", "chrome-2026-10", "--server-name", serverName, + "--ca", caFile, "--token-file", tokenFile, "--socks", "", + "--http", proxyListen, "--dial-timeout", "4s", "--open-timeout", "12s", + "--idle-timeout", "20s", "--max-connections", "128", + } +} + func hysteriaClientCommandArgs(config string) []string { return []string{"client", "--disable-update-check", "-c", config} } diff --git a/scripts/stealth-pilot/main_test.go b/scripts/stealth-pilot/main_test.go index 29baf5c..a604ee2 100644 --- a/scripts/stealth-pilot/main_test.go +++ b/scripts/stealth-pilot/main_test.go @@ -260,6 +260,27 @@ func TestProxyKeepAliveDoerDoesNotMutateCallerRequest(t *testing.T) { } } +func TestAutoCARClientCommandPinsCurrentH3Profile(t *testing.T) { + want := []string{ + "client", "--server", "10.242.90.20:8443", "--transport", "h3", + "--h3-fingerprint", "chrome-2026-10", "--server-name", "cover.test", + "--ca", "/pilot/server.crt", "--token-file", "/pilot/token", "--socks", "", + "--http", "127.0.0.1:18080", "--dial-timeout", "4s", "--open-timeout", "12s", + "--idle-timeout", "20s", "--max-connections", "128", + } + got := autocarClientCommandArgs( + "10.242.90.20:8443", "cover.test", "/pilot/server.crt", "/pilot/token", "127.0.0.1:18080", + ) + if len(got) != len(want) { + t.Fatalf("AutoCAR client arguments = %q, want %q", got, want) + } + for index := range want { + if got[index] != want[index] { + t.Fatalf("AutoCAR client arguments = %q, want %q", got, want) + } + } +} + func TestHysteriaClientCommandDisablesUpdateCheck(t *testing.T) { want := []string{"client", "--disable-update-check", "-c", "/pilot/hysteria-client.yaml"} got := hysteriaClientCommandArgs("/pilot/hysteria-client.yaml")