diff --git a/README.md b/README.md index 87a82e7..2996d2d 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,12 @@ HTTP/3/UDP,UDP 不可用时让新 TCP 流继续走 HTTPS/HTTP/2/TCP;SOCKS5 U 升级连接;它仍是网站流量,不是新的代理隧道。限制见 [WebSocket 网站兼容性](docs/WEB_COVER.md#website-websocket-support-in-source-builds)。 -v1.0.1 是功能增强与问题修复版本。默认仍为 `native` 服务端与 `auto` 客户端; -**Web-cover 是需要显式开启的实验性功能**,发布不代表其被动抗识别能力已经验证。 -变更、升级与限制见 [v1.0.1 发布说明](docs/releases/v1.0.1.md)。 +当前源码默认使用 `web` 服务端与 `web-auto` 客户端、诊断和吞吐测试路径。 +服务端仍须明确提供且只能提供一个 `--cover-root` 或 `--cover-upstream`; +`init` 默认生成独立的 `server/cover/index.html` 与匹配的两端配置。 +已保存的显式 `native`/`auto` 配置仍按原模式运行。默认值变化不证明浏览器流量等价 +或被动抗识别能力;升级步骤见 [默认值迁移](docs/DEPLOYMENT.md#11-migrating-to-web-cover-defaults)。 +历史 v1.0.1 的默认值和功能边界见 [v1.0.1 发布说明](docs/releases/v1.0.1.md)。 AutoCAR 的身份验证、`autocar/2` 协议、TCP/UDP framing、速率协商、pacing、 熔断回退和资源边界均由 AutoCAR 实现;自有协议不提供第三方代理协议兼容模式。 @@ -105,8 +108,9 @@ go build -trimpath -o autocar ./cmd/autocar ./autocar client --config ./autocar-config/client/client.json --check ``` -生成的 `server/` 包含服务端配置、证书、私钥和随机令牌;`client/` 只包含客户端配置、 -信任证书和相同令牌,不含私钥。通过可信通道把两份目录分别送到对应机器,保持权限 +生成的 `server/` 包含服务端配置、独立的公开网站目录 `cover/`、证书、私钥和随机令牌; +`client/` 只包含客户端配置、信任证书和相同令牌,不含私钥。 +通过可信通道把两份目录分别送到对应机器,保持权限 并交给服务运行用户,再执行: ```bash @@ -117,7 +121,10 @@ autocar doctor --config /path/to/client/client.json --target example.com:443 autocar client --config /path/to/client/client.json ``` -`init` 只生成 native/auto 的新部署,不安装服务、不改防火墙、不轮换现有凭据。 +`init` 默认生成 web/web-auto 的新部署;用 `--protocol native` 可生成原有的 +native/auto 配置。它不安装服务、不改防火墙、不轮换现有凭据。 +生成的网站是可替换的简短欢迎页;仅将公开内容放入 `server/cover/`,不要把 +`server/`、配置目录或仓库根目录作为 `cover-root`。 `--out` 的父目录必须存在,目标目录必须不存在;即使已有目录为空也拒绝覆盖。 默认生成有效期 365 天的自签名证书(`--days` 可调);用 IP 连接但希望使用 DNS 证书名时, 可指定 `--server-name`。服务端监听与 `--server` 相同的数字端口,仍须自行开放 TCP/UDP。 @@ -144,6 +151,7 @@ Unix 目录权限为 `0700`、文件为 `0600`;Windows 需另行限制 ACL。 ./autocar server \ --listen :8443 \ --tcp-listen :8443 \ + --cover-root ./site \ --cert server.crt \ --key server.key \ --token-file token @@ -158,7 +166,11 @@ Unix 目录权限为 `0700`、文件为 `0600`;Windows 需另行限制 ACL。 --token-file token ``` -### Web-cover 模式(v1.0.1,实验性、显式启用) +上例使用默认的 web/web-auto;先准备独立、只含公开内容的 `./site` 目录。 +如需继续使用 Native 模式,服务端加 `--protocol native` 并去掉 cover 参数, +客户端、`doctor` 和 `bench-client` 加 `--transport auto`。 + +### Web-cover 模式(当前默认) 准备一个你拥有或获授权使用的网站目录,然后在同一数字端口上启用 HTTPS/H2 与 H3。`--cover-root` 和 `--cover-upstream` 必须且只能选一个: @@ -201,7 +213,7 @@ fork 的 ChromeParrot 行为,但仍属于 web H3,不会切换成 `autocar/2` --server relay.example.com:8443 \ --ca server.crt \ --token-file token \ - --transport auto \ + --transport web-auto \ --target example.com:443 ``` @@ -261,15 +273,15 @@ fork 的 ChromeParrot 行为,但仍属于 web H3,不会切换成 `autocar/2` "server": "relay.example.com:8443", "ca": "server.crt", "token-file": "relay-token", - "transport": "auto" + "transport": "web-auto" } ``` ```bash ./autocar client --config /path/to/client.json ./autocar doctor --config /path/to/client.json --target example.com:443 --json -# 临时强制 TCP/TLS 诊断,不修改配置文件 -./autocar doctor --config /path/to/client.json --transport tls --target example.com:443 +# 临时强制 HTTPS/H2 诊断,不修改配置文件 +./autocar doctor --config /path/to/client.json --transport h2 --target example.com:443 ``` 字段名对应命令行长选项,但不加 `--`;命令行同名选项优先。配置中的相对文件路径 @@ -281,20 +293,25 @@ fork 的 ChromeParrot 行为,但仍属于 web H3,不会切换成 `autocar/2` 要让 `client` 和 `doctor` 共用同一个文件,只保存二者共有的隧道参数; `socks/http/https` 等客户端专属参数放在启动命令中。中继使用单独的 [服务端模板](examples/server.json):`./autocar server --config /path/to/server.json`。 +同时复制示例的独立 [cover 目录](examples/cover/index.html) 到服务端配置文件旁, +或将 `cover-root` 改为你自己的公开网站目录。 配置仅在进程启动时读取,修改后需重启;自动回退和恢复只影响新连接, 已中断的业务连接仍需要应用重新发起,不会自动重放请求。 ## Pacing 模式 +本节适用于 Native 模式:服务端显式使用 `--protocol native`,客户端使用 +`--transport auto`、`quic` 或 `tls`。Web 路径不参与 Native pacing 协商。 + ```bash -# 默认:温和探测带宽并根据 RTT/loss 收敛 -./autocar client [连接参数] --pacing adaptive --pacing-profile balanced +# Native 默认:温和探测带宽并根据 RTT/loss 收敛 +./autocar client [连接参数] --transport auto --pacing adaptive --pacing-profile balanced # 共享链路更保守 -./autocar client [连接参数] --pacing adaptive --pacing-profile conservative +./autocar client [连接参数] --transport auto --pacing adaptive --pacing-profile conservative # 不使用 AutoCAR 应用层 pacing;观察当前 quic-go/Reno 基线 -./autocar client [连接参数] --pacing reno +./autocar client [连接参数] --transport auto --pacing reno ``` 只有测得真实容量时才使用 `fixed-rate`。该模式只在 QUIC 路径上协商;上传和下载 @@ -302,6 +319,7 @@ fork 的 ChromeParrot 行为,但仍属于 web H3,不会切换成 `autocar/2` ```bash ./autocar server [服务端参数] \ + --protocol native \ --pacing fixed-rate \ --max-upload-mbps 80 \ --max-download-mbps 250 \ @@ -333,11 +351,14 @@ Native 模式的 ALPN 是 `autocar/2`;web 模式使用标准 `h2`、`h3` 与 `http/1.1`。v1.0.0 和 v1.0.1 的 native 模式使用相同的 `autocar/2` 协议, v1.0.0 不支持 web 模式。这里的版本号 `v1.0.0` 不等于已经移除的旧协议 AutoCAR v1。 两种模式均不提供第三方代理协议或旧 AutoCAR v1 兼容模式,客户端与服务端必须 -显式选择匹配的模式。`client --transport` 接受 `auto`、`quic`、`tls`、 +选择匹配的模式。当前默认是服务端 `web` 与客户端、`doctor`、`bench-client` +的 `web-auto`;已有显式 `native`/`auto` 配置不会被改写。 +`client --transport` 接受 `auto`、`quic`、`tls`、 `web-auto`、`h3`、`h2`;`bench-client` 另提供 `direct` 对照路径。 -启用 web 时两端都应升级到 v1.0.1;升级不会自动修改现有 native 配置。 +Web 模式要求两端都支持对应功能;只升级一端而仍省略模式参数可能导致协议不匹配。 +迁移时协调两端切换,或在升级前显式固定服务端 `native` 与客户端 `auto`。 先保留旧二进制和配置,验证实际使用的 TCP、UDP 路径后再切换;具体步骤见 -[部署与升级](docs/DEPLOYMENT.md#10-v100-to-v101-upgrade-and-rollback)。 +[默认值迁移](docs/DEPLOYMENT.md#11-migrating-to-web-cover-defaults)。 ## 验证 diff --git a/cmd/autocar/common.go b/cmd/autocar/common.go index 6d06e38..bd28780 100644 --- a/cmd/autocar/common.go +++ b/cmd/autocar/common.go @@ -47,7 +47,7 @@ type tunnelFlags struct { 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") - fs.StringVar(&flags.mode, "transport", "auto", "transport: auto, quic, tls, web-auto, h3, or h2") + fs.StringVar(&flags.mode, "transport", "web-auto", "transport: web-auto (H3 with H2 fallback), h3, h2, or native auto, quic, tls") fs.StringVar(&flags.serverName, "server-name", "", "TLS certificate DNS name; defaults to relay host") fs.StringVar(&flags.caFile, "ca", "", "PEM trust anchor for the relay certificate") fs.BoolVar(&flags.systemRoots, "system-roots", false, "trust the operating-system CA set instead of --ca") diff --git a/cmd/autocar/doctor_test.go b/cmd/autocar/doctor_test.go index fe32692..dbd1104 100644 --- a/cmd/autocar/doctor_test.go +++ b/cmd/autocar/doctor_test.go @@ -226,7 +226,7 @@ func TestDoctorHumanOutputIsDirectionallyExplicit(t *testing.T) { RelayNegotiatedBytesPerSecond: 0, }} var stdout bytes.Buffer - err := runDoctorWith(context.Background(), []string{"--target", "example.com:443"}, &stdout, &bytes.Buffer{}, func(tunnelFlags) (closeDialer, error) { + err := runDoctorWith(context.Background(), []string{"--transport", "auto", "--target", "example.com:443"}, &stdout, &bytes.Buffer{}, func(tunnelFlags) (closeDialer, error) { return dialer, nil }) if err != nil { diff --git a/cmd/autocar/init.go b/cmd/autocar/init.go index 199b434..fc6fdf8 100644 --- a/cmd/autocar/init.go +++ b/cmd/autocar/init.go @@ -23,10 +23,11 @@ func runInit(args []string) error { return runInitWith(args, os.Stdout) } -// init creates a native deployment bundle only. It never contacts the relay, +// init creates a matching web or native deployment bundle. It never contacts the relay, // installs services, opens a firewall, or overwrites an existing output path. func runInitWith(args []string, output io.Writer) error { fs := flag.NewFlagSet("init", flag.ContinueOnError) + protocolText := fs.String("protocol", "web", "deployment protocol: web or native") server := fs.String("server", "", "client-visible relay host:numeric-port (required; bracket IPv6)") serverName := fs.String("server-name", "", "certificate DNS name or IP; defaults to the relay host") directory := fs.String("out", "autocar-config", "new private output directory; must not already exist") @@ -34,6 +35,10 @@ func runInitWith(args []string, output io.Writer) error { if err := parseCommandFlags(fs, args); err != nil { return err } + protocolMode := strings.ToLower(strings.TrimSpace(*protocolText)) + if protocolMode != "web" && protocolMode != "native" { + return fmt.Errorf("invalid --protocol %q; want web or native", *protocolText) + } if strings.TrimSpace(*directory) == "" { return errors.New("--out must name a new directory") } @@ -63,16 +68,22 @@ func runInitWith(args []string, output io.Writer) error { return fmt.Errorf("generate shared token: %w", err) } token := []byte(base64.RawURLEncoding.EncodeToString(random) + "\n") - serverConfig, err := json.MarshalIndent(map[string]any{ - "protocol": "native", "listen": ":" + port, + serverOptions := map[string]any{ + "protocol": protocolMode, "listen": ":" + port, "cert": "server.crt", "key": "server.key", "token-file": "relay-token", - }, "", " ") + } + transportMode := "auto" + if protocolMode == "web" { + serverOptions["cover-root"] = "cover" + transportMode = "web-auto" + } + serverConfig, err := json.MarshalIndent(serverOptions, "", " ") if err != nil { return err } clientConfig, err := json.MarshalIndent(map[string]any{ "server": net.JoinHostPort(host, port), "server-name": name, - "ca": "server.crt", "token-file": "relay-token", "transport": "auto", + "ca": "server.crt", "token-file": "relay-token", "transport": transportMode, }, "", " ") if err != nil { return err @@ -84,15 +95,18 @@ func runInitWith(args []string, output io.Writer) error { {"client/server.crt", cert}, {"client/relay-token", token}, {"server/server.json", append(serverConfig, '\n')}, {"client/client.json", append(clientConfig, '\n')}, - {"README.txt", []byte(initBundleInstructions)}, + {"README.txt", []byte(initDeploymentInstructions(protocolMode))}, + } + if protocolMode == "web" { + files = append(files, initBundleFile{"server/cover/index.html", []byte(initCoverIndex)}) } if err := writeInitBundle(*directory, files); err != nil { return err } - _, err = fmt.Fprintf(output, "Created native configuration bundle in %q.\n"+ + _, err = fmt.Fprintf(output, "Created %s configuration bundle in %q.\n"+ "Copy only server/ to the relay and only client/ to the client over an authenticated channel.\n"+ "The server private key is not included in client/. Both directories contain a secret token.\n"+ - "See README.txt for offline checks and startup. No service or network connection was started.\n", *directory) + "See README.txt for offline checks and startup. No service or network connection was started.\n", protocolMode, *directory) if err != nil { return errors.New("bundle was created, but writing the completion message failed; do not overwrite it") } @@ -177,6 +191,15 @@ func writeInitBundleContents(directory string, files []initBundleFile) error { } } for _, file := range files { + if file.path == "server/cover/index.html" { + coverDirectory := filepath.Join(directory, "server", "cover") + if err := os.Mkdir(coverDirectory, 0o700); err != nil { + return err + } + if err := os.Chmod(coverDirectory, 0o700); err != nil { + return err + } + } if err := writeNewInitFile(filepath.Join(directory, filepath.FromSlash(file.path)), file.data); err != nil { return fmt.Errorf("write bundle file %s: %w", file.path, err) } @@ -202,6 +225,40 @@ func writeNewInitFile(path string, data []byte) error { return file.Close() } +func initDeploymentInstructions(protocolMode string) string { + if protocolMode == "native" { + return initBundleInstructions + } + instructions := strings.Replace(initBundleInstructions, "AutoCAR native deployment bundle", "AutoCAR web-cover deployment bundle", 1) + instructions = strings.Replace(instructions, "Existing destination policy and native/auto defaults apply.", + "Existing destination policy is unchanged. This bundle explicitly selects web/web-auto.", 1) + return instructions + ` +The public website is served only from server/cover/. Its index.html is a +placeholder; replace it with website content you own or are authorized to serve. +Keep the token, key, certificate, and configuration outside that directory. +Never change cover-root to the whole server/ directory or put secrets in cover/. +The generated self-signed certificate is trusted by the matching client bundle, +but ordinary browsers require a publicly trusted certificate for a trusted site. +For TCP, web-auto tries H3 and falls back to H2 after an H3 transport failure. +SOCKS5 UDP requires H3 and has no H2 fallback. Native pacing and mTLS options +require a separate native deployment generated with init --protocol=native. +` +} + +const initCoverIndex = ` + + + + + Welcome + + +

Welcome

+

This site is ready for your content.

+ + +` + const initBundleInstructions = `AutoCAR native deployment bundle This is a private, self-signed-certificate deployment, not a publicly trusted diff --git a/cmd/autocar/init_integration_test.go b/cmd/autocar/init_integration_test.go index 79eb090..299612c 100644 --- a/cmd/autocar/init_integration_test.go +++ b/cmd/autocar/init_integration_test.go @@ -42,7 +42,7 @@ func TestInitGeneratedBundleCarriesNativeTraffic(t *testing.T) { // overridden below with the independently bound QUIC/TLS ephemeral ports; // nothing reserves and releases a port in anticipation of another listener. if err := runInitWith([]string{ - "--server", target.Listener.Addr().String(), "--out", dir, + "--protocol=native", "--server", target.Listener.Addr().String(), "--out", dir, }, io.Discard); err != nil { t.Fatal(err) } @@ -184,3 +184,143 @@ func TestInitGeneratedBundleCarriesNativeTraffic(t *testing.T) { }) } } + +// Only this local harness permits loopback destinations. It checks the generated +// web bundle against live H2/H3 transports without changing relay policy. +func TestInitGeneratedDefaultBundleCarriesWebTraffic(t *testing.T) { + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + payload, err := io.ReadAll(io.LimitReader(r.Body, 64<<10)) + if err != nil { + http.Error(w, "read test payload", http.StatusBadRequest) + return + } + _, _ = w.Write(payload) + })) + t.Cleanup(func() { + target.CloseClientConnections() + target.Close() + }) + dir := filepath.Join(t.TempDir(), "generated web bundle") + if err := runInitWith([]string{"--server", target.Listener.Addr().String(), "--out", dir}, io.Discard); err != nil { + t.Fatal(err) + } + + serverFlags := flag.NewFlagSet("server", flag.ContinueOnError) + protocol := serverFlags.String("protocol", "", "") + listen := serverFlags.String("listen", "", "") + certFile := serverFlags.String("cert", "", "") + keyFile := serverFlags.String("key", "", "") + tokenFile := serverFlags.String("token-file", "", "") + coverRoot := serverFlags.String("cover-root", "", "") + if err := parseFlagsWithConfig(serverFlags, []string{ + "--config", filepath.Join(dir, "server", "server.json"), "--listen=127.0.0.1:0", + }); err != nil { + t.Fatal(err) + } + if *protocol != "web" || *coverRoot != filepath.Join(dir, "server", "cover") { + t.Fatal("generated web protocol or cover directory did not survive config parsing") + } + certificate, err := security.LoadKeyPair(*certFile, *keyFile) + if err != nil { + t.Fatal(err) + } + token, err := config.LoadSecret(*tokenFile, "") + if err != nil { + t.Fatal(err) + } + tlsConfig, err := security.NewServerTLSConfig(security.ServerTLSOptions{ + Certificates: []tls.Certificate{certificate}, + }) + if err != nil { + t.Fatal(err) + } + cover, err := buildCoverHandler(*coverRoot, "") + if err != nil { + t.Fatal(err) + } + relay, err := tunnel.ListenWeb(tunnel.WebServerConfig{ + TCPAddress: *listen, UDPAddress: *listen, + Token: token, TLSConfig: tlsConfig, Cover: cover, + Dialer: &net.Dialer{Timeout: 5 * time.Second}, + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + serveDone := make(chan error, 1) + go func() { serveDone <- relay.Serve(ctx) }() + t.Cleanup(func() { + cancel() + _ = relay.Close() + select { + case err := <-serveDone: + if err != nil { + t.Errorf("web relay shutdown: %v", err) + } + case <-time.After(5 * time.Second): + t.Error("web relay did not shut down") + } + }) + + for _, mode := range []string{"h2", "h3", "web-auto"} { + t.Run(mode, func(t *testing.T) { + var tf tunnelFlags + clientFlags := flag.NewFlagSet("client", flag.ContinueOnError) + addTunnelFlags(clientFlags, &tf) + args := []string{ + "--config", filepath.Join(dir, "client", "client.json"), + "--server", relay.TCPAddr().String(), "--open-timeout=10s", + } + if mode != "web-auto" { + args = append(args, "--transport", mode) + } + if err := parseFlagsWithConfig(clientFlags, args); err != nil { + t.Fatal(err) + } + if tf.mode != mode { + t.Fatalf("parsed transport = %q, want %q", tf.mode, mode) + } + dialer, err := buildTunnelDialer(tf) + if err != nil { + t.Fatal(err) + } + defer dialer.Close() + probeCtx, stopProbe := context.WithTimeout(ctx, 10*time.Second) + defer stopProbe() + conn, err := dialer.DialContext(probeCtx, "tcp", target.Listener.Addr().String()) + if err != nil { + t.Fatalf("authenticated web tunnel open: %v", err) + } + defer conn.Close() + deadline, _ := probeCtx.Deadline() + if err := conn.SetDeadline(deadline); err != nil { + t.Fatal(err) + } + payload := bytes.Repeat([]byte("generated bundle web traffic\n"), 1024) + request, err := http.NewRequestWithContext(probeCtx, http.MethodPost, target.URL, bytes.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + if err := request.Write(conn); err != nil { + t.Fatalf("write web tunnel payload: %v", err) + } + response, err := http.ReadResponse(bufio.NewReader(conn), request) + if err != nil { + t.Fatalf("read web tunnel response: %v", err) + } + defer response.Body.Close() + got, err := io.ReadAll(io.LimitReader(response.Body, int64(len(payload))+1)) + if err != nil || response.StatusCode != http.StatusOK || !bytes.Equal(got, payload) { + t.Fatalf("web payload roundtrip: status=%d bytes=%d error=%v", response.StatusCode, len(got), err) + } + wantTransport := mode + if mode == "web-auto" { + wantTransport = "h3" + } + reporter, ok := dialer.(selectedTransportReporter) + if !ok || reporter.SelectedTransport() != wantTransport { + t.Fatalf("successful %s exchange did not report transport %s", mode, wantTransport) + } + }) + } +} diff --git a/cmd/autocar/init_test.go b/cmd/autocar/init_test.go index c74a3a1..b3d5b92 100644 --- a/cmd/autocar/init_test.go +++ b/cmd/autocar/init_test.go @@ -10,6 +10,8 @@ import ( "errors" "flag" "io" + "net/http" + "net/http/httptest" "os" "path/filepath" "reflect" @@ -24,17 +26,31 @@ import ( func TestInitCreatesPrivatePortableMatchingBundle(t *testing.T) { for _, test := range []struct { - server string - name string + server string + name string + protocol string + transport string }{ - {"relay.example.com:8443", ""}, - {"127.0.0.1:443", "relay.example.com"}, - {"[::1]:8443", ""}, + {"relay.example.com:8443", "", "", "web-auto"}, + {"127.0.0.1:443", "relay.example.com", "", "web-auto"}, + {"[::1]:8443", "", "", "web-auto"}, + {"relay.example.com:8443", "", "native", "auto"}, + {"127.0.0.1:443", "relay.example.com", "native", "auto"}, + {"[::1]:8443", "", "native", "auto"}, + {"relay.example.com:8443", "", "web", "web-auto"}, + {"relay.example.com:8443", "", " Native ", "auto"}, } { - t.Run(test.server+test.name, func(t *testing.T) { + t.Run(test.server+test.name+"/"+test.protocol, func(t *testing.T) { dir := filepath.Join(t.TempDir(), "new bundle") var stdout bytes.Buffer args := []string{"--server", test.server, "--out", dir, "--days=2"} + if test.protocol != "" { + args = append(args, "--protocol", test.protocol) + } + wantProtocol := "web" + if test.transport == "auto" { + wantProtocol = "native" + } if test.name != "" { args = append(args, "--server-name", test.name) } @@ -44,8 +60,16 @@ func TestInitCreatesPrivatePortableMatchingBundle(t *testing.T) { if !strings.Contains(stdout.String(), "No service or network connection was started") { t.Fatal("initialization output did not state its offline scope") } + if !strings.Contains(stdout.String(), "Created "+wantProtocol+" configuration bundle") { + t.Fatal("initialization output did not identify the generated protocol") + } files := snapshotInitBundle(t, dir) wantFiles := []string{".gitignore", "server/.gitignore", "client/.gitignore", "README.txt", "server/server.json", "server/server.crt", "server/server.key", "server/relay-token", "client/client.json", "client/server.crt", "client/relay-token"} + wantDirectories := []string{".", "server", "client"} + if wantProtocol == "web" { + wantFiles = append(wantFiles, "server/cover/index.html") + wantDirectories = append(wantDirectories, "server/cover") + } if len(files) != len(wantFiles) { t.Fatalf("unexpected bundle file count: %d", len(files)) } @@ -61,7 +85,7 @@ func TestInitCreatesPrivatePortableMatchingBundle(t *testing.T) { } } if runtime.GOOS != "windows" { - for _, name := range []string{".", "server", "client"} { + for _, name := range wantDirectories { info, err := os.Stat(filepath.Join(dir, name)) if err != nil || info.Mode().Perm() != 0o700 { t.Fatalf("directory %s must have mode 0700", name) @@ -101,7 +125,7 @@ func TestInitCreatesPrivatePortableMatchingBundle(t *testing.T) { if err := parseFlagsWithConfig(fs, []string{"--config", filepath.Join(dir, "client", "client.json")}); err != nil { t.Fatal(err) } - if tf.mode != "auto" || tf.systemRoots || tf.server != test.server { + if tf.mode != test.transport || tf.systemRoots || tf.server != test.server { t.Fatal("generated client changed transport, server or trust defaults") } if err := cert.VerifyHostname(tf.serverName); err != nil { @@ -120,9 +144,21 @@ func TestInitCreatesPrivatePortableMatchingBundle(t *testing.T) { } _ = dialer.Close() server, err := readCommandConfig(filepath.Join(dir, "server", "server.json")) - if err != nil || server["protocol"] != "native" || server["allow-private"] != nil || server["disable-tcp-fallback"] != nil { + if err != nil || server["protocol"] != wantProtocol || server["allow-private"] != nil || server["disable-tcp-fallback"] != nil { t.Fatal("generated server changed safe defaults") } + if wantProtocol == "web" { + if server["cover-root"] != "cover" || server["cover-upstream"] != nil { + t.Fatal("web bundle does not use its isolated static cover directory") + } + } else { + if server["cover-root"] != nil || server["cover-upstream"] != nil { + t.Fatal("native bundle unexpectedly contains cover options") + } + if files["README.txt"] != sha256.Sum256([]byte(initBundleInstructions)) { + t.Fatal("native bundle instructions changed") + } + } if err := runInitWith(args, io.Discard); err == nil { t.Fatal("existing output was accepted") } @@ -148,6 +184,9 @@ func TestInitRejectsInvalidOptionsBeforeWriting(t *testing.T) { {"--server", "relay:443", "--server-name", "*.example.com"}, {"--server", "relay:443", "--server-name", "relay:443"}, {"--server", "relay:443", "--days=0"}, {"--server", "relay:443", "--days=1826"}, + {"--server", "relay:443", "--protocol="}, + {"--server", "relay:443", "--protocol", "auto"}, + {"--server", "relay:443", "--protocol", "quic"}, {"--server", "relay:443", "unexpected"}, } { dir := filepath.Join(t.TempDir(), "not-created") @@ -223,24 +262,89 @@ func TestInitHelpAndPartialFailure(t *testing.T) { func TestInitGeneratedConfigurationsPassOfflineChecks(t *testing.T) { clearPreflightEnvironment(t) + dnsCalls := denyPreflightDNS(t) + for _, protocol := range []string{"default-web", "native"} { + t.Run(protocol, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "bundle") + args := []string{"--server=relay.invalid:8443", "--out", dir} + if protocol == "native" { + args = append(args, "--protocol=native") + } + if err := runInitWith(args, io.Discard); err != nil { + t.Fatal(err) + } + before := snapshotInitBundle(t, dir) + for _, role := range []string{"server", "client"} { + if err := run(context.Background(), []string{role, "--config", filepath.Join(dir, role, role+".json"), "--check"}); err != nil { + t.Fatalf("generated %s offline check: %v", role, err) + } + } + if !reflect.DeepEqual(before, snapshotInitBundle(t, dir)) { + t.Fatal("offline checks changed the generated bundle") + } + // The client trust anchor must contain only the certificate. + data, err := os.ReadFile(filepath.Join(dir, "client", "server.crt")) + if err != nil { + t.Fatal(err) + } + block, rest := pem.Decode(data) + if block == nil || block.Type != "CERTIFICATE" || len(bytes.TrimSpace(rest)) != 0 { + t.Fatal("client trust anchor has unexpected PEM material") + } + }) + } + if dnsCalls.Load() != 0 { + t.Fatal("generated configuration checks performed a DNS lookup") + } +} + +func TestInitDefaultWebCoverKeepsCredentialsOutsideDocumentRoot(t *testing.T) { dir := filepath.Join(t.TempDir(), "bundle") if err := runInitWith([]string{"--server=relay.invalid:8443", "--out", dir}, io.Discard); err != nil { t.Fatal(err) } - for _, role := range []string{"server", "client"} { - if err := run(context.Background(), []string{role, "--config", filepath.Join(dir, role, role+".json"), "--check"}); err != nil { - t.Fatalf("generated %s offline check: %v", role, err) + root := filepath.Join(dir, "server", "cover") + entries, err := os.ReadDir(root) + if err != nil || len(entries) != 1 || entries[0].Name() != "index.html" || entries[0].IsDir() { + t.Fatal("public cover directory contains unexpected files") + } + page, err := os.ReadFile(filepath.Join(root, "index.html")) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"relay-token", "server.key", "server.crt", "server.json"} { + private, err := os.ReadFile(filepath.Join(dir, "server", name)) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(page, bytes.TrimSpace(private)) { + t.Fatalf("cover page contains private bundle file %s", name) + } + } + handler, err := buildCoverHandler(root, "") + if err != nil { + t.Fatal(err) + } + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "https://relay.invalid/", nil)) + if response.Code != http.StatusOK || !bytes.Equal(response.Body.Bytes(), page) { + t.Fatalf("cover page response: status=%d", response.Code) + } + for _, path := range []string{"/relay-token", "/server.key", "/server.crt", "/server.json", "/../server.key"} { + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "https://relay.invalid"+path, nil)) + if response.Code == http.StatusOK { + t.Fatalf("cover served private path %s", path) } } - // Confirm this really is a certificate-only client package, not another - // private key hidden behind the trust-anchor filename. - data, err := os.ReadFile(filepath.Join(dir, "client", "server.crt")) + instructions, err := os.ReadFile(filepath.Join(dir, "README.txt")) if err != nil { t.Fatal(err) } - block, rest := pem.Decode(data) - if block == nil || block.Type != "CERTIFICATE" || len(bytes.TrimSpace(rest)) != 0 { - t.Fatal("client trust anchor has unexpected PEM material") + for _, text := range []string{"web-cover deployment bundle", "web/web-auto", "server/cover/", "outside that directory", "init --protocol=native"} { + if !bytes.Contains(instructions, []byte(text)) { + t.Fatalf("web instructions omit %q", text) + } } } diff --git a/cmd/autocar/main.go b/cmd/autocar/main.go index b556b87..33b43d1 100644 --- a/cmd/autocar/main.go +++ b/cmd/autocar/main.go @@ -67,7 +67,7 @@ func printUsage() { Usage: autocar server [options] run the remote native or web-cover relay autocar client [options] run local SOCKS5 and HTTP(S) proxies - autocar init [options] generate a new private native deployment bundle + autocar init [options] generate a private web-cover bundle (or explicit native) autocar cert [options] generate a self-signed TLS certificate autocar token [options] generate a strong shared token autocar bench-server [options] run a benchmark source/sink target diff --git a/cmd/autocar/main_test.go b/cmd/autocar/main_test.go index 4ea3e53..c903617 100644 --- a/cmd/autocar/main_test.go +++ b/cmd/autocar/main_test.go @@ -246,6 +246,7 @@ func TestRunHelpAndUnknownCommand(t *testing.T) { func TestServerRejectsFallbackSourceLimitAboveGlobalLimit(t *testing.T) { err := runServer(context.Background(), []string{ + "--protocol", "native", "--cert", "unused.crt", "--key", "unused.key", "--max-streams", "1", @@ -287,7 +288,7 @@ func TestServerUDPLimitValidation(t *testing.T) { } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - arguments := append([]string{"--cert", "unused.crt", "--key", "unused.key"}, test.arguments...) + arguments := append([]string{"--protocol", "native", "--cert", "unused.crt", "--key", "unused.key"}, test.arguments...) err := runServer(context.Background(), arguments) if err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("runServer error = %v, want substring %q", err, test.want) diff --git a/cmd/autocar/preflight_test.go b/cmd/autocar/preflight_test.go index 5bd4cd9..2bbb0b0 100644 --- a/cmd/autocar/preflight_test.go +++ b/cmd/autocar/preflight_test.go @@ -89,6 +89,9 @@ func TestPreflightClientAllTransportsStayOffline(t *testing.T) { } }) } + if err := runClient(context.Background(), files.clientArgs()); err != nil { + t.Fatalf("default web-auto preflight: %v", err) + } if got := dnsCalls.Load(); got != 0 { t.Fatalf("offline checks attempted %d DNS connections", got) } @@ -107,8 +110,10 @@ func TestPreflightServerModesStayOffline(t *testing.T) { name string args []string }{ - {"native", nil}, - {"native mTLS", []string{"--client-ca", files.cert}}, + {"native", []string{"--protocol", "native"}}, + {"native mTLS", []string{"--protocol", "native", "--client-ca", files.cert}}, + {"default web static", []string{"--cover-root", files.cover}}, + {"default web upstream", []string{"--cover-upstream", "https://cover.invalid/"}}, {"web static", []string{"--protocol", "web", "--cover-root", files.cover}}, {"web unresolved upstream", []string{"--protocol", "web", "--cover-upstream", "https://cover.invalid/"}}, {"web live upstream", []string{"--protocol", "web", "--cover-upstream", upstream.URL}}, @@ -199,7 +204,8 @@ func TestPreflightClientRejectsInvalidConfiguration(t *testing.T) { {"CA malformed", "parse CA file", []string{"--ca", files.token}}, {"token missing", "stat secret file", []string{"--token-file", missing}}, {"mTLS pair missing", "supplied together", []string{"--client-cert", files.cert}}, - {"mTLS key missing", "open TLS private key", []string{"--client-cert", files.cert, "--client-key", missing}}, + {"mTLS key missing", "open TLS private key", []string{"--transport", "auto", "--client-cert", files.cert, "--client-key", missing}}, + {"default web mTLS", "web transports do not support mTLS", []string{"--client-cert", files.cert, "--client-key", files.key}}, {"HTTPS pair missing", "requires --proxy-cert", []string{"--https", "127.0.0.1:8443"}}, {"HTTPS key missing", "open TLS private key", []string{"--https", "127.0.0.1:8443", "--proxy-cert", files.cert, "--proxy-key", missing}}, {"idle timeout", "--idle-timeout", []string{"--idle-timeout", "-1s"}}, @@ -248,7 +254,8 @@ func TestPreflightServerRejectsInvalidConfiguration(t *testing.T) { {"web mTLS", "incompatible", []string{"--protocol", "web", "--cover-root", files.cover, "--client-ca", files.cert}}, } { t.Run(test.name, func(t *testing.T) { - err := runServer(context.Background(), append(files.serverArgs(), test.args...)) + args := append(files.serverArgs(), "--protocol", "native") + err := runServer(context.Background(), append(args, test.args...)) if err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("error = %v, want substring %q", err, test.want) } @@ -257,7 +264,7 @@ func TestPreflightServerRejectsInvalidConfiguration(t *testing.T) { if err := os.WriteFile(files.token, []byte("short"), 0o600); err != nil { t.Fatal(err) } - if err := runServer(context.Background(), files.serverArgs()); err == nil || !strings.Contains(err.Error(), "token length") { + if err := runServer(context.Background(), append(files.serverArgs(), "--cover-root", files.cover)); err == nil || !strings.Contains(err.Error(), "token length") { t.Fatalf("short token accepted: %v", err) } } @@ -286,12 +293,13 @@ func TestPreflightLocalFilesAndLogs(t *testing.T) { slog.SetDefault(slog.New(slog.NewTextHandler(&output, nil))) t.Cleanup(func() { slog.SetDefault(original) }) if err := runClient(context.Background(), append(files.clientArgs(), + "--transport", "auto", "--https", "127.0.0.1:8443", "--proxy-cert", files.cert, "--proxy-key", files.key, "--client-cert", files.cert, "--client-key", files.key, )); err != nil { t.Fatal(err) } - if err := runServer(context.Background(), files.serverArgs()); err != nil { + if err := runServer(context.Background(), append(files.serverArgs(), "--cover-root", files.cover)); err != nil { t.Fatal(err) } for path, previous := range before { @@ -359,7 +367,7 @@ func TestServerExplicitEmptyClientCAFailsClosed(t *testing.T) { cancel() for _, check := range []string{"true", "false"} { t.Run("check="+check, func(t *testing.T) { - args := append(files.serverArgs(), "--check="+check, "--listen", "127.0.0.1:0", "--client-ca", emptyCA) + args := append(files.serverArgs(), "--protocol", "native", "--check="+check, "--listen", "127.0.0.1:0", "--client-ca", emptyCA) err := runServer(ctx, args) if err == nil || !strings.Contains(err.Error(), "client CA") { t.Fatalf("empty client CA must never disable mTLS, error = %v", err) @@ -380,8 +388,8 @@ func TestPreflightRejectsInsecureCredentialPermissions(t *testing.T) { args []string }{ {"client token", files.token, runClient, files.clientArgs()}, - {"server token", files.token, runServer, files.serverArgs()}, - {"server key", files.key, runServer, files.serverArgs()}, + {"server token", files.token, runServer, append(files.serverArgs(), "--cover-root", files.cover)}, + {"server key", files.key, runServer, append(files.serverArgs(), "--cover-root", files.cover)}, {"proxy key", files.key, runClient, append(files.clientArgs(), "--https", "127.0.0.1:8443", "--proxy-cert", files.cert, "--proxy-key", files.key)}, } { t.Run(test.name, func(t *testing.T) { diff --git a/cmd/autocar/server.go b/cmd/autocar/server.go index 566da3b..53aa648 100644 --- a/cmd/autocar/server.go +++ b/cmd/autocar/server.go @@ -25,7 +25,7 @@ import ( func runServer(parent context.Context, args []string) error { fs := flag.NewFlagSet("server", flag.ContinueOnError) check := fs.Bool("check", false, "validate local configuration and exit without DNS, network connections, or listeners (numeric ports required)") - serverProtocolText := fs.String("protocol", "native", "relay protocol: native or web") + serverProtocolText := fs.String("protocol", "web", "relay protocol: web (requires a cover origin) or native") listen := fs.String("listen", ":443", "primary UDP listen address") tcpListen := fs.String("tcp-listen", "", "TCP listener address; defaults to --listen") disableFallback := fs.Bool("disable-tcp-fallback", false, "disable the TCP/TLS fallback listener") diff --git a/cmd/autocar/web_cli_test.go b/cmd/autocar/web_cli_test.go index 6cbe666..7fd8077 100644 --- a/cmd/autocar/web_cli_test.go +++ b/cmd/autocar/web_cli_test.go @@ -56,11 +56,13 @@ func TestValidateServerProtocolOptions(t *testing.T) { } func TestRunServerValidatesWebCoverBeforeCredentials(t *testing.T) { - err := runServer(context.Background(), []string{"--protocol", "web"}) - if err == nil || !strings.Contains(err.Error(), "exactly one") { - t.Fatalf("missing web cover error = %v", err) + for _, args := range [][]string{nil, {"--protocol", "web"}} { + err := runServer(context.Background(), args) + if err == nil || !strings.Contains(err.Error(), "exactly one") { + t.Fatalf("missing web cover error for %v = %v", args, err) + } } - err = runServer(context.Background(), []string{ + err := runServer(context.Background(), []string{ "--protocol", "web", "--cover-root", "/srv/www", "--client-ca", "clients.pem", @@ -92,7 +94,7 @@ func TestBuildUpstreamCoverRejectsNonHTTPOrigin(t *testing.T) { } } -func TestRunWebServerAcceptsCanceledLifecycle(t *testing.T) { +func TestRunDefaultWebServerAcceptsCanceledLifecycle(t *testing.T) { directory := t.TempDir() certFile := filepath.Join(directory, "server.crt") keyFile := filepath.Join(directory, "server.key") @@ -115,7 +117,6 @@ func TestRunWebServerAcceptsCanceledLifecycle(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() if err := runServer(ctx, []string{ - "--protocol", "web", "--listen", "127.0.0.1:0", "--cover-root", coverRoot, "--cert", certFile, @@ -146,6 +147,9 @@ func TestTunnelHelpListsWebTransports(t *testing.T) { var flags tunnelFlags addTunnelFlags(fs, &flags) fs.PrintDefaults() + if flags.mode != "web-auto" || !strings.Contains(output.String(), `default "web-auto"`) { + t.Fatalf("client transport default must be web-auto: flags=%q help=%s", flags.mode, output.String()) + } for _, mode := range []string{"web-auto", "h3", "h2"} { if !strings.Contains(output.String(), mode) { t.Errorf("tunnel help omitted %q: %s", mode, output.String()) diff --git a/cmd/autocar/web_defaults_test.go b/cmd/autocar/web_defaults_test.go new file mode 100644 index 0000000..60bbf04 --- /dev/null +++ b/cmd/autocar/web_defaults_test.go @@ -0,0 +1,77 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "testing" + + "github.com/cppla/autocar/internal/tunnel" +) + +func TestTunnelTransportDefaultAndExplicitNativeCompatibility(t *testing.T) { + for _, test := range []struct { + name, config, want string + args []string + }{ + {name: "omitted", want: "web-auto"}, + {name: "empty config", config: `{}`, want: "web-auto"}, + {name: "native CLI", args: []string{"--transport=auto"}, want: "auto"}, + {name: "native config", config: `{"transport":"auto"}`, want: "auto"}, + {name: "CLI overrides native config", config: `{"transport":"auto"}`, args: []string{"--transport=web-auto"}, want: "web-auto"}, + } { + t.Run(test.name, func(t *testing.T) { + fs := flag.NewFlagSet("client", flag.ContinueOnError) + var flags tunnelFlags + addTunnelFlags(fs, &flags) + args := test.args + if test.config != "" { + args = append([]string{"--config", writeTestCommandConfig(t, test.config)}, args...) + } + if err := parseFlagsWithConfig(fs, args); err != nil { + t.Fatal(err) + } + if flags.mode != test.want { + t.Fatalf("transport=%q want=%q", flags.mode, test.want) + } + }) + } +} + +func TestServerExplicitNativeConfigurationSurvivesDefaultChange(t *testing.T) { + clearPreflightEnvironment(t) + files := newPreflightFiles(t) + for _, args := range [][]string{ + {"--protocol=native"}, + {"--config", writeTestCommandConfig(t, `{"protocol":"native"}`)}, + {"--config", writeTestCommandConfig(t, `{"protocol":"web"}`), "--protocol=native"}, + } { + if err := runServer(context.Background(), append(files.serverArgs(), args...)); err != nil { + t.Fatalf("explicit native configuration %v: %v", args, err) + } + } +} + +func TestDoctorDefaultsToWebAuto(t *testing.T) { + dialer := &fakeDoctorDialer{snapshot: tunnel.ClientSnapshot{ + SelectedTransport: "h3", ClientPacing: "not-applicable", RelayPacing: "not-applicable", + }} + var stdout, stderr bytes.Buffer + err := runDoctorWith(context.Background(), []string{"--target", "target.invalid:443", "--json"}, &stdout, &stderr, func(flags tunnelFlags) (closeDialer, error) { + if flags.mode != "web-auto" { + t.Fatalf("doctor default transport=%q", flags.mode) + } + return dialer, nil + }) + if err != nil { + t.Fatal(err) + } + var result doctorResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatal(err) + } + if result.RequestedTransport != "web-auto" || result.SelectedTransport != "h3" || !dialer.closed { + t.Fatalf("default doctor result=%+v closed=%t", result, dialer.closed) + } +} diff --git a/cmd/autocar/web_public_origin_test.go b/cmd/autocar/web_public_origin_test.go index 9fb5550..c3dce27 100644 --- a/cmd/autocar/web_public_origin_test.go +++ b/cmd/autocar/web_public_origin_test.go @@ -15,7 +15,7 @@ func TestRunServerPublicOriginValidationBeforeCredentials(t *testing.T) { args []string want string }{ - {"native", []string{"--cover-public-origin", "https://site.test"}, "requires --protocol=web"}, + {"native", []string{"--protocol", "native", "--cover-public-origin", "https://site.test"}, "requires --protocol=web"}, {"static", []string{"--protocol", "web", "--cover-root", "/unused", "--cover-public-origin", "https://site.test"}, "requires --protocol=web"}, {"http public", []string{"--protocol", "web", "--cover-upstream", "http://origin.test", "--cover-public-origin", "http://site.test"}, "configure upstream cover"}, {"whitespace public", []string{"--protocol", "web", "--cover-upstream", "http://origin.test", "--cover-public-origin", " "}, "configure upstream cover"}, diff --git a/docker-compose.yml b/docker-compose.yml index 876462b..67c2503 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,8 +18,10 @@ services: profiles: ["server"] command: - server + - --protocol=web - --listen=:8443 - --tcp-listen=:8443 + - --cover-root=/srv/www - --cert=/run/autocar/server.crt - --key=/run/autocar/server.key - --token-file=/run/autocar/relay-token @@ -27,6 +29,7 @@ services: - "${AUTOCAR_RELAY_PORT:-443}:8443/udp" - "${AUTOCAR_RELAY_PORT:-443}:8443/tcp" volumes: + - ./examples/cover:/srv/www:ro - ./secrets/server.crt:/run/autocar/server.crt:ro - ./secrets/server.key:/run/autocar/server.key:ro - ./secrets/relay-token:/run/autocar/relay-token:ro @@ -38,7 +41,7 @@ services: - client - --server=${AUTOCAR_RELAY:-relay.example.com:443} - --server-name=${AUTOCAR_SERVER_NAME:-relay.example.com} - - --transport=${AUTOCAR_TRANSPORT:-auto} + - --transport=${AUTOCAR_TRANSPORT:-web-auto} - --ca=/run/autocar/relay-ca.crt - --token-file=/run/autocar/relay-token - --socks=0.0.0.0:1080 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7205a62..95caaf3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,9 +2,11 @@ AutoCAR is a split proxy. It terminates local proxy connections and creates new connections at an authenticated relay. The native preferred QUIC path avoids -nesting a reliable TCP stream inside another reliable TCP stream. The opt-in +nesting a reliable TCP stream inside another reliable TCP stream. The default web-cover path instead prioritizes normal HTTP compatibility: H3 is preferred, with genuine HTTPS/H2 over TCP available when UDP is blocked. +Current source CLI defaults are `web` on the relay and `web-auto` on the client; +explicit native configurations remain available and unchanged. ```mermaid flowchart LR diff --git a/docs/BENCHMARK.md b/docs/BENCHMARK.md index 8ce4e99..cd8002c 100644 --- a/docs/BENCHMARK.md +++ b/docs/BENCHMARK.md @@ -12,6 +12,11 @@ Start the bounded target: ./autocar bench-server --listen 127.0.0.1:9000 ``` +`bench-client` defaults to `web-auto` in current source builds. The example +below explicitly selects native `quic` to measure native pacing; match the +transport family to the relay. For web measurements, use `web-auto`, `h3`, or +`h2`; web paths report native pacing as `not-applicable`. + Measure direct and tunneled paths with the same payload, warmup and iterations: ```bash @@ -38,9 +43,10 @@ used that rounded index for medians too, selecting the upper middle observation for even counts. Recompute from raw samples when comparing those reports. The selected transport and sender metadata must remain identical across all measured iterations or the command fails. `transport` is the requested policy -(`auto`, `quic`, `tls` or `direct`); `selected_transport` records the actual -measured path (`quic`, `tls` or `direct`). Do not treat `transport: "auto"` as -evidence that QUIC carried the payload. The output also separates: +(`auto`, `quic`, `tls`, `web-auto`, `h3`, `h2`, or `direct`); +`selected_transport` records the actual measured path (`quic`, `tls`, `h3`, `h2`, +or `direct`). Do not treat `transport: "auto"` or `"web-auto"` as evidence that +QUIC or H3 carried the payload. Native QUIC output also separates: - `local_tx_acceleration`: the client's current sender; - `payload_sender_acceleration`: the sender of the measured payload; diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 9d8d932..805035f 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -2,10 +2,14 @@ AutoCAR has two trusted endpoints: a local proxy client and a relay. Native mode prefers AutoCAR v2 over QUIC/UDP and can use a separate native TLS/TCP listener. -Experimental, opt-in web-cover mode presents one normal website over H1/H2/TCP -and H3/UDP and can carry new TCP flows over H2 when UDP is unavailable. Choose one relay protocol -per endpoint and configure clients to match it. The defaults remain a `native` -relay and an `auto` client; installing v1.0.1 does not enable web-cover. +Web-cover mode presents one normal website over H1/H2/TCP and H3/UDP and can +carry new TCP flows over H2 when UDP is unavailable. Current source builds default +to a `web` relay and `web-auto` for `client`, `doctor`, and `bench-client`. +The relay still requires exactly one explicit cover source. Choose one relay +protocol per endpoint and configure clients to match it. Existing explicit +`native`/`auto` settings remain valid; see [default migration](#11-migrating-to-web-cover-defaults) +before upgrading commands or configs that omit those settings. The historical +v1.0.1 binary defaults to `native`/`auto`. ## 1. Build and create credentials @@ -18,7 +22,7 @@ files, and benchmark commands reject them before startup. Normal flag syntax and ### Generate a new paired bundle With a build containing `init` and `--check` (not the published v1.0.1 binary), -you can generate matching native server/client configuration without contacting +you can generate matching web server/client configuration without contacting any network: ```bash @@ -43,6 +47,11 @@ contain the shared token and must remain private. Unix directories are `0700` and files `0600`; Windows deployments must restrict their ACLs independently. Assign ownership/read access to each host's runtime user. Generated `.gitignore` files help prevent accidental Git adds; they are not a storage/access boundary. +The default bundle includes `server/cover/index.html`, a small public welcome +page, and sets `cover-root` to that dedicated directory. Replace its content with +your own public site if needed. Never point cover-root at `server/`, the credential +directory, or the repository root. Use `init --protocol native` to generate the +original native relay/auto client bundle without a cover directory. If writing fails, the incomplete private directory is preserved for inspection; do not deploy it or expect a retry to overwrite it. @@ -52,8 +61,10 @@ the two directories independently movable. Both TCP and UDP listeners use the numeric port in `--server`. No firewall, DNS, service installation or system settings are changed. Low ports still require appropriate capability/port mapping; don't use root simply to bind a low port. Local proxy listeners stay -loopback-only and the default destination policy is unchanged. For public-CA or -experimental web-cover deployments, use the manual configuration below instead. +loopback-only and the default destination policy is unchanged. The generated +self-signed certificate is suitable for a private trust deployment, not proof of +browser-equivalent traffic. For public-CA deployments or a fixed upstream cover, +use the manual configuration below instead. ### Manual credentials and runtime user @@ -92,6 +103,7 @@ name and validity checks and has no certificate-verification bypass. ```bash sudo -u autocar /usr/local/bin/autocar server \ + --protocol native \ --listen :8443 \ --tcp-listen :8443 \ --cert /etc/autocar/server.crt \ @@ -132,8 +144,9 @@ relay. ## 3. Web-cover relay +Web is the current default relay protocol, but a cover source is still required. Use web-cover only with a domain and site content you own or are authorized to -serve. The static form is: +serve. The static form makes the protocol explicit for service configuration: ```bash sudo -u autocar /usr/local/bin/autocar server \ @@ -241,6 +254,9 @@ is unavailable rather than crossing the TLS fallback. ### Web-cover client +`web-auto` is the current default for `client`, `doctor`, and `bench-client`. +This example records it explicitly so that the chosen protocol stays clear: + ```bash ./autocar client \ --server relay.example.com:8443 \ @@ -441,6 +457,10 @@ and [examples/server.json](../examples/server.json) into your credential directory and adjust addresses and filenames. These files contain references, not secrets; keep the existing token and private-key permission requirements. Do not overwrite an existing deployment config without reviewing/backing it up. +The supplied templates select web/web-auto. Copy the dedicated +[examples/cover](../examples/cover/index.html) directory alongside the server +JSON, or change `cover-root` to your authorized public-only site. Explicit +native/auto JSON from an existing deployment continues to use that family. ```bash autocar client --config /etc/autocar/client.json @@ -496,7 +516,8 @@ application requests or migrate already-open streams. ## 5. Pacing -Use `adaptive-balanced` first. `conservative` reduces probing on shared or +For native deployments, select `--protocol native` on the relay and a native +client transport. Use `adaptive-balanced` first. `conservative` reduces probing on shared or shallow-buffer paths; `aggressive` should be enabled only after measuring both throughput and queue delay. `reno` disables the AutoCAR pacing layer and leaves the native upstream quic-go path as the baseline. Web H3 uses the separate pinned @@ -507,6 +528,7 @@ Fixed-rate is appropriate only for a measured, provisioned link: ```bash # Relay policy: finite limits in both directions and explicit client opt-in. ./autocar server [credential/policy flags] \ + --protocol native \ --pacing fixed-rate \ --max-upload-mbps 80 \ --max-download-mbps 250 \ @@ -567,7 +589,7 @@ Wants=network-online.target Type=simple User=autocar Group=autocar -ExecStart=/usr/local/bin/autocar server --listen=:443 --tcp-listen=:443 --cert=/etc/autocar/server.crt --key=/etc/autocar/server.key --token-file=/etc/autocar/relay-token --pacing=adaptive --pacing-profile=balanced +ExecStart=/usr/local/bin/autocar server --protocol=web --listen=:443 --tcp-listen=:443 --cover-root=/srv/autocar-cover --cert=/etc/autocar/server.crt --key=/etc/autocar/server.key --token-file=/etc/autocar/relay-token Restart=on-failure RestartSec=2 NoNewPrivileges=true @@ -576,7 +598,7 @@ AmbientCapabilities=CAP_NET_BIND_SERVICE PrivateTmp=true ProtectSystem=strict ProtectHome=true -ReadOnlyPaths=/etc/autocar +ReadOnlyPaths=/etc/autocar /srv/autocar-cover LimitNOFILE=65536 [Install] @@ -588,22 +610,22 @@ port 443 as an unprivileged user. Alternatively bind both listeners to a port at or above 1024, remove those lines and redirect/publish the port outside the process. -For web-cover, use the same hardening but select web protocol and an authorized -cover source: +Prepare `/srv/autocar-cover` as an authorized public-only site tree and ensure +`User=autocar` can traverse and read it. For an existing native deployment, +retain its client `--transport=auto` and use this explicit native command: ```ini -ExecStart=/usr/local/bin/autocar server --protocol=web --listen=:443 --tcp-listen=:443 --cover-root=/srv/autocar-cover --cert=/etc/autocar/server.crt --key=/etc/autocar/server.key --token-file=/etc/autocar/relay-token +ExecStart=/usr/local/bin/autocar server --protocol=native --listen=:443 --tcp-listen=:443 --cert=/etc/autocar/server.crt --key=/etc/autocar/server.key --token-file=/etc/autocar/relay-token --pacing=adaptive --pacing-profile=balanced ``` -Add the cover directory to `ReadOnlyPaths` (or an equivalent read-only bind) and -ensure `User=autocar` can traverse and read it. +The native service does not need the cover path in `ReadOnlyPaths`. With a post-v1.0.1 configuration-capable build, first manually copy [examples/server.json](../examples/server.json) to `/etc/autocar/server.json` and adjust its settings, as described in the reusable-configuration section. Templates are not bundled or installed automatically by the release archive or -container image. After preparing that runtime-readable file, the native service -can use: +container image. After preparing that runtime-readable file and its dedicated +cover directory, the web service can use: ```ini ExecStart=/usr/local/bin/autocar server --config=/etc/autocar/server.json --listen=:443 @@ -631,25 +653,27 @@ sudo install -o 65532 -g 65532 -m 0600 \ docker run --rm \ -p 443:8443/udp -p 443:8443/tcp \ -v /etc/autocar-container:/etc/autocar:ro \ + -v /srv/autocar-cover:/srv/www:ro \ autocar:local server \ + --protocol=web --cover-root=/srv/www \ --listen=:8443 --tcp-listen=:8443 \ --cert=/etc/autocar/server.crt \ --key=/etc/autocar/server.key \ --token-file=/etc/autocar/relay-token ``` -For a static web-cover container, mount the authorized site read-only and add -the web flags while keeping both TCP and UDP port publications: +Prepare `/srv/autocar-cover` as an authorized public-only site readable by the +container user. Keep its mount separate from the credentials. For native mode, +omit the site mount, select the native protocol explicitly, and use an `auto`, +`quic`, or `tls` client: ```bash docker run --rm \ -p 443:8443/udp -p 443:8443/tcp \ -v /etc/autocar-container:/etc/autocar:ro \ - -v /srv/autocar-cover:/srv/www:ro \ autocar:local server \ - --protocol=web \ + --protocol=native \ --listen=:8443 --tcp-listen=:8443 \ - --cover-root=/srv/www \ --cert=/etc/autocar/server.crt \ --key=/etc/autocar/server.key \ --token-file=/etc/autocar/relay-token @@ -663,6 +687,14 @@ unless the rootless runtime has been explicitly authorized to bind `443`. The image does not need host networking. Apply memory, CPU, PID and file-descriptor limits appropriate to the configured connection limits. +The checked-in `docker-compose.yml` selects a web relay and defaults clients to +`web-auto`. It mounts `examples/cover` read-only at `/srv/www`; replace that +dedicated sample directory with your authorized site, keeping secrets outside +it. `AUTOCAR_TRANSPORT=h2` or `h3` can require one web path. To keep a native +Compose deployment, override the relay command with `--protocol=native` and no +cover flags, and set `AUTOCAR_TRANSPORT=auto` for its clients. Changing only the +client transport does not change the relay protocol. + ## 9. Validation and upgrade Before production: @@ -746,3 +778,32 @@ configuration when a feature change is not intended. These are deployment acceptance steps, not a claim that every operating system, network, or application has been tested. Cross-compiled release archives alone are not native runtime validation for their target platform. + +## 11. Migrating to web-cover defaults + +Current source builds change only omitted selections: `server --protocol` +defaults to `web`, and `client`, `doctor`, and `bench-client --transport` default +to `web-auto`. A server with no cover source fails validation; it does not +automatically serve its working directory. Saved JSON or CLI settings that +explicitly select `native` and `auto` retain their meaning. Native and web +families remain incompatible and there is no silent fallback between them. + +For an existing native deployment, choose one of these paths before restarting: + +- Preserve native behavior by recording `--protocol=native` on the relay and + `--transport=auto` on clients, diagnostics, and benchmarks, or the matching + `"protocol": "native"` and `"transport": "auto"` JSON keys. Native configurations + must not include web cover fields. +- Migrate both endpoints to web/web-auto in a coordinated deployment. Prepare + exactly one dedicated public-only `cover-root` or fixed authorized + `cover-upstream`, keep keys/tokens outside the site tree, and verify ordinary + H1/H2/H3 responses, authenticated TCP, H3 CONNECT-UDP where needed, and H3-to-H2 + TCP fallback. Use a second TCP/UDP port to stage a rolling migration while the + original native endpoint remains available. + +Retain the previous executable, service configuration and protected credentials. +Restarting either endpoint interrupts active streams; applications must reconnect. +For rollback, restore the matching native relay/client selections together and +repeat the path checks. A successful local `--check` validates configuration, +not network availability or traffic classification. The default change itself +does not establish equivalence to browser traffic or passive-fingerprint quality. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 9a49a20..731dcdd 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -10,6 +10,9 @@ AutoCAR has two intentionally separate wire families: Neither family provides third-party proxy protocol or AutoCAR v1 compatibility. A relay selects one family with `server --protocol=native|web`; clients must select a matching native (`auto|quic|tls`) or web (`web-auto|h3|h2`) transport. +Current source CLI defaults select `web` and `web-auto`; a web relay still +requires exactly one explicit cover source. Explicit native settings retain +their existing wire format, and neither family silently downgrades to the other. ## Native protocol v2 diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 9dcd3bf..f4561fa 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -2,7 +2,12 @@ An ordinary AutoCAR release delivers functionality and bug fixes. It does not certify browser equivalence, passive indistinguishability, or a comparative -privacy advantage. v1.0.1 keeps native mode as the default and labels web-cover +privacy advantage. Current source defaults to a web relay and web-auto clients, +diagnostics, and benchmarks; exactly one explicit cover source remains required. +Explicit native/auto configurations retain their meaning. Release notes must +call out the coordinated endpoint migration or explicit native/auto option; +see [deployment migration](DEPLOYMENT.md#11-migrating-to-web-cover-defaults). +The historical v1.0.1 release kept native/auto defaults and introduced web-cover as an experimental, explicitly enabled feature. ## Ordinary release quality @@ -10,7 +15,7 @@ as an experimental, explicitly enabled feature. From the exact, clean release commit, use an explicit version and UTC build time: ```sh -make release VERSION=v1.0.1 BUILD_DATE=2026-09-08T00:00:00Z +make release VERSION=v1.1.0 BUILD_DATE=2026-10-04T00:00:00Z ``` Use a recorded UTC build time rather than copying the example timestamp. The diff --git a/docs/WEB_COVER.md b/docs/WEB_COVER.md index a1af77a..441285d 100644 --- a/docs/WEB_COVER.md +++ b/docs/WEB_COVER.md @@ -1,17 +1,25 @@ # Web-cover mode -AutoCAR v1.0.1 introduces an experimental, opt-in `web` relay mode for deployments +AutoCAR v1.0.1 introduced an experimental, opt-in `web` relay mode for deployments that need a genuine HTTPS origin and reliable TCP service across both UDP-capable and UDP-blocked networks. It is intended for lawful privacy, normal website compatibility, and service continuity on infrastructure the operator owns or is authorized to use. -The default remains a `native` relay and an `auto` client. A normal v1.0.1 -feature/bugfix release does not certify passive-fingerprint quality. Enable -web-cover only after validating the application's required TCP and UDP paths; -see [upgrade and rollback](DEPLOYMENT.md#10-v100-to-v101-upgrade-and-rollback). -Web clients and relays must both support v1.0.1 web mode; v1.0.0 supports native -`autocar/2` only. Web mode never silently downgrades to that native protocol. +Current source builds default to a `web` relay and `web-auto` for clients, +`doctor`, and `bench-client`; the historical v1.0.1 binary retains native/auto +defaults. The relay still requires exactly one explicit `--cover-root` or +`--cover-upstream`. `init` defaults to a paired web/web-auto bundle with a +dedicated `server/cover/index.html`; `init --protocol native` retains the original +native/auto bundle. Existing explicit native/auto JSON is unchanged. + +Validate the application's required TCP and UDP paths before deployment; see +[default migration and rollback](DEPLOYMENT.md#11-migrating-to-web-cover-defaults). +Coordinate the protocol change on both endpoints, or explicitly pin native/auto +before upgrading an existing native deployment. Web clients and relays must +both support the required web features; v1.0.0 supports native `autocar/2` only. +Web mode never silently downgrades to that native protocol. Changing defaults +does not certify passive-fingerprint quality. It is not an “undetectable” mode. Standard HTTP does not make all TLS, QUIC, HTTP framing, packet-size, timing, traffic-volume, endpoint, or application @@ -142,7 +150,10 @@ separate modes and are not wire-compatible. ## Relay configuration -The relay requires exactly one cover source. +The relay defaults to web and requires exactly one explicit cover source. There +is no automatic working-directory or credential-directory cover. Use a dedicated +public-only directory, such as the generated `server/cover/`; never serve the +bundle's `server/` directory or the repository root. ### Static directory @@ -364,6 +375,10 @@ still explicitly choose `--ca` or `--system-roots`. ## Client transports +`web-auto` is the default for `client`, `doctor`, and `bench-client` in current +source builds. The explicit setting below also works with an already configured +web relay and makes the protocol family visible in saved commands. + Recommended configuration: ```bash diff --git a/docs/releases/v1.1.0.md b/docs/releases/v1.1.0.md new file mode 100644 index 0000000..3d16919 --- /dev/null +++ b/docs/releases/v1.1.0.md @@ -0,0 +1,53 @@ +# AutoCAR v1.1.0 + +面向个人部署的功能增强与稳定性更新。新部署默认启用 Web-cover:服务端默认 +`web`,客户端、`doctor` 和隧道基准测试默认 `web-auto`(H3 优先,新 TCP 流在 +传输失败时回退 H2)。这次默认值变更不代表流量与真实浏览器等价或抗识别效果 +已经得到验证。 + +## 主要变化 + +- `autocar init` 默认生成匹配的 Web-cover 双端配置、自签证书、独立网站目录 + 和受保护的随机令牌;`init --protocol=native` 保留 native/auto 部署方式。 +- 支持 JSON 配置,命令行可覆盖配置项,文件路径相对于配置目录解析。 + `client --check`、`server --check` 提供离线检查,不连接网络或打开监听器。 +- `doctor` 提供认证 TCP 路径检查、传输状态和稳定错误码;可显式选择 UDP DNS + 检查,必须指定测试目标和 DNS 名称,不默认访问第三方解析器。 +- 完善网站上游代理的流式请求/响应、trailers、WebSocket 和固定公开源会话支持; + 静态网站目录限制路径逃逸和私密文件访问。 +- 改善 H2 连接复用、TLS 恢复、超时和退出资源回收,以及 H3 连接初始化、 + 认证和取消边界;完善 native QUIC/TLS 的取消、回退和关闭行为。 +- 修复 SOCKS5 UDP 会话与目标队列隔离、HTTP 代理流式传输、基准测试退出回收、 + native 自适应 pacing 和命令行意外位置参数处理。 + +## 升级前必须确认 + +显式设置了 `protocol: native` 和 `transport: auto` 的旧配置不变。省略这些字段 +的旧启动命令或配置会采用新的 Web 默认值,不能只更换一端二进制并假定协议不变。 + +- 保留旧部署:升级前在服务端明确设置 `--protocol=native`,客户端明确设置 + `--transport=auto`;使用 JSON 时补上对应字段。 +- 切换 Web-cover:准备独立网站目录或已获授权的固定 HTTP(S) 上游,服务端明确 + 设置 `--cover-root` 或 `--cover-upstream` 二选一,客户端使用 `web-auto`。 + 推荐在独立端口验证后同时切换两端,保留旧二进制、配置和凭据以便回滚。 +- 手动启动时不会自动把当前目录当作网站,也不会自动访问第三方站点。 + 缺少 cover 配置会明确拒绝启动;`init` 自动生成的仅是可替换的示例页面。 +- v1.0.0 不支持 Web 参数;旧版 native 回滚需恢复原配置,不能携带新 Web 参数。 + v1.0.1 没有 `init`、JSON 配置和离线检查命令。 + +下载后先验证 `SHA256SUMS`,再按自己的网络、证书、出口策略和应用进行验收。 + +## 已知限制 + +- H2 只承载 TCP;H3 到 H2 的回退只影响新 TCP 流,不迁移已建立的流,也不承载 UDP。 + H3 CONNECT-UDP 单包上限为 1,150 字节,无分片或 Capsule 数据回退。 +- Web 不支持 mTLS 和 native fixed-rate pacing。需要这些能力时显式使用 native。 +- H2 目标端 EOF 会结束该 CONNECT 响应并停止剩余上传;收到 EOF 后仍需继续上传的 + 应用应使用 H3。正常客户端上传半关闭仍允许接收目标回复。 +- 默认握手画像、网站 cover 和本地功能测试不能证明与真实浏览器不可区分。 + 未完成的全量被动识别实验不作为本版本的性能或抗识别结论。 +- 交叉构建不等于所有目标系统均完成原生运行测试。 + +安装包覆盖 Linux amd64/arm64、macOS arm64、Windows amd64,内含可执行文件、 +MIT 许可证和第三方许可清单。部署说明见 +[DEPLOYMENT.md](https://github.com/cppla/autocar/blob/v1.1.0/docs/DEPLOYMENT.md)。 diff --git a/examples/client.json b/examples/client.json index e5f5540..67bdfce 100644 --- a/examples/client.json +++ b/examples/client.json @@ -2,5 +2,5 @@ "server": "relay.example.com:8443", "ca": "server.crt", "token-file": "relay-token", - "transport": "auto" + "transport": "web-auto" } diff --git a/examples/cover/index.html b/examples/cover/index.html new file mode 100644 index 0000000..5789da4 --- /dev/null +++ b/examples/cover/index.html @@ -0,0 +1,19 @@ + + + + + + Welcome + + + +
+

Welcome

+

This website is being prepared. Please check back later.

+
+ + diff --git a/examples/server.json b/examples/server.json index d240ccf..b3b644a 100644 --- a/examples/server.json +++ b/examples/server.json @@ -1,6 +1,7 @@ { - "protocol": "native", + "protocol": "web", "listen": ":8443", + "cover-root": "cover", "cert": "server.crt", "key": "server.key", "token-file": "relay-token", diff --git a/scripts/docker-integration.sh b/scripts/docker-integration.sh index 19c22ed..701d18f 100755 --- a/scripts/docker-integration.sh +++ b/scripts/docker-integration.sh @@ -462,6 +462,7 @@ create_secret_volume "$invalid_client_volume" --security-opt no-new-privileges:true \ --mount "type=volume,src=${relay_volume},dst=/run/autocar" \ "$integration_image_id" init \ + --protocol=native \ --server=relay:8443 \ --out=/run/autocar/setup @@ -479,6 +480,7 @@ create_secret_volume "$invalid_client_volume" --mount "type=volume,src=${relay_volume},dst=/run/autocar,readonly" \ "$integration_image_id" server \ --config=/run/autocar/setup/server/server.json \ + --protocol=native \ --check "$docker_bin" run --rm \ --name "$wrong_token_container" \ @@ -536,6 +538,7 @@ create_secret_volume "$invalid_client_volume" --mount "type=volume,src=${valid_client_volume},dst=/run/autocar,readonly" \ "$integration_image_id" client \ --config=/run/autocar/client.json \ + --transport=auto \ --check integration_network_id=$("$docker_bin" network create --label "$integration_label" "$integration_network") @@ -572,6 +575,7 @@ relay_container_id=$("$docker_bin" run -d \ --mount "type=volume,src=${relay_volume},dst=/run/autocar,readonly" \ "$integration_image_id" server \ --config=/run/autocar/setup/server/server.json \ + --protocol=native \ --allow-private \ --max-streams=8 \ --max-connections=4 \ diff --git a/scripts/netem-integration.sh b/scripts/netem-integration.sh index d5bfbdc..16d79bf 100755 --- a/scripts/netem-integration.sh +++ b/scripts/netem-integration.sh @@ -300,6 +300,7 @@ wait_for_log "${BENCH_PID}" "${ARTIFACT_DIR}/bench-server.log" "benchmark server start_background "${ARTIFACT_DIR}/relay.log" \ ip netns exec "${SERVER_NS}" env QUIC_GO_DISABLE_GSO=true \ "${AUTOCAR_BIN}" server \ + --protocol=native \ --listen="${SERVER_IP}:${RELAY_PORT}" \ --tcp-listen="${SERVER_IP}:${RELAY_PORT}" \ --cert="${WORK_DIR}/server.crt" \ @@ -320,6 +321,7 @@ wait_for_log "${RELAY_PID}" "${ARTIFACT_DIR}/relay.log" "transport=tls" start_background "${ARTIFACT_DIR}/relay-adaptive.log" \ ip netns exec "${SERVER_NS}" env QUIC_GO_DISABLE_GSO=true \ "${AUTOCAR_BIN}" server \ + --protocol=native \ --listen="${SERVER_IP}:${ADAPTIVE_RELAY_PORT}" \ --tcp-listen="${SERVER_IP}:${ADAPTIVE_RELAY_PORT}" \ --cert="${WORK_DIR}/server.crt" \ @@ -333,6 +335,7 @@ wait_for_log "${ADAPTIVE_RELAY_PID}" "${ARTIFACT_DIR}/relay-adaptive.log" "trans start_background "${ARTIFACT_DIR}/relay-reno.log" \ ip netns exec "${SERVER_NS}" env QUIC_GO_DISABLE_GSO=true \ "${AUTOCAR_BIN}" server \ + --protocol=native \ --listen="${SERVER_IP}:${RENO_RELAY_PORT}" \ --tcp-listen="${SERVER_IP}:${RENO_RELAY_PORT}" \ --cert="${WORK_DIR}/server.crt" \ diff --git a/scripts/release-smoke.py b/scripts/release-smoke.py index 3882cb5..7e0877f 100644 --- a/scripts/release-smoke.py +++ b/scripts/release-smoke.py @@ -123,9 +123,12 @@ def ready(self, reference, markers): time.sleep(0.1) raise RuntimeError("fixture startup deadline exceeded") - def benchmark(self, binary, stage, mode, *, wrong_token=False): + def benchmark(self, binary, stage, mode, *, wrong_token=False, default_transport=False): + if default_transport and mode != "web-auto": + raise ValueError("the default transport receipt must expect web-auto") + transport_args = [] if default_transport else ["--transport=" + mode] for direction in (["download"] if wrong_token else ["download", "upload"]): - result = self.run(binary, ["bench-client", "--transport=" + mode, "--server=relay:8443", + result = self.run(binary, ["bench-client"] + transport_args + ["--server=relay:8443", "--server-name=relay", "--ca=/fixture/server.crt", "--token-file=/fixture/" + ("wrong-token" if wrong_token else "token"), "--target=target:9000", "--bytes=65536", "--iterations=3", "--warmup=0", @@ -214,7 +217,10 @@ def main(): "--key=/fixture/server.key", "--token-file=/fixture/token", "--allow-private", "--deny-ports=none", "--max-streams=16", "--max-connections=8", "--max-client-connections=8"] for stage, server_binary in (("baseline", previous), ("upgrade", current), ("rollback", previous)): - relay = lab.run(server_binary, common, alias="relay", background=True) + # v1.0.0 predates --protocol; only the upgraded binary needs + # the explicit native compatibility opt-in. + server_args = common + (["--protocol=native"] if stage == "upgrade" else []) + relay = lab.run(server_binary, server_args, alias="relay", background=True) lab.ready(relay, ["transport=quic", "transport=tls"]) for client_name, client_binary in (("v1.0.0-client", previous), ("new-client", current)): for mode in ("quic", "tls"): @@ -222,10 +228,12 @@ def main(): for mode in ("quic", "tls"): lab.benchmark(current, stage + "/invalid-token", mode, wrong_token=True) lab.stop(relay) - relay = lab.run(current, common + ["--protocol=web", "--cover-root=/fixture/site"], alias="relay", background=True) + # Keep the established receipt identities while exercising the + # current server and paired client defaults without CLI overrides. + relay = lab.run(current, common + ["--cover-root=/fixture/site"], alias="relay", background=True) lab.ready(relay, ["transport=h3", "transport=h2"]) for mode in ("h2", "h3", "web-auto"): - lab.benchmark(current, "experimental-web", mode) + lab.benchmark(current, "experimental-web", mode, default_transport=mode == "web-auto") for mode in ("h2", "h3"): lab.benchmark(current, "experimental-web/invalid-token", mode, wrong_token=True) lab.stop(relay) @@ -235,7 +243,7 @@ def main(): "current_binary_sha256": hashlib.sha256(current.read_bytes()).hexdigest(), "previous_binary_sha256": hashlib.sha256(previous.read_bytes()).hexdigest(), "checks": lab.checks, "check_count": len(lab.checks), - "scope": "native upgrade and rollback, authenticated TCP upload/download, experimental web TCP; no comparative claim"} + "scope": "native upgrade and rollback, authenticated TCP upload/download, default web TCP; no comparative claim"} # Print PASS only after all resources have been successfully cleaned up. print(json.dumps(result, indent=2)) diff --git a/scripts/test_release_smoke.py b/scripts/test_release_smoke.py index 6e5db88..0f7df41 100644 --- a/scripts/test_release_smoke.py +++ b/scripts/test_release_smoke.py @@ -48,6 +48,7 @@ def test_accepted_paths_validate_each_direction_and_owned_target(self): for mode in ("quic", "tls", "h2", "h3", "web-auto"): with self.subTest(mode=mode): def execute(binary, args, **kwargs): + self.assertIn("--transport=" + mode, args) self.assertIn("--server=relay:8443", args) self.assertIn("--target=target:9000", args) self.assertIn("--token-file=/fixture/token", args) @@ -60,6 +61,25 @@ def execute(binary, args, **kwargs): self.assertEqual(len(self.lab.checks), 10) self.assertEqual({check["direction"] for check in self.lab.checks}, {"upload", "download"}) + def test_default_transport_omits_override_and_keeps_web_auto_receipts(self): + def execute(binary, args, **kwargs): + self.assertFalse(any(value.startswith("--transport") for value in args)) + self.assertIn("--server=relay:8443", args) + self.assertIn("--target=target:9000", args) + self.assertIn("--token-file=/fixture/token", args) + direction = next(value.split("=", 1)[1] for value in args if value.startswith("--mode=")) + return completed(json.dumps(benchmark_output("web-auto", direction))) + + with mock.patch.object(self.lab, "run", side_effect=execute): + self.lab.benchmark(Path("/not-executed"), "experimental-web", "web-auto", default_transport=True) + self.assertEqual(len(self.lab.checks), 2) + self.assertEqual({check["transport"] for check in self.lab.checks}, {"web-auto"}) + self.assertEqual({check["direction"] for check in self.lab.checks}, {"upload", "download"}) + with mock.patch.object(self.lab, "run") as run: + with self.assertRaises(ValueError): + self.lab.benchmark(Path("/not-executed"), "stage", "quic", default_transport=True) + run.assert_not_called() + def test_incomplete_or_wrong_payload_cannot_pass(self): invalid = [ {"selected_transport": "direct"}, @@ -236,6 +256,7 @@ class MainMatrixTests(unittest.TestCase): def exercise_main(self, corruption=None, cleanup_failure=False): original_benchmark = smoke.Lab.benchmark generated_tokens = [] + self.invocations = [] class MockLab(smoke.Lab): def __enter__(self): @@ -250,6 +271,7 @@ def __exit__(self, *args): raise RuntimeError("controlled cleanup failure") def run(self, binary, args, **kwargs): + self_test.invocations.append((binary.name, list(args))) if args[0] == "version": if binary.name == "previous": version = "v9.9.9" if corruption == "previous-metadata" else "v1.0.0" @@ -258,12 +280,12 @@ def run(self, binary, args, **kwargs): return completed(f"autocar {VERSION} (commit {commit}, built {BUILD_DATE}, linux/amd64, go1.25.13)") if args[0] == "bench-client": options = dict(value[2:].split("=", 1) for value in args if value.startswith("--") and "=" in value) + mode = options.get("transport", "web-auto") if options["token-file"].endswith("wrong-token"): - mode = options["transport"] error = (f"tunnel: web-cover {mode.upper()} server authentication failed" if mode in ("h2", "h3") else "tunnel: remote error: authentication failed (status 2)") return completed(stderr=error, code=1) - return completed(json.dumps(benchmark_output(options["transport"], options["mode"]))) + return completed(json.dumps(benchmark_output(mode, options["mode"]))) return "mock-container" if kwargs.get("background") else completed() def ready(self, *args): @@ -310,6 +332,27 @@ def test_full_matrix_contains_exactly_38_checks(self): self.assertEqual(sum(check["authentication"] == "accepted" for check in result["checks"]), 30) self.assertEqual(sum(check["authentication"] == "rejected" for check in result["checks"]), 8) + def test_upgrade_opts_into_native_and_web_stage_exercises_cli_defaults(self): + self.exercise_main() + server_calls = [(binary, args) for binary, args in self.invocations if args[0] == "server"] + self.assertEqual([binary for binary, _ in server_calls], ["previous", "current", "previous", "current"]) + for index in (0, 2): + self.assertFalse(any(value.startswith("--protocol") for value in server_calls[index][1])) + self.assertNotIn("--cover-root=/fixture/site", server_calls[index][1]) + self.assertIn("--protocol=native", server_calls[1][1]) + self.assertNotIn("--cover-root=/fixture/site", server_calls[1][1]) + self.assertFalse(any(value.startswith("--protocol") for value in server_calls[3][1])) + self.assertIn("--cover-root=/fixture/site", server_calls[3][1]) + for binary, args in self.invocations: + if binary == "previous": + self.assertFalse(any(value.startswith("--protocol") for value in args)) + default_clients = [(binary, args) for binary, args in self.invocations + if args[0] == "bench-client" and not any(value.startswith("--transport") for value in args)] + self.assertEqual(len(default_clients), 2) + self.assertTrue(all(binary == "current" for binary, _ in default_clients)) + self.assertEqual({next(value for value in args if value.startswith("--mode=")) + for _, args in default_clients}, {"--mode=download", "--mode=upload"}) + def test_unexpected_binary_metadata_cannot_pass(self): for corruption in ("current-metadata", "previous-metadata"): with self.subTest(corruption=corruption):