Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,15 @@ 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 握手画像。
项目不依赖外部代理应用模块,依赖边界由自动检查验证。

## 设计目标与实现边界

| 公开设计目标 | 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,也不保证对其他流公平 |
Expand Down Expand Up @@ -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` 反向代理一个固定、已获授权的
Expand All @@ -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
Expand Down Expand Up @@ -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)、
Expand Down
25 changes: 20 additions & 5 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
2 changes: 1 addition & 1 deletion THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
30 changes: 28 additions & 2 deletions cmd/autocar/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,36 @@ type tunnelFlags struct {
openTimeout time.Duration
h2WriteTimeout time.Duration
fallbackTTL time.Duration
h3Fingerprint string
h3Fingerprint h3FingerprintFlag
pacing string
pacingProfile string
uploadMbps uint64
downloadMbps uint64
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")
Expand All @@ -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")
Expand All @@ -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")
}
Expand Down
7 changes: 7 additions & 0 deletions cmd/autocar/config_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (
"sort"
"strconv"
"time"

"github.com/cppla/autocar/internal/tunnel"
)

const maxCommandConfigSize = 64 << 10
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions cmd/autocar/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions cmd/autocar/doctor_guidance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
144 changes: 144 additions & 0 deletions cmd/autocar/h3_profile_migration_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading
Loading