diff --git a/README.md b/README.md index 63e477e..49c5f80 100644 --- a/README.md +++ b/README.md @@ -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 运行整个中继。 diff --git a/cmd/autocar/doctor.go b/cmd/autocar/doctor.go index 288ec8d..b9f057e 100644 --- a/cmd/autocar/doctor.go +++ b/cmd/autocar/doctor.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "net" + "net/netip" "os" "strconv" "strings" @@ -16,7 +17,9 @@ import ( "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 ( @@ -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 { @@ -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 @@ -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) } @@ -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 { @@ -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 @@ -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": @@ -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": @@ -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"+ @@ -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") diff --git a/cmd/autocar/doctor_dns.go b/cmd/autocar/doctor_dns.go new file mode 100644 index 0000000..e1aee4f --- /dev/null +++ b/cmd/autocar/doctor_dns.go @@ -0,0 +1,298 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/binary" + "errors" + "net/netip" + "strings" + "sync" + "time" + + "github.com/cppla/autocar/internal/transport" + "golang.org/x/net/dns/dnsmessage" +) + +type doctorDNSResult struct { + Questions int `json:"questions"` + Answers int `json:"answers"` + Authorities int `json:"authorities"` + Additionals int `json:"additionals"` + RCode string `json:"rcode"` +} + +func doctorDNSTarget(target string) (netip.AddrPort, error) { + endpoint, err := netip.ParseAddrPort(target) + if err != nil || endpoint.Port() == 0 || endpoint.Addr().Zone() != "" { + return netip.AddrPort{}, errors.New("UDP DNS requires a numeric IP and nonzero port") + } + addr := endpoint.Addr().Unmap() + if addr.IsUnspecified() || addr.IsMulticast() || addr == netip.AddrFrom4([4]byte{255, 255, 255, 255}) { + return netip.AddrPort{}, errors.New("UDP DNS requires an unzoned unicast target") + } + return netip.AddrPortFrom(addr, endpoint.Port()), nil +} + +func doctorDNSName(value string) (dnsmessage.Name, error) { + bare := strings.TrimSuffix(value, ".") + if bare == "" || len(bare) > 253 { + return dnsmessage.Name{}, errors.New("an explicit ASCII DNS name is required") + } + for _, label := range strings.Split(bare, ".") { + if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { + return dnsmessage.Name{}, errors.New("invalid DNS label") + } + for i := range label { + c := label[i] + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-') { + return dnsmessage.Name{}, errors.New("DNS labels must be ASCII letters, digits or hyphens") + } + } + } + return dnsmessage.NewName(strings.ToLower(bare) + ".") +} + +// The opening context detaches from successful PacketConn setup. Independently +// close that owned packet on the full exchange budget, then join the worker; +// enqueueing Send or opening a logical H3 association is not a DNS health proof. +func probeDoctorDNS(ctx context.Context, dialer transport.PacketDialer, target netip.AddrPort, name dnsmessage.Name) (*doctorDNSResult, error) { + if err := doctorProbeContextError(ctx); err != nil { + return nil, err + } + var entropy [2]byte + if _, err := rand.Read(entropy[:]); err != nil { + return nil, errors.New("DNS transaction entropy unavailable") + } + id := binary.BigEndian.Uint16(entropy[:]) + query := dnsmessage.Message{Header: dnsmessage.Header{ID: id, RecursionDesired: true}, Questions: []dnsmessage.Question{{Name: name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET}}} + wire, err := query.Pack() + if err != nil { + return nil, errors.New("DNS query encoding failed") + } + packet, err := dialer.DialPacket(ctx) + if err != nil { + if packet != nil { + _ = packet.Close() + } + if cause := doctorProbeContextError(ctx); cause != nil { + return nil, cause + } + return nil, err + } + if packet == nil { + return nil, errors.New("tunnel returned no packet connection") + } + closePacket := sync.OnceFunc(func() { _ = packet.Close() }) + watcherJoined := make(chan struct{}) + stopWatcher := context.AfterFunc(ctx, func() { defer close(watcherJoined); closePacket() }) + defer func() { + closePacket() + if !stopWatcher() { + <-watcherJoined + } + }() + if err := doctorProbeContextError(ctx); err != nil { + return nil, err + } + type exchange struct { + result *doctorDNSResult + err error + } + outcome, joined := make(chan exchange, 1), make(chan struct{}) + go func() { + defer close(joined) + if err := packet.Send(wire, target.String()); err != nil { + outcome <- exchange{err: err} + return + } + // A single query, bounded discards, no retries and no TCP fallback. + for discarded := 0; discarded < 8; discarded++ { + payload, source, err := packet.Receive() + if err != nil { + outcome <- exchange{err: err} + return + } + if result, err := doctorDNSResponse(payload, source, target, id, name); err == nil { + outcome <- exchange{result: result} + return + } + } + outcome <- exchange{err: errors.New("no matching classic DNS response within discard limit")} + }() + var received exchange + select { + case received = <-outcome: + case <-ctx.Done(): + received.err = context.Cause(ctx) + } + closePacket() + <-joined + if err := doctorProbeContextError(ctx); err != nil { + return nil, err + } + return received.result, received.err +} + +func doctorProbeContextError(ctx context.Context) error { + if err := context.Cause(ctx); err != nil { + return err + } + if deadline, ok := ctx.Deadline(); ok && !time.Now().Before(deadline) { + return context.DeadlineExceeded + } + return nil +} + +var errDoctorDNSResponse = errors.New("unmatched or unsupported classic DNS response") + +func doctorDNSResponse(payload []byte, source string, target netip.AddrPort, id uint16, name dnsmessage.Name) (*doctorDNSResult, error) { + endpoint, err := doctorDNSTarget(source) + if err != nil || endpoint != target || !doctorDNSFraming(payload) { + return nil, errDoctorDNSResponse + } + var message dnsmessage.Message + if err := message.Unpack(payload); err != nil || message.ID != id || !message.Response || message.OpCode != 0 || message.Truncated || message.RCode != dnsmessage.RCodeSuccess || len(message.Questions) != 1 { + return nil, errDoctorDNSResponse + } + question := message.Questions[0] + if !doctorDNSASCIIEqual(question.Name.String(), name.String()) || question.Type != dnsmessage.TypeA || question.Class != dnsmessage.ClassINET { + return nil, errDoctorDNSResponse + } + // NOERROR with no answers is a valid exchange, not a resolution claim. + return &doctorDNSResult{Questions: 1, Answers: len(message.Answers), Authorities: len(message.Authorities), Additionals: len(message.Additionals), RCode: "NOERROR"}, nil +} + +func doctorDNSASCIIEqual(a, b string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + x, y := a[i], b[i] + if x >= 'A' && x <= 'Z' { + x += 'a' - 'A' + } + if y >= 'A' && y <= 'Z' { + y += 'a' - 'A' + } + if x > 127 || y > 127 || x != y { + return false + } + } + return true +} + +// Unpack alone accepts trailing bytes and some typed bodies outside RDLENGTH. +// First bound every locally encoded field, then let Unpack expand names against +// the original packet. This is a small classic probe, not a general DNS server. +func doctorDNSFraming(wire []byte) bool { + if len(wire) < 12 || len(wire) > 512 || binary.BigEndian.Uint16(wire[4:6]) != 1 { + return false + } + off, ok := doctorDNSWireName(wire, 12, len(wire)) + if !ok || off+4 > len(wire) { + return false + } + off += 4 + for section := 6; section < 12; section += 2 { + count := int(binary.BigEndian.Uint16(wire[section : section+2])) + for i := 0; i < count; i++ { + off, ok = doctorDNSWireName(wire, off, len(wire)) + if !ok || off+10 > len(wire) { + return false + } + typ := dnsmessage.Type(binary.BigEndian.Uint16(wire[off : off+2])) + length := int(binary.BigEndian.Uint16(wire[off+8 : off+10])) + off += 10 + end := off + length + if end > len(wire) || !doctorDNSWireBody(wire, typ, off, end) { + return false + } + off = end + } + } + return off == len(wire) +} + +func doctorDNSWireName(wire []byte, off, end int) (int, bool) { + for off < end { + n := int(wire[off]) + off++ + switch n & 0xc0 { + case 0: + if n == 0 { + return off, true + } + if off+n > end { + return off, false + } + off += n + case 0xc0: + if off >= end { + return off, false + } + pointer := (n&0x3f)<<8 | int(wire[off]) + return off + 1, pointer < len(wire) + default: + return off, false + } + } + return off, false +} + +func doctorDNSWireBody(wire []byte, typ dnsmessage.Type, off, end int) bool { + name := func() bool { var ok bool; off, ok = doctorDNSWireName(wire, off, end); return ok } + switch typ { + case dnsmessage.TypeOPT: + // No EDNS was offered; do not mistake an extended error for NOERROR. + return false + case dnsmessage.TypeA: + return end-off == 4 + case dnsmessage.TypeAAAA: + return end-off == 16 + case dnsmessage.TypeNS, dnsmessage.TypeCNAME, dnsmessage.TypePTR: + return name() && off == end + case dnsmessage.TypeMX: + off += 2 + return name() && off == end + case dnsmessage.TypeSOA: + return name() && name() && off+20 == end + case dnsmessage.TypeSRV: + off += 6 + return name() && off == end + case dnsmessage.TypeTXT: + for off < end { + n := int(wire[off]) + off++ + off += n + if off > end { + return false + } + } + return off == end + case dnsmessage.TypeSVCB, dnsmessage.TypeHTTPS: + off += 2 + if !name() { + return false + } + previous := -1 + for off < end { + if off+4 > end { + return false + } + key := int(binary.BigEndian.Uint16(wire[off : off+2])) + length := int(binary.BigEndian.Uint16(wire[off+2 : off+4])) + if key <= previous { + return false + } + previous = key + off += 4 + length + if off > end { + return false + } + } + return off == end + default: + return true // Unknown records are opaque and already length-bounded. + } +} diff --git a/cmd/autocar/doctor_dns_test.go b/cmd/autocar/doctor_dns_test.go new file mode 100644 index 0000000..0eaf324 --- /dev/null +++ b/cmd/autocar/doctor_dns_test.go @@ -0,0 +1,401 @@ +package main + +import ( + "encoding/binary" + "net/netip" + "strings" + "testing" + + "golang.org/x/net/dns/dnsmessage" +) + +func TestDoctorDNSName(t *testing.T) { + longest := strings.Repeat("a", 63) + "." + strings.Repeat("b", 63) + "." + strings.Repeat("c", 63) + "." + strings.Repeat("d", 61) + valid := []struct{ label, input, canonical string }{ + {"relative", "Example.Test", "example.test."}, + {"absolute", "Example.Test.", "example.test."}, + {"single_label", "Example", "example."}, + {"hyphen_and_punycode", "XN--BCHER-KVA.a-b.test", "xn--bcher-kva.a-b.test."}, + {"maximum_wire_name", longest, longest + "."}, + } + for _, test := range valid { + t.Run("valid_"+test.label, func(t *testing.T) { + name, err := doctorDNSName(test.input) + if err != nil || name.String() != test.canonical { + t.Error("valid name did not produce the canonical absolute ASCII name") + } + }) + } + invalid := []struct{ label, input string }{ + {"empty", ""}, {"root", "."}, {"empty_label", "example..test"}, + {"two_terminal_dots", "example.test.."}, {"leading_dot", ".example.test"}, + {"leading_hyphen", "-example.test"}, {"trailing_hyphen", "example-.test"}, + {"underscore", "_example.test"}, {"leading_space", " example.test"}, + {"trailing_space", "example.test "}, {"tab", "example\ttest"}, + {"newline", "example.test\n"}, {"unicode", "exámple.test"}, + {"unicode_case_alias", "Key.test"}, {"URL", "https://example.test"}, + {"port", "example.test:53"}, {"wildcard", "*.example.test"}, + {"long_label", strings.Repeat("a", 64) + ".test"}, + {"wire_name_too_long", longest + "d"}, + } + for _, test := range invalid { + t.Run("invalid_"+test.label, func(t *testing.T) { + if _, err := doctorDNSName(test.input); err == nil { + t.Error("invalid name accepted") + } + }) + } +} + +func TestDoctorDNSTarget(t *testing.T) { + valid := []struct{ label, input, canonical string }{ + {"IPv4", "127.0.0.1:53", "127.0.0.1:53"}, + {"IPv6", "[::1]:53", "[::1]:53"}, + {"IPv6_expanded", "[0:0:0:0:0:0:0:1]:53", "[::1]:53"}, + {"mapped_IPv4", "[::ffff:127.0.0.1]:53", "127.0.0.1:53"}, + {"minimum_port", "127.0.0.1:1", "127.0.0.1:1"}, + {"maximum_port", "127.0.0.1:65535", "127.0.0.1:65535"}, + } + for _, test := range valid { + t.Run("valid_"+test.label, func(t *testing.T) { + got, err := doctorDNSTarget(test.input) + if err != nil || got != netip.MustParseAddrPort(test.canonical) { + t.Error("valid numeric target did not produce the canonical endpoint") + } + }) + } + invalid := []struct{ label, input string }{ + {"empty", ""}, {"hostname", "resolver.test:53"}, {"missing_port", "127.0.0.1"}, + {"named_port", "127.0.0.1:domain"}, {"zero_port", "127.0.0.1:0"}, + {"large_port", "127.0.0.1:65536"}, {"negative_port", "127.0.0.1:-1"}, + {"unbracketed_IPv6", "::1:53"}, {"zone", "[fe80::1%lo0]:53"}, + {"mapped_zone", "[::ffff:127.0.0.1%lo0]:53"}, + {"unspecified_IPv4", "0.0.0.0:53"}, {"unspecified_IPv6", "[::]:53"}, + {"mapped_unspecified", "[::ffff:0.0.0.0]:53"}, + {"multicast_IPv4", "224.0.0.1:53"}, {"multicast_IPv6", "[ff02::1]:53"}, + {"mapped_multicast", "[::ffff:224.0.0.1]:53"}, + {"leading_space", " 127.0.0.1:53"}, {"trailing_space", "127.0.0.1:53 "}, + } + for _, test := range invalid { + t.Run("invalid_"+test.label, func(t *testing.T) { + if _, err := doctorDNSTarget(test.input); err == nil { + t.Error("invalid numeric target accepted") + } + }) + } +} + +func TestDoctorDNSResponse(t *testing.T) { + name := doctorDNSUnitName(t, "example.test.") + target := netip.MustParseAddrPort("127.0.0.1:53") + const id = uint16(0x4712) + base := func() dnsmessage.Message { + return dnsmessage.Message{ + Header: dnsmessage.Header{ID: id, Response: true, RecursionDesired: true, RecursionAvailable: true}, + Questions: []dnsmessage.Question{{Name: name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET}}, + } + } + check := func(t *testing.T, payload []byte, source string, endpoint netip.AddrPort, want [4]int, valid bool) { + t.Helper() + before := string(payload) + result, err := doctorDNSResponse(payload, source, endpoint, id, name) + if string(payload) != before { + t.Error("response validation mutated caller-owned packet bytes") + } + if !valid { + if err == nil || result != nil { + t.Error("invalid response returned a result instead of nil and an error") + } + return + } + if err != nil || result == nil { + t.Error("valid response rejected") + return + } + if [4]int{result.Questions, result.Answers, result.Authorities, result.Additionals} != want || result.RCode != "NOERROR" { + t.Error("valid response metadata changed") + } + } + resource := func(body dnsmessage.ResourceBody) dnsmessage.Resource { + return dnsmessage.Resource{Header: dnsmessage.ResourceHeader{Name: name, Class: dnsmessage.ClassINET, TTL: 60}, Body: body} + } + known := []struct { + label string + typ dnsmessage.Type + body dnsmessage.ResourceBody + raw []byte + }{ + {"A", dnsmessage.TypeA, &dnsmessage.AResource{A: [4]byte{192, 0, 2, 1}}, []byte{192, 0, 2, 1}}, + {"AAAA", dnsmessage.TypeAAAA, &dnsmessage.AAAAResource{AAAA: [16]byte{0x20, 0x01, 0x0d, 0xb8, 15: 1}}, make([]byte, 16)}, + {"NS", dnsmessage.TypeNS, &dnsmessage.NSResource{NS: name}, []byte{1, 'x', 0}}, + {"CNAME", dnsmessage.TypeCNAME, &dnsmessage.CNAMEResource{CNAME: doctorDNSUnitName(t, "alias.example.test.")}, []byte{1, 'x', 0}}, + {"PTR", dnsmessage.TypePTR, &dnsmessage.PTRResource{PTR: name}, []byte{1, 'x', 0}}, + {"MX", dnsmessage.TypeMX, &dnsmessage.MXResource{Pref: 10, MX: name}, []byte{0, 10, 1, 'x', 0}}, + {"SOA", dnsmessage.TypeSOA, &dnsmessage.SOAResource{NS: name, MBox: name, Serial: 1, Refresh: 2, Retry: 3, Expire: 4, MinTTL: 5}, append([]byte{0xc0, 12, 0xc0, 12}, make([]byte, 20)...)}, + {"TXT", dnsmessage.TypeTXT, &dnsmessage.TXTResource{TXT: []string{"", "opaque"}}, []byte{0, 1, 'x'}}, + {"SRV", dnsmessage.TypeSRV, &dnsmessage.SRVResource{Priority: 1, Weight: 2, Port: 443, Target: name}, []byte{0, 1, 0, 2, 1, 187, 1, 'x', 0}}, + {"SVCB", dnsmessage.TypeSVCB, &dnsmessage.SVCBResource{Priority: 1, Target: doctorDNSUnitName(t, "."), Params: []dnsmessage.SVCParam{{Key: dnsmessage.SVCParamPort, Value: []byte{1, 187}}}}, []byte{0, 1, 0, 0, 3, 0, 2, 1, 187}}, + {"HTTPS", dnsmessage.TypeHTTPS, &dnsmessage.HTTPSResource{SVCBResource: dnsmessage.SVCBResource{Priority: 1, Target: doctorDNSUnitName(t, ".")}}, []byte{0, 1, 0}}, + } + t.Run("valid_NOERROR_NODATA", func(t *testing.T) { + check(t, doctorDNSUnitPack(t, base()), target.String(), target, [4]int{1, 0, 0, 0}, true) + }) + for _, test := range known { + t.Run("valid_"+test.label, func(t *testing.T) { + message := base() + message.Answers = []dnsmessage.Resource{resource(test.body)} + check(t, doctorDNSUnitPack(t, message), target.String(), target, [4]int{1, 1, 0, 0}, true) + }) + // A second, well-framed opaque RR supplies bytes after the first body. + // A typed decoder must not borrow them from an undersized RDLENGTH. + t.Run("invalid_"+test.label+"_short_body", func(t *testing.T) { + body := append([]byte(nil), test.raw[:len(test.raw)-1]...) + check(t, doctorDNSUnitRaw(id, test.typ, body, true), target.String(), target, [4]int{}, false) + }) + if test.typ != dnsmessage.TypeTXT { + t.Run("invalid_"+test.label+"_extra_body_octet", func(t *testing.T) { + body := append(append([]byte(nil), test.raw...), 0) + check(t, doctorDNSUnitRaw(id, test.typ, body, true), target.String(), target, [4]int{}, false) + }) + } + } + t.Run("valid_all_sections_and_unknown", func(t *testing.T) { + message := base() + message.Answers = []dnsmessage.Resource{resource(known[0].body)} + message.Authorities = []dnsmessage.Resource{resource(known[6].body)} + message.Additionals = []dnsmessage.Resource{resource(&dnsmessage.UnknownResource{Type: 65280, Data: []byte{0xc0, 0xff, 0x40, 0x80}})} + check(t, doctorDNSUnitPack(t, message), target.String(), target, [4]int{1, 1, 1, 1}, true) + }) + t.Run("valid_ASCII_question_case", func(t *testing.T) { + message := base() + message.Questions[0].Name = doctorDNSUnitName(t, "EXAMPLE.Test.") + check(t, doctorDNSUnitPack(t, message), target.String(), target, [4]int{1, 0, 0, 0}, true) + }) + t.Run("valid_mapped_source", func(t *testing.T) { + check(t, doctorDNSUnitPack(t, base()), "[::ffff:127.0.0.1]:53", target, [4]int{1, 0, 0, 0}, true) + }) + t.Run("valid_IPv6_source_spelling", func(t *testing.T) { + check(t, doctorDNSUnitPack(t, base()), "[0:0:0:0:0:0:0:1]:53", netip.MustParseAddrPort("[::1]:53"), [4]int{1, 0, 0, 0}, true) + }) + t.Run("valid_Builder_compressed_names", func(t *testing.T) { + builder := dnsmessage.NewBuilder(nil, base().Header) + builder.EnableCompression() + if err := builder.StartQuestions(); err != nil { + t.Fatal("could not build question section") + } + if err := builder.Question(base().Questions[0]); err != nil { + t.Fatal("could not build question") + } + if err := builder.StartAnswers(); err != nil { + t.Fatal("could not build answer section") + } + header := dnsmessage.ResourceHeader{Name: name, Class: dnsmessage.ClassINET, TTL: 60} + if err := builder.CNAMEResource(header, dnsmessage.CNAMEResource{CNAME: doctorDNSUnitName(t, "alias.example.test.")}); err != nil { + t.Fatal("could not build compressed CNAME") + } + payload, err := builder.Finish() + if err != nil { + t.Fatal("could not finish compressed message") + } + // Independently witness a compressed owner and label-plus-pointer RDATA. + answer := 12 + len(doctorDNSUnitQuestionName()) + 4 + body := answer + 2 + 10 + if len(payload) <= body+6 || payload[answer] != 0xc0 || payload[body] != 5 || payload[body+6] != 0xc0 { + t.Fatal("positive fixture did not actually contain name compression") + } + check(t, payload, target.String(), target, [4]int{1, 1, 0, 0}, true) + }) + t.Run("valid_raw_compressed_CNAME", func(t *testing.T) { + check(t, doctorDNSUnitRaw(id, dnsmessage.TypeCNAME, []byte{5, 'a', 'l', 'i', 'a', 's', 0xc0, 12}, false), target.String(), target, [4]int{1, 1, 0, 0}, true) + }) + t.Run("valid_maximum_classic_message", func(t *testing.T) { + check(t, doctorDNSUnitSized(t, base(), name, 512), target.String(), target, [4]int{1, 1, 0, 0}, true) + }) + t.Run("invalid_oversized_framed_message", func(t *testing.T) { + check(t, doctorDNSUnitSized(t, base(), name, 513), target.String(), target, [4]int{}, false) + }) + mutations := []struct { + label string + apply func(*dnsmessage.Message) + }{ + {"ID", func(m *dnsmessage.Message) { m.ID++ }}, + {"query_not_response", func(m *dnsmessage.Message) { m.Response = false }}, + {"opcode", func(m *dnsmessage.Message) { m.OpCode = 1 }}, + {"truncated", func(m *dnsmessage.Message) { m.Truncated = true }}, + {"no_question", func(m *dnsmessage.Message) { m.Questions = nil }}, + {"two_questions", func(m *dnsmessage.Message) { m.Questions = append(m.Questions, m.Questions[0]) }}, + {"question_name", func(m *dnsmessage.Message) { m.Questions[0].Name = doctorDNSUnitName(t, "other.test.") }}, + {"Unicode_question_case_alias", func(m *dnsmessage.Message) { m.Questions[0].Name = doctorDNSUnitName(t, "example.teſt.") }}, + {"question_type", func(m *dnsmessage.Message) { m.Questions[0].Type = dnsmessage.TypeAAAA }}, + {"question_class", func(m *dnsmessage.Message) { m.Questions[0].Class = dnsmessage.ClassCHAOS }}, + } + for _, test := range mutations { + t.Run("invalid_"+test.label, func(t *testing.T) { + message := base() + test.apply(&message) + check(t, doctorDNSUnitPack(t, message), target.String(), target, [4]int{}, false) + }) + } + for _, source := range []struct{ label, value string }{ + {"IP", "127.0.0.2:53"}, {"port", "127.0.0.1:54"}, {"hostname", "resolver.test:53"}, + {"empty", ""}, {"malformed", "not-an-endpoint"}, {"zone", "[::1%lo0]:53"}, + {"mapped_zone", "[::ffff:127.0.0.1%lo0]:53"}, + } { + t.Run("invalid_source_"+source.label, func(t *testing.T) { + check(t, doctorDNSUnitPack(t, base()), source.value, target, [4]int{}, false) + }) + } + for code := dnsmessage.RCode(1); code <= 15; code++ { + t.Run("invalid_rcode_"+doctorDNSUnitDecimal(uint16(code)), func(t *testing.T) { + message := base() + message.RCode = code + check(t, doctorDNSUnitPack(t, message), target.String(), target, [4]int{}, false) + }) + } + for _, section := range []string{"answer", "authority", "additional"} { + for _, extended := range []bool{false, true} { + label := section + if extended { + label += "_extended_rcode" + } + t.Run("invalid_OPT_"+label, func(t *testing.T) { + message := base() + ttl := uint32(0) + if extended { + ttl = 1 << 24 + } + opts := []dnsmessage.Resource{{Header: dnsmessage.ResourceHeader{Name: doctorDNSUnitName(t, "."), Class: 512, TTL: ttl}, Body: &dnsmessage.OPTResource{}}} + switch section { + case "answer": + message.Answers = opts + case "authority": + message.Authorities = opts + case "additional": + message.Additionals = opts + } + check(t, doctorDNSUnitPack(t, message), target.String(), target, [4]int{}, false) + }) + } + } + malformed := []struct { + label string + body []byte + typ dnsmessage.Type + }{ + {"empty_name_RDATA", nil, dnsmessage.TypeCNAME}, + {"truncated_pointer", []byte{0xc0}, dnsmessage.TypeCNAME}, + {"pointer_outside_packet", []byte{0xff, 0xff}, dnsmessage.TypeCNAME}, + {"reserved_label_01", []byte{0x40, 0}, dnsmessage.TypeCNAME}, + {"reserved_label_10", []byte{0x80, 0}, dnsmessage.TypeCNAME}, + {"unterminated_label", []byte{1, 'x'}, dnsmessage.TypeCNAME}, + {"TXT_string_exceeds_RDATA", []byte{3, 'x'}, dnsmessage.TypeTXT}, + {"SVCB_incomplete_tuple", []byte{0, 1, 0, 0}, dnsmessage.TypeSVCB}, + {"SVCB_tuple_value_exceeds_RDATA", []byte{0, 1, 0, 0, 3, 0, 2, 1}, dnsmessage.TypeSVCB}, + {"SVCB_duplicate_keys", []byte{0, 1, 0, 0, 3, 0, 0, 0, 3, 0, 0}, dnsmessage.TypeSVCB}, + {"HTTPS_decreasing_keys", []byte{0, 1, 0, 0, 4, 0, 0, 0, 3, 0, 0}, dnsmessage.TypeHTTPS}, + } + for _, test := range malformed { + t.Run("invalid_"+test.label, func(t *testing.T) { + check(t, doctorDNSUnitRaw(id, test.typ, test.body, true), target.String(), target, [4]int{}, false) + }) + } + t.Run("invalid_compression_self_loop", func(t *testing.T) { + payload := doctorDNSUnitRaw(id, dnsmessage.TypeCNAME, []byte{0, 0}, false) + body := 12 + len(doctorDNSUnitQuestionName()) + 4 + 2 + 10 + payload[body], payload[body+1] = byte(0xc0|(body>>8)), byte(body) + check(t, payload, target.String(), target, [4]int{}, false) + }) + t.Run("invalid_declared_RDLENGTH_beyond_packet", func(t *testing.T) { + payload := doctorDNSUnitRaw(id, dnsmessage.TypeA, []byte{1, 2, 3, 4}, false) + length := 12 + len(doctorDNSUnitQuestionName()) + 4 + 2 + 8 + binary.BigEndian.PutUint16(payload[length:length+2], 65535) + check(t, payload, target.String(), target, [4]int{}, false) + }) + t.Run("invalid_trailing_octets", func(t *testing.T) { + payload := append(doctorDNSUnitPack(t, base()), 0, 0, 0) + check(t, payload, target.String(), target, [4]int{}, false) + }) + t.Run("invalid_truncated_message_prefixes", func(t *testing.T) { + message := base() + message.Answers = []dnsmessage.Resource{resource(known[0].body)} + payload := doctorDNSUnitPack(t, message) + for end := 0; end < len(payload); end++ { + check(t, append([]byte(nil), payload[:end]...), target.String(), target, [4]int{}, false) + } + }) + t.Run("invalid_excess_record_count", func(t *testing.T) { + payload := doctorDNSUnitPack(t, base()) + binary.BigEndian.PutUint16(payload[6:8], 65535) + check(t, payload, target.String(), target, [4]int{}, false) + }) +} + +func doctorDNSUnitName(t *testing.T, text string) dnsmessage.Name { + t.Helper() + name, err := dnsmessage.NewName(text) + if err != nil { + t.Fatal("could not prepare a fixture name") + } + return name +} + +func doctorDNSUnitPack(t *testing.T, message dnsmessage.Message) []byte { + t.Helper() + payload, err := message.Pack() + if err != nil { + t.Fatal("could not pack a fixture message") + } + return payload +} + +func doctorDNSUnitSized(t *testing.T, message dnsmessage.Message, name dnsmessage.Name, size int) []byte { + t.Helper() + body := &dnsmessage.UnknownResource{Type: 65280} + message.Answers = []dnsmessage.Resource{{Header: dnsmessage.ResourceHeader{Name: name, Class: dnsmessage.ClassINET}, Body: body}} + initial := doctorDNSUnitPack(t, message) + if len(initial) > size { + t.Fatal("fixture message overhead exceeded requested size") + } + body.Data = make([]byte, size-len(initial)) + payload := doctorDNSUnitPack(t, message) + if len(payload) != size { + t.Fatal("fixture message did not have the intended byte length") + } + return payload +} + +func doctorDNSUnitQuestionName() []byte { + return []byte{7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 4, 't', 'e', 's', 't', 0} +} + +// doctorDNSUnitRaw is independent of Message.Pack and the production scanner. +// The first RDATA has exactly its declared byte length, even when its type +// requires more or fewer bytes. An optional complete opaque record immediately +// follows it, so a decoder accidentally reading past RDLENGTH can find bytes. +func doctorDNSUnitRaw(id uint16, typ dnsmessage.Type, body []byte, following bool) []byte { + header := make([]byte, 12) + binary.BigEndian.PutUint16(header[:2], id) + binary.BigEndian.PutUint16(header[2:4], 0x8180) + binary.BigEndian.PutUint16(header[4:6], 1) + answers := uint16(1) + if following { + answers++ + } + binary.BigEndian.PutUint16(header[6:8], answers) + payload := append(header, doctorDNSUnitQuestionName()...) + payload = append(payload, 0, 1, 0, 1) + rr := []byte{0xc0, 12, byte(typ >> 8), byte(typ), 0, 1, 0, 0, 0, 60, byte(len(body) >> 8), byte(len(body))} + payload = append(payload, rr...) + payload = append(payload, body...) + if following { + payload = append(payload, 0xc0, 12, 0xff, 0, 0, 1, 0, 0, 0, 60, 0, 0) + } + return payload +} + +func doctorDNSUnitDecimal(value uint16) string { + if value < 10 { + return string(rune('0' + value)) + } + return "1" + string(rune('0'+value-10)) +} diff --git a/cmd/autocar/doctor_udp_human_test.go b/cmd/autocar/doctor_udp_human_test.go new file mode 100644 index 0000000..59c6d93 --- /dev/null +++ b/cmd/autocar/doctor_udp_human_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "bytes" + "context" + "strings" + "testing" +) + +func TestDoctorUDPHumanOutput(t *testing.T) { + for _, style := range []string{"valid", "private receive error"} { + t.Run(style, func(t *testing.T) { + packet := newDoctorUDPPacket(style) + dialer := &doctorUDPFakeDialer{packet: packet} + // No affirmative JSON flag: a later false flag alone would not + // exercise human failure output's privacy contract. + args := doctorUDPArgs() + args = args[:len(args)-1] + var stdout, stderr bytes.Buffer + err := runDoctorWith(context.Background(), args, &stdout, &stderr, dialer.build) + if style == "valid" { + if err != nil || !strings.Contains(stdout.String(), "Authenticated relay UDP DNS exchange: OK") || !strings.Contains(stdout.String(), "NOERROR (questions=1 answers=0 authorities=0 additionals=0)") { + t.Errorf("human UDP success: %v; output=%s", err, stdout.String()) + } + } else if commandExitCode(err) != doctorExitProbeFailure || stdout.Len() != 0 || !strings.Contains(err.Error(), "authenticated tunnel UDP DNS exchange failed") { + t.Errorf("human UDP failure: %v; output=%s", err, stdout.String()) + } + if stderr.Len() != 0 || !packet.isClosed() || !dialer.closed.Load() || packet.active.Load() != 0 { + t.Error("human probe did not reclaim its owned packet and dialer cleanly") + } + doctorUDPRedacted(t, stdout.String()+stderr.String(), err, "private-receive-error", "Example.test", "example.test", "127.0.0.1:5300") + }) + } +} diff --git a/cmd/autocar/doctor_udp_test.go b/cmd/autocar/doctor_udp_test.go new file mode 100644 index 0000000..4176c41 --- /dev/null +++ b/cmd/autocar/doctor_udp_test.go @@ -0,0 +1,620 @@ +package main + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/netip" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/cppla/autocar/internal/security" + "github.com/cppla/autocar/internal/transport" + "github.com/cppla/autocar/internal/tunnel" + "golang.org/x/net/dns/dnsmessage" +) + +// These public-command tests intentionally compile before udp-dns exists. +// Missing flags on that source are a missing opt-in feature, not evidence of +// an already-existing UDP defect. Parser internals are not used by this file. +func doctorUDPArgs() []string { + return []string{"--probe", "udp-dns", "--transport", "quic", "--target", "127.0.0.1:5300", "--dns-name", "Example.test", "--open-timeout", "1s", "--json"} +} + +func doctorUDPJSON(t *testing.T, data []byte) map[string]any { + t.Helper() + var result map[string]any + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("decode command JSON: %v; output=%q", err, data) + } + return result +} + +func doctorUDPRedacted(t *testing.T, output string, err error, secrets ...string) { + t.Helper() + if err != nil { + output += err.Error() + } + for _, secret := range secrets { + if secret != "" && strings.Contains(output, secret) { + t.Errorf("command disclosed private probe input %q", secret) + } + } +} + +func doctorUDPAssertResult(t *testing.T, data []byte, selected string, answers int) { + t.Helper() + result := doctorUDPJSON(t, data) + if result["status"] != "ok" || result["probe"] != "authenticated_udp_dns" || result["selected_transport"] != selected { + t.Fatalf("UDP command metadata=%v", result) + } + dns, ok := result["dns"].(map[string]any) + if !ok { + t.Fatalf("missing redacted DNS result: %v", result) + } + want := map[string]any{"questions": float64(1), "answers": float64(answers), "authorities": float64(0), "additionals": float64(0), "rcode": "NOERROR"} + if len(dns) != len(want) { + t.Fatalf("unexpected DNS result fields: %v", dns) + } + for key, value := range want { + if dns[key] != value { + t.Errorf("DNS %s=%v, want %v", key, dns[key], value) + } + } + doctorUDPRedacted(t, string(data), nil, "example.test", "Example.test", "127.0.0.1:5300") +} + +func TestDoctorUDPFlagsConfigAndPrivacy(t *testing.T) { + t.Run("invalid inputs never build", func(t *testing.T) { + cases := []struct{ name, flag, value string }{ + {"unknown probe", "probe", "private-unknown-probe"}, + {"hostname target", "target", "private-resolver.test:53"}, + {"missing target", "target", ""}, + {"zero port", "target", "127.0.0.1:0"}, + {"named port", "target", "127.0.0.1:domain"}, + {"large port", "target", "127.0.0.1:65536"}, + {"zone", "target", "[fe80::1%private-zone]:53"}, + {"unspecified v4", "target", "0.0.0.0:53"}, + {"unspecified v6", "target", "[::]:53"}, + {"multicast v4", "target", "224.0.0.1:53"}, + {"multicast v6", "target", "[ff02::1]:53"}, + {"missing name", "dns-name", ""}, + {"root name", "dns-name", "."}, + {"empty label", "dns-name", "private..test"}, + {"leading hyphen", "dns-name", "-private.test"}, + {"trailing hyphen", "dns-name", "private-.test"}, + {"unicode", "dns-name", "privaté.test"}, + {"URL", "dns-name", "https://private.test"}, + {"control", "dns-name", "private\ttest"}, + {"long label", "dns-name", strings.Repeat("p", 64) + ".test"}, + {"long name", "dns-name", strings.Repeat(strings.Repeat("p", 63)+".", 4) + "test"}, + {"TCP rejects DNS name", "probe", "tcp-open"}, + {"H2 unsupported", "transport", "h2"}, + {"TLS unsupported", "transport", "tls"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + args := append(doctorUDPArgs(), "--"+tc.flag, tc.value) + var stdout, stderr bytes.Buffer + builds := 0 + err := runDoctorWith(context.Background(), args, &stdout, &stderr, func(tunnelFlags) (closeDialer, error) { + builds++ + return nil, errors.New("private-builder-must-not-run") + }) + if commandExitCode(err) != doctorExitUsageFailure || builds != 0 { + t.Errorf("exit=%d builds=%d err=%v", commandExitCode(err), builds, err) + } + if result := doctorUDPJSON(t, stdout.Bytes()); result["status"] != "failed" { + t.Errorf("usage result=%v", result) + } + if stderr.Len() != 0 { + t.Errorf("unexpected stderr=%q", stderr.String()) + } + if strings.Contains(tc.value, "private") { + doctorUDPRedacted(t, stdout.String()+stderr.String(), err, tc.value) + } + }) + } + }) + t.Run("numeric IPv6 canonical target", func(t *testing.T) { + packet := newDoctorUDPPacket("valid") + dialer := &doctorUDPFakeDialer{packet: packet} + var stdout bytes.Buffer + err := runDoctorWith(context.Background(), append(doctorUDPArgs(), "--target", "[0:0:0:0:0:0:0:1]:5300"), &stdout, &bytes.Buffer{}, dialer.build) + if err != nil { + t.Fatalf("IPv6 UDP probe: %v; output=%s", err, stdout.String()) + } + packet.mu.Lock() + target := packet.target + packet.mu.Unlock() + if target != "[::1]:5300" { + t.Errorf("numeric IPv6 target=%q", target) + } + doctorUDPAssertResult(t, stdout.Bytes(), "quic", 0) + }) + t.Run("missing packet capability never falls back", func(t *testing.T) { + dialer := &fakeDoctorDialer{snapshot: tunnel.ClientSnapshot{SelectedTransport: "quic"}} + var stdout bytes.Buffer + err := runDoctorWith(context.Background(), doctorUDPArgs(), &stdout, &bytes.Buffer{}, func(tunnelFlags) (closeDialer, error) { return dialer, nil }) + if commandExitCode(err) != doctorExitUsageFailure || dialer.network != "" || !dialer.closed { + t.Errorf("unsupported capability exit=%d network=%q closed=%v err=%v", commandExitCode(err), dialer.network, dialer.closed, err) + } + if doctorUDPJSON(t, stdout.Bytes())["code"] != "udp_unsupported" { + t.Errorf("missing packet capability output=%s", stdout.String()) + } + }) + t.Run("human error is redacted", func(t *testing.T) { + packet := newDoctorUDPPacket("private receive error") + dialer := &doctorUDPFakeDialer{packet: packet} + var stdout, stderr bytes.Buffer + err := runDoctorWith(context.Background(), append(doctorUDPArgs(), "--json=false"), &stdout, &stderr, dialer.build) + if commandExitCode(err) != doctorExitProbeFailure || !packet.isClosed() || !dialer.closed.Load() { + t.Errorf("human UDP failure exit=%d cleanup=%v/%v err=%v", commandExitCode(err), packet.isClosed(), dialer.closed.Load(), err) + } + doctorUDPRedacted(t, stdout.String()+stderr.String(), err, "private-receive-error", "Example.test", "127.0.0.1:5300") + }) + t.Run("config and CLI precedence", func(t *testing.T) { + file := filepath.Join(t.TempDir(), "private-doctor-config.json") + data, err := json.Marshal(map[string]any{"probe": "udp-dns", "transport": "quic", "target": "127.0.0.1:5400", "dns-name": "private.config.test.", "open-timeout": "1s", "json": true}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, data, 0o600); err != nil { + t.Fatal(err) + } + packet := newDoctorUDPPacket("valid") + dialer := &doctorUDPFakeDialer{packet: packet} + var stdout, stderr bytes.Buffer + err = runDoctorWith(context.Background(), []string{"--config", file, "--target", "[::ffff:127.0.0.1]:5300", "--dns-name", "Example.test"}, &stdout, &stderr, dialer.build) + if err != nil { + t.Fatalf("config UDP probe: %v; output=%s", err, stdout.String()) + } + doctorUDPAssertResult(t, stdout.Bytes(), "quic", 0) + packet.mu.Lock() + target := packet.target + packet.mu.Unlock() + if target != "127.0.0.1:5300" || packet.sends.Load() != 1 || !dialer.closed.Load() { + t.Errorf("target=%q sends=%d closed=%v", target, packet.sends.Load(), dialer.closed.Load()) + } + doctorUDPRedacted(t, stdout.String()+stderr.String(), err, file, "private.config.test") + }) +} + +func TestDoctorUDPExchangeValidatesAndDiscards(t *testing.T) { + for _, style := range []string{"valid", "answer", "wrong ID then valid", "wrong source then valid", "discard limit", "query not response", "opcode", "wrong question", "wrong type", "wrong class", "truncated", "rcode", "OPT", "trailing bytes", "oversized", "private send error", "private receive error", "packet open error"} { + t.Run(style, func(t *testing.T) { + packet := newDoctorUDPPacket(style) + dialer := &doctorUDPFakeDialer{packet: packet} + if style == "packet open error" { + dialer.openErr = errors.New("private-packet-open-error") + } + var stdout, stderr bytes.Buffer + err := runDoctorWith(context.Background(), doctorUDPArgs(), &stdout, &stderr, dialer.build) + wantOK := style == "valid" || style == "answer" || style == "wrong ID then valid" || style == "wrong source then valid" + if wantOK { + if err != nil { + t.Errorf("valid UDP exchange failed: %v; output=%s", err, stdout.String()) + } else { + answers := 0 + if style == "answer" { + answers = 1 + } + doctorUDPAssertResult(t, stdout.Bytes(), "quic", answers) + } + } else if commandExitCode(err) != doctorExitProbeFailure || doctorUDPJSON(t, stdout.Bytes())["status"] != "failed" { + t.Errorf("invalid reply exit=%d err=%v output=%s", commandExitCode(err), err, stdout.String()) + } + wantSends := int32(1) + if style == "packet open error" { + wantSends = 0 + } + if packet.sends.Load() != wantSends || dialer.tcpCalls.Load() != 0 || packet.receives.Load() > 9 { + t.Errorf("sends=%d TCP=%d receives=%d", packet.sends.Load(), dialer.tcpCalls.Load(), packet.receives.Load()) + } + if (style == "wrong ID then valid" || style == "wrong source then valid") && packet.receives.Load() != 2 { + t.Errorf("discarded reply was not followed by one receive: %d", packet.receives.Load()) + } + if !dialer.closed.Load() || !packet.isClosed() || packet.active.Load() != 0 || dialer.earlySnapshot.Load() { + t.Errorf("lifecycle closed=%v packetclosed=%v active=%d earlymetadata=%v", dialer.closed.Load(), packet.isClosed(), packet.active.Load(), dialer.earlySnapshot.Load()) + } + doctorUDPRedacted(t, stdout.String()+stderr.String(), err, "private-packet-open-error", "private-send-error", "private-receive-error", "Example.test", "example.test", "127.0.0.1:5300", "203.0.113.9") + }) + } +} + +func TestDoctorUDPCancellationAndTimeoutJoin(t *testing.T) { + for _, phase := range []string{"send", "receive"} { + for _, reason := range []string{"parent cause", "probe timeout"} { + t.Run(phase+"/"+reason, func(t *testing.T) { + packet := newDoctorUDPPacket("blocked " + phase) + dialer := &doctorUDPFakeDialer{packet: packet} + ctx, cancel := context.WithCancelCause(context.Background()) + var stdout, stderr bytes.Buffer + result := make(chan error, 1) + joined := make(chan struct{}) + args := append(doctorUDPArgs(), "--open-timeout", "100ms") + if reason == "parent cause" { + args = append(args, "--open-timeout", "1s") + } + go func() { + defer close(joined) + result <- runDoctorWith(ctx, args, &stdout, &stderr, dialer.build) + }() + t.Cleanup(func() { + cancel(context.Canceled) + _ = dialer.Close() + doctorUDPWait(t, joined, "command fatal cleanup") + }) + entered, returned := packet.sendEntered, packet.sendReturned + if phase == "receive" { + entered, returned = packet.receiveEntered, packet.receiveReturned + } + select { + case <-entered: + case <-joined: + t.Fatalf("command exited before actual %s entry: %v; output=%s", phase, <-result, stdout.String()) + case <-time.After(2 * time.Second): + t.Fatalf("command never entered actual %s", phase) + } + if reason == "parent cause" { + cancel(errors.New("private-doctor-cancellation-cause")) + } + doctorUDPWait(t, joined, "bounded command return") + err := <-result + if commandExitCode(err) != doctorExitProbeFailure || doctorUDPJSON(t, stdout.Bytes())["status"] != "failed" { + t.Errorf("canceled/expired exit=%d err=%v output=%s", commandExitCode(err), err, stdout.String()) + } + select { + case <-returned: + default: + t.Error("command returned before actual blocked packet call completed") + } + if !packet.isClosed() || !dialer.closed.Load() || packet.active.Load() != 0 || packet.sends.Load() != 1 || dialer.tcpCalls.Load() != 0 { + t.Errorf("cleanup closed=%v/%v active=%d sends=%d TCP=%d", packet.isClosed(), dialer.closed.Load(), packet.active.Load(), packet.sends.Load(), dialer.tcpCalls.Load()) + } + doctorUDPRedacted(t, stdout.String()+stderr.String(), err, "private-doctor-cancellation-cause", "Example.test", "127.0.0.1:5300") + }) + } + } +} + +func TestDoctorUDPDefaultTCPContractUnchanged(t *testing.T) { + for _, explicit := range []bool{false, true} { + t.Run(fmt.Sprintf("explicit=%v", explicit), func(t *testing.T) { + dialer := &fakeDoctorDialer{snapshot: tunnel.ClientSnapshot{SelectedTransport: "tls", ClientPacing: "tls-fallback", RelayPacing: "tls-fallback"}} + args := []string{"--target", "ordinary.test:443", "--json"} + if explicit { + args = append(args, "--probe", "tcp-open") + } + var stdout bytes.Buffer + err := runDoctorWith(context.Background(), args, &stdout, &bytes.Buffer{}, func(tunnelFlags) (closeDialer, error) { return dialer, nil }) + if err != nil { + t.Fatalf("existing TCP contract: %v", err) + } + result := doctorUDPJSON(t, stdout.Bytes()) + if result["probe"] != "authenticated_tcp_open" || result["selected_transport"] != "tls" || result["dns"] != nil || dialer.network != "tcp" || !dialer.closed { + t.Fatalf("TCP behavior changed: %v; dial=%q closed=%v", result, dialer.network, dialer.closed) + } + }) + } +} + +type doctorUDPReply struct { + payload []byte + address string + valid bool +} + +type doctorUDPPacket struct { + mu sync.Mutex + style string + target string + replies []doctorUDPReply + sendEntered chan struct{} + sendReturned chan struct{} + receiveEntered chan struct{} + receiveReturned chan struct{} + done chan struct{} + once sync.Once + sendEntryOnce sync.Once + sendReturnOnce sync.Once + receiveEntryOnce sync.Once + receiveReturnOnce sync.Once + sends atomic.Int32 + receives atomic.Int32 + active atomic.Int32 + verified atomic.Bool +} + +func newDoctorUDPPacket(style string) *doctorUDPPacket { + return &doctorUDPPacket{style: style, sendEntered: make(chan struct{}), sendReturned: make(chan struct{}), receiveEntered: make(chan struct{}), receiveReturned: make(chan struct{}), done: make(chan struct{})} +} + +func (p *doctorUDPPacket) Send(payload []byte, address string) error { + p.sends.Add(1) + p.active.Add(1) + defer func() { p.active.Add(-1); p.sendReturnOnce.Do(func() { close(p.sendReturned) }) }() + p.sendEntryOnce.Do(func() { close(p.sendEntered) }) + if p.style == "blocked send" { + <-p.done + return net.ErrClosed + } + if p.style == "private send error" { + return errors.New("private-send-error") + } + query, err := doctorUDPQuery(payload) + if err != nil { + return err + } + replies, err := doctorUDPReplies(query, address, p.style) + if err != nil { + return err + } + p.mu.Lock() + p.target, p.replies = address, replies + p.mu.Unlock() + return nil +} + +func (p *doctorUDPPacket) Receive() ([]byte, string, error) { + p.receives.Add(1) + p.active.Add(1) + defer func() { p.active.Add(-1); p.receiveReturnOnce.Do(func() { close(p.receiveReturned) }) }() + p.receiveEntryOnce.Do(func() { close(p.receiveEntered) }) + if p.style == "private receive error" { + return nil, "", errors.New("private-receive-error") + } + p.mu.Lock() + if len(p.replies) != 0 && p.style != "blocked receive" { + reply := p.replies[0] + p.replies = p.replies[1:] + p.mu.Unlock() + if reply.valid { + p.verified.Store(true) + } + return reply.payload, reply.address, nil + } + p.mu.Unlock() + <-p.done + return nil, "", net.ErrClosed +} + +func (p *doctorUDPPacket) Close() error { p.once.Do(func() { close(p.done) }); return nil } +func (p *doctorUDPPacket) isClosed() bool { + select { + case <-p.done: + return true + default: + return false + } +} + +type doctorUDPFakeDialer struct { + packet *doctorUDPPacket + openErr error + closed atomic.Bool + tcpCalls atomic.Int32 + earlySnapshot atomic.Bool +} + +func (d *doctorUDPFakeDialer) build(tunnelFlags) (closeDialer, error) { return d, nil } +func (d *doctorUDPFakeDialer) DialContext(context.Context, string, string) (net.Conn, error) { + d.tcpCalls.Add(1) + return nil, errors.New("unexpected TCP fallback") +} +func (d *doctorUDPFakeDialer) DialPacket(context.Context) (transport.PacketConn, error) { + return d.packet, d.openErr +} +func (d *doctorUDPFakeDialer) Close() error { d.closed.Store(true); return d.packet.Close() } +func (d *doctorUDPFakeDialer) Snapshot() tunnel.ClientSnapshot { + if !d.packet.verified.Load() { + d.earlySnapshot.Store(true) + } + return tunnel.ClientSnapshot{SelectedTransport: "quic", ClientPacing: "adaptive-balanced", RelayPacing: "adaptive-balanced"} +} + +func doctorUDPQuery(payload []byte) (dnsmessage.Message, error) { + var query dnsmessage.Message + if err := query.Unpack(payload); err != nil { + return query, err + } + if len(payload) > 512 || query.Response || query.OpCode != 0 || !query.RecursionDesired || len(query.Questions) != 1 || len(query.Answers)+len(query.Authorities)+len(query.Additionals) != 0 { + return query, errors.New("not one classic recursive DNS question") + } + question := query.Questions[0] + if !strings.EqualFold(question.Name.String(), "example.test.") || question.Type != dnsmessage.TypeA || question.Class != dnsmessage.ClassINET { + return query, errors.New("DNS query name/type/class mismatch") + } + return query, nil +} + +func doctorUDPReplies(query dnsmessage.Message, address, style string) ([]doctorUDPReply, error) { + response := dnsmessage.Message{Header: dnsmessage.Header{ID: query.ID, Response: true, RecursionDesired: query.RecursionDesired}, Questions: append([]dnsmessage.Question(nil), query.Questions...)} + valid := style == "valid" || style == "answer" || style == "wrong ID then valid" || style == "wrong source then valid" + switch style { + case "answer": + response.Answers = []dnsmessage.Resource{{Header: dnsmessage.ResourceHeader{Name: query.Questions[0].Name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET, TTL: 1}, Body: &dnsmessage.AResource{A: [4]byte{203, 0, 113, 9}}}} + case "query not response": + response.Response = false + case "opcode": + response.OpCode = 1 + case "wrong question": + response.Questions[0].Name = dnsmessage.MustNewName("private.other.test.") + case "wrong type": + response.Questions[0].Type = dnsmessage.TypeAAAA + case "wrong class": + response.Questions[0].Class = dnsmessage.ClassCHAOS + case "truncated": + response.Truncated = true + case "rcode": + response.RCode = dnsmessage.RCodeNameError + case "OPT": + response.Additionals = []dnsmessage.Resource{{Header: dnsmessage.ResourceHeader{Name: dnsmessage.MustNewName("."), Type: dnsmessage.TypeOPT, Class: 512}, Body: &dnsmessage.OPTResource{}}} + } + wire, err := response.Pack() + if err != nil { + return nil, err + } + if style == "trailing bytes" { + wire = append(wire, 0x7f) + } + if style == "oversized" { + wire = append(wire, make([]byte, 513-len(wire))...) + } + reply := doctorUDPReply{payload: wire, address: address, valid: valid} + if style == "wrong ID then valid" { + bad := append([]byte(nil), wire...) + bad[1] ^= 1 + return []doctorUDPReply{{payload: bad, address: address}, reply}, nil + } + if style == "wrong source then valid" { + return []doctorUDPReply{{payload: wire, address: "127.0.0.1:5301"}, reply}, nil + } + if !valid { + if style == "discard limit" { + reply.address = "127.0.0.1:5301" + } + // Allow either immediate protocol rejection or bounded rejection after + // discarded replies. A ninth invalid reply must never trigger a resend. + return []doctorUDPReply{reply, reply, reply, reply, reply, reply, reply, reply, reply}, nil + } + return []doctorUDPReply{reply}, nil +} + +func doctorUDPWait(t *testing.T, done <-chan struct{}, description string) { + t.Helper() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatalf("did not join %s", description) + } +} + +type doctorUDPServer interface { + Addr() net.Addr + Serve(context.Context) error + Close() error +} + +func TestDoctorUDPRealAuthenticatedDNSExchange(t *testing.T) { + for _, mode := range []string{"quic", "h3"} { + t.Run(mode, func(t *testing.T) { + directory := t.TempDir() + certFile, keyFile := filepath.Join(directory, "server.crt"), filepath.Join(directory, "server.key") + if err := security.WriteSelfSignedCertificate(certFile, keyFile, security.CertificateOptions{Hosts: []string{"127.0.0.1"}, ValidFor: time.Hour}); err != nil { + t.Fatal(err) + } + certificate, err := security.LoadKeyPair(certFile, keyFile) + if err != nil { + t.Fatal(err) + } + const token = "doctor-owned-udp-auth-token" + tokenFile := filepath.Join(directory, "token") + if err := os.WriteFile(tokenFile, []byte(token+"\n"), 0o600); err != nil { + t.Fatal(err) + } + endpoint, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + dnsDone := make(chan struct{}) + dnsResult := make(chan error, 1) + var queries atomic.Int32 + go func() { + defer close(dnsDone) + buffer := make([]byte, 513) + for { + n, peer, err := endpoint.ReadFromUDPAddrPort(buffer) + if err != nil { + if !errors.Is(err, net.ErrClosed) { + select { + case dnsResult <- err: + default: + } + } + return + } + queries.Add(1) + query, err := doctorUDPQuery(buffer[:n]) + if err == nil { + var replies []doctorUDPReply + replies, err = doctorUDPReplies(query, endpoint.LocalAddr().String(), "valid") + if err == nil { + _, err = endpoint.WriteToUDPAddrPort(replies[0].payload, peer) + } + } + select { + case dnsResult <- err: + default: + } + } + }() + t.Cleanup(func() { _ = endpoint.Close(); doctorUDPWait(t, dnsDone, "owned DNS socket worker") }) + var resolves, tcpDials, coverCalls atomic.Int32 + resolver := tunnel.UDPResolverFunc(func(_ context.Context, address string) ([]netip.AddrPort, error) { + resolves.Add(1) + if address != endpoint.LocalAddr().String() { + return nil, errors.New("non-owned UDP target rejected") + } + return []netip.AddrPort{endpoint.LocalAddr().(*net.UDPAddr).AddrPort()}, nil + }) + destination := transport.DialFunc(func(context.Context, string, string) (net.Conn, error) { + tcpDials.Add(1) + return nil, errors.New("TCP destination is outside UDP probe") + }) + var server doctorUDPServer + if mode == "quic" { + server, err = tunnel.ListenQUIC(tunnel.QUICServerConfig{Address: "127.0.0.1:0", Token: token, TLSConfig: &tls.Config{Certificates: []tls.Certificate{certificate}}, Dialer: destination, UDPResolver: resolver}) + } else { + server, err = tunnel.ListenWebH3(tunnel.WebH3ServerConfig{Address: "127.0.0.1:0", Token: token, TLSConfig: &tls.Config{Certificates: []tls.Certificate{certificate}}, Dialer: destination, UDPResolver: resolver, Cover: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { coverCalls.Add(1); w.WriteHeader(http.StatusNotFound) })}) + } + if err != nil { + t.Fatal(err) + } + serverCtx, cancelServer := context.WithCancel(context.Background()) + serveResult, serveJoined := make(chan error, 1), make(chan struct{}) + go func() { defer close(serveJoined); serveResult <- server.Serve(serverCtx) }() + t.Cleanup(func() { + if err := server.Close(); err != nil && !errors.Is(err, net.ErrClosed) { + t.Errorf("close owned relay: %v", err) + } + cancelServer() + doctorUDPWait(t, serveJoined, "owned relay Serve") + if err := <-serveResult; err != nil { + t.Errorf("owned relay Serve: %v", err) + } + }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var stdout, stderr bytes.Buffer + err = runDoctorWith(ctx, []string{"--server", server.Addr().String(), "--ca", certFile, "--token-file", tokenFile, "--transport", mode, "--h3-fingerprint", "native", "--probe", "udp-dns", "--target", endpoint.LocalAddr().String(), "--dns-name", "Example.test", "--open-timeout", "2s", "--json"}, &stdout, &stderr, buildTunnelDialer) + if err != nil { + t.Errorf("real owned DNS exchange: %v; output=%s stderr=%s", err, stdout.String(), stderr.String()) + } else { + doctorUDPAssertResult(t, stdout.Bytes(), mode, 0) + } + if queries.Load() != 1 || resolves.Load() != 1 || tcpDials.Load() != 0 || coverCalls.Load() != 0 { + t.Errorf("DNS queries=%d resolver=%d TCP=%d cover=%d", queries.Load(), resolves.Load(), tcpDials.Load(), coverCalls.Load()) + } + select { + case err := <-dnsResult: + if err != nil { + t.Errorf("actual DNS query/response: %v", err) + } + case <-time.After(time.Second): + t.Error("owned DNS endpoint never completed a real exchange") + } + doctorUDPRedacted(t, stdout.String()+stderr.String(), err, token, tokenFile, certFile, endpoint.LocalAddr().String(), "Example.test") + }) + } +} diff --git a/internal/tunnel/datagram.go b/internal/tunnel/datagram.go index 322f4c9..f39dbca 100644 --- a/internal/tunnel/datagram.go +++ b/internal/tunnel/datagram.go @@ -1051,6 +1051,9 @@ func randomNonZeroUint32() (uint32, error) { } func resolveUDPDestinations(ctx context.Context, resolver UDPResolver, address string) ([]netip.AddrPort, error) { + if err := contextError(ctx); err != nil { + return nil, err + } _, portText, err := net.SplitHostPort(address) if err != nil { return nil, fmt.Errorf("tunnel: invalid UDP destination: %w", err) @@ -1063,6 +1066,11 @@ func resolveUDPDestinations(ctx context.Context, resolver UDPResolver, address s if err != nil { return nil, err } + // A custom resolver can return a successful result after its budget. Do + // not turn that late result into an outbound UDP write or a live target. + if err := contextError(ctx); err != nil { + return nil, err + } wantPort := uint16(portNumber) seen := make(map[netip.AddrPort]struct{}, len(resolved)) destinations := make([]netip.AddrPort, 0, len(resolved)) diff --git a/internal/tunnel/udp_resolver_deadline_test.go b/internal/tunnel/udp_resolver_deadline_test.go new file mode 100644 index 0000000..a53eeb8 --- /dev/null +++ b/internal/tunnel/udp_resolver_deadline_test.go @@ -0,0 +1,385 @@ +package tunnel + +import ( + "bytes" + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "net/http" + "net/netip" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/cppla/autocar/internal/transport" +) + +type udpResolverDeadlineReceipt struct { + payload []byte + source netip.AddrPort +} + +type udpResolverDeadlineRequest struct { + auth *webServerConnectionAuth + full bool + short bool + proof bool // Inspected only after joined closes. + joined chan struct{} +} + +type udpResolverDeadlineTransfer struct { + firstErr error + err error + replies []packetReceiveResult +} + +// The sole synthetic behavior is a resolver returning a real owned address +// with nil error after its actual server-side context deadline. TLS, QUIC, +// authentication, UDP writes, replies and subsequent association reuse are real. +func TestUDPResolverDeadlineRejectsLateSuccess(t *testing.T) { + for _, mode := range []string{"native_quic", "h3"} { + t.Run(mode, func(t *testing.T) { + f := newWebH2DialSharingFixture(t) + caller, stopCaller := context.WithTimeout(f.ctx, 2*time.Second) + defer stopCaller() + echo, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + target := echo.LocalAddr().(*net.UDPAddr).AddrPort() + f.close = append(f.close, func() { _ = echo.Close() }) + receipts := make(chan udpResolverDeadlineReceipt, 3) + echoJoined := make(chan struct{}) + f.register(echoJoined) + var echoErr error + var unexpected atomic.Int32 + f.checks = append(f.checks, func() { + if echoErr != nil { + t.Errorf("owned UDP echo: %v", echoErr) + } + }) + go func() { + defer close(echoJoined) + buffer := make([]byte, 256) + for { + if err := echo.SetDeadline(time.Now().Add(7 * time.Second)); err != nil { + echoErr = err + return + } + n, source, err := echo.ReadFromUDPAddrPort(buffer) + if err != nil { + if !errors.Is(err, net.ErrClosed) { + echoErr = err + } + return + } + select { + case receipts <- udpResolverDeadlineReceipt{append([]byte(nil), buffer[:n]...), source}: + default: + unexpected.Add(1) + } + if _, err := echo.WriteToUDPAddrPort(buffer[:n], source); err != nil { + if !errors.Is(err, net.ErrClosed) { + echoErr = err + } + return + } + } + }() + + expired, firstResolverJoined := make(chan error, 1), make(chan struct{}) + var resolutions atomic.Int32 + resolver := UDPResolverFunc(func(ctx context.Context, address string) ([]netip.AddrPort, error) { + joined := make(chan struct{}) + f.register(joined) + defer close(joined) + if address != target.String() { + unexpected.Add(1) + return nil, errors.New("UDP deadline fixture refuses a non-owned numeric target") + } + if resolutions.Add(1) == 1 { + defer close(firstResolverJoined) + select { + case <-ctx.Done(): + expired <- context.Cause(ctx) + case <-f.ctx.Done(): + return nil, context.Cause(f.ctx) + } + } + return []netip.AddrPort{target}, nil + }) + serverTLS, clientTLS := testTLSConfigs(t) + var packet transport.PacketConn + var h3 *WebH3Client + var native *Client + var h3Handler *webTunnelHandler + var nativeServer *QUICServer + var closeServer func() error + var serve func(context.Context) error + var entropy webH2AuthEntropyCounter + requests := make(chan *udpResolverDeadlineRequest, 3) + var requestCount atomic.Int32 + refuseTCP := transport.DialFunc(func(context.Context, string, string) (net.Conn, error) { + unexpected.Add(1) + return nil, errors.New("UDP deadline fixture never dials TCP") + }) + if mode == "native_quic" { + nativeServer, err = ListenQUIC(QUICServerConfig{ + Address: "127.0.0.1:0", Token: testToken, TLSConfig: serverTLS, + Dialer: refuseTCP, UDPResolver: resolver, DialTimeout: 100 * time.Millisecond, + HandshakeTimeout: time.Second, MaxUDPSessions: 1, MaxClientUDPSessions: 1, + MaxUDPDestinations: 1, + }) + if err != nil { + t.Fatal(err) + } + closeServer, serve = nativeServer.Close, nativeServer.Serve + native, err = NewClient(ClientConfig{ + ServerAddress: nativeServer.Addr().String(), Token: testToken, TLSConfig: clientTLS, + QUICDialTimeout: time.Second, HandshakeTimeout: time.Second, MaxUDPSessions: 1, + }) + } else { + server, serverErr := ListenWebH3(WebH3ServerConfig{ + Address: "127.0.0.1:0", Token: webTestToken, TLSConfig: serverTLS, + Dialer: refuseTCP, UDPResolver: resolver, DialTimeout: 100 * time.Millisecond, + HandshakeTimeout: time.Second, MaxUDPSessions: 1, MaxClientUDPSessions: 1, + MaxUDPDestinations: 1, + Cover: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + unexpected.Add(1) + http.NotFound(w, r) + }), + }) + if serverErr != nil { + t.Fatal(serverErr) + } + closeServer, serve = server.Close, server.Serve + h3Handler = server.server.Handler.(*webTunnelHandler) + original := server.server.Handler + server.server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount.Add(1) + joined := make(chan struct{}) + f.register(joined) + defer close(joined) + auth, _ := r.Context().Value(webServerConnectionAuthContextKey{}).(*webServerConnectionAuth) + bearer := r.Header.Get("Proxy-Authorization") + _, short := parseWebSessionBearer(bearer) + request := &udpResolverDeadlineRequest{auth: auth, full: len(bearer) >= 385 && len(bearer) <= 2047, short: short, joined: joined} + select { + case requests <- request: + default: + unexpected.Add(1) + } + original.ServeHTTP(w, r) + request.proof = w.Header().Get(webAuthResponseHeader) != "" + }) + h3, err = NewWebH3Client(WebH3ClientConfig{ + ServerAddress: server.Addr().String(), Token: webTestToken, TLSConfig: clientTLS, + FingerprintProfile: H3FingerprintNative, DialTimeout: time.Second, HandshakeTimeout: time.Second, + MaxUDPSessions: 1, MaxUDPDestinations: 1, + }) + } + // Publish server cleanup before any possible client constructor failure. + f.close = append(f.close, func() { + if err := normalizeWebServerCloseError(closeServer()); err != nil { + t.Errorf("owned UDP server Close: %v", err) + } + }) + if err != nil { + t.Fatal(err) + } + if h3 != nil { + h3.signer = newWebAuthSigner(mustWebAuthKey(t, webTestToken), nil, &entropy) + f.close = append(f.close, func() { _ = h3.Close() }) + } else { + f.close = append(f.close, func() { _ = native.Close() }) + } + serveJoined, serveResult := make(chan struct{}), make(chan error, 1) + f.register(serveJoined) + f.checks = append(f.checks, func() { + if err := <-serveResult; err != nil { + t.Errorf("owned UDP Serve: %v", err) + } + }) + go func() { defer close(serveJoined); serveResult <- serve(f.ctx) }() + if h3 != nil { + packet, err = h3.DialPacket(caller) + } else { + packet, err = native.DialPacket(caller) + } + if err != nil { + t.Fatal(err) + } + f.close = append(f.close, func() { _ = packet.Close() }) + var initialH3Session *webH3ClientSession + var verified tls.ConnectionState + if h3 != nil { + h3.mu.Lock() + initialH3Session = h3.conns[h3.conn] + h3.mu.Unlock() + if initialH3Session == nil { + t.Fatal("public H3 packet opening did not retain a physical session") + } + verified = initialH3Session.conn.ConnectionState().TLS + } else { + verified = packet.(*quicPacketConn).dispatcher.conn.ConnectionState().TLS + } + if verified.Version != tls.VersionTLS13 || len(verified.VerifiedChains) == 0 { + t.Fatal("public packet opening did not actually verify TLS1.3") + } + + releaseHealthy := make(chan struct{}) + ungate := sync.OnceFunc(func() { close(releaseHealthy) }) + f.close = append(f.close, ungate) + firstSent, transferJoined := make(chan error, 1), make(chan struct{}) + transferResult := make(chan udpResolverDeadlineTransfer, 1) + f.register(transferJoined) + const late, healthyOne, healthyTwo = "late-result-must-not-deliver", "healthy-one", "healthy-two" + go func() { + defer close(transferJoined) + result := udpResolverDeadlineTransfer{firstErr: packet.Send([]byte(late), target.String())} + firstSent <- result.firstErr + select { + case <-releaseHealthy: + case <-f.ctx.Done(): + result.err = context.Cause(f.ctx) + transferResult <- result + return + } + for _, payload := range []string{healthyOne, healthyTwo} { + if err := packet.Send([]byte(payload), target.String()); err != nil { + result.err = err + transferResult <- result + return + } + } + // Two healthy replies, plus the old implementation's unexpected late + // reply, fit this fixed bound. Close independently unblocks Receive. + seen := make(map[string]bool) + for len(result.replies) < 3 && len(seen) < 2 { + payload, address, err := packet.Receive() + if err != nil { + result.err = err + break + } + result.replies = append(result.replies, packetReceiveResult{payload: payload, address: address}) + if string(payload) == healthyOne || string(payload) == healthyTwo { + seen[string(payload)] = true + } + } + transferResult <- result + }() + select { + case cause := <-expired: + if cause != context.DeadlineExceeded { + t.Errorf("resolver's actual server budget ended with %v, want DeadlineExceeded", cause) + } + case <-f.ctx.Done(): + t.Fatal("resolver did not reach its actual configured server deadline") + } + f.wait(firstResolverJoined, "late successful resolver callback") + var firstErr error + select { + case firstErr = <-firstSent: + case <-f.ctx.Done(): + t.Fatal("first public packet Send exceeded fixture budget") + } + var firstRequest *udpResolverDeadlineRequest + if h3 != nil && firstErr != nil { + // At capacity one, join the rejected handler's real admission + // release before opening the healthy target. Old success owns a + // live handler and must instead continue to the healthy controls. + select { + case firstRequest = <-requests: + case <-f.ctx.Done(): + t.Fatal("rejected first H3 request observation missing") + } + f.wait(firstRequest.joined, "first rejected CONNECT-UDP handler completed") + } + if err := caller.Err(); err != nil { + t.Fatalf("caller ended before the server-only deadline witness: %v", err) + } + ungate() + f.wait(transferJoined, "real public packet payload transfer") + result := <-transferResult + if mode == "native_quic" { + if result.firstErr != nil { + t.Errorf("native queued first Send: %v", result.firstErr) + } + } else { + var refused *WebConnectError + if !errors.Is(result.firstErr, transport.ErrPacketTargetUnavailable) || !errors.As(result.firstErr, &refused) || refused.StatusCode != http.StatusBadGateway { + t.Errorf("late H3 resolution Send=%v, want recoverable authenticated 502", result.firstErr) + } + } + if result.err != nil { + t.Errorf("later same-association transfer: %v", result.err) + } + if len(result.replies) != 2 || string(result.replies[0].payload) != healthyOne || string(result.replies[1].payload) != healthyTwo { + t.Errorf("same-association replies=%+v, want exactly the two healthy payloads", result.replies) + } + for _, reply := range result.replies { + if reply.address != target.String() { + t.Errorf("public packet reply source=%q, want owned target %q", reply.address, target.String()) + } + } + var observed []udpResolverDeadlineReceipt + for len(observed) < len(result.replies) { + select { + case receipt := <-receipts: + observed = append(observed, receipt) + case <-f.ctx.Done(): + t.Fatal("real reply lacked its owned origin receipt") + } + } + if len(observed) != 2 || !bytes.Equal(observed[0].payload, []byte(healthyOne)) || !bytes.Equal(observed[1].payload, []byte(healthyTwo)) { + t.Errorf("origin receipt order=%+v, want healthy first and no expired-resolution payload", observed) + } + if len(observed) >= 2 && observed[len(observed)-2].source != observed[len(observed)-1].source { + t.Error("two healthy packets did not reuse the same relay UDP socket") + } + if h3 != nil { + h3.mu.Lock() + same := h3.conns[h3.conn] == initialH3Session + ready := initialH3Session.authState == webH3ClientAuthReady && initialH3Session.auth != nil && !initialH3Session.retired + h3.mu.Unlock() + if !same || !ready || initialH3Session.conn.Context().Err() != nil || entropy.nonceReads.Load() != 1 { + t.Errorf("H3 same/Ready/live/nonce=%t/%t/%t/%d", same, ready, initialH3Session.conn.Context().Err() == nil, entropy.nonceReads.Load()) + } + } + if err := packet.Close(); err != nil { + t.Error(err) + } + if h3 != nil { + var auth *webServerConnectionAuth + count := int(requestCount.Load()) + for i := 0; i < count; i++ { + request := firstRequest + if i != 0 || request == nil { + select { + case request = <-requests: + case <-f.ctx.Done(): + t.Fatal("actual H3 request observation missing") + } + } + f.wait(request.joined, "actual CONNECT-UDP handler completed") + if request.auth == nil || !request.proof || (i == 0 && (!request.full || request.short)) || (i > 0 && (!request.short || request.full || request.auth != auth)) { + t.Errorf("actual H3 request %d lost full/short/proof/physical authentication continuity", i) + } + auth = request.auth + } + if count != 2 || resolutions.Load() != 2 || len(h3.udpSlots) != 0 || len(h3Handler.udp.slots) != 0 { + t.Errorf("H3 request/resolver/client/server slots=%d/%d/%d/%d, want 2/2/0/0", count, resolutions.Load(), len(h3.udpSlots), len(h3Handler.udp.slots)) + } + } else if resolutions.Load() != 3 { + t.Errorf("native resolver calls=%d, want one expired and two healthy", resolutions.Load()) + } + if unexpected.Load() != 0 { + t.Errorf("unexpected request/target/echo overflow count=%d", unexpected.Load()) + } + t.Log(fmt.Sprintf("%s: actual 100ms server resolver deadline; healthy same-association UDP socket, complete payload replies and independent joins evaluated", mode)) + }) + } +}