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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,28 @@ fork 的 ChromeParrot 行为,但仍属于 web H3,不会切换成 `autocar/2`
`0` 表示探测成功,`1` 表示网络/认证/目标探测失败,`2` 表示参数或本地配置错误。
成功只证明该 TCP 路径此刻可用,不代表目标应用协议正确、不可识别或链路更快。

包含最新源码改动的构建还支持显式 UDP DNS 往返探测(已发布的 v1.0.1
不含此功能)。默认仍为 `--probe tcp-open`;UDP 探测不默认查询第三方解析器:

```bash
./autocar doctor --config /path/to/client.json --transport h3 \
--probe udp-dns --target 192.0.2.53:53 --dns-name probe.example.test --json
```

上例地址是文档占位符,请替换为你有权限使用、从中继可达的 DNS 服务的
**数字 IP:端口**,并明确选择查询名;IPv6 使用 `[地址]:端口`,不接受 zone。
发送一次经典 DNS A/IN 查询,不使用 EDNS,不重试,也不回退到 TCP。
支持 `quic`、`auto`、`h3`、`web-auto` 的 UDP 路径;`tls`/`h2` 在本地拒绝。
`--open-timeout` 覆盖建连、发送和等待有效回包,超时/取消会关闭并回收本次探测。
至多丢弃 8 个不匹配或不支持的回包,仅接受不超过 512 字节、未截断、问题与
来源匹配且返回 `NOERROR` 的完整响应。输出只增加 DNS 返回码及各段记录数,
不包含查询名、目标、解析地址、事务 ID 或原始报文;经过中继的 DNS 查询本身
仍会被所选解析器看到。`NOERROR` 无答案也算往返成功,不证明递归解析、
答案可信、DNSSEC、任意 DNS 报文兼容、吞吐或抗识别能力。失败码为
`udp_probe_failed`(退出 1)或 `udp_unsupported`(退出 2),其他参数和配置
错误沿用现有失败码。`probe`、`dns-name` 也可保存在 doctor 专用 JSON 配置中,
不要将这两个命令专属字段放入与 `client` 共用的配置。

生产环境若直接绑定 `443`,应给服务进程最小的 `CAP_NET_BIND_SERVICE` 能力,
或在主机/容器外层做端口映射;不要仅为绑定低端口而以 root 运行整个中继。

Expand Down
94 changes: 82 additions & 12 deletions cmd/autocar/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,17 @@ import (
"fmt"
"io"
"net"
"net/netip"
"os"
"strconv"
"strings"
"syscall"
"time"

"github.com/cppla/autocar/internal/protocol"
"github.com/cppla/autocar/internal/transport"
"github.com/cppla/autocar/internal/tunnel"
"golang.org/x/net/dns/dnsmessage"
)

const (
Expand All @@ -35,6 +38,7 @@ type doctorResult struct {
RelayNegotiatedBytesPerSecond uint64 `json:"relay_negotiated_bytes_per_second"`
ElapsedMilliseconds float64 `json:"elapsed_ms"`
LastTransportEvent *tunnel.ClientEvent `json:"last_transport_event,omitempty"`
DNS *doctorDNSResult `json:"dns,omitempty"`
}

type doctorFailure struct {
Expand Down Expand Up @@ -64,7 +68,9 @@ func runDoctorWith(parent context.Context, args []string, stdout, stderr io.Writ
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)")
target := fs.String("target", "", "target host:port (TCP), or numeric IP:port (UDP DNS), through the authenticated relay (required)")
probe := fs.String("probe", "tcp-open", "probe: tcp-open or udp-dns (explicit target and DNS name required)")
dnsName := fs.String("dns-name", "", "explicit ASCII DNS name for udp-dns; no default resolver or query")
jsonOutput := fs.Bool("json", false, "emit machine-readable JSON")
parseErr := parseFlagsWithConfig(fs, args)
effectiveJSON = effectiveJSON || *jsonOutput
Expand All @@ -81,6 +87,32 @@ func runDoctorWith(parent context.Context, args []string, stdout, stderr io.Writ
return doctorFail(stdout, effectiveJSON, doctorExitUsageFailure, "invalid_arguments", errors.New("doctor does not accept positional arguments"), 0)
}
normalizedTarget := strings.TrimSpace(*target)
normalizedProbe := strings.ToLower(strings.TrimSpace(*probe))
var udpTarget netip.AddrPort
var queryName dnsmessage.Name
switch normalizedProbe {
case "tcp-open":
if *dnsName != "" {
return doctorFail(stdout, effectiveJSON, doctorExitUsageFailure, "invalid_arguments", errors.New("DNS name requires udp-dns"), 0)
}
case "udp-dns":
var err error
udpTarget, err = doctorDNSTarget(normalizedTarget)
if err != nil {
return doctorFail(stdout, effectiveJSON, doctorExitUsageFailure, "invalid_target", err, 0)
}
normalizedTarget = udpTarget.String()
queryName, err = doctorDNSName(*dnsName)
if err != nil || tf.openTimeout <= 0 {
return doctorFail(stdout, effectiveJSON, doctorExitUsageFailure, "invalid_arguments", errors.New("invalid DNS name or probe timeout"), 0)
}
mode := strings.ToLower(strings.TrimSpace(tf.mode))
if mode == "tls" || mode == "h2" {
return doctorFail(stdout, effectiveJSON, doctorExitUsageFailure, "udp_unsupported", nil, 0)
}
default:
return doctorFail(stdout, effectiveJSON, doctorExitUsageFailure, "invalid_arguments", errors.New("unknown probe"), 0)
}
if err := validateDoctorTarget(normalizedTarget); err != nil {
return doctorFail(stdout, effectiveJSON, doctorExitUsageFailure, "invalid_target", err, 0)
}
Expand All @@ -93,19 +125,38 @@ func runDoctorWith(parent context.Context, args []string, stdout, stderr io.Writ
return doctorFail(stdout, effectiveJSON, doctorExitUsageFailure, "configuration_failed", fmt.Errorf("configure authenticated tunnel: %w", err), 0)
}
defer dialer.Close()
var packetDialer transport.PacketDialer
if normalizedProbe == "udp-dns" {
var ok bool
packetDialer, ok = dialer.(transport.PacketDialer)
if !ok {
return doctorFail(stdout, effectiveJSON, doctorExitUsageFailure, "udp_unsupported", nil, 0)
}
}

probeCtx, cancel := context.WithTimeout(parent, tf.openTimeout)
defer cancel()
started := time.Now()
conn, err := dialer.DialContext(probeCtx, "tcp", normalizedTarget)
elapsed := time.Since(started)
cancel()
if err != nil {
return doctorFail(stdout, effectiveJSON, doctorExitProbeFailure, "probe_failed", fmt.Errorf("authenticated tunnel TCP open failed: %w", err), elapsed)
}
if conn == nil {
return doctorFail(stdout, effectiveJSON, doctorExitProbeFailure, "probe_failed", errors.New("authenticated tunnel returned no TCP connection"), elapsed)
var dnsResult *doctorDNSResult
var elapsed time.Duration
if normalizedProbe == "udp-dns" {
dnsResult, err = probeDoctorDNS(probeCtx, packetDialer, udpTarget, queryName)
elapsed = time.Since(started)
if err != nil {
return doctorFail(stdout, effectiveJSON, doctorExitProbeFailure, "udp_probe_failed", err, elapsed)
}
} else {
conn, openErr := dialer.DialContext(probeCtx, "tcp", normalizedTarget)
elapsed = time.Since(started)
cancel()
if openErr != nil {
return doctorFail(stdout, effectiveJSON, doctorExitProbeFailure, "probe_failed", fmt.Errorf("authenticated tunnel TCP open failed: %w", openErr), elapsed)
}
if conn == nil {
return doctorFail(stdout, effectiveJSON, doctorExitProbeFailure, "probe_failed", errors.New("authenticated tunnel returned no TCP connection"), elapsed)
}
_ = conn.Close()
}
_ = conn.Close()

reporter, ok := dialer.(doctorSnapshotReporter)
if !ok {
Expand All @@ -126,6 +177,10 @@ func runDoctorWith(parent context.Context, args []string, stdout, stderr io.Writ
RelayNegotiatedBytesPerSecond: snapshot.RelayNegotiatedBytesPerSecond,
ElapsedMilliseconds: durationMilliseconds(elapsed),
LastTransportEvent: snapshot.LastEvent,
DNS: dnsResult,
}
if dnsResult != nil {
result.Probe = "authenticated_udp_dns"
}
// Failure handling remains fail-closed when an affirmative JSON flag was
// swallowed by another argument or lies beyond flag's stopping point. Once
Expand Down Expand Up @@ -218,7 +273,9 @@ func doctorFailureGuidance(code string, err error) (string, string) {
}
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."
return "target", "Set --target to a reachable host:port; udp-dns requires a numeric unicast IP. Use [IPv6-address]:port for IPv6 and a numeric port from 1 to 65535."
case "udp_unsupported":
return "udp_unsupported", "Use quic, auto, h3, or web-auto for udp-dns; TLS and H2 have no UDP probe and will not fall back to TCP."
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":
Expand Down Expand Up @@ -293,6 +350,10 @@ func doctorFailureMessage(code string) string {
return "local tunnel configuration failed"
case "probe_failed":
return "authenticated tunnel TCP open failed"
case "udp_probe_failed":
return "authenticated tunnel UDP DNS exchange failed"
case "udp_unsupported":
return "selected tunnel does not support the UDP DNS probe"
case "metadata_unavailable":
return "authenticated tunnel opened but transport metadata is unavailable"
case "internal_error":
Expand All @@ -312,8 +373,12 @@ func writeDoctorJSON(output io.Writer, value any) error {
}

func writeDoctorHuman(output io.Writer, result doctorResult) error {
heading := "Authenticated relay TCP probe: OK\n"
if result.DNS != nil {
heading = "Authenticated relay UDP DNS exchange: OK\n"
}
if _, err := fmt.Fprintf(output,
"Authenticated relay TCP probe: OK\n"+
heading+
" requested transport: %s\n"+
" selected transport: %s\n"+
" client pacing: %s (%d bytes/s)\n"+
Expand All @@ -329,6 +394,11 @@ func writeDoctorHuman(output io.Writer, result doctorResult) error {
); err != nil {
return errors.New("write doctor output failed")
}
if result.DNS != nil {
if _, err := fmt.Fprintf(output, " DNS response: %s (questions=%d answers=%d authorities=%d additionals=%d)\n", result.DNS.RCode, result.DNS.Questions, result.DNS.Answers, result.DNS.Authorities, result.DNS.Additionals); err != nil {
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 errors.New("write doctor output failed")
Expand Down
Loading
Loading