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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 路径此刻可用,不代表目标应用协议正确、不可识别或链路更快。

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cmd/autocar/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down
189 changes: 189 additions & 0 deletions cmd/autocar/config_file.go
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading