diff --git a/README.md b/README.md index bd71a7b..ac7d45e 100644 --- a/README.md +++ b/README.md @@ -163,8 +163,10 @@ fork 的 ChromeParrot 行为,但仍属于 web H3,不会切换成 `autocar/2` `doctor` 不以“本地端口已监听”代替中继健康。它会通过经过证书和令牌认证的 隧道实际打开一次目标 TCP 连接,再报告真正选中的 `quic`、`tls`、`h3` 或 `h2` 以及耗时;native QUIC 还报告 pacing 与协商速率,web 路径则报告 -`not-applicable`。加 `--json` 可得到稳定的机器可读结果;失败 JSON 只包含稳定错误码 -和脱敏说明,原始网络错误、远端消息及本地路径仅在人类输出的诊断日志中出现。退出码 +`not-applicable`。加 `--json` 可得到稳定的机器可读结果;失败保留稳定错误码 +和脱敏说明,另附 `diagnosis` 分类与 `hint` 排查建议。人读模式也给出安全提示, +不回显原始远端消息、参数值或本地路径。无法可靠分类的错误会提示检查两端配置, +不会把所有失败都断言成认证问题。退出码 `0` 表示探测成功,`1` 表示网络/认证/目标探测失败,`2` 表示参数或本地配置错误。 成功只证明该 TCP 路径此刻可用,不代表目标应用协议正确、不可识别或链路更快。 @@ -181,6 +183,40 @@ fork 的 ChromeParrot 行为,但仍属于 web H3,不会切换成 `autocar/2` `socks5h` 会把域名交给远端。AutoCAR 不伪造目标证书,也不解密目标 HTTPS。 +### 保存配置,启动和诊断共用 + +以下配置文件功能为 v1.0.1 之后新增,需使用包含本改动的构建。 +不必每次重复填写连接参数:将 [客户端模板](examples/client.json) 保存为 +自己的 `client.json`,修改中继地址,并把证书与令牌放在配置文件旁: + +```json +{ + "server": "relay.example.com:8443", + "ca": "server.crt", + "token-file": "relay-token", + "transport": "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 +``` + +字段名对应命令行长选项,但不加 `--`;命令行同名选项优先。配置中的相对文件路径 +以配置文件所在目录为基准,命令行路径仍以当前目录为基准。布尔值用 JSON +`true/false`,时长用 `"15s"` 等字符串。未知字段、重复字段与无效值会直接报错; +不支持注释、环境变量展开或配置嵌套。令牌和私钥仍单独存文件并检查权限, +不要把原文写进 JSON。 + +要让 `client` 和 `doctor` 共用同一个文件,只保存二者共有的隧道参数; +`socks/http/https` 等客户端专属参数放在启动命令中。中继使用单独的 +[服务端模板](examples/server.json):`./autocar server --config /path/to/server.json`。 +配置仅在进程启动时读取,修改后需重启;自动回退和恢复只影响新连接, +已中断的业务连接仍需要应用重新发起,不会自动重放请求。 + ## Pacing 模式 ```bash diff --git a/cmd/autocar/client.go b/cmd/autocar/client.go index f316d1d..f5fc6f6 100644 --- a/cmd/autocar/client.go +++ b/cmd/autocar/client.go @@ -38,7 +38,7 @@ func runClient(parent context.Context, args []string) error { maxConnections := fs.Int("max-connections", 1024, "maximum clients per local proxy listener") idleTimeout := fs.Duration("idle-timeout", 5*time.Minute, "local proxy idle timeout") shutdownTimeout := fs.Duration("shutdown-timeout", 10*time.Second, "graceful shutdown timeout") - if err := fs.Parse(args); err != nil { + if err := parseFlagsWithConfig(fs, args); err != nil { return err } if *socksAddress == "" && *httpAddress == "" && *httpsAddress == "" { diff --git a/cmd/autocar/config_file.go b/cmd/autocar/config_file.go new file mode 100644 index 0000000..fda1015 --- /dev/null +++ b/cmd/autocar/config_file.go @@ -0,0 +1,189 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strconv" + "time" +) + +const maxCommandConfigSize = 64 << 10 + +// parseFlagsWithConfig preserves flag's CLI syntax and precedence. A config is +// a single, bounded JSON object whose keys are this command's long flag names. +// It contains file references, never inline credentials. Each command still +// performs its normal semantic and credential validation after parsing. +func parseFlagsWithConfig(fs *flag.FlagSet, args []string) error { + configPath := fs.String("config", "", "JSON options file; CLI overrides it, file paths are relative to its directory") + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 0 { + return errors.New("positional arguments are not supported; use named options") + } + if *configPath == "" { + return nil + } + // Save explicit flags before Set marks config options as visited too. + explicit := make(map[string]string) + fs.Visit(func(f *flag.Flag) { explicit[f.Name] = f.Value.String() }) + values, err := readCommandConfig(*configPath) + if err != nil { + return err + } + base, err := filepath.Abs(filepath.Dir(*configPath)) + if err != nil { + return fmt.Errorf("resolve config directory: %w", err) + } + keys := make([]string, 0, len(values)) + for name := range values { + keys = append(keys, name) + } + sort.Strings(keys) + for _, name := range keys { + f := fs.Lookup(name) + if f == nil || name == "config" { + return fmt.Errorf("unknown or unsupported config option %q for %s", name, fs.Name()) + } + value, err := commandConfigValue(f, values[name]) + if err != nil { + return fmt.Errorf("config option %q: %w", name, err) + } + if commandConfigFileOption(name) && value != "" && !filepath.IsAbs(value) { + value = filepath.Join(base, value) + } + // 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 { + return fmt.Errorf("invalid config option %q; see command help for its type and format", name) + } + if cliValue, ok := explicit[name]; ok { + if err := fs.Set(name, cliValue); err != nil { + return fmt.Errorf("restore command-line option %q: %w", name, err) + } + } + } + return nil +} + +func readCommandConfig(path string) (map[string]any, error) { + // Check before opening so accidental directories/devices/FIFOs are not + // treated as configuration streams. Check the opened file again below. + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("stat config file: %w", err) + } + if !info.Mode().IsRegular() || info.Size() > maxCommandConfigSize { + return nil, errors.New("config must be a regular JSON file no larger than 64 KiB") + } + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open config file: %w", err) + } + defer f.Close() + info, err = f.Stat() + if err != nil { + return nil, fmt.Errorf("stat opened config file: %w", err) + } + if !info.Mode().IsRegular() || info.Size() > maxCommandConfigSize { + return nil, errors.New("config must be a regular JSON file no larger than 64 KiB") + } + data, err := io.ReadAll(io.LimitReader(f, maxCommandConfigSize+1)) + if err != nil { + return nil, fmt.Errorf("read config file: %w", err) + } + if len(data) > maxCommandConfigSize { + return nil, errors.New("config exceeds 64 KiB") + } + return decodeCommandConfig(data) +} + +func decodeCommandConfig(data []byte) (map[string]any, error) { + // Token-by-token decoding detects duplicate keys that a normal map decode + // would silently accept (including escaped spellings of the same key). + d := json.NewDecoder(bytes.NewReader(data)) + d.UseNumber() + token, err := d.Token() + if err != nil || token != json.Delim('{') { + return nil, errors.New("config must contain one JSON object") + } + values := make(map[string]any) + for d.More() { + token, err := d.Token() + if err != nil { + return nil, errors.New("invalid config JSON") + } + name, ok := token.(string) + if !ok || name == "" { + return nil, errors.New("config option names must be nonempty strings") + } + if _, exists := values[name]; exists { + return nil, fmt.Errorf("duplicate config option %q", name) + } + var value any + if err := d.Decode(&value); err != nil { + return nil, errors.New("invalid config JSON") + } + switch value.(type) { + case string, bool, json.Number: + default: + return nil, fmt.Errorf("config option %q must be a string, boolean, or integer", name) + } + values[name] = value + } + if token, err := d.Token(); err != nil || token != json.Delim('}') { + return nil, errors.New("invalid config JSON") + } + if _, err := d.Token(); err != io.EOF { + return nil, errors.New("config must contain exactly one JSON object") + } + return values, nil +} + +func commandConfigValue(f *flag.Flag, value any) (string, error) { + getter, ok := f.Value.(flag.Getter) + if !ok { + return "", errors.New("unsupported option type") + } + switch getter.Get().(type) { + case string: + if text, ok := value.(string); ok { + return text, nil + } + return "", errors.New("must be a JSON string") + case bool: + if enabled, ok := value.(bool); ok { + return strconv.FormatBool(enabled), nil + } + return "", errors.New("must be a JSON boolean") + case time.Duration: + if text, ok := value.(string); ok { + return text, nil + } + return "", errors.New("must be a duration string such as 15s") + default: + // Numeric flags require JSON numbers, not CLI-specific string syntax. + // flag's integer parser still enforces integral values and range limits. + if v, ok := value.(json.Number); ok { + return v.String(), nil + } + return "", errors.New("must be a JSON integer") + } +} + +func commandConfigFileOption(name string) bool { + switch name { + case "ca", "client-cert", "client-key", "token-file", "proxy-cert", "proxy-key", + "proxy-password-file", "cert", "key", "client-ca", "cover-root": + return true + default: + return false + } +} diff --git a/cmd/autocar/config_file_test.go b/cmd/autocar/config_file_test.go new file mode 100644 index 0000000..ce7bbe1 --- /dev/null +++ b/cmd/autocar/config_file_test.go @@ -0,0 +1,189 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func writeTestCommandConfig(t *testing.T, text string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "connection.json") + if err := os.WriteFile(path, []byte(text), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestCommandConfigPrecedenceAndRelativePaths(t *testing.T) { + path := writeTestCommandConfig(t, `{ + "server":"relay.example.com:8443", "ca":"certs/ca.pem", + "token-file":"relay-token", "transport":"quic", "system-roots":false, + "open-timeout":"25s", "upload-mbps":18446744073709551615 + }`) + fs := flag.NewFlagSet("test", flag.ContinueOnError) + var tf tunnelFlags + addTunnelFlags(fs, &tf) + if err := parseFlagsWithConfig(fs, []string{"--transport=tls", "--config", path, "--ca", "cli.pem", "--open-timeout", "20s"}); err != nil { + t.Fatal(err) + } + if tf.mode != "tls" || tf.caFile != "cli.pem" || tf.openTimeout != 20*time.Second { + t.Fatalf("CLI did not take precedence: mode=%s ca=%s timeout=%s", tf.mode, tf.caFile, tf.openTimeout) + } + if tf.server != "relay.example.com:8443" || tf.tokenFile != filepath.Join(filepath.Dir(path), "relay-token") || tf.uploadMbps != ^uint64(0) { + t.Fatal("config strings, relative paths, or exact integer were not retained") + } + if tf.primaryTimeout != 5*time.Second || tf.systemRoots { + t.Fatal("unspecified defaults or explicit false were not retained") + } +} + +func TestCommandConfigPathOptions(t *testing.T) { + for _, name := range []string{"ca", "client-cert", "client-key", "token-file", "proxy-cert", "proxy-key", "proxy-password-file", "cert", "key", "client-ca", "cover-root"} { + t.Run(name, func(t *testing.T) { + for _, relative := range []string{"", "nested/file", filepath.Join(t.TempDir(), "absolute")} { + data, err := json.Marshal(map[string]string{name: relative}) + if err != nil { + t.Fatal(err) + } + path := writeTestCommandConfig(t, string(data)) + fs := flag.NewFlagSet("test", flag.ContinueOnError) + got := fs.String(name, "default", "") + if err := parseFlagsWithConfig(fs, []string{"--config=" + path}); err != nil { + t.Fatal(err) + } + want := relative + if relative != "" && !filepath.IsAbs(relative) { + want = filepath.Join(filepath.Dir(path), relative) + } + if *got != want { + t.Fatalf("path = %q; want %q", *got, want) + } + } + }) + } +} + +func TestCommandConfigRejectsInvalid(t *testing.T) { + for _, text := range []string{ + ``, `null`, `[]`, `{`, `{} {}`, `{} garbage`, + `{"server":"a","server":"b"}`, `{"server":"a","\u0073erver":"b"}`, + `{"server":null}`, `{"server":[]}`, `{"server":{}}`, `{"":true}`, + `{"unknown":true}`, `{"config":"other.json"}`, `{"--server":"a"}`, + `{"server":false}`, `{"system-roots":"false"}`, `{"system-roots":0}`, + `{"open-timeout":0}`, `{"open-timeout":5}`, `{"upload-mbps":-1}`, `{"upload-mbps":1.5}`, + `{"upload-mbps":"1"}`, `{"upload-mbps":"0x10"}`, `{"upload-mbps":1e3}`, + `{"upload-mbps":18446744073709551616}`, `{"upload-mbps":true}`, + `{"open-timeout":"secret-invalid-duration"}`, + } { + t.Run(text, func(t *testing.T) { + path := writeTestCommandConfig(t, text) + fs := flag.NewFlagSet("test", flag.ContinueOnError) + fs.SetOutput(io.Discard) + var tf tunnelFlags + addTunnelFlags(fs, &tf) + err := parseFlagsWithConfig(fs, []string{"--config", path, "--open-timeout=30s"}) + if err == nil { + t.Fatal("invalid config was accepted, including when overridden") + } + if strings.Contains(err.Error(), "secret-invalid-duration") { + t.Fatal("invalid config value leaked into diagnostics") + } + }) + } +} + +func TestCommandConfigIntegerFlagTypes(t *testing.T) { + for _, value := range []string{`"1"`, `"0x10"`, `1.5`, `1e3`, `true`, `1`} { + for _, unsigned := range []bool{false, true} { + path := writeTestCommandConfig(t, `{"limit":`+value+`}`) + fs := flag.NewFlagSet("test", flag.ContinueOnError) + if unsigned { + fs.Uint64("limit", 0, "") + } else { + fs.Int("limit", 0, "") + } + err := parseFlagsWithConfig(fs, []string{"--config", path}) + if (err == nil) != (value == "1") { + t.Fatalf("unsigned=%t value=%s err=%v", unsigned, value, err) + } + } + } +} + +func TestCommandConfigExplicitFalseAndEmptyOverride(t *testing.T) { + path := writeTestCommandConfig(t, `{"system-roots":true,"ca":"private.pem"}`) + fs := flag.NewFlagSet("test", flag.ContinueOnError) + var tf tunnelFlags + addTunnelFlags(fs, &tf) + if err := parseFlagsWithConfig(fs, []string{"--config", path, "--system-roots=false", "--ca="}); err != nil { + t.Fatal(err) + } + if tf.systemRoots || tf.caFile != "" { + t.Fatal("explicit false and empty CLI values did not override config") + } +} + +func TestCommandConfigLimitsAndMissingFile(t *testing.T) { + for _, path := range []string{ + filepath.Join(t.TempDir(), "missing.json"), t.TempDir(), + writeTestCommandConfig(t, strings.Repeat(" ", maxCommandConfigSize)+"{}"), + } { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + if err := parseFlagsWithConfig(fs, []string{"--config", path}); err == nil { + t.Fatal("missing, nonregular or oversized config accepted") + } + } + path := writeTestCommandConfig(t, "{}"+strings.Repeat(" ", maxCommandConfigSize-2)) + fs := flag.NewFlagSet("test", flag.ContinueOnError) + if err := parseFlagsWithConfig(fs, []string{"--config", path}); err != nil { + t.Fatalf("maximum-sized valid config: %v", err) + } +} + +func TestCommandConfigPreservesCLIParsing(t *testing.T) { + for _, args := range [][]string{ + nil, {"--transport=tls"}, {"--config=", "--transport=tls"}, + {"--config", filepath.Join(t.TempDir(), "missing"), "--help"}, + {"--server", "--config", "--transport=tls"}, + } { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + fs.SetOutput(io.Discard) + var tf tunnelFlags + addTunnelFlags(fs, &tf) + err := parseFlagsWithConfig(fs, args) + if len(args) != 0 && args[len(args)-1] == "--help" && !errors.Is(err, flag.ErrHelp) { + t.Fatalf("help did not return flag.ErrHelp: %v", err) + } + if err != nil && !errors.Is(err, flag.ErrHelp) { + t.Fatalf("%v: %v", args, err) + } + } + for _, args := range [][]string{{"unexpected"}, {"--", "unexpected"}, {"--config"}, {"--bogus"}} { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + fs.SetOutput(io.Discard) + if err := parseFlagsWithConfig(fs, args); err == nil { + t.Fatalf("invalid CLI accepted: %v", args) + } + } +} + +func TestCommandConfigWiredIntoClientAndServer(t *testing.T) { + // These fail before any credential access or listener is opened, proving + // each command consumes its config and keeps normal semantic validation. + client := writeTestCommandConfig(t, `{"socks":"", "http":"", "https":""}`) + if err := runClient(context.Background(), []string{"--config", client}); err == nil || !strings.Contains(err.Error(), "at least one") { + t.Fatalf("client did not use config: %v", err) + } + server := writeTestCommandConfig(t, `{"protocol":"not-a-protocol"}`) + if err := runServer(context.Background(), []string{"--config", server}); err == nil || !strings.Contains(err.Error(), "--protocol") { + t.Fatalf("server did not use config: %v", err) + } +} diff --git a/cmd/autocar/doctor.go b/cmd/autocar/doctor.go index 847befa..288ec8d 100644 --- a/cmd/autocar/doctor.go +++ b/cmd/autocar/doctor.go @@ -2,6 +2,7 @@ package main import ( "context" + "crypto/x509" "encoding/json" "errors" "flag" @@ -11,6 +12,7 @@ import ( "os" "strconv" "strings" + "syscall" "time" "github.com/cppla/autocar/internal/protocol" @@ -39,6 +41,8 @@ type doctorFailure struct { Status string `json:"status"` Code string `json:"code"` Error string `json:"error"` + Diagnosis string `json:"diagnosis"` + Hint string `json:"hint"` ElapsedMilliseconds float64 `json:"elapsed_ms,omitempty"` } @@ -55,28 +59,20 @@ func runDoctor(parent context.Context, args []string) error { func runDoctorWith(parent context.Context, args []string, stdout, stderr io.Writer, build doctorDialerBuilder) error { fs := flag.NewFlagSet("doctor", flag.ContinueOnError) effectiveJSON := doctorJSONRequested(args) - if effectiveJSON { - // JSON mode keeps flag package diagnostics (which can echo arbitrary - // arguments) out of stderr and returns the closed failure schema below. - fs.SetOutput(io.Discard) - } else { - fs.SetOutput(stderr) - } + // Flag diagnostics can echo arbitrary arguments, including credentials or + // paths. Both output modes use static failure text; help is restored below. + fs.SetOutput(io.Discard) var tf tunnelFlags addTunnelFlags(fs, &tf) target := fs.String("target", "", "TCP target host:port to open through the authenticated relay (required)") jsonOutput := fs.Bool("json", false, "emit machine-readable JSON") - if err := fs.Parse(args); err != nil { + parseErr := parseFlagsWithConfig(fs, args) + effectiveJSON = effectiveJSON || *jsonOutput + if err := parseErr; err != nil { if errors.Is(err, flag.ErrHelp) { - if effectiveJSON { - // The pre-scan suppresses FlagSet output to keep malformed JSON-mode - // arguments out of diagnostics. Help is static and safe, so restore - // the normal human-readable command help instead of succeeding - // silently for `doctor --json -h`. - fmt.Fprintln(stderr, "Usage of doctor:") - fs.SetOutput(stderr) - fs.PrintDefaults() - } + fmt.Fprintln(stderr, "Usage of doctor:") + fs.SetOutput(stderr) + fs.PrintDefaults() return err } return doctorFail(stdout, effectiveJSON, doctorExitUsageFailure, "invalid_arguments", err, 0) @@ -187,23 +183,104 @@ func validateDoctorTarget(target string) error { } func doctorFail(stdout io.Writer, jsonOutput bool, exitCode int, code string, err error, elapsed time.Duration) error { + publicError := doctorFailureMessage(code) + diagnosis, hint := doctorFailureGuidance(code, err) if jsonOutput { - publicError := doctorFailureMessage(code) failure := doctorFailure{ Status: "failed", Code: code, Error: publicError, + Diagnosis: diagnosis, + Hint: hint, ElapsedMilliseconds: durationMilliseconds(elapsed), } if encodeErr := writeDoctorJSON(stdout, failure); encodeErr != nil { return withExitCode(exitCode, fmt.Errorf("%s: %w", publicError, encodeErr)) } - // JSON is a privacy-preserving automation contract. Keep raw transport - // errors, remote messages and local filesystem paths out of both the JSON - // document and the top-level error subsequently logged by main. + // Preserve the existing JSON code/error and top-level error contract. return withExitCode(exitCode, errors.New(publicError)) } - return withExitCode(exitCode, err) + // Never wrap the raw cause: main logs this error, and remote messages or + // filesystem errors can contain private information in either output mode. + return withExitCode(exitCode, fmt.Errorf("%s [%s]\n hint: %s", publicError, diagnosis, hint)) +} + +// 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) { + switch code { + case "invalid_arguments": + if errors.Is(err, os.ErrNotExist) { + return "config_file_missing", "Check that the --config file exists and its path is correct." + } + if errors.Is(err, os.ErrPermission) { + return "config_file_permission", "Ensure the current user can read the --config file and access its directory; do not make credential files world-readable." + } + return "arguments", "Run autocar doctor --help; check option names, values, and any configuration file. Positional arguments are not supported." + case "invalid_target": + return "target", "Set --target to a reachable TCP host:port; use [IPv6-address]:port for IPv6 and a numeric port from 1 to 65535." + case "metadata_unavailable", "internal_error": + return "internal", "Check autocar version on both ends and use a matching supported release. If this persists, report the doctor code and version without credentials." + case "configuration_failed": + if errors.Is(err, os.ErrNotExist) { + return "file_missing", "Check that configured CA, token, and optional client certificate/key files exist and their paths are correct." + } + if errors.Is(err, os.ErrPermission) { + return "file_permission", "Ensure the current user can read configured credential files. Keep token and private-key files private; do not make them world-readable." + } + return "configuration", "Check --server host:port, --transport, exactly one of --ca or --system-roots, and --token-file or AUTOCAR_TOKEN. Use doctor --help to check timeout and pacing options." + } + + if errors.Is(err, context.Canceled) { + return "canceled", "The probe was canceled. Run it again when you want to check the connection." + } + var unknownAuthority x509.UnknownAuthorityError + if errors.As(err, &unknownAuthority) { + return "certificate_trust", "Use the correct relay CA with --ca, or --system-roots for a publicly trusted certificate. Verify the CA through a trusted channel; do not disable certificate verification." + } + var hostnameError x509.HostnameError + if errors.As(err, &hostnameError) { + return "certificate_name", "Check that --server-name matches a DNS name or IP in the relay certificate. Connecting to an IP may require the certificate's DNS name; do not disable certificate verification." + } + var certificateError x509.CertificateInvalidError + if errors.As(err, &certificateError) { + if certificateError.Reason == x509.Expired { + return "certificate_time", "Check the client and relay clocks and the certificate validity dates; renew an expired certificate before retrying." + } + return "certificate_invalid", "Check the relay certificate chain, server usage, and CA configuration; install a valid certificate instead of disabling verification." + } + var remoteError *tunnel.RemoteError + if errors.As(err, &remoteError) { + switch remoteError.Status { + case protocol.StatusUnauthorized: + return "authentication_rejected", "Ensure both ends use the same token from --token-file or AUTOCAR_TOKEN. Verify it locally without printing or sharing the token." + case protocol.StatusDialFailed: + return "target_open_failed", "The relay could not open the target. Check target DNS, port, and reachability from the relay, then its outbound policy and logs." + case protocol.StatusBusy: + return "relay_busy", "The relay reported a capacity limit. Check active connections, stream limits, and relay load before retrying." + default: + return "relay_rejected", "The relay rejected the request. Check matching versions, the target and outbound policy, and relay logs without sharing credentials." + } + } + var webError *tunnel.WebConnectError + if errors.As(err, &webError) { + return "web_connect_rejected", "The authenticated web relay rejected CONNECT. Check target reachability, outbound policy, relay capacity, and relay logs." + } + var dnsError *net.DNSError + if errors.As(err, &dnsError) { + return "dns", "Check the relay hostname and the client's DNS resolver. If using a separate fallback relay, check its hostname too." + } + var networkError net.Error + if errors.Is(err, context.DeadlineExceeded) || errors.As(err, &networkError) && networkError.Timeout() { + return "timeout", "Check relay and target reachability, firewall rules, and relay load. Check --dial-timeout and --open-timeout; auto modes also need --quic-attempt-timeout smaller than --open-timeout." + } + if errors.Is(err, syscall.ECONNREFUSED) { + return "connection_refused", "Check the relay address and port, confirm the server is listening on the required transport, and check firewall reject rules." + } + if errors.Is(err, protocol.ErrBadMagic) || errors.Is(err, protocol.ErrBadVersion) || errors.Is(err, protocol.ErrBadKind) { + return "protocol_mismatch", "Confirm the port serves AutoCAR and both ends use matching native or web modes and compatible versions." + } + return "unclassified", "Check relay address, certificate, matching modes and tokens, TCP/UDP firewall rules, and target reachability from the relay. For web mode, also check the public origin and matching versions. The cause could not be classified safely." } func doctorFailureMessage(code string) string { @@ -229,7 +306,7 @@ func writeDoctorJSON(output io.Writer, value any) error { encoder := json.NewEncoder(output) encoder.SetIndent("", " ") if err := encoder.Encode(value); err != nil { - return fmt.Errorf("write doctor JSON: %w", err) + return errors.New("write doctor JSON failed") } return nil } @@ -250,11 +327,11 @@ func writeDoctorHuman(output io.Writer, result doctorResult) error { result.RelayNegotiatedBytesPerSecond, result.ElapsedMilliseconds, ); err != nil { - return fmt.Errorf("write doctor output: %w", err) + return errors.New("write doctor output failed") } if result.LastTransportEvent != nil { if _, err := fmt.Fprintf(output, " transport event: %s (%s)\n", result.LastTransportEvent.Kind, result.LastTransportEvent.Reason); err != nil { - return fmt.Errorf("write doctor output: %w", err) + return errors.New("write doctor output failed") } } return nil diff --git a/cmd/autocar/doctor_guidance_test.go b/cmd/autocar/doctor_guidance_test.go new file mode 100644 index 0000000..c79f4a7 --- /dev/null +++ b/cmd/autocar/doctor_guidance_test.go @@ -0,0 +1,193 @@ +package main + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "flag" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/cppla/autocar/internal/protocol" + "github.com/cppla/autocar/internal/tunnel" +) + +func TestDoctorFailureGuidanceUsesTypedCausesAndRedactsDetails(t *testing.T) { + const private = "private-token-and-host /private/customer/credential" + certificate := &x509.Certificate{} + tests := []struct { + name string + code string + cause error + diagnosis string + hintPart string + }{ + {"arguments", "invalid_arguments", errors.New(private), "arguments", "--help"}, + {"missing config", "invalid_arguments", &os.PathError{Op: "stat", Path: private, Err: os.ErrNotExist}, "config_file_missing", "--config"}, + {"config permission", "invalid_arguments", &os.PathError{Op: "open", Path: private, Err: os.ErrPermission}, "config_file_permission", "--config"}, + {"target", "invalid_target", errors.New(private), "target", "--target"}, + {"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"}, + {"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"}, + {"certificate dates", "probe_failed", x509.CertificateInvalidError{Cert: certificate, Reason: x509.Expired, Detail: private}, "certificate_time", "clocks"}, + {"certificate usage", "probe_failed", x509.CertificateInvalidError{Cert: certificate, Reason: x509.IncompatibleUsage, Detail: private}, "certificate_invalid", "server usage"}, + {"authentication", "probe_failed", &tunnel.RemoteError{Status: protocol.StatusUnauthorized, Message: private}, "authentication_rejected", "same token"}, + {"remote target", "probe_failed", &tunnel.RemoteError{Status: protocol.StatusDialFailed, Message: private}, "target_open_failed", "from the relay"}, + {"remote capacity", "probe_failed", &tunnel.RemoteError{Status: protocol.StatusBusy, Message: private}, "relay_busy", "capacity"}, + {"remote internal", "probe_failed", &tunnel.RemoteError{Status: protocol.StatusInternal, Message: private}, "relay_rejected", "relay logs"}, + {"web rejection", "probe_failed", &tunnel.WebConnectError{Transport: private, StatusCode: 502}, "web_connect_rejected", "authenticated web relay"}, + {"dns", "probe_failed", &net.DNSError{Err: private, Name: private, Server: private, IsNotFound: true}, "dns", "DNS resolver"}, + {"dns timeout stays dns", "probe_failed", &net.DNSError{Err: private, Name: private, IsTimeout: true}, "dns", "DNS resolver"}, + {"context timeout", "probe_failed", context.DeadlineExceeded, "timeout", "--open-timeout"}, + {"network timeout", "probe_failed", &net.OpError{Op: private, Net: "tcp", Err: os.ErrDeadlineExceeded}, "timeout", "--open-timeout"}, + {"refused", "probe_failed", &net.OpError{Op: private, Net: "tcp", Err: syscall.ECONNREFUSED}, "connection_refused", "listening"}, + {"wrong protocol", "probe_failed", protocol.ErrBadMagic, "protocol_mismatch", "matching native or web"}, + {"wrong version", "probe_failed", protocol.ErrBadVersion, "protocol_mismatch", "compatible versions"}, + {"untyped error is not classified by text", "probe_failed", errors.New("x509: unknown authority; unauthorized; connection refused " + private), "unclassified", "could not be classified safely"}, + {"metadata", "metadata_unavailable", errors.New(private), "internal", "version"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Wrapping must preserve type classification but must not make any + // source-provided text observable, including in the main error. + cause := fmt.Errorf("%s: %w", private, test.cause) + for _, jsonOutput := range []bool{false, true} { + var stdout bytes.Buffer + err := doctorFail(&stdout, jsonOutput, doctorExitProbeFailure, test.code, cause, time.Millisecond) + if err == nil || commandExitCode(err) != doctorExitProbeFailure { + t.Fatalf("exit=%d err=%v", commandExitCode(err), err) + } + if strings.Contains(stdout.String()+err.Error(), private) || errors.Is(err, cause) { + t.Fatalf("failure leaked or retained a private cause: stdout=%q err=%v", stdout.String(), err) + } + if jsonOutput { + var failure doctorFailure + if decodeErr := json.Unmarshal(stdout.Bytes(), &failure); decodeErr != nil { + t.Fatal(decodeErr) + } + if failure.Code != test.code || failure.Error != doctorFailureMessage(test.code) || err.Error() != failure.Error { + t.Fatalf("existing code/error contract changed: %+v err=%v", failure, err) + } + if failure.Diagnosis != test.diagnosis || !strings.Contains(failure.Hint, test.hintPart) { + t.Fatalf("failure=%+v, want diagnosis=%q hint containing %q", failure, test.diagnosis, test.hintPart) + } + } else if stdout.Len() != 0 || !strings.Contains(err.Error(), "["+test.diagnosis+"]") || !strings.Contains(err.Error(), test.hintPart) { + t.Fatalf("human guidance stdout=%q err=%v", stdout.String(), err) + } + } + }) + } +} + +func TestDoctorHumanFailuresDoNotEchoArgumentsOrCauses(t *testing.T) { + const private = "/private/customer/token-secret" + tests := []struct { + name string + args []string + }{ + {"invalid flag value", []string{"--dial-timeout=" + private}}, + {"unknown flag", []string{"--" + private}}, + {"configuration", []string{"--target", "example.com:443"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + err := runDoctorWith(context.Background(), test.args, &stdout, &stderr, func(tunnelFlags) (closeDialer, error) { + return nil, &os.PathError{Op: "open", Path: private, Err: os.ErrPermission} + }) + if err == nil || commandExitCode(err) != doctorExitUsageFailure || !strings.Contains(err.Error(), "hint:") { + t.Fatalf("exit=%d err=%v", commandExitCode(err), err) + } + if strings.Contains(stdout.String()+stderr.String()+err.Error(), private) { + t.Fatalf("private input was echoed: stdout=%q stderr=%q err=%v", stdout.String(), stderr.String(), err) + } + }) + } +} + +func TestDoctorHumanHelpStillPrintsUsage(t *testing.T) { + var stdout, stderr bytes.Buffer + err := runDoctorWith(context.Background(), []string{"--help"}, &stdout, &stderr, nil) + if !errors.Is(err, flag.ErrHelp) || stdout.Len() != 0 || !strings.Contains(stderr.String(), "Usage of doctor:") || !strings.Contains(stderr.String(), "-target") { + t.Fatalf("stdout=%q stderr=%q err=%v", stdout.String(), stderr.String(), err) + } +} + +func TestDoctorConfigJSONAndCommandLineOverrides(t *testing.T) { + configFile := filepath.Join(t.TempDir(), "doctor.json") + if err := os.WriteFile(configFile, []byte(`{"server":"config.example:443","transport":"quic","target":"config-target.example:443","json":true}`), 0o600); err != nil { + t.Fatal(err) + } + for _, probeFailure := range []bool{false, true} { + for _, overrideJSON := range []bool{false, true} { + name := fmt.Sprintf("failure=%t/json-overridden=%t", probeFailure, overrideJSON) + t.Run(name, func(t *testing.T) { + args := []string{"--config", configFile, "--server", "cli.example:443", "--target", "cli-target.example:8443"} + if overrideJSON { + args = append(args, "--json=false") + } + dialer := &fakeDoctorDialer{snapshot: tunnel.ClientSnapshot{SelectedTransport: "quic"}} + if probeFailure { + dialer.dialErr = &tunnel.RemoteError{Status: protocol.StatusUnauthorized, Message: "private token"} + } + var stdout, stderr bytes.Buffer + err := runDoctorWith(context.Background(), args, &stdout, &stderr, func(tf tunnelFlags) (closeDialer, error) { + if tf.server != "cli.example:443" || tf.mode != "quic" { + t.Fatalf("CLI/config flags = %+v", tf) + } + return dialer, nil + }) + if (err != nil) != probeFailure || dialer.target != "cli-target.example:8443" || stderr.Len() != 0 { + t.Fatalf("err=%v target=%q stderr=%q", err, dialer.target, stderr.String()) + } + if !overrideJSON { + if !json.Valid(stdout.Bytes()) { + t.Fatalf("config JSON=true was not respected: %q", stdout.String()) + } + if probeFailure { + var failure doctorFailure + if decodeErr := json.Unmarshal(stdout.Bytes(), &failure); decodeErr != nil || failure.Diagnosis != "authentication_rejected" { + t.Fatalf("failure=%+v decode=%v", failure, decodeErr) + } + } + } else if probeFailure { + if stdout.Len() != 0 || !strings.Contains(err.Error(), "hint:") { + t.Fatalf("CLI JSON=false failure: stdout=%q err=%v", stdout.String(), err) + } + } else if !strings.Contains(stdout.String(), "Authenticated relay TCP probe: OK") { + t.Fatalf("CLI JSON=false success: stdout=%q", stdout.String()) + } + }) + } + } +} + +type privateDoctorOutputFailure struct{} + +func (privateDoctorOutputFailure) Write([]byte) (int, error) { + return 0, errors.New("private output /private/customer/token") +} + +func TestDoctorOutputFailuresDoNotLeakWriterErrors(t *testing.T) { + for _, err := range []error{ + doctorFail(privateDoctorOutputFailure{}, true, doctorExitProbeFailure, "probe_failed", errors.New("private cause"), 0), + writeDoctorJSON(privateDoctorOutputFailure{}, doctorResult{}), + writeDoctorHuman(privateDoctorOutputFailure{}, doctorResult{}), + } { + if err == nil || strings.Contains(err.Error(), "private") { + t.Fatalf("writer error was not redacted: %v", err) + } + } +} diff --git a/cmd/autocar/doctor_test.go b/cmd/autocar/doctor_test.go index 1c84343..fe32692 100644 --- a/cmd/autocar/doctor_test.go +++ b/cmd/autocar/doctor_test.go @@ -167,6 +167,19 @@ func TestDoctorCommandOpensRealAuthenticatedQUICPath(t *testing.T) { t.Errorf("serve QUIC: %v", serveErr) } }) + // The config directory differs from the process working directory. Relative + // credential paths must resolve against the file, not the invocation cwd. + configFile := filepath.Join(directory, "doctor.json") + configData, err := json.Marshal(map[string]any{ + "server": server.Addr().String(), "ca": "server.crt", "token-file": "token", + "transport": "quic", "json": true, + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configFile, configData, 0o600); err != nil { + t.Fatal(err) + } var observed *recordingDoctorDialer builder := func(flags tunnelFlags) (closeDialer, error) { @@ -184,12 +197,8 @@ func TestDoctorCommandOpensRealAuthenticatedQUICPath(t *testing.T) { } var stdout, stderr bytes.Buffer err = runDoctorWith(context.Background(), []string{ - "--server", server.Addr().String(), - "--ca", certFile, - "--token-file", tokenFile, - "--transport", "quic", + "--config", configFile, "--target", target.Addr().String(), - "--json", }, &stdout, &stderr, builder) if err != nil { var probeErr error @@ -394,7 +403,7 @@ func TestDoctorFailuresHaveStableCodesAndExitCodes(t *testing.T) { err := runDoctorWith(context.Background(), []string{"--target", "example.com:443"}, &bytes.Buffer{}, &bytes.Buffer{}, func(tunnelFlags) (closeDialer, error) { return nil, errors.New("bad credentials") }) - if commandExitCode(err) != doctorExitUsageFailure || !strings.Contains(err.Error(), "configure authenticated tunnel") { + if commandExitCode(err) != doctorExitUsageFailure || !strings.Contains(err.Error(), "local tunnel configuration failed") || !strings.Contains(err.Error(), "hint:") { t.Fatalf("exit=%d err=%v", commandExitCode(err), err) } }) @@ -404,7 +413,7 @@ func TestDoctorFailuresHaveStableCodesAndExitCodes(t *testing.T) { err := runDoctorWith(context.Background(), []string{"--target", "example.com:443"}, &bytes.Buffer{}, &bytes.Buffer{}, func(tunnelFlags) (closeDialer, error) { return dialer, nil }) - if commandExitCode(err) != doctorExitProbeFailure || !strings.Contains(err.Error(), "returned no TCP connection") { + if commandExitCode(err) != doctorExitProbeFailure || !strings.Contains(err.Error(), "authenticated tunnel TCP open failed") || !strings.Contains(err.Error(), "hint:") { t.Fatalf("exit=%d err=%v", commandExitCode(err), err) } }) diff --git a/cmd/autocar/server.go b/cmd/autocar/server.go index 1beb963..5728012 100644 --- a/cmd/autocar/server.go +++ b/cmd/autocar/server.go @@ -55,7 +55,7 @@ func runServer(parent context.Context, args []string) error { allowClientRates := fs.Bool("allow-client-rates", false, "native protocol: allow authenticated clients to request rates within server maxima") dialTimeout := fs.Duration("dial-timeout", 4*time.Second, "remote destination dial timeout") handshakeTimeout := fs.Duration("handshake-timeout", 10*time.Second, "authentication and initial stream-open timeout") - if err := fs.Parse(args); err != nil { + if err := parseFlagsWithConfig(fs, args); err != nil { return err } serverProtocol := strings.ToLower(strings.TrimSpace(*serverProtocolText)) diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 0fe187b..83f85dc 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -220,8 +220,12 @@ check a local listener. The result identifies the selected transport and elapsed time. Native QUIC also reports sender/rate negotiation; web H2/H3 reports pacing as `not-applicable`. Exit status is `0` for success, `1` for a failed live probe and `2` for invalid arguments or local configuration. Failed -JSON results expose only a stable code and redacted description; use human mode -when detailed local diagnostics are required. +JSON results retain a stable code and redacted description, with additive +`diagnosis` and `hint` fields for actionable troubleshooting. Human-mode failures +also use safe descriptions rather than echoing raw remote messages, arguments, +or local paths. Unknown failures remain explicitly unclassified; in particular, +a web cover response alone does not prove token rejection. Use `--json` on the +command line when automation needs JSON even if loading its config file fails. Native auto-mode logs expose only stable fallback/recovery event and reason codes, never tokens, full targets, relay addresses or raw transport errors. Its @@ -230,6 +234,55 @@ consumers that fall behind should read the concurrency-safe snapshot as the authoritative latest state. The most recently completed native path is tracked separately from QUIC circuit health. +### Reusable configuration files (after v1.0.1) + +Builds containing the configuration-file change support `--config` for `client`, +`server`, and `doctor`; the published v1.0.1 binary does not. Existing CLI-only +commands are unchanged. Copy [examples/client.json](../examples/client.json) +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. + +```bash +autocar client --config /etc/autocar/client.json +autocar doctor --config /etc/autocar/client.json --target example.com:443 --json +autocar server --config /etc/autocar/server.json +``` + +A config is one JSON object (maximum 64 KiB). Its keys are the selected command's +long option names without `--`. CLI options override matching config entries, +including explicit empty strings and `false`; other entries keep their values. +Use JSON booleans, exact integers for numeric options, and strings for addresses, +paths and durations such as `"15s"`. Duplicate/unknown keys, invalid types, +arrays, nested objects and trailing content are rejected, even if an invalid +entry is overridden on the command line. There are no comments, includes, shell +expansion, environment-variable expansion, or automatic reloads. +These commands also reject unexpected positional arguments instead of silently +ignoring them; use named options for every setting. + +Relative credential/certificate/cover-root paths in JSON are resolved against +the config file's directory (not the process working directory). Paths supplied +on the CLI keep their existing working-directory semantics. To switch from a +configured private CA to system roots, explicitly clear the old setting: +`--ca= --system-roots`. Credentials themselves are never accepted inline in JSON; +`token-file` and `proxy-password-file` retain the existing environment fallback +when unset. Do not put passwords in addresses, URLs, or arbitrary string options. + +The supplied client template intentionally contains only shared tunnel options, +so it can also be used by `doctor`. Client-only options such as `socks`, `http`, +and `https` are valid in a client-specific file but rejected by `doctor`; pass +them on the client command line if you want to reuse one connection config. +Server files are separate and cannot be used as client connection profiles. +Use a trusted, runtime-readable config and restart the process after editing it. +`--help` never reads config files or starts a network probe. + +For routine diagnosis, use the same file with `doctor`, then temporarily require +`--transport=tls` (native) or `--transport=h2` (web) to isolate the TCP path. A +successful TCP check does not validate UDP. Fallback/recovery applies to new +flows; applications must reconnect broken streams. AutoCAR does not replay +application requests or migrate already-open streams. + ## 5. Pacing Use `adaptive-balanced` first. `conservative` reduces probing on shared or @@ -320,6 +373,21 @@ ExecStart=/usr/local/bin/autocar server --protocol=web --listen=:443 --tcp-liste Add the cover directory to `ReadOnlyPaths` (or an equivalent read-only bind) and ensure `User=autocar` can traverse and read it. +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: + +```ini +ExecStart=/usr/local/bin/autocar server --config=/etc/autocar/server.json --listen=:443 +``` + +The sample omits `tcp-listen`, so both protocols inherit the overridden port. +Keep the same unprivileged runtime user and hardening above. `Restart=on-failure` +restarts a failed process; transport recovery within a live process is separate. + ## 8. Container ```bash diff --git a/examples/client.json b/examples/client.json new file mode 100644 index 0000000..e5f5540 --- /dev/null +++ b/examples/client.json @@ -0,0 +1,6 @@ +{ + "server": "relay.example.com:8443", + "ca": "server.crt", + "token-file": "relay-token", + "transport": "auto" +} diff --git a/examples/server.json b/examples/server.json new file mode 100644 index 0000000..070abcf --- /dev/null +++ b/examples/server.json @@ -0,0 +1,7 @@ +{ + "protocol": "native", + "listen": ":8443", + "cert": "server.crt", + "key": "server.key", + "token-file": "relay-token" +} diff --git a/internal/tunnel/native_recovery_generation_test.go b/internal/tunnel/native_recovery_generation_test.go new file mode 100644 index 0000000..450e8cb --- /dev/null +++ b/internal/tunnel/native_recovery_generation_test.go @@ -0,0 +1,249 @@ +package tunnel + +import ( + "context" + "crypto/tls" + "errors" + "net" + "sync" + "testing" + "time" + + "github.com/cppla/autocar/internal/protocol" + quic "github.com/quic-go/quic-go" +) + +// The first request stays pending until a later request has proved that the +// shared QUIC path is healthy. Only then does the peer reset the older stream. +// The failed request may use TLS, but it must not send subsequent requests into +// a fresh fallback cooldown. Channel ordering avoids a timing-dependent race. +func TestNativeLateFailurePreservesNewerQUICHealth(t *testing.T) { + for _, health := range []string{"tcp", "udp", "authenticated-error"} { + t.Run(health, func(t *testing.T) { + target, closeTarget := startHalfCloseTarget(t) + defer closeTarget() + serverTLS, clientTLS := testTLSConfigs(t) + fallback := startTLSServer(t, serverTLS, &net.Dialer{}, testToken) + entered, release := make(chan struct{}), make(chan struct{}) + primary := startNativeRecoveryPeer(t, serverTLS, entered, release) + client, err := NewClient(ClientConfig{ + ServerAddress: primary, FallbackAddress: fallback.Addr().String(), + Token: testToken, TLSConfig: clientTLS, + PrimaryAttemptTimeout: 5 * time.Second, FallbackCooldown: time.Hour, + }) + if err != nil { + t.Fatal(err) + } + defer client.Close() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + olderDone := make(chan error, 1) + go func() { + conn, err := client.DialContext(ctx, "tcp", target) + if conn != nil { + _ = conn.Close() + } + olderDone <- err + }() + select { + case <-entered: + case <-ctx.Done(): + t.Fatal("older QUIC request did not reach the peer") + } + switch health { + case "tcp": + conn, err := client.DialContext(ctx, "tcp", "healthy.test:443") + if err != nil { + t.Fatal(err) + } + _ = conn.Close() + case "udp": + packet, err := client.DialPacket(ctx) + if err != nil { + t.Fatal(err) + } + defer packet.Close() + case "authenticated-error": + _, err := client.DialContext(ctx, "tcp", "rejected.test:443") + var remoteErr *RemoteError + if !errors.As(err, &remoteErr) { + t.Fatalf("health response = %v, want authenticated rejection", err) + } + } + close(release) + select { + case err := <-olderDone: + if err != nil { + t.Fatalf("older stream did not retain its TLS fallback: %v", err) + } + case <-ctx.Done(): + t.Fatal("older stream did not finish") + } + conn, err := client.DialContext(ctx, "tcp", target) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + if _, ok := conn.(*quicStreamConn); !ok { + t.Fatalf("new stream used %T: older failure reopened a healthy QUIC circuit", conn) + } + }) + } +} + +func TestNativePrimaryCompletionOwnsOnlyItsProbe(t *testing.T) { + for _, completion := range []string{"canceled", "failed", "stale-failure"} { + t.Run(completion, func(t *testing.T) { + now := time.Date(2026, time.September, 14, 0, 0, 0, 0, time.UTC) + client := &Client{fallbackCooldown: time.Second} + _, _, older := client.shouldTryPrimary(now) + if completion == "stale-failure" { + client.primaryHealthy() + } + _, _, failed := client.shouldTryPrimary(now) + client.primaryFailed(failed, now, ClientReasonQUICAttemptTimeout) + try, _, probe := client.shouldTryPrimary(now.Add(time.Second)) + if !try || probe.id == 0 { + t.Fatal("half-open probe was not admitted") + } + if completion == "canceled" { + client.primaryProbeFinished(older) + } else { + client.primaryFailed(older, now.Add(time.Second), ClientReasonQUICHandshakeFailed) + } + if try, _, _ := client.shouldTryPrimary(now.Add(time.Hour)); try { + t.Fatal("older completion released another caller's half-open probe") + } + client.primaryProbeFinished(probe) + if try, _, replacement := client.shouldTryPrimary(now.Add(time.Hour)); !try || replacement.id == probe.id { + t.Fatal("canceling the owning probe did not permit one replacement") + } + }) + } +} + +func TestNativeCurrentFailureStillOpensCooldown(t *testing.T) { + now := time.Date(2026, time.September, 14, 0, 0, 0, 0, time.UTC) + client := &Client{fallbackCooldown: time.Second} + client.primaryHealthy() + _, _, attempt := client.shouldTryPrimary(now) + client.primaryFailed(attempt, now, ClientReasonQUICAttemptTimeout) + if try, reason, _ := client.shouldTryPrimary(now); try || reason != ClientReasonQUICAttemptTimeout { + t.Fatalf("current failure did not open cooldown: try=%v reason=%q", try, reason) + } + if try, _, _ := client.shouldTryPrimary(now.Add(time.Second)); !try { + t.Fatal("current failure changed the configured cooldown duration") + } +} + +func TestNativeSharedDialFailurePreservesLaterHealth(t *testing.T) { + _, clientTLS := testTLSConfigs(t) + client, err := NewClient(ClientConfig{ + ServerAddress: "127.0.0.1:4433", FallbackAddress: "127.0.0.1:4433", + Token: testToken, TLSConfig: clientTLS, QUICDialTimeout: 5 * time.Second, + }) + if err != nil { + t.Fatal(err) + } + defer client.Close() + entered, release := make(chan struct{}), make(chan struct{}) + dialFailure := errors.New("controlled older shared dial failure") + client.dialQUIC = func(ctx context.Context, _ string, _ *tls.Config, _ *quic.Config) (*quic.Conn, error) { + close(entered) + select { + case <-release: + return nil, dialFailure + case <-ctx.Done(): + return nil, context.Cause(ctx) + } + } + done := make(chan error, 1) + go func() { + _, err := client.connection(context.Background()) + done <- err + }() + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("shared dial did not start") + } + client.primaryHealthy() + close(release) + if err := <-done; !errors.Is(err, dialFailure) { + t.Fatalf("shared dial = %v, want controlled failure", err) + } + if try, _, _ := client.shouldTryPrimary(time.Now()); !try { + t.Fatal("background dial failure reopened a circuit after later health evidence") + } +} + +func startNativeRecoveryPeer(t *testing.T, config *tls.Config, entered chan<- struct{}, release <-chan struct{}) string { + t.Helper() + listener, err := quic.ListenAddr("127.0.0.1:0", mustServerTLSConfig(t, config), hardenedQUICServerConfig(nil, 16)) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + conn, err := listener.Accept(ctx) + if err != nil { + return + } + defer conn.CloseWithError(applicationShutdown, "test finished") + var handlers sync.WaitGroup + defer handlers.Wait() + for index := 0; ; index++ { + stream, err := conn.AcceptStream(ctx) + if err != nil { + return + } + handlers.Add(1) + go func(first bool) { + defer handlers.Done() + defer stream.CancelRead(streamCanceled) + request, err := protocol.ReadRequest(stream) + if err != nil { + return + } + if first { + close(entered) + select { + case <-release: + case <-ctx.Done(): + } + stream.CancelWrite(streamCanceled) + return + } + response := protocol.Response{ + Status: protocol.StatusOK, + TxMode: request.TxMode, TxProfile: request.TxProfile, + RxMode: protocol.PacingAdaptive, RxProfile: protocol.ProfileBalanced, + } + if request.Network == protocol.NetworkUDP { + response.SessionID = 42 + } + if request.Address == "rejected.test:443" { + response.Status = protocol.StatusDialFailed + } + if err := protocol.WriteResponse(stream, response); err != nil { + return + } + if request.Network == protocol.NetworkUDP { + select { + case <-ctx.Done(): + case <-stream.Context().Done(): + } + } + _ = stream.Close() + }(index == 0) + } + }() + t.Cleanup(func() { + cancel() + _ = listener.Close() + <-done + }) + return listener.Addr().String() +} diff --git a/internal/tunnel/observability_test.go b/internal/tunnel/observability_test.go index 6bc4de4..41f559d 100644 --- a/internal/tunnel/observability_test.go +++ b/internal/tunnel/observability_test.go @@ -180,7 +180,7 @@ func TestFallbackEventAndSnapshotRequireSuccessfulAuthenticatedTLSPath(t *testin func TestRecoveryEventIsEmittedOnceAndHandlerPanicsAreContained(t *testing.T) { noFallbackEvents := make(chan ClientEvent, 1) neverFellBack := &Client{eventHandler: func(event ClientEvent) { noFallbackEvents <- event }} - neverFellBack.primaryFailed(time.Now(), ClientReasonQUICDialFailed) + neverFellBack.primaryFailed(nativePrimaryAttempt{}, time.Now(), ClientReasonQUICDialFailed) neverFellBack.primarySucceeded(clientPathMetadata{}) if snapshot := neverFellBack.Snapshot(); snapshot.LastEvent != nil { t.Fatalf("QUIC retry without a successful TLS fallback emitted %+v", snapshot.LastEvent) @@ -193,7 +193,7 @@ func TestRecoveryEventIsEmittedOnceAndHandlerPanicsAreContained(t *testing.T) { events := make(chan ClientEvent, 4) client := &Client{eventHandler: func(event ClientEvent) { events <- event }} - client.primaryFailed(time.Now(), ClientReasonQUICStreamOpenFailed) + client.primaryFailed(nativePrimaryAttempt{}, time.Now(), ClientReasonQUICStreamOpenFailed) client.recordFallback(ClientReasonQUICStreamOpenFailed) fallbackEvent := receiveClientEvent(t, events) client.primaryHealthy() @@ -221,7 +221,7 @@ func TestRecoveryEventIsEmittedOnceAndHandlerPanicsAreContained(t *testing.T) { panicsContained <- struct{}{} panic("observer must not crash tunnel") }} - panicking.primaryFailed(time.Now(), ClientReasonQUICDialFailed) + panicking.primaryFailed(nativePrimaryAttempt{}, time.Now(), ClientReasonQUICDialFailed) panicking.recordFallback(ClientReasonQUICDialFailed) panicking.primarySucceeded(clientPathMetadata{}) receiveClientEventSignal := func() { @@ -242,7 +242,7 @@ func TestLateFallbackCompletionUpdatesSelectionWithoutReopeningCircuit(t *testin fallbackCooldown: time.Millisecond, eventHandler: func(event ClientEvent) { events <- event }, } - client.primaryFailed(time.Now().Add(-time.Second), ClientReasonQUICDialFailed) + client.primaryFailed(nativePrimaryAttempt{}, time.Now().Add(-time.Second), ClientReasonQUICDialFailed) client.recordFallback(ClientReasonQUICDialFailed) if event := receiveClientEvent(t, events); event.Kind != ClientEventFallback { t.Fatalf("initial event = %+v", event) @@ -250,10 +250,10 @@ func TestLateFallbackCompletionUpdatesSelectionWithoutReopeningCircuit(t *testin // One caller becomes the recovery probe while a concurrent caller starts a // TLS fallback. The QUIC path can succeed before that TLS dial completes. - if tryPrimary, _ := client.shouldTryPrimary(time.Now()); !tryPrimary { + if tryPrimary, _, _ := client.shouldTryPrimary(time.Now()); !tryPrimary { t.Fatal("cooldown probe was not admitted") } - tryPrimary, lateFallbackReason := client.shouldTryPrimary(time.Now()) + tryPrimary, lateFallbackReason, _ := client.shouldTryPrimary(time.Now()) if tryPrimary { t.Fatal("concurrent caller unexpectedly joined the cooldown probe") } @@ -269,7 +269,7 @@ func TestLateFallbackCompletionUpdatesSelectionWithoutReopeningCircuit(t *testin snapshot.LastEvent == nil || snapshot.LastEvent.Kind != ClientEventFallback { t.Fatalf("latest successful path snapshot = %+v", snapshot) } - if tryPrimary, _ := client.shouldTryPrimary(time.Now()); !tryPrimary { + if tryPrimary, _, _ := client.shouldTryPrimary(time.Now()); !tryPrimary { t.Fatal("late TLS completion reopened the healthy QUIC circuit") } } @@ -304,7 +304,7 @@ func TestClientEventHandlerIsSerializedInTransitionOrder(t *testing.T) { close(handledAll) } }} - client.primaryFailed(time.Now(), ClientReasonQUICDialFailed) + client.primaryFailed(nativePrimaryAttempt{}, time.Now(), ClientReasonQUICDialFailed) fallbackReturned := make(chan struct{}) go func() { @@ -364,7 +364,7 @@ func TestClientEventQueueIsBoundedAndReleasesStorage(t *testing.T) { handled = append(handled, event.Kind) }} - client.primaryFailed(time.Now(), ClientReasonQUICDialFailed) + client.primaryFailed(nativePrimaryAttempt{}, time.Now(), ClientReasonQUICDialFailed) client.recordFallback(ClientReasonQUICDialFailed) select { case <-entered: diff --git a/internal/tunnel/quic.go b/internal/tunnel/quic.go index 9015a5d..ee98ec3 100644 --- a/internal/tunnel/quic.go +++ b/internal/tunnel/quic.go @@ -460,18 +460,20 @@ type Client struct { pacer *connectionPacer dialing *quicDialAttempt closed bool - // primaryFailedAt and primaryProbing implement a small circuit breaker. + // primaryFailedAt and primaryProbeID implement a small circuit breaker. // They are guarded by mu together with the QUIC connection state. - primaryFailedAt time.Time - primaryProbing bool - localMode string - localRate uint64 - remoteMode string - remoteRate uint64 - selectedTransport string - eventHandler ClientEventHandler - primaryFailReason ClientEventReason - lastEvent ClientEvent + primaryFailedAt time.Time + primaryProbeID uint64 + nextPrimaryID uint64 + primaryHealthGeneration uint64 + localMode string + localRate uint64 + remoteMode string + remoteRate uint64 + selectedTransport string + eventHandler ClientEventHandler + primaryFailReason ClientEventReason + lastEvent ClientEvent // lastSelectedFallback describes the most recently completed path. It is // deliberately independent from primaryFailedAt: a TLS dial that completes // after a concurrent QUIC recovery is still the latest real selection, but @@ -487,9 +489,17 @@ type Client struct { } type quicDialAttempt struct { - done chan struct{} - cancel context.CancelFunc - err error + done chan struct{} + cancel context.CancelFunc + err error + healthGeneration uint64 +} + +// A caller owns only its own half-open probe. Health learned after it started +// (including through a UDP association) supersedes its eventual failure. +type nativePrimaryAttempt struct { + id uint64 + healthGeneration uint64 } type clientPathMetadata struct { @@ -650,8 +660,10 @@ func (c *Client) DialContext(ctx context.Context, network, address string) (net. if err := protocol.WriteRequest(io.Discard, request); err != nil { return nil, err } + var primaryAttempt nativePrimaryAttempt if c.fallback != nil { - tryPrimary, fallbackReason := c.shouldTryPrimary(time.Now()) + tryPrimary, fallbackReason, attempt := c.shouldTryPrimary(time.Now()) + primaryAttempt = attempt if !tryPrimary { fallbackConn, fallbackErr := c.fallback.DialContext(ctx, network, address) if fallbackErr == nil { @@ -676,7 +688,7 @@ func (c *Client) DialContext(ctx context.Context, network, address string) (net. fallbackReason := ClientReasonQUICDialFailed for attempt := 0; attempt < 2; attempt++ { if err := contextError(ctx); err != nil { - c.primaryProbeFinished() + c.primaryProbeFinished(primaryAttempt) return nil, err } if err := contextError(primaryCtx); err != nil { @@ -687,7 +699,7 @@ func (c *Client) DialContext(ctx context.Context, network, address string) (net. conn, err := c.connection(primaryCtx) if err != nil { if callerErr := contextError(ctx); callerErr != nil { - c.primaryProbeFinished() + c.primaryProbeFinished(primaryAttempt) return nil, callerErr } primaryErr = err @@ -701,7 +713,7 @@ func (c *Client) DialContext(ctx context.Context, network, address string) (net. primaryErr = fmt.Errorf("tunnel: open QUIC stream: %w", err) fallbackReason = ClientReasonQUICStreamOpenFailed if callerErr := contextError(ctx); callerErr != nil { - c.primaryProbeFinished() + c.primaryProbeFinished(primaryAttempt) return nil, callerErr } if contextError(primaryCtx) != nil { @@ -744,7 +756,7 @@ func (c *Client) DialContext(ctx context.Context, network, address string) (net. if callerErr := contextError(ctx); callerErr != nil { // A caller controls only its own stream. Canceling one request must // never tear down the multiplexed connection and every other flow. - c.primaryProbeFinished() + c.primaryProbeFinished(primaryAttempt) return nil, callerErr } if contextError(primaryCtx) != nil || conn.Context().Err() == nil { @@ -761,11 +773,11 @@ func (c *Client) DialContext(ctx context.Context, network, address string) (net. continue } if callerErr := contextError(ctx); callerErr != nil { - c.primaryProbeFinished() + c.primaryProbeFinished(primaryAttempt) return nil, callerErr } if c.fallback != nil { - c.primaryFailed(time.Now(), fallbackReason) + c.primaryFailed(primaryAttempt, time.Now(), fallbackReason) fallbackConn, fallbackErr := c.fallback.DialContext(ctx, network, address) if fallbackErr == nil { c.recordFallback(fallbackReason) @@ -773,7 +785,7 @@ func (c *Client) DialContext(ctx context.Context, network, address string) (net. } return nil, errors.Join(primaryErr, fmt.Errorf("tunnel: TLS fallback: %w", fallbackErr)) } - c.primaryProbeFinished() + c.primaryProbeFinished(primaryAttempt) if primaryErr == nil { primaryErr = errors.New("tunnel: QUIC connection unavailable") } @@ -783,20 +795,20 @@ func (c *Client) DialContext(ctx context.Context, network, address string) (net. // shouldTryPrimary returns false while the fallback circuit is open. Once the // cooldown expires exactly one caller becomes the QUIC probe; concurrent // callers keep using TLS instead of all paying the UDP timeout. -func (c *Client) shouldTryPrimary(now time.Time) (bool, ClientEventReason) { +func (c *Client) shouldTryPrimary(now time.Time) (bool, ClientEventReason, nativePrimaryAttempt) { c.mu.Lock() defer c.mu.Unlock() - if c.primaryFailedAt.IsZero() { - return true, "" - } - if now.Before(c.primaryFailedAt.Add(c.fallbackCooldown)) { - return false, c.activePrimaryFailureReason() + if !c.primaryFailedAt.IsZero() { + if now.Before(c.primaryFailedAt.Add(c.fallbackCooldown)) || c.primaryProbeID != 0 { + return false, c.activePrimaryFailureReason(), nativePrimaryAttempt{} + } } - if c.primaryProbing { - return false, c.activePrimaryFailureReason() + c.nextPrimaryID++ + attempt := nativePrimaryAttempt{id: c.nextPrimaryID, healthGeneration: c.primaryHealthGeneration} + if !c.primaryFailedAt.IsZero() { + c.primaryProbeID = attempt.id } - c.primaryProbing = true - return true, "" + return true, "", attempt } func (c *Client) primarySucceeded(metadata clientPathMetadata) { @@ -810,7 +822,8 @@ func (c *Client) primarySucceeded(metadata clientPathMetadata) { c.remoteRate = metadata.remoteRate c.selectedTransport = "quic" c.primaryFailedAt = time.Time{} - c.primaryProbing = false + c.primaryHealthGeneration++ + c.primaryProbeID = 0 c.primaryFailReason = "" c.lastSelectedFallback = false if recovered { @@ -834,22 +847,31 @@ func (c *Client) primarySucceeded(metadata clientPathMetadata) { func (c *Client) primaryHealthy() { c.mu.Lock() c.primaryFailedAt = time.Time{} - c.primaryProbing = false + c.primaryHealthGeneration++ + c.primaryProbeID = 0 c.primaryFailReason = "" c.mu.Unlock() } -func (c *Client) primaryFailed(now time.Time, reason ClientEventReason) { +func (c *Client) primaryFailed(attempt nativePrimaryAttempt, now time.Time, reason ClientEventReason) { c.mu.Lock() - c.primaryFailedAt = now - c.primaryProbing = false - c.primaryFailReason = reason + // This request still gets its own fallback, but an older stream failure is + // not grounds to cool down a path proved healthy after the request began. + if attempt.healthGeneration == c.primaryHealthGeneration { + c.primaryFailedAt = now + c.primaryFailReason = reason + } + if c.primaryProbeID == attempt.id { + c.primaryProbeID = 0 + } c.mu.Unlock() } -func (c *Client) primaryProbeFinished() { +func (c *Client) primaryProbeFinished(attempt nativePrimaryAttempt) { c.mu.Lock() - c.primaryProbing = false + if c.primaryProbeID == attempt.id { + c.primaryProbeID = 0 + } c.mu.Unlock() } @@ -1004,7 +1026,10 @@ func (c *Client) connection(ctx context.Context) (*quic.Conn, error) { // derived from the client rather than from whichever caller happened to // arrive first. Every caller still waits with its own context below. dialCtx, cancel := context.WithTimeout(c.ctx, c.dialTimeout) - attempt = &quicDialAttempt{done: make(chan struct{}), cancel: cancel} + attempt = &quicDialAttempt{ + done: make(chan struct{}), cancel: cancel, + healthGeneration: c.primaryHealthGeneration, + } c.dialing = attempt go c.runQUICDial(attempt, dialCtx) } @@ -1052,9 +1077,11 @@ func (c *Client) runQUICDial(attempt *quicDialAttempt, dialCtx context.Context) attempt.err = fmt.Errorf("tunnel: dial QUIC: %w", err) // Publish the open circuit before waking waiters. Otherwise callers // waiting on the same failed UDP handshake could each pay another timeout. - if c.fallback != nil && !c.closed && c.ctx.Err() == nil { + // The shared dial doesn't own a caller's probe ID: only that caller may + // release it. Later authenticated health also supersedes this failure. + if c.fallback != nil && !c.closed && c.ctx.Err() == nil && + attempt.healthGeneration == c.primaryHealthGeneration { c.primaryFailedAt = time.Now() - c.primaryProbing = false c.primaryFailReason = ClientReasonQUICDialFailed } } diff --git a/internal/tunnel/tunnel_test.go b/internal/tunnel/tunnel_test.go index 74dac1d..a7880fe 100644 --- a/internal/tunnel/tunnel_test.go +++ b/internal/tunnel/tunnel_test.go @@ -553,7 +553,7 @@ func TestFallbackCircuitBreakerAllowsOnlyOneConcurrentProbe(t *testing.T) { if err := <-probeResult; err != nil { t.Fatalf("half-open probe did not fall back to TLS: %v", err) } - if tryPrimary, _ := client.shouldTryPrimary(time.Now()); tryPrimary { + if tryPrimary, _, _ := client.shouldTryPrimary(time.Now()); tryPrimary { t.Fatal("failed half-open probe did not reopen the cooldown") } } @@ -566,13 +566,13 @@ func TestFallbackCircuitBreakerCooldownBoundary(t *testing.T) { fallbackCooldown: cooldown, primaryFailReason: ClientReasonQUICDialFailed, } - if try, reason := client.shouldTryPrimary(failedAt.Add(cooldown - time.Nanosecond)); try || reason != ClientReasonQUICDialFailed { + if try, reason, _ := client.shouldTryPrimary(failedAt.Add(cooldown - time.Nanosecond)); try || reason != ClientReasonQUICDialFailed { t.Fatalf("before expiry: try=%v reason=%q", try, reason) } - if try, reason := client.shouldTryPrimary(failedAt.Add(cooldown)); !try || reason != "" { + if try, reason, _ := client.shouldTryPrimary(failedAt.Add(cooldown)); !try || reason != "" { t.Fatalf("at expiry: try=%v reason=%q", try, reason) } - if try, reason := client.shouldTryPrimary(failedAt.Add(2 * cooldown)); try || reason != ClientReasonQUICDialFailed { + if try, reason, _ := client.shouldTryPrimary(failedAt.Add(2 * cooldown)); try || reason != ClientReasonQUICDialFailed { t.Fatalf("pending probe: try=%v reason=%q", try, reason) } }