From 70aea6cd728af711fc9c845048f3fd4d8e188fb1 Mon Sep 17 00:00:00 2001 From: windyboy Date: Fri, 14 Aug 2026 18:17:26 +0800 Subject: [PATCH] feat(ednsdiag): add DoQ/DoH3/DNSCrypt transports, proxy support, probe & compare --- .../.github/workflows/ci.yml | 28 +- .agents/skills/encrypted-dns-skill/README.md | 98 ++++++- .agents/skills/encrypted-dns-skill/SKILL.md | 32 ++- .../encrypted-dns-skill/cmd/ednsdiag/main.go | 246 ++++++++++++++-- .../cmd/ednsdiag/main_test.go | 269 +++++++++++++++--- .agents/skills/encrypted-dns-skill/go.mod | 22 +- .agents/skills/encrypted-dns-skill/go.sum | 42 +++ .../encrypted-dns-skill/internal/edns/dns.go | 82 +++++- .../internal/edns/dns_test.go | 206 +++++++++++++- .../encrypted-dns-skill/internal/edns/doh.go | 62 +++- .../internal/edns/doh_test.go | 69 ++++- .../encrypted-dns-skill/internal/edns/dot.go | 44 ++- .../internal/edns/dot_test.go | 66 ++++- .../internal/edns/model.go | 48 +++- .../internal/edns/providers.go | 103 +++++-- .../internal/edns/providers_test.go | 45 ++- .../internal/edns/query.go | 87 ++++-- .../references/providers.md | 17 +- .../references/security.md | 60 +++- .../references/standards.md | 22 +- .../schemas/result-v1.schema.json | 253 +++++++++++++--- 21 files changed, 1665 insertions(+), 236 deletions(-) diff --git a/.agents/skills/encrypted-dns-skill/.github/workflows/ci.yml b/.agents/skills/encrypted-dns-skill/.github/workflows/ci.yml index 957c6f9..6f4d9e9 100644 --- a/.agents/skills/encrypted-dns-skill/.github/workflows/ci.yml +++ b/.agents/skills/encrypted-dns-skill/.github/workflows/ci.yml @@ -13,9 +13,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v7 with: go-version-file: go.mod cache: true @@ -29,6 +29,12 @@ jobs: fi - name: Check module files run: go mod tidy && git diff --exit-code + - name: Validate Agent Skill package + run: python3 scripts/validate_skill.py . + - name: Static analysis + run: go run honnef.co/go/tools/cmd/staticcheck@v0.7.0 ./... + - name: Vulnerability scan + run: go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./... test: strategy: @@ -38,15 +44,25 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v7 with: go-version-file: go.mod cache: true - name: Resolve modules run: go mod download - - name: Test - run: go test ./... + - name: Test with race detector + run: go test -race ./... + - name: DNSCrypt interoperability + if: matrix.os == 'ubuntu-latest' + env: + EDNSDIAG_DNSCRYPT_INTEROP: "1" + run: go test ./internal/edns -run '^TestDNSCryptAdGuardInteroperability$' -count=1 + - name: Public DoH and DoT interoperability + if: matrix.os == 'ubuntu-latest' + env: + EDNSDIAG_PUBLIC_INTEROP: "1" + run: go test ./internal/edns -run '^TestPublicCloudflareDo[HT]Interoperability$' -count=1 -v - name: Vet run: go vet ./... diff --git a/.agents/skills/encrypted-dns-skill/README.md b/.agents/skills/encrypted-dns-skill/README.md index c82be37..cbe8517 100644 --- a/.agents/skills/encrypted-dns-skill/README.md +++ b/.agents/skills/encrypted-dns-skill/README.md @@ -15,9 +15,9 @@ URLs, TLS sessions, or DNS wire messages themselves. | --- | --- | --- | | DNS over HTTPS (DoH) | Available (GET and POST) | [RFC 8484](https://www.rfc-editor.org/rfc/rfc8484.html) | | DNS over TLS (DoT) | Available (strict authentication) | [RFC 7858](https://www.rfc-editor.org/rfc/rfc7858.html), [RFC 8310](https://www.rfc-editor.org/rfc/rfc8310.html) | -| DNS over QUIC (DoQ) | Planned | [RFC 9250](https://www.rfc-editor.org/rfc/rfc9250.html) | -| DoH over HTTP/3 (DoH3) | Planned | RFC 8484 over HTTP/3 | -| DNSCrypt | Planned | [DNSCrypt protocol specification](https://github.com/DNSCrypt/dnscrypt-protocol) | +| DNS over QUIC (DoQ) | Available | [RFC 9250](https://www.rfc-editor.org/rfc/rfc9250.html) | +| DoH over HTTP/3 (DoH3) | Available (GET and POST) | RFC 8484 over HTTP/3 | +| DNSCrypt | Available (v2 over UDP) | [DNSCrypt protocol specification](https://github.com/DNSCrypt/dnscrypt-protocol) | | Oblivious DoH (ODoH) | Research | [RFC 9230](https://www.rfc-editor.org/rfc/rfc9230.html) | | Anonymized DNSCrypt | Research | [Anonymized DNSCrypt specification](https://github.com/DNSCrypt/dnscrypt-protocol/blob/master/ANONYMIZED-DNSCRYPT.txt) | @@ -37,7 +37,7 @@ addresses returned in DNS answers. ## Requirements -- Go 1.26 or later when running or building from source +- Go 1.26.6 or later when running or building from source - Network access to the selected encrypted DNS resolver - A host that supports the [Agent Skills package format](https://agentskills.io/specification) when using the repository as a Skill @@ -66,6 +66,14 @@ the repository root: go run ./cmd/ednsdiag capabilities go run ./cmd/ednsdiag query example.com A --protocol doh --provider cloudflare go run ./cmd/ednsdiag query gmail.com MX --protocol dot --provider google --timeout 5s +HTTPS_PROXY=http://127.0.0.1:8080 go run ./cmd/ednsdiag query example.com A --protocol doh +go run ./cmd/ednsdiag query example.com A --protocol dot --proxy http://127.0.0.1:8080 +go run ./cmd/ednsdiag query example.com AAAA --protocol doq --provider adguard +go run ./cmd/ednsdiag query example.com HTTPS --protocol doh3 --provider cloudflare +go run ./cmd/ednsdiag query example.com A --protocol dnscrypt --provider adguard +go run ./cmd/ednsdiag probe example.com A --protocol dot --provider cloudflare +go run ./cmd/ednsdiag compare example.com A \ + --target doh:cloudflare --target dot:google ``` The first run may download the modules pinned in `go.mod` and `go.sum`. @@ -86,23 +94,51 @@ does not currently publish release binaries. ednsdiag capabilities ednsdiag version ednsdiag query [type] \ - [--protocol doh|dot] \ + [--protocol doh|dot|doq|doh3|dnscrypt|odoh|anonymized-dnscrypt] \ [--provider cloudflare|google|quad9|adguard] \ [--method post|get] \ + [--proxy http://host:port] \ [--timeout 5s] +ednsdiag probe [type] [query options] +ednsdiag compare [type] \ + --target protocol:provider[:method] \ + --target protocol:provider[:method] \ + [--proxy http://host:port] \ + [--attempt-timeout 5s] [--timeout 30s] [--max-attempts 4] ``` Defaults are `A`, `doh`, `cloudflare`, `post`, and `5s`. `--method` applies -only to DoH. The timeout must be between `250ms` and `30s`. +only to DoH and DoH3. The timeout must be between `250ms` and `30s`. +Research protocols are accepted as inputs so automation receives a structured +`unsupported` result and exit code `4`; they are never silently substituted. + +DoH and DoT honor Go's standard `HTTPS_PROXY`/`https_proxy` and +`NO_PROXY`/`no_proxy` environment variables. `--proxy` overrides environment +selection and accepts an `http://` or `https://` proxy URL, including optional +Basic-auth userinfo. DoT uses HTTP CONNECT before its resolver TLS handshake. +DoH3, DoQ, and DNSCrypt are UDP/QUIC transports and cannot use this TCP CONNECT +proxy; an explicit proxy combined with one of those protocols is rejected. +The same `--proxy` is shared by every DoH/DoT target in a `compare` operation. +See Go's official [`ProxyFromEnvironment` documentation](https://pkg.go.dev/net/http#ProxyFromEnvironment) +for environment-variable and `NO_PROXY` matching rules. + +`compare` accepts 2–8 unique, allowlisted targets, bounded by `--max-attempts`. +Its total timeout is `250ms`–`60s`; each attempt timeout is `250ms`–`30s` and +cannot exceed the total. Comparison targets start concurrently, results retain +the requested target order, and answers are never merged. + +Supported record types are `A`, `AAAA`, `CNAME`, `MX`, `TXT`, `NS`, `SOA`, +`CAA`, `SRV`, `PTR`, `HTTPS`, and `SVCB`. For `PTR`, pass an IP address; the CLI +constructs the reverse name. Other IP literals and local names remain blocked. Built-in resolver profiles: -| Provider | Profile | -| --- | --- | -| Cloudflare | Unfiltered | -| Google | Unfiltered | -| Quad9 | Security-filtered | -| AdGuard | Ad- and security-filtered | +| Provider | Profile | DoH | DoT | DoQ | DoH3 | DNSCrypt | +| --- | --- | --- | --- | --- | --- | --- | +| Cloudflare | Unfiltered | Yes | Yes | No | Yes | No | +| Google | Unfiltered | Yes | Yes | No | Yes | No | +| Quad9 | Security-filtered | Yes | Yes | No | No | No | +| AdGuard | Ad- and security-filtered | Yes | Yes | Yes | No | Yes | Filtering policies can affect DNS answers. Results always identify the provider and profile used. @@ -122,11 +158,37 @@ Every query returns structured JSON compatible with it is not local DNSSEC validation. - `transport.bootstrap: system_resolver` means the operating system resolver was used to locate the encrypted resolver endpoint. +- `transport.proxy`, when present, is the HTTP(S) proxy endpoint actually + selected for DoH or DoT. Embedded credentials are never reported. +- DNSCrypt reports `bootstrap: stamp_ip`, the authenticated provider name, + resolver certificate serial, and selected crypto construction. +- DoH and DoH3 subtract a valid HTTP `Age` value from returned answer TTLs and + report it as `transport.http_age_seconds`. +- Truncated or non-representable DNS answers are protocol failures rather than + partial `completed: true` results. + +Human-readable usage errors go to stderr. Machine-readable operational results +go to stdout. Stable exit codes are: + +| Code | Meaning | +| --- | --- | +| `0` | The requested encrypted DNS operation completed; inspect `dns.rcode`. | +| `1` | Local or internal failure. | +| `2` | Invalid input or CLI usage. | +| `3` | Transport or DNS protocol failure. | +| `4` | Known but unsupported capability or provider/protocol combination. | + +See [`references/contracts.md`](references/contracts.md) for the complete v1 +command and result contract. ## Security Model - DoH uses standard `application/dns-message` wire messages. - DoT verifies the PKIX certificate chain and configured authentication domain. +- DoT advertises ALPN `dot`; an empty selection is accepted and reported, while + selection of a different application protocol is rejected. +- DNSCrypt validates the resolver stamp, Ed25519-signed certificate, validity + interval, provider identity, and encrypted response before accepting DNS data. - Plaintext fallback is prohibited. - DNS errors are not retried through another protocol as transport failures. - Provider and protocol results remain separate. @@ -140,7 +202,19 @@ model and privacy boundaries. ```bash go test ./... +go test -race ./... go vet ./... +go run honnef.co/go/tools/cmd/staticcheck@v0.7.0 ./... +go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./... +python3 scripts/validate_skill.py . +``` + +Public endpoint interoperability tests are opt-in and skip cleanly when the +host cannot reach the network: + +```bash +EDNSDIAG_PUBLIC_INTEROP=1 go test ./internal/edns \ + -run '^TestPublicCloudflareDo[HT]Interoperability$' -count=1 -v ``` Protocol behavior must remain aligned with diff --git a/.agents/skills/encrypted-dns-skill/SKILL.md b/.agents/skills/encrypted-dns-skill/SKILL.md index e6a41aa..6e47b79 100644 --- a/.agents/skills/encrypted-dns-skill/SKILL.md +++ b/.agents/skills/encrypted-dns-skill/SKILL.md @@ -9,7 +9,7 @@ Use `ednsdiag` for encrypted DNS work. Do not assemble protocol requests with `curl`, `openssl`, or ad-hoc scripts when `ednsdiag` supports the operation. The executable requires network access. -Prefer an installed `ednsdiag` executable. When it is unavailable and Go 1.26+ +Prefer an installed `ednsdiag` executable. When it is unavailable and Go 1.26.6+ is installed, run the source from the skill root with: ```bash @@ -35,14 +35,27 @@ Only use a protocol when its reported status is `available`. Never describe a ```bash ednsdiag query example.com A --protocol doh --provider cloudflare ednsdiag query gmail.com MX --protocol dot --provider google --timeout 5s +ednsdiag query example.com AAAA --protocol doq --provider adguard +ednsdiag query example.com HTTPS --protocol doh3 --provider cloudflare +ednsdiag query example.com A --protocol dnscrypt --provider adguard +ednsdiag probe example.com A --protocol dot --provider cloudflare +ednsdiag compare example.com A --target doh:cloudflare --target dot:google ednsdiag capabilities ednsdiag version ``` -Use `--method get` or `--method post` only with DoH. The default is POST. +Use `--method get` or `--method post` only with DoH or DoH3. The default is POST. Built-in providers are `cloudflare`, `google`, `quad9`, and `adguard`. Provider -filtering policies differ and are included in the result. `probe` and `compare` -remain reserved until their capabilities are implemented. +protocol support and filtering policies differ and are included in the result. +Run `capabilities` and do not infer an unsupported endpoint. `probe` executes +one diagnostic query while labeling the operation for automation. `compare` +requires two or more explicit `protocol:provider[:method]` targets and preserves +each result independently. + +For a user-requested HTTP(S) proxy, pass `--proxy http://host:port`. Without +that flag, DoH and DoT honor `HTTPS_PROXY` and `NO_PROXY`. Only DoH and DoT can +use this CONNECT proxy; do not add `--proxy` to DoH3, DoQ, or DNSCrypt commands. +Never expose proxy credentials when quoting a command or interpreting output. ## Required behavior @@ -65,7 +78,16 @@ remain reserved until their capabilities are implemented. - Read `transport.server_authenticated` separately from DNSSEC fields. - Read `transport.bootstrap`; `system_resolver` means resolving the encrypted resolver endpoint itself used the operating system resolver. +- If `transport.proxy` is present, the exchange used that sanitized proxy + endpoint; credentials are deliberately omitted. +- For DNSCrypt, `stamp_ip` means the authenticated resolver stamp supplied the + connection address; verify `resolver.authentication_name` and certificate + metadata in the result. - Empty answers with `NOERROR` represent NODATA. +- Treat truncated or non-representable answers as protocol failures; never + infer a partial result from an incomplete exchange. +- For DoH and DoH3, `transport.http_age_seconds` is already subtracted from + answer TTLs when an HTTP cache reports an age. - A filtering resolver may synthesize `NXDOMAIN`; disclose the provider. ## References @@ -78,6 +100,8 @@ remain reserved until their capabilities are implemented. modifying a built-in provider. - Keep output compatible with [schemas/result-v1.schema.json](schemas/result-v1.schema.json). +- Read [references/contracts.md](references/contracts.md) when integrating the + CLI with an agent or changing command, exit-code, or JSON behavior. ## Scope diff --git a/.agents/skills/encrypted-dns-skill/cmd/ednsdiag/main.go b/.agents/skills/encrypted-dns-skill/cmd/ednsdiag/main.go index 7e05ef4..2e8f01f 100644 --- a/.agents/skills/encrypted-dns-skill/cmd/ednsdiag/main.go +++ b/.agents/skills/encrypted-dns-skill/cmd/ednsdiag/main.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "os" + "strconv" "strings" "time" @@ -14,6 +15,20 @@ import ( const version = "0.1.0-dev" +const ( + exitSuccess = 0 + exitLocal = 1 + exitUsage = 2 + exitTransport = 3 + exitUnsupported = 4 +) + +var ( + runQuery = edns.Query + runProbe = edns.Probe + runCompare = edns.Compare +) + type capability struct { Protocol string `json:"protocol"` Status string `json:"status"` @@ -51,9 +66,9 @@ func run(args []string, stdout, stderr io.Writer) int { Capabilities: []capability{ {Protocol: "doh", Status: "available", Standard: "RFC 8484", Note: "RFC wire format over HTTP GET or POST"}, {Protocol: "dot", Status: "available", Standard: "RFC 7858 and RFC 8310", Note: "strict PKIX and authentication-domain validation"}, - {Protocol: "doq", Status: "planned", Standard: "RFC 9250"}, - {Protocol: "doh3", Status: "planned", Standard: "RFC 8484 over HTTP/3"}, - {Protocol: "dnscrypt", Status: "planned", Standard: "DNSCrypt protocol specification"}, + {Protocol: "doq", Status: "available", Standard: "RFC 9250", Note: "RFC wire format over dedicated QUIC streams"}, + {Protocol: "doh3", Status: "available", Standard: "RFC 8484 over HTTP/3", Note: "RFC wire format over HTTP/3 GET or POST"}, + {Protocol: "dnscrypt", Status: "available", Standard: "DNSCrypt protocol specification", Note: "DNSCrypt v2 with authenticated resolver certificates"}, {Protocol: "odoh", Status: "research", Standard: "RFC 9230", Note: "No maintained Go dependency has been selected."}, {Protocol: "anonymized-dnscrypt", Status: "research", Standard: "Anonymized DNSCrypt specification"}, }, @@ -68,7 +83,7 @@ func run(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stdout, version) return 0 - case "query": + case "query", "probe": options, timeout, err := parseQueryArgs(args[1:]) if err != nil { fmt.Fprintln(stderr, err) @@ -77,21 +92,31 @@ func run(args []string, stdout, stderr io.Writer) int { } ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - result := edns.Query(ctx, options) + var result edns.Result + if args[0] == "probe" { + result = runProbe(ctx, options) + } else { + result = runQuery(ctx, options) + } if code := writeJSON(stdout, stderr, result); code != 0 { return code } - if result.Completed { - return 0 - } - if result.Error != nil && result.Error.Class == "input" { - return 2 - } - return 3 + return resultExitCode(result.Completed, result.Error) - case "probe", "compare": - fmt.Fprintf(stderr, "%s is not implemented in %s; run ednsdiag capabilities\n", args[0], version) - return 4 + case "compare": + options, timeout, err := parseCompareArgs(args[1:]) + if err != nil { + fmt.Fprintln(stderr, err) + writeCompareUsage(stderr) + return exitUsage + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + result := runCompare(ctx, options) + if code := writeJSON(stdout, stderr, result); code != 0 { + return code + } + return resultExitCode(result.Completed, result.Error) default: fmt.Fprintf(stderr, "unknown command %q\n", args[0]) @@ -129,8 +154,6 @@ func parseQueryArgs(args []string) (edns.QueryOptions, time.Duration, error) { options.Protocol = strings.ToLower(value) case "provider": options.Provider = strings.ToLower(value) - case "url": - options.EndpointURL = value case "method": options.Method = strings.ToLower(value) case "timeout": @@ -139,6 +162,8 @@ func parseQueryArgs(args []string) (edns.QueryOptions, time.Duration, error) { return options, 0, fmt.Errorf("invalid timeout %q: %w", value, err) } timeout = parsed + case "proxy": + options.Proxy = value default: return options, 0, fmt.Errorf("unknown query option --%s", key) } @@ -153,18 +178,191 @@ func parseQueryArgs(args []string) (edns.QueryOptions, time.Duration, error) { if len(positionals) == 2 { options.RecordType = strings.ToUpper(positionals[1]) } - if options.Protocol != "doh" && options.Protocol != "dot" { - return options, 0, fmt.Errorf("protocol %q is not available", options.Protocol) + if !knownRecordType(options.RecordType) { + return options, 0, fmt.Errorf("unsupported record type %q", options.RecordType) + } + if !knownProtocol(options.Protocol) { + return options, 0, fmt.Errorf("unknown protocol %q", options.Protocol) + } + if _, err := edns.FindProvider(options.Provider); err != nil { + return options, 0, err } if options.Method != "get" && options.Method != "post" { return options, 0, fmt.Errorf("DoH method must be get or post") } - if options.Protocol == "dot" && options.Method != "post" { - return options, 0, fmt.Errorf("--method applies only to DoH") + if options.Protocol != "doh" && options.Protocol != "doh3" && options.Method != "post" { + return options, 0, fmt.Errorf("--method applies only to DoH and DoH3") + } + if err := edns.ValidateProxyURL(options.Proxy); err != nil { + return options, 0, err + } + if options.Proxy != "" && options.Protocol != "doh" && options.Protocol != "dot" { + return options, 0, fmt.Errorf("--proxy applies only to DoH and DoT") } return options, timeout, nil } +func parseCompareArgs(args []string) (edns.CompareOptions, time.Duration, error) { + options := edns.CompareOptions{RecordType: "A", AttemptTimeout: 5 * time.Second, MaxAttempts: 4} + totalTimeout := 30 * time.Second + positionals := make([]string, 0, 2) + seenTargets := map[string]bool{} + + for index := 0; index < len(args); index++ { + argument := args[index] + if !strings.HasPrefix(argument, "--") { + positionals = append(positionals, argument) + continue + } + key, value, found := strings.Cut(strings.TrimPrefix(argument, "--"), "=") + if !found { + index++ + if index >= len(args) { + return options, 0, fmt.Errorf("--%s requires a value", key) + } + value = args[index] + } + switch key { + case "target": + target, err := parseCompareTarget(value) + if err != nil { + return options, 0, err + } + identity := target.Protocol + ":" + target.Provider + ":" + target.Method + if seenTargets[identity] { + return options, 0, fmt.Errorf("duplicate comparison target %q", value) + } + seenTargets[identity] = true + options.Targets = append(options.Targets, target) + case "timeout": + parsed, err := time.ParseDuration(value) + if err != nil { + return options, 0, fmt.Errorf("invalid timeout %q: %w", value, err) + } + totalTimeout = parsed + case "attempt-timeout": + parsed, err := time.ParseDuration(value) + if err != nil { + return options, 0, fmt.Errorf("invalid attempt timeout %q: %w", value, err) + } + options.AttemptTimeout = parsed + case "max-attempts": + parsed, err := strconv.Atoi(value) + if err != nil { + return options, 0, fmt.Errorf("invalid max attempts %q", value) + } + options.MaxAttempts = parsed + case "proxy": + options.Proxy = value + default: + return options, 0, fmt.Errorf("unknown compare option --%s", key) + } + } + + if len(positionals) < 1 || len(positionals) > 2 { + return options, 0, fmt.Errorf("compare requires a domain and optional record type") + } + options.Name = positionals[0] + if len(positionals) == 2 { + options.RecordType = strings.ToUpper(positionals[1]) + } + if !knownRecordType(options.RecordType) { + return options, 0, fmt.Errorf("unsupported record type %q", options.RecordType) + } + if len(options.Targets) < 2 { + return options, 0, fmt.Errorf("compare requires at least two --target values") + } + if options.MaxAttempts < 2 || options.MaxAttempts > 8 { + return options, 0, fmt.Errorf("max attempts must be between 2 and 8") + } + if len(options.Targets) > options.MaxAttempts { + return options, 0, fmt.Errorf("comparison targets exceed max attempts") + } + if totalTimeout < 250*time.Millisecond || totalTimeout > 60*time.Second { + return options, 0, fmt.Errorf("compare timeout must be between 250ms and 60s") + } + if options.AttemptTimeout < 250*time.Millisecond || options.AttemptTimeout > 30*time.Second { + return options, 0, fmt.Errorf("attempt timeout must be between 250ms and 30s") + } + if options.AttemptTimeout > totalTimeout { + return options, 0, fmt.Errorf("attempt timeout cannot exceed compare timeout") + } + if err := edns.ValidateProxyURL(options.Proxy); err != nil { + return options, 0, err + } + if options.Proxy != "" { + for _, target := range options.Targets { + if target.Protocol != "doh" && target.Protocol != "dot" { + return options, 0, fmt.Errorf("--proxy cannot be used with %s comparison targets", target.Protocol) + } + } + } + return options, totalTimeout, nil +} + +func parseCompareTarget(value string) (edns.CompareTarget, error) { + parts := strings.Split(value, ":") + if len(parts) < 2 || len(parts) > 3 { + return edns.CompareTarget{}, fmt.Errorf("target %q must be protocol:provider[:method]", value) + } + target := edns.CompareTarget{Protocol: strings.ToLower(parts[0]), Provider: strings.ToLower(parts[1]), Method: "post"} + if len(parts) == 3 { + target.Method = strings.ToLower(parts[2]) + } + if !knownProtocol(target.Protocol) { + return target, fmt.Errorf("unknown protocol %q", target.Protocol) + } + if _, err := edns.FindProvider(target.Provider); err != nil { + return target, err + } + if target.Method != "get" && target.Method != "post" { + return target, fmt.Errorf("target method must be get or post") + } + if target.Protocol != "doh" && target.Protocol != "doh3" && target.Method != "post" { + return target, fmt.Errorf("GET method applies only to DoH and DoH3 targets") + } + return target, nil +} + +func knownProtocol(protocol string) bool { + switch protocol { + case "doh", "dot", "doq", "doh3", "dnscrypt", "odoh", "anonymized-dnscrypt": + return true + default: + return false + } +} + +func knownRecordType(recordType string) bool { + switch recordType { + case "A", "AAAA", "CNAME", "MX", "TXT", "NS", "SOA", "CAA", "SRV", "PTR", "HTTPS", "SVCB": + return true + default: + return false + } +} + +func resultExitCode(completed bool, resultError *edns.ErrorInfo) int { + if completed { + return exitSuccess + } + if resultError == nil { + return exitLocal + } + switch resultError.Class { + case "internal": + return exitLocal + case "input": + return exitUsage + case "unsupported": + return exitUnsupported + case "transport", "protocol": + return exitTransport + default: + return exitLocal + } +} + func writeJSON(stdout, stderr io.Writer, value any) int { encoder := json.NewEncoder(stdout) encoder.SetIndent("", " ") @@ -180,5 +378,9 @@ func writeUsage(writer io.Writer) { } func writeQueryUsage(writer io.Writer) { - fmt.Fprintln(writer, "usage: ednsdiag query [type] [--protocol doh|dot] [--provider cloudflare|google|quad9|adguard] [--url https://host/dns-query] [--method post|get] [--timeout 5s]") + fmt.Fprintln(writer, "usage: ednsdiag [type] [--protocol doh|dot|doq|doh3|dnscrypt|odoh|anonymized-dnscrypt] [--provider cloudflare|google|quad9|adguard] [--method post|get] [--proxy http://host:port] [--timeout 5s]") +} + +func writeCompareUsage(writer io.Writer) { + fmt.Fprintln(writer, "usage: ednsdiag compare [type] --target protocol:provider[:method] --target protocol:provider[:method] [--proxy http://host:port] [--attempt-timeout 5s] [--timeout 30s] [--max-attempts 4]") } diff --git a/.agents/skills/encrypted-dns-skill/cmd/ednsdiag/main_test.go b/.agents/skills/encrypted-dns-skill/cmd/ednsdiag/main_test.go index 8bd58f1..e16b897 100644 --- a/.agents/skills/encrypted-dns-skill/cmd/ednsdiag/main_test.go +++ b/.agents/skills/encrypted-dns-skill/cmd/ednsdiag/main_test.go @@ -2,10 +2,16 @@ package main import ( "bytes" + "context" "encoding/json" + "os" + "path/filepath" "strings" "testing" "time" + + "github.com/santhosh-tekuri/jsonschema/v6" + "github.com/windyboy/encrypted-dns-skill/internal/edns" ) func TestCapabilities(t *testing.T) { @@ -21,62 +27,259 @@ func TestCapabilities(t *testing.T) { if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { t.Fatalf("decode capabilities: %v", err) } - if result.SchemaVersion != 1 { - t.Fatalf("schema version = %d, want 1", result.SchemaVersion) - } - if result.Command != "capabilities" { - t.Fatalf("command = %q, want capabilities", result.Command) - } - if len(result.Capabilities) == 0 { - t.Fatal("capabilities list is empty") + if result.SchemaVersion != 1 || result.Command != "capabilities" { + t.Fatalf("unexpected capabilities envelope: %#v", result) } available := map[string]bool{} for _, item := range result.Capabilities { available[item.Protocol] = item.Status == "available" } - if !available["doh"] || !available["dot"] { - t.Fatalf("DoH and DoT must be available: %#v", available) - } - if available["doq"] || available["doh3"] || available["dnscrypt"] { - t.Fatalf("planned transports must not be available: %#v", available) + if !available["doh"] || !available["dot"] || !available["doq"] || !available["doh3"] || !available["dnscrypt"] { + t.Fatalf("DoH, DoT, DoQ, DoH3, and DNSCrypt must be available: %#v", available) } } -func TestReservedCommandIsNotImplemented(t *testing.T) { +func TestQueryProbeAndCompareGoldenContracts(t *testing.T) { + originalQuery, originalProbe, originalCompare := runQuery, runProbe, runCompare + t.Cleanup(func() { + runQuery, runProbe, runCompare = originalQuery, originalProbe, originalCompare + }) + + queryCalls := 0 + probeCalls := 0 + runQuery = func(_ context.Context, options edns.QueryOptions) edns.Result { + queryCalls++ + return successfulResult("query", options.Protocol, options.Provider, "203.0.113.10") + } + runProbe = func(_ context.Context, options edns.QueryOptions) edns.Result { + probeCalls++ + return successfulResult("probe", options.Protocol, options.Provider, "203.0.113.10") + } + runCompare = func(_ context.Context, _ edns.CompareOptions) edns.CompareResult { + first := successfulResult("query", "doh", "cloudflare", "203.0.113.10") + second := successfulResult("query", "dot", "google", "203.0.113.20") + return edns.CompareResult{ + SchemaVersion: 1, + Operation: "compare", + Completed: true, + Query: edns.QueryInfo{Name: "example.com", Type: "A"}, + Attempts: []edns.Result{first, second}, + Summary: edns.CompareSummary{Total: 2, Completed: 2}, + } + } + + tests := []struct { + name string + args []string + golden string + }{ + {name: "query", args: []string{"query", "example.com", "A"}, golden: "query.golden.json"}, + {name: "probe", args: []string{"probe", "example.com", "A"}, golden: "probe.golden.json"}, + {name: "compare", args: []string{"compare", "example.com", "A", "--target", "doh:cloudflare", "--target", "dot:google"}, golden: "compare.golden.json"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + if code := run(test.args, &stdout, &stderr); code != exitSuccess { + t.Fatalf("run returned %d; stderr=%q", code, stderr.String()) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + want, err := os.ReadFile(filepath.Join("testdata", test.golden)) + if err != nil { + t.Fatalf("read golden: %v", err) + } + want = bytes.ReplaceAll(want, []byte("\r\n"), []byte("\n")) + if !bytes.Equal(stdout.Bytes(), want) { + t.Fatalf("stdout does not match %s\nwant:\n%s\ngot:\n%s", test.golden, want, stdout.Bytes()) + } + validateResultSchema(t, stdout.Bytes()) + }) + } + + if queryCalls != 1 || probeCalls != 1 { + t.Fatalf("query calls = %d, probe calls = %d; each operation must invoke only its own runner", queryCalls, probeCalls) + } +} + +func TestUnsupportedProtocolReturnsStableJSONAndExitCode(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - - code := run([]string{"probe"}, &stdout, &stderr) - if code != 4 { - t.Fatalf("run probe returned %d, want 4", code) + code := run([]string{"query", "example.com", "A", "--protocol", "odoh", "--provider", "cloudflare"}, &stdout, &stderr) + if code != exitUnsupported { + t.Fatalf("run returned %d, want %d; stderr=%q", code, exitUnsupported, stderr.String()) } - if !strings.Contains(stderr.String(), "not implemented") { - t.Fatalf("stderr = %q, want not implemented message", stderr.String()) + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty for a structured operational result", stderr.String()) + } + var result edns.Result + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("decode result: %v", err) + } + if result.Error == nil || result.Error.Class != "unsupported" { + t.Fatalf("unexpected error result: %#v", result) + } + validateResultSchema(t, stdout.Bytes()) +} + +func TestUsageDiagnosticsStayOnStderr(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + code := run([]string{"compare", "example.com", "--target", "doh:cloudflare"}, &stdout, &stderr) + if code != exitUsage { + t.Fatalf("run returned %d, want %d", code, exitUsage) + } + if stdout.Len() != 0 || !strings.Contains(stderr.String(), "at least two") { + t.Fatalf("stdout=%q stderr=%q", stdout.String(), stderr.String()) } } -func TestParseQueryArgsAllowsInterspersedOptions(t *testing.T) { - options, timeout, err := parseQueryArgs([]string{"example.com", "MX", "--protocol", "dot", "--provider=quad9", "--timeout", "3s"}) +func TestParseQueryArgsAllowsSupportedAndResearchProtocols(t *testing.T) { + for _, protocol := range []string{"dot", "doq", "doh3", "dnscrypt", "odoh", "anonymized-dnscrypt"} { + t.Run(protocol, func(t *testing.T) { + options, timeout, err := parseQueryArgs([]string{"example.com", "MX", "--protocol", protocol, "--provider=quad9", "--timeout", "3s"}) + if err != nil { + t.Fatalf("parse query args: %v", err) + } + if options.Protocol != protocol || timeout != 3*time.Second { + t.Fatalf("unexpected options=%#v timeout=%v", options, timeout) + } + }) + } +} + +func TestParseQueryArgsAcceptsProxyForDoHAndDoTOnly(t *testing.T) { + for _, protocol := range []string{"doh", "dot"} { + options, _, err := parseQueryArgs([]string{"example.com", "--protocol", protocol, "--proxy", "http://proxy.example:8080"}) + if err != nil { + t.Fatalf("parse %s proxy: %v", protocol, err) + } + if options.Proxy != "http://proxy.example:8080" { + t.Fatalf("proxy = %q", options.Proxy) + } + } + if _, _, err := parseQueryArgs([]string{"example.com", "--protocol", "doq", "--provider", "adguard", "--proxy", "http://proxy.example:8080"}); err == nil { + t.Fatal("DoQ accepted an HTTP proxy") + } + if _, _, err := parseQueryArgs([]string{"example.com", "--proxy", "socks5://proxy.example:1080"}); err == nil { + t.Fatal("unsupported proxy scheme was accepted") + } +} + +func TestParseCompareArgsAcceptsSharedProxyForTCPAndHTTPTargets(t *testing.T) { + options, _, err := parseCompareArgs([]string{ + "example.com", "--target", "doh:cloudflare", "--target", "dot:google", "--proxy", "https://proxy.example:8443", + }) if err != nil { - t.Fatalf("parse query args: %v", err) + t.Fatalf("parse compare proxy: %v", err) } - if options.Name != "example.com" || options.RecordType != "MX" || options.Protocol != "dot" || options.Provider != "quad9" { - t.Fatalf("unexpected options: %#v", options) + if options.Proxy != "https://proxy.example:8443" { + t.Fatalf("proxy = %q", options.Proxy) } - if timeout != 3*time.Second { - t.Fatalf("timeout = %v, want 3s", timeout) + if _, _, err := parseCompareArgs([]string{ + "example.com", "--target", "doh:cloudflare", "--target", "doq:adguard", "--proxy", "http://proxy.example:8080", + }); err == nil { + t.Fatal("compare accepted a proxy with a QUIC target") + } +} + +func TestParseCompareArgsRejectsDuplicatesAndLimits(t *testing.T) { + for _, args := range [][]string{ + {"example.com", "--target", "doh:cloudflare", "--target", "doh:cloudflare"}, + {"example.com", "--target", "doh:cloudflare", "--target", "dot:google", "--max-attempts", "1"}, + {"example.com", "--target", "doh:cloudflare", "--target", "dot:google", "--attempt-timeout", "10s", "--timeout", "5s"}, + } { + if _, _, err := parseCompareArgs(args); err == nil { + t.Fatalf("parseCompareArgs(%q) succeeded, want error", args) + } + } +} + +func TestResultExitCodes(t *testing.T) { + tests := []struct { + completed bool + class string + want int + }{ + {completed: true, want: exitSuccess}, + {class: "internal", want: exitLocal}, + {class: "input", want: exitUsage}, + {class: "transport", want: exitTransport}, + {class: "protocol", want: exitTransport}, + {class: "unsupported", want: exitUnsupported}, + } + for _, test := range tests { + var resultError *edns.ErrorInfo + if test.class != "" { + resultError = &edns.ErrorInfo{Class: test.class} + } + if got := resultExitCode(test.completed, resultError); got != test.want { + t.Fatalf("resultExitCode(%v, %q) = %d, want %d", test.completed, test.class, got, test.want) + } } } func TestUnknownCommand(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - - code := run([]string{"unknown"}, &stdout, &stderr) - if code != 2 { - t.Fatalf("run unknown returned %d, want 2", code) + if code := run([]string{"unknown"}, &stdout, &stderr); code != exitUsage { + t.Fatalf("run unknown returned %d, want %d", code, exitUsage) } - if !strings.Contains(stderr.String(), "unknown command") { - t.Fatalf("stderr = %q, want unknown command message", stderr.String()) + if !strings.Contains(stderr.String(), "unknown command") || stdout.Len() != 0 { + t.Fatalf("stdout=%q stderr=%q", stdout.String(), stderr.String()) + } +} + +func successfulResult(operation, protocol, provider, address string) edns.Result { + return edns.Result{ + SchemaVersion: 1, + Operation: operation, + Completed: true, + Query: edns.QueryInfo{Name: "example.com", Type: "A"}, + Resolver: edns.ResolverInfo{Provider: provider, Endpoint: provider + ".example:443", Profile: "test"}, + Transport: edns.TransportInfo{ + Protocol: protocol, + Encrypted: true, + ServerAuthenticated: true, + ElapsedMS: 12, + Bootstrap: "test_fixture", + }, + DNS: edns.DNSInfo{ + RCode: "NOERROR", + RCodeValue: 0, + Answers: []edns.AnswerRecord{{ + "name": "example.com", "type": "A", "ttl": float64(60), "address": address, + }}, + }, + } +} + +func validateResultSchema(t *testing.T, document []byte) { + t.Helper() + schemaBytes, err := os.ReadFile(filepath.Join("..", "..", "schemas", "result-v1.schema.json")) + if err != nil { + t.Fatalf("read result schema: %v", err) + } + var schemaDocument any + if err := json.Unmarshal(schemaBytes, &schemaDocument); err != nil { + t.Fatalf("decode result schema: %v", err) + } + compiler := jsonschema.NewCompiler() + if err := compiler.AddResource("result-v1.schema.json", schemaDocument); err != nil { + t.Fatalf("add result schema: %v", err) + } + schema, err := compiler.Compile("result-v1.schema.json") + if err != nil { + t.Fatalf("compile result schema: %v", err) + } + var value any + if err := json.Unmarshal(document, &value); err != nil { + t.Fatalf("decode result JSON: %v", err) + } + if err := schema.Validate(value); err != nil { + t.Fatalf("result does not validate against result-v1: %v", err) } } diff --git a/.agents/skills/encrypted-dns-skill/go.mod b/.agents/skills/encrypted-dns-skill/go.mod index a665ce0..81312a1 100644 --- a/.agents/skills/encrypted-dns-skill/go.mod +++ b/.agents/skills/encrypted-dns-skill/go.mod @@ -1,7 +1,25 @@ module github.com/windyboy/encrypted-dns-skill -go 1.26.0 +go 1.26.6 require golang.org/x/net v0.58.0 -require golang.org/x/text v0.41.0 // indirect +require ( + github.com/ameshkov/dnscrypt/v2 v2.4.0 + github.com/miekg/dns v1.1.72 + github.com/quic-go/quic-go v0.61.0 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 +) + +require ( + github.com/AdguardTeam/golibs v0.32.7 // indirect + github.com/ameshkov/dnsstamps v1.0.3 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + golang.org/x/tools v0.48.0 // indirect +) diff --git a/.agents/skills/encrypted-dns-skill/go.sum b/.agents/skills/encrypted-dns-skill/go.sum index cc9727d..ac7ca3e 100644 --- a/.agents/skills/encrypted-dns-skill/go.sum +++ b/.agents/skills/encrypted-dns-skill/go.sum @@ -1,4 +1,46 @@ +github.com/AdguardTeam/golibs v0.32.7 h1:3dmGlAVgmvquCCwHsvEl58KKcRAK3z1UnjMnwSIeDH4= +github.com/AdguardTeam/golibs v0.32.7/go.mod h1:bE8KV1zqTzgZjmjFyBJ9f9O5DEKO717r7e57j1HclJA= +github.com/ameshkov/dnscrypt/v2 v2.4.0 h1:if6ZG2cuQmcP2TwSY+D0+8+xbPfoatufGlOQTMNkI9o= +github.com/ameshkov/dnscrypt/v2 v2.4.0/go.mod h1:WpEFV2uhebXb8Jhes/5/fSdpmhGV8TL22RDaeWwV6hI= +github.com/ameshkov/dnsstamps v1.0.3 h1:Srzik+J9mivH1alRACTbys2xOxs0lRH9qnTA7Y1OYVo= +github.com/ameshkov/dnsstamps v1.0.3/go.mod h1:Ii3eUu73dx4Vw5O4wjzmT5+lkCwovjzaEZZ4gKyIH5A= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA= +github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/.agents/skills/encrypted-dns-skill/internal/edns/dns.go b/.agents/skills/encrypted-dns-skill/internal/edns/dns.go index ceaf24a..d389365 100644 --- a/.agents/skills/encrypted-dns-skill/internal/edns/dns.go +++ b/.agents/skills/encrypted-dns-skill/internal/edns/dns.go @@ -6,6 +6,7 @@ import ( "encoding/binary" "fmt" "net" + "net/netip" "strings" "golang.org/x/net/dns/dnsmessage" @@ -24,20 +25,31 @@ var recordTypes = map[string]dnsmessage.Type{ "SRV": dnsmessage.TypeSRV, "SVCB": dnsmessage.TypeSVCB, "HTTPS": dnsmessage.TypeHTTPS, + "PTR": dnsmessage.TypePTR, } func BuildQuery(name, recordType string) ([]byte, QueryInfo, uint16, error) { - canonical, err := canonicalName(name) - if err != nil { - return nil, QueryInfo{}, 0, err - } - typeName := strings.ToUpper(recordType) qtype, ok := recordTypes[typeName] if !ok { return nil, QueryInfo{}, 0, fmt.Errorf("unsupported record type %q", recordType) } + var canonical string + var err error + if typeName == "PTR" { + address, parseErr := netip.ParseAddr(strings.TrimSpace(name)) + if parseErr != nil { + return nil, QueryInfo{}, 0, fmt.Errorf("PTR queries require an IPv4 or IPv6 address") + } + canonical = reverseName(address.Unmap()) + } else { + canonical, err = canonicalName(name) + } + if err != nil { + return nil, QueryInfo{}, 0, err + } + dnsName, err := dnsmessage.NewName(canonical + ".") if err != nil { return nil, QueryInfo{}, 0, fmt.Errorf("encode domain name: %w", err) @@ -63,6 +75,22 @@ func BuildQuery(name, recordType string) ([]byte, QueryInfo, uint16, error) { return wire, QueryInfo{Name: canonical, Type: typeName}, id, nil } +func reverseName(address netip.Addr) string { + if address.Is4() { + bytes := address.As4() + return fmt.Sprintf("%d.%d.%d.%d.in-addr.arpa", bytes[3], bytes[2], bytes[1], bytes[0]) + } + + bytes := address.As16() + var builder strings.Builder + // Each IPv6 nibble is emitted from least to most significant per RFC 3596. + for index := len(bytes) - 1; index >= 0; index-- { + fmt.Fprintf(&builder, "%x.%x.", bytes[index]&0x0f, bytes[index]>>4) + } + builder.WriteString("ip6.arpa") + return builder.String() +} + func ParseResponse(wire []byte, expectedID uint16, query QueryInfo) (DNSInfo, error) { var message dnsmessage.Message if err := message.Unpack(wire); err != nil { @@ -71,6 +99,12 @@ func ParseResponse(wire []byte, expectedID uint16, query QueryInfo) (DNSInfo, er if !message.Header.Response { return DNSInfo{}, fmt.Errorf("received a DNS query instead of a response") } + if message.Header.OpCode != 0 { + return DNSInfo{}, fmt.Errorf("DNS response uses unexpected opcode %d", message.Header.OpCode) + } + if message.Header.Truncated { + return DNSInfo{}, fmt.Errorf("DNS response is truncated") + } if message.Header.ID != expectedID { return DNSInfo{}, fmt.Errorf("DNS transaction ID mismatch") } @@ -79,13 +113,20 @@ func ParseResponse(wire []byte, expectedID uint16, query QueryInfo) (DNSInfo, er } wantType := recordTypes[query.Type] question := message.Questions[0] - if trimRoot(question.Name.String()) != query.Name || question.Type != wantType { + if trimRoot(question.Name.String()) != query.Name || question.Type != wantType || question.Class != dnsmessage.ClassINET { return DNSInfo{}, fmt.Errorf("DNS response question does not match request") } answers := make([]AnswerRecord, 0, len(message.Answers)) for _, resource := range message.Answers { - answers = append(answers, normalizeAnswer(resource)) + if resource.Header.Class != dnsmessage.ClassINET { + return DNSInfo{}, fmt.Errorf("DNS answer %q uses unsupported class %d", trimRoot(resource.Header.Name.String()), resource.Header.Class) + } + answer, err := normalizeAnswer(resource) + if err != nil { + return DNSInfo{}, err + } + answers = append(answers, answer) } return DNSInfo{ @@ -129,7 +170,7 @@ func canonicalName(input string) (string, error) { return ascii, nil } -func normalizeAnswer(resource dnsmessage.Resource) AnswerRecord { +func normalizeAnswer(resource dnsmessage.Resource) (AnswerRecord, error) { record := AnswerRecord{ "name": trimRoot(resource.Header.Name.String()), "type": typeName(resource.Header.Type), @@ -177,13 +218,32 @@ func normalizeAnswer(resource dnsmessage.Resource) AnswerRecord { record["tag"] = string(body.Data[2 : 2+tagLength]) record["value"] = string(body.Data[2+tagLength:]) } else { - record["rdata_base64"] = base64.StdEncoding.EncodeToString(body.Data) + return nil, fmt.Errorf("CAA answer contains a truncated tag") } } else { - record["rdata_base64"] = base64.StdEncoding.EncodeToString(body.Data) + return nil, fmt.Errorf("DNS answer type %s cannot be represented by result-v1", typeName(resource.Header.Type)) + } + default: + return nil, fmt.Errorf("DNS answer type %s has an unexpected wire representation", typeName(resource.Header.Type)) + } + return record, nil +} + +func applyHTTPAge(info *DNSInfo, ageSeconds int64) { + if ageSeconds <= 0 { + return + } + for _, answer := range info.Answers { + ttl, ok := answer["ttl"].(uint32) + if !ok { + continue + } + if ageSeconds >= int64(ttl) { + answer["ttl"] = uint32(0) + } else { + answer["ttl"] = ttl - uint32(ageSeconds) } } - return record } func addSVCBFields(record AnswerRecord, priority uint16, target dnsmessage.Name, params []dnsmessage.SVCParam) { diff --git a/.agents/skills/encrypted-dns-skill/internal/edns/dns_test.go b/.agents/skills/encrypted-dns-skill/internal/edns/dns_test.go index 5573c81..0ba3c25 100644 --- a/.agents/skills/encrypted-dns-skill/internal/edns/dns_test.go +++ b/.agents/skills/encrypted-dns-skill/internal/edns/dns_test.go @@ -2,6 +2,8 @@ package edns import ( "encoding/binary" + "reflect" + "strings" "testing" "golang.org/x/net/dns/dnsmessage" @@ -68,13 +70,87 @@ func TestBuildQueryIDNAAndBlockedNames(t *testing.T) { } } +func TestBuildPTRQueryFromIPAddress(t *testing.T) { + _, ipv4, _, err := BuildQuery("192.0.2.1", "PTR") + if err != nil { + t.Fatalf("build IPv4 PTR query: %v", err) + } + if ipv4.Name != "1.2.0.192.in-addr.arpa" || ipv4.Type != "PTR" { + t.Fatalf("unexpected IPv4 PTR query: %#v", ipv4) + } + _, ipv6, _, err := BuildQuery("2001:db8::1", "PTR") + if err != nil { + t.Fatalf("build IPv6 PTR query: %v", err) + } + if ipv6.Name != "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa" { + t.Fatalf("unexpected IPv6 PTR name: %q", ipv6.Name) + } + _, mappedIPv4, _, err := BuildQuery("::ffff:192.0.2.1", "PTR") + if err != nil { + t.Fatalf("build IPv4-mapped PTR query: %v", err) + } + if mappedIPv4.Name != ipv4.Name { + t.Fatalf("IPv4-mapped PTR name = %q, want %q", mappedIPv4.Name, ipv4.Name) + } + if _, _, _, err := BuildQuery("example.com", "PTR"); err == nil { + t.Fatal("PTR query accepted a non-IP input") + } +} + +func TestNormalizeSupportedAnswerTypes(t *testing.T) { + name := dnsmessage.MustNewName("example.com.") + target := dnsmessage.MustNewName("target.example.") + resources := []dnsmessage.Resource{ + {Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeAAAA, TTL: 60}, Body: &dnsmessage.AAAAResource{AAAA: [16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}}}, + {Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeCNAME, TTL: 60}, Body: &dnsmessage.CNAMEResource{CNAME: target}}, + {Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeMX, TTL: 60}, Body: &dnsmessage.MXResource{Pref: 10, MX: target}}, + {Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeTXT, TTL: 60}, Body: &dnsmessage.TXTResource{TXT: []string{"one", "two"}}}, + {Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeNS, TTL: 60}, Body: &dnsmessage.NSResource{NS: target}}, + {Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeSOA, TTL: 60}, Body: &dnsmessage.SOAResource{NS: target, MBox: target, Serial: 1}}, + {Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeSRV, TTL: 60}, Body: &dnsmessage.SRVResource{Priority: 1, Weight: 2, Port: 443, Target: target}}, + {Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypePTR, TTL: 60}, Body: &dnsmessage.PTRResource{PTR: target}}, + {Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeSVCB, TTL: 60}, Body: &dnsmessage.SVCBResource{Priority: 1, Target: target}}, + {Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeHTTPS, TTL: 60}, Body: &dnsmessage.HTTPSResource{SVCBResource: dnsmessage.SVCBResource{Priority: 1, Target: target}}}, + } + wantTypes := []string{"AAAA", "CNAME", "MX", "TXT", "NS", "SOA", "SRV", "PTR", "SVCB", "HTTPS"} + wantFields := []map[string]any{ + {"address": "2001:db8::1"}, + {"target": "target.example"}, + {"priority": uint16(10), "exchange": "target.example"}, + {"strings": []string{"one", "two"}}, + {"host": "target.example"}, + {"primary_ns": "target.example", "responsible_mailbox": "target.example", "serial": uint32(1)}, + {"priority": uint16(1), "weight": uint16(2), "port": uint16(443), "target": "target.example"}, + {"target": "target.example"}, + {"priority": uint16(1), "target": "target.example", "params": []map[string]any{}}, + {"priority": uint16(1), "target": "target.example", "params": []map[string]any{}}, + } + for index, resource := range resources { + record, err := normalizeAnswer(resource) + if err != nil { + t.Fatalf("normalize %s: %v", wantTypes[index], err) + } + if record["type"] != wantTypes[index] || record["name"] != "example.com" || record["ttl"] != uint32(60) { + t.Fatalf("unexpected %s normalization: %#v", wantTypes[index], record) + } + for field, want := range wantFields[index] { + if got := record[field]; !reflect.DeepEqual(got, want) { + t.Fatalf("%s field %s = %#v, want %#v", wantTypes[index], field, got, want) + } + } + } +} + func TestNormalizeCAA(t *testing.T) { name := dnsmessage.MustNewName("example.com.") data := append([]byte{0, 5}, []byte("issueletsencrypt.org")...) - record := normalizeAnswer(dnsmessage.Resource{ + record, err := normalizeAnswer(dnsmessage.Resource{ Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.Type(257), Class: dnsmessage.ClassINET, TTL: 300}, Body: &dnsmessage.UnknownResource{Type: dnsmessage.Type(257), Data: data}, }) + if err != nil { + t.Fatalf("normalize CAA: %v", err) + } if record["tag"] != "issue" || record["value"] != "letsencrypt.org" { t.Fatalf("unexpected CAA normalization: %#v", record) } @@ -98,3 +174,131 @@ func TestParseResponseRejectsTransactionMismatch(t *testing.T) { t.Fatal("test response ID was not encoded") } } + +func TestParseResponseRejectsIncompleteOrNonStandardMessages(t *testing.T) { + queryWire, query, transactionID, err := BuildQuery("example.com", "A") + if err != nil { + t.Fatalf("build query: %v", err) + } + var request dnsmessage.Message + if err := request.Unpack(queryWire); err != nil { + t.Fatalf("unpack query: %v", err) + } + validAnswer := dnsmessage.Resource{ + Header: dnsmessage.ResourceHeader{Name: request.Questions[0].Name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET, TTL: 60}, + Body: &dnsmessage.AResource{A: [4]byte{192, 0, 2, 1}}, + } + + tests := []struct { + name string + message dnsmessage.Message + want string + }{ + { + name: "truncated", + message: dnsmessage.Message{Header: dnsmessage.Header{ID: transactionID, Response: true, Truncated: true}, + Questions: request.Questions}, + want: "truncated", + }, + { + name: "unexpected opcode", + message: dnsmessage.Message{Header: dnsmessage.Header{ID: transactionID, Response: true, OpCode: 1}, + Questions: request.Questions}, + want: "opcode", + }, + { + name: "non-IN question", + message: dnsmessage.Message{Header: dnsmessage.Header{ID: transactionID, Response: true}, Questions: []dnsmessage.Question{{ + Name: request.Questions[0].Name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassCHAOS, + }}}, + want: "question does not match", + }, + { + name: "non-IN answer", + message: dnsmessage.Message{Header: dnsmessage.Header{ID: transactionID, Response: true}, Questions: request.Questions, Answers: []dnsmessage.Resource{{ + Header: dnsmessage.ResourceHeader{Name: request.Questions[0].Name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassCHAOS, TTL: 60}, + Body: &dnsmessage.AResource{A: [4]byte{192, 0, 2, 1}}, + }}}, + want: "unsupported class", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + wire, err := test.message.Pack() + if err != nil { + t.Fatalf("pack response: %v", err) + } + if _, err := ParseResponse(wire, transactionID, query); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want substring %q", err, test.want) + } + }) + } + + valid := dnsmessage.Message{Header: dnsmessage.Header{ID: transactionID, Response: true}, Questions: request.Questions, Answers: []dnsmessage.Resource{validAnswer}} + if _, err := valid.Pack(); err != nil { + t.Fatalf("valid fixture does not pack: %v", err) + } +} + +func TestParseResponseRejectsNonRepresentableAnswers(t *testing.T) { + queryWire, query, transactionID, err := BuildQuery("example.com", "CAA") + if err != nil { + t.Fatalf("build query: %v", err) + } + var request dnsmessage.Message + if err := request.Unpack(queryWire); err != nil { + t.Fatalf("unpack query: %v", err) + } + + tests := []struct { + name string + record dnsmessage.Resource + contains string + }{ + { + name: "truncated CAA tag", + record: dnsmessage.Resource{ + Header: dnsmessage.ResourceHeader{Name: request.Questions[0].Name, Type: dnsmessage.Type(257), Class: dnsmessage.ClassINET, TTL: 60}, + Body: &dnsmessage.UnknownResource{Type: dnsmessage.Type(257), Data: []byte{0, 5, 'i'}}, + }, + contains: "truncated tag", + }, + { + name: "unknown answer type", + record: dnsmessage.Resource{ + Header: dnsmessage.ResourceHeader{Name: request.Questions[0].Name, Type: dnsmessage.Type(99), Class: dnsmessage.ClassINET, TTL: 60}, + Body: &dnsmessage.UnknownResource{Type: dnsmessage.Type(99), Data: []byte{1, 2}}, + }, + contains: "cannot be represented", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + message := dnsmessage.Message{ + Header: dnsmessage.Header{ID: transactionID, Response: true}, + Questions: request.Questions, + Answers: []dnsmessage.Resource{test.record}, + } + wire, err := message.Pack() + if err != nil { + t.Fatalf("pack response: %v", err) + } + if _, err := ParseResponse(wire, transactionID, query); err == nil || !strings.Contains(err.Error(), test.contains) { + t.Fatalf("error = %v, want substring %q", err, test.contains) + } + }) + } +} + +func TestApplyHTTPAgeClampsAnswerTTL(t *testing.T) { + info := DNSInfo{Answers: []AnswerRecord{ + {"ttl": uint32(120)}, + {"ttl": uint32(30)}, + }} + applyHTTPAge(&info, 45) + if info.Answers[0]["ttl"] != uint32(75) || info.Answers[1]["ttl"] != uint32(0) { + t.Fatalf("unexpected aged TTLs: %#v", info.Answers) + } +} diff --git a/.agents/skills/encrypted-dns-skill/internal/edns/doh.go b/.agents/skills/encrypted-dns-skill/internal/edns/doh.go index 24f3c24..893b2f4 100644 --- a/.agents/skills/encrypted-dns-skill/internal/edns/doh.go +++ b/.agents/skills/encrypted-dns-skill/internal/edns/doh.go @@ -11,28 +11,46 @@ import ( "net" "net/http" "net/url" + "strconv" "strings" "time" ) const maxDNSMessageSize = 65535 -func exchangeDoH(ctx context.Context, provider Provider, wire []byte, method string) ([]byte, TransportInfo, error) { - client := newDoHClient(provider.DoHURL) - return exchangeDoHWithClient(ctx, client, provider.DoHURL, wire, method) +func exchangeDoH(ctx context.Context, provider Provider, wire []byte, method, explicitProxy string) ([]byte, TransportInfo, error) { + client, proxyLabel, err := newDoHClient(provider.DoHURL, explicitProxy) + if err != nil { + return nil, TransportInfo{Protocol: "doh", Encrypted: true, Bootstrap: "system_resolver"}, err + } + response, info, err := exchangeDoHWithClient(ctx, client, provider.DoHURL, wire, method) + info.Proxy = proxyLabel + return response, info, err } -func newDoHClient(endpoint string) *http.Client { - origin, _ := url.Parse(endpoint) +func newDoHClient(endpoint, explicitProxy string) (*http.Client, string, error) { + return newDoHClientWithTLSConfig(endpoint, explicitProxy, &tls.Config{MinVersion: tls.VersionTLS12}) +} + +func newDoHClientWithTLSConfig(endpoint, explicitProxy string, tlsConfig *tls.Config) (*http.Client, string, error) { + origin, err := url.Parse(endpoint) + if err != nil { + return nil, "", fmt.Errorf("parse DoH endpoint: %w", err) + } + proxyURL, err := resolveProxy(origin, explicitProxy) + if err != nil { + return nil, "", fmt.Errorf("select DoH proxy: %w", err) + } transport := &http.Transport{ - ForceAttemptHTTP2: true, - DialContext: (&net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}).DialContext, - TLSClientConfig: &tls.Config{ - MinVersion: tls.VersionTLS12, - }, + ForceAttemptHTTP2: true, + DialContext: (&net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}).DialContext, + TLSClientConfig: tlsConfig.Clone(), TLSHandshakeTimeout: 5 * time.Second, } - return &http.Client{ + if proxyURL != nil { + transport.Proxy = http.ProxyURL(proxyURL) + } + client := &http.Client{ Transport: transport, CheckRedirect: func(request *http.Request, via []*http.Request) error { if len(via) >= 3 { @@ -47,12 +65,17 @@ func newDoHClient(endpoint string) *http.Client { return nil }, } + return client, proxyDisplayURL(proxyURL), nil } func exchangeDoHWithClient(ctx context.Context, client *http.Client, endpoint string, wire []byte, method string) ([]byte, TransportInfo, error) { + return exchangeHTTPSDNSWithClient(ctx, client, endpoint, wire, method, "doh") +} + +func exchangeHTTPSDNSWithClient(ctx context.Context, client *http.Client, endpoint string, wire []byte, method, protocol string) ([]byte, TransportInfo, error) { started := time.Now() info := TransportInfo{ - Protocol: "doh", + Protocol: protocol, Encrypted: true, Bootstrap: "system_resolver", } @@ -94,12 +117,27 @@ func exchangeDoHWithClient(ctx context.Context, client *http.Client, endpoint st defer response.Body.Close() info.HTTPVersion = response.Proto + if age := response.Header.Get("Age"); age != "" { + parsedAge, err := strconv.ParseInt(age, 10, 64) + if err != nil || parsedAge < 0 { + return nil, info, fmt.Errorf("DoH server returned invalid Age header %q", age) + } + info.HTTPAgeSeconds = parsedAge + } if response.TLS == nil || len(response.TLS.VerifiedChains) == 0 { return nil, info, fmt.Errorf("DoH server TLS identity was not verified") } info.ServerAuthenticated = true info.TLSVersion = tlsVersionName(response.TLS.Version) info.ALPN = response.TLS.NegotiatedProtocol + if protocol == "doh3" { + if response.ProtoMajor != 3 { + return nil, info, fmt.Errorf("DoH3 server used unexpected HTTP version %q", response.Proto) + } + if info.ALPN != "h3" { + return nil, info, fmt.Errorf("DoH3 server negotiated unexpected ALPN protocol %q", info.ALPN) + } + } if response.StatusCode < 200 || response.StatusCode > 299 { return nil, info, fmt.Errorf("DoH server returned HTTP status %d", response.StatusCode) diff --git a/.agents/skills/encrypted-dns-skill/internal/edns/doh_test.go b/.agents/skills/encrypted-dns-skill/internal/edns/doh_test.go index 3ac2579..6bf7aaf 100644 --- a/.agents/skills/encrypted-dns-skill/internal/edns/doh_test.go +++ b/.agents/skills/encrypted-dns-skill/internal/edns/doh_test.go @@ -4,6 +4,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "testing" "golang.org/x/net/dns/dnsmessage" @@ -42,6 +43,7 @@ func TestExchangeDoHGETAndPOST(t *testing.T) { return } writer.Header().Set("Content-Type", "application/dns-message") + writer.Header().Set("Age", "10") _, _ = writer.Write(responseWire) })) defer server.Close() @@ -56,13 +58,78 @@ func TestExchangeDoHGETAndPOST(t *testing.T) { if err != nil { t.Fatalf("exchange DoH: %v", err) } - if len(response) == 0 || !info.Encrypted || !info.ServerAuthenticated { + if len(response) == 0 || !info.Encrypted || !info.ServerAuthenticated || info.HTTPAgeSeconds != 10 { t.Fatalf("unexpected result: response=%d info=%#v", len(response), info) } }) } } +func TestExchangeDoHThroughHTTPConnectProxy(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + payload, err := io.ReadAll(request.Body) + if err != nil { + http.Error(writer, err.Error(), http.StatusBadRequest) + return + } + var query dnsmessage.Message + if err := query.Unpack(payload); err != nil { + http.Error(writer, err.Error(), http.StatusBadRequest) + return + } + response := dnsmessage.Message{ + Header: dnsmessage.Header{ID: query.Header.ID, Response: true}, + Questions: query.Questions, + } + responseWire, err := response.Pack() + if err != nil { + http.Error(writer, err.Error(), http.StatusInternalServerError) + return + } + writer.Header().Set("Content-Type", "application/dns-message") + _, _ = writer.Write(responseWire) + })) + defer server.Close() + + endpoint, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("parse test endpoint: %v", err) + } + proxyURL, proxyError := startConnectProxy(t, endpoint.Host, "Basic dXNlcjpzZWNyZXQ=") + testTransport := server.Client().Transport.(*http.Transport) + client, proxyLabel, err := newDoHClientWithTLSConfig(server.URL, proxyURL, testTransport.TLSClientConfig) + if err != nil { + t.Fatalf("create proxied DoH client: %v", err) + } + if proxyLabel == "" || proxyLabel == proxyURL { + t.Fatalf("proxy label = %q, want sanitized URL", proxyLabel) + } + wire, _, _, err := BuildQuery("example.com", "A") + if err != nil { + t.Fatalf("build query: %v", err) + } + if _, _, err := exchangeDoHWithClient(t.Context(), client, server.URL, wire, "post"); err != nil { + t.Fatalf("exchange DoH through proxy: %v", err) + } + client.CloseIdleConnections() + if err := <-proxyError; err != nil { + t.Fatalf("serve CONNECT proxy: %v", err) + } +} + +func TestExchangeDoHRejectsInvalidAge(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("Content-Type", "application/dns-message") + writer.Header().Set("Age", "invalid") + _, _ = writer.Write([]byte{1}) + })) + defer server.Close() + + if _, _, err := exchangeDoHWithClient(t.Context(), server.Client(), server.URL, []byte{1}, "post"); err == nil { + t.Fatal("invalid HTTP Age was accepted") + } +} + func TestExchangeDoHRejectsHTTPError(t *testing.T) { server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { http.Error(writer, "unavailable", http.StatusServiceUnavailable) diff --git a/.agents/skills/encrypted-dns-skill/internal/edns/dot.go b/.agents/skills/encrypted-dns-skill/internal/edns/dot.go index 0829151..cbc5a8b 100644 --- a/.agents/skills/encrypted-dns-skill/internal/edns/dot.go +++ b/.agents/skills/encrypted-dns-skill/internal/edns/dot.go @@ -3,22 +3,25 @@ package edns import ( "context" "crypto/tls" - "encoding/binary" "fmt" "io" - "net" + "net/url" "time" ) -func exchangeDoT(ctx context.Context, provider Provider, wire []byte) ([]byte, TransportInfo, error) { - return exchangeDoTWithTLSConfig(ctx, provider, wire, &tls.Config{ +func exchangeDoT(ctx context.Context, provider Provider, wire []byte, explicitProxy string) ([]byte, TransportInfo, error) { + return exchangeDoTWithTLSConfigAndProxy(ctx, provider, wire, &tls.Config{ ServerName: provider.DoTName, MinVersion: tls.VersionTLS12, NextProtos: []string{"dot"}, - }) + }, explicitProxy) } func exchangeDoTWithTLSConfig(ctx context.Context, provider Provider, wire []byte, tlsConfig *tls.Config) ([]byte, TransportInfo, error) { + return exchangeDoTWithTLSConfigAndProxy(ctx, provider, wire, tlsConfig, "") +} + +func exchangeDoTWithTLSConfigAndProxy(ctx context.Context, provider Provider, wire []byte, tlsConfig *tls.Config, explicitProxy string) ([]byte, TransportInfo, error) { started := time.Now() info := TransportInfo{ Protocol: "dot", @@ -26,7 +29,13 @@ func exchangeDoTWithTLSConfig(ctx context.Context, provider Provider, wire []byt Bootstrap: "system_resolver", } - rawConnection, err := (&net.Dialer{}).DialContext(ctx, "tcp", provider.DoTAddr) + endpoint := &url.URL{Scheme: "https", Host: provider.DoTAddr} + proxyURL, err := resolveProxy(endpoint, explicitProxy) + if err != nil { + return nil, info, fmt.Errorf("select DoT proxy: %w", err) + } + info.Proxy = proxyDisplayURL(proxyURL) + rawConnection, err := dialTCP(ctx, provider.DoTAddr, proxyURL) if err != nil { info.ElapsedMS = time.Since(started).Milliseconds() return nil, info, fmt.Errorf("connect to DoT server: %w", err) @@ -66,29 +75,10 @@ func exchangeDoTWithTLSConfig(ctx context.Context, provider Provider, wire []byt } func exchangeTCPFrame(connection io.ReadWriter, wire []byte) ([]byte, error) { - if len(wire) == 0 || len(wire) > maxDNSMessageSize { - return nil, fmt.Errorf("invalid DNS message length %d", len(wire)) - } - frame := make([]byte, 2+len(wire)) - binary.BigEndian.PutUint16(frame[:2], uint16(len(wire))) - copy(frame[2:], wire) - if err := writeAll(connection, frame); err != nil { + if err := writeDNSFrame(connection, wire); err != nil { return nil, fmt.Errorf("write framed DNS query: %w", err) } - - var lengthBytes [2]byte - if _, err := io.ReadFull(connection, lengthBytes[:]); err != nil { - return nil, fmt.Errorf("read DNS response length: %w", err) - } - length := int(binary.BigEndian.Uint16(lengthBytes[:])) - if length == 0 { - return nil, fmt.Errorf("DoT server returned an empty DNS message") - } - response := make([]byte, length) - if _, err := io.ReadFull(connection, response); err != nil { - return nil, fmt.Errorf("read DNS response: %w", err) - } - return response, nil + return readDNSFrame(connection) } func writeAll(writer io.Writer, payload []byte) error { diff --git a/.agents/skills/encrypted-dns-skill/internal/edns/dot_test.go b/.agents/skills/encrypted-dns-skill/internal/edns/dot_test.go index b827184..35c525f 100644 --- a/.agents/skills/encrypted-dns-skill/internal/edns/dot_test.go +++ b/.agents/skills/encrypted-dns-skill/internal/edns/dot_test.go @@ -104,6 +104,59 @@ func TestExchangeDoTAuthenticatesServer(t *testing.T) { } } +func TestExchangeDoTThroughHTTPConnectProxy(t *testing.T) { + certificate, roots := newTestCertificate(t, "resolver.test") + listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{ + Certificates: []tls.Certificate{certificate}, + MinVersion: tls.VersionTLS12, + NextProtos: []string{"dot"}, + }) + if err != nil { + t.Fatalf("listen for DoT: %v", err) + } + defer listener.Close() + + serverError := make(chan error, 1) + go func() { + connection, err := listener.Accept() + if err != nil { + serverError <- err + return + } + defer connection.Close() + response, err := serveOneDoTQuery(connection) + if err == nil { + err = writeAll(connection, response) + } + serverError <- err + }() + + proxyURL, proxyError := startConnectProxy(t, listener.Addr().String(), "Basic dXNlcjpzZWNyZXQ=") + queryWire, _, _, err := BuildQuery("example.com", "A") + if err != nil { + t.Fatalf("build query: %v", err) + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + _, info, err := exchangeDoTWithTLSConfigAndProxy(ctx, Provider{DoTAddr: listener.Addr().String(), DoTName: "resolver.test"}, queryWire, &tls.Config{ + RootCAs: roots, + MinVersion: tls.VersionTLS12, + NextProtos: []string{"dot"}, + }, proxyURL) + if err != nil { + t.Fatalf("exchange DoT through proxy: %v", err) + } + if info.Proxy == "" || strings.Contains(info.Proxy, "secret") || strings.Contains(info.Proxy, "user") { + t.Fatalf("proxy metadata was missing or exposed credentials: %#v", info) + } + if err := <-serverError; err != nil { + t.Fatalf("serve DoT: %v", err) + } + if err := <-proxyError; err != nil { + t.Fatalf("serve CONNECT proxy: %v", err) + } +} + func TestExchangeDoTAllowsMissingALPN(t *testing.T) { certificate, roots := newTestCertificate(t, "resolver.test") listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{ @@ -136,13 +189,13 @@ func TestExchangeDoTAllowsMissingALPN(t *testing.T) { } ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) defer cancel() - _, info, err := exchangeDoTWithTLSConfig(ctx, Provider{DoTAddr: listener.Addr().String(), DoTName: "resolver.test"}, queryWire, &tls.Config{ + response, info, err := exchangeDoTWithTLSConfig(ctx, Provider{DoTAddr: listener.Addr().String(), DoTName: "resolver.test"}, queryWire, &tls.Config{ RootCAs: roots, MinVersion: tls.VersionTLS12, NextProtos: []string{"dot"}, }) if err != nil { - t.Fatalf("exchange DoT without server ALPN: %v", err) + t.Fatalf("exchange DoT without ALPN: %v", err) } if err := <-serverError; err != nil { t.Fatalf("serve DoT: %v", err) @@ -150,6 +203,9 @@ func TestExchangeDoTAllowsMissingALPN(t *testing.T) { if !info.ServerAuthenticated || info.ALPN != "" { t.Fatalf("unexpected transport info: %#v", info) } + if _, err := ParseResponse(response, binary.BigEndian.Uint16(queryWire[:2]), QueryInfo{Name: "example.com", Type: "A"}); err != nil { + t.Fatalf("parse response without ALPN: %v", err) + } } func TestExchangeDoTRejectsUnexpectedALPN(t *testing.T) { @@ -205,7 +261,6 @@ func newTestCertificate(t *testing.T, name string) (tls.Certificate, *x509.CertP } template := &x509.Certificate{ SerialNumber: big.NewInt(1), - DNSNames: []string{name}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, @@ -213,6 +268,11 @@ func newTestCertificate(t *testing.T, name string) (tls.Certificate, *x509.CertP IsCA: true, BasicConstraintsValid: true, } + if address := net.ParseIP(name); address != nil { + template.IPAddresses = []net.IP{address} + } else { + template.DNSNames = []string{name} + } der, err := x509.CreateCertificate(rand.Reader, template, template, publicKey, privateKey) if err != nil { t.Fatalf("create certificate: %v", err) diff --git a/.agents/skills/encrypted-dns-skill/internal/edns/model.go b/.agents/skills/encrypted-dns-skill/internal/edns/model.go index e73d793..901d2f3 100644 --- a/.agents/skills/encrypted-dns-skill/internal/edns/model.go +++ b/.agents/skills/encrypted-dns-skill/internal/edns/model.go @@ -1,12 +1,29 @@ package edns +import "time" + type QueryOptions struct { Name string RecordType string Protocol string Provider string Method string - EndpointURL string + Proxy string +} + +type CompareTarget struct { + Protocol string `json:"protocol"` + Provider string `json:"provider"` + Method string `json:"method,omitempty"` +} + +type CompareOptions struct { + Name string + RecordType string + Targets []CompareTarget + AttemptTimeout time.Duration + MaxAttempts int + Proxy string } type Result struct { @@ -21,15 +38,34 @@ type Result struct { Error *ErrorInfo `json:"error,omitempty"` } +type CompareResult struct { + SchemaVersion int `json:"schema_version"` + Operation string `json:"operation"` + Completed bool `json:"completed"` + Query QueryInfo `json:"query"` + Attempts []Result `json:"attempts"` + Summary CompareSummary `json:"summary"` + Error *ErrorInfo `json:"error,omitempty"` +} + +type CompareSummary struct { + Total int `json:"total"` + Completed int `json:"completed"` + Failed int `json:"failed"` + Unsupported int `json:"unsupported"` +} + type QueryInfo struct { Name string `json:"name"` Type string `json:"type"` } type ResolverInfo struct { - Provider string `json:"provider"` - Endpoint string `json:"endpoint"` - Profile string `json:"profile"` + Provider string `json:"provider"` + Endpoint string `json:"endpoint"` + Profile string `json:"profile"` + AuthenticationName string `json:"authentication_name,omitempty"` + CertificateSerial uint32 `json:"certificate_serial,omitempty"` } type TransportInfo struct { @@ -41,6 +77,10 @@ type TransportInfo struct { TLSVersion string `json:"tls_version,omitempty"` ALPN string `json:"alpn,omitempty"` HTTPVersion string `json:"http_version,omitempty"` + HTTPAgeSeconds int64 `json:"http_age_seconds,omitempty"` + QUICVersion string `json:"quic_version,omitempty"` + CryptoConstruction string `json:"crypto_construction,omitempty"` + Proxy string `json:"proxy,omitempty"` } type DNSInfo struct { diff --git a/.agents/skills/encrypted-dns-skill/internal/edns/providers.go b/.agents/skills/encrypted-dns-skill/internal/edns/providers.go index 798916b..65872df 100644 --- a/.agents/skills/encrypted-dns-skill/internal/edns/providers.go +++ b/.agents/skills/encrypted-dns-skill/internal/edns/providers.go @@ -1,49 +1,102 @@ package edns import ( + "errors" "fmt" "strings" ) +type UnsupportedError struct { + Message string +} + +func (err *UnsupportedError) Error() string { return err.Message } + +func IsUnsupported(err error) bool { + var unsupported *UnsupportedError + return errors.As(err, &unsupported) +} + type Provider struct { - ID string - Profile string - DoHURL string - DoTAddr string - DoTName string + ID string + Profile string + SourceURL string + VerifiedDate string + DoHURL string + DoTAddr string + DoTName string + DoQAddr string + DoQName string + DoH3URL string + DNSCryptStamp string } var providers = map[string]Provider{ "cloudflare": { - ID: "cloudflare", - Profile: "unfiltered", - DoHURL: "https://cloudflare-dns.com/dns-query", - DoTAddr: "one.one.one.one:853", - DoTName: "one.one.one.one", + ID: "cloudflare", + Profile: "unfiltered", + SourceURL: "https://developers.cloudflare.com/1.1.1.1/encryption/", + VerifiedDate: "2026-08-13", + DoHURL: "https://cloudflare-dns.com/dns-query", + DoTAddr: "one.one.one.one:853", + DoTName: "one.one.one.one", + DoH3URL: "https://cloudflare-dns.com/dns-query", }, "google": { - ID: "google", - Profile: "unfiltered", - DoHURL: "https://dns.google/dns-query", - DoTAddr: "dns.google:853", - DoTName: "dns.google", + ID: "google", + Profile: "unfiltered", + SourceURL: "https://developers.google.com/speed/public-dns/docs/secure-transports", + VerifiedDate: "2026-08-13", + DoHURL: "https://dns.google/dns-query", + DoTAddr: "dns.google:853", + DoTName: "dns.google", + DoH3URL: "https://dns.google/dns-query", }, "quad9": { - ID: "quad9", - Profile: "security-filtered", - DoHURL: "https://dns.quad9.net/dns-query", - DoTAddr: "dns.quad9.net:853", - DoTName: "dns.quad9.net", + ID: "quad9", + Profile: "security-filtered", + SourceURL: "https://docs.quad9.net/services/", + VerifiedDate: "2026-08-13", + DoHURL: "https://dns.quad9.net/dns-query", + DoTAddr: "dns.quad9.net:853", + DoTName: "dns.quad9.net", }, "adguard": { - ID: "adguard", - Profile: "ad-and-security-filtered", - DoHURL: "https://dns.adguard-dns.com/dns-query", - DoTAddr: "dns.adguard-dns.com:853", - DoTName: "dns.adguard-dns.com", + ID: "adguard", + Profile: "ad-and-security-filtered", + SourceURL: "https://adguard-dns.io/kb/en/public-dns/overview/", + VerifiedDate: "2026-08-13", + DoHURL: "https://dns.adguard-dns.com/dns-query", + DoTAddr: "dns.adguard-dns.com:853", + DoTName: "dns.adguard-dns.com", + DoQAddr: "dns.adguard-dns.com:853", + DoQName: "dns.adguard-dns.com", + DNSCryptStamp: "sdns://AQMAAAAAAAAAETk0LjE0MC4xNC4xNDo1NDQzINErR_JS3PLCu_iZEIbq95zkSV2LFsigxDIuUso_OQhzIjIuZG5zY3J5cHQuZGVmYXVsdC5uczEuYWRndWFyZC5jb20", }, } +func (provider Provider) Endpoint(protocol string) (string, error) { + var endpoint string + switch strings.ToLower(protocol) { + case "doh": + endpoint = provider.DoHURL + case "dot": + endpoint = provider.DoTAddr + case "doq": + endpoint = provider.DoQAddr + case "doh3": + endpoint = provider.DoH3URL + case "dnscrypt": + endpoint = provider.DNSCryptStamp + default: + return "", &UnsupportedError{Message: fmt.Sprintf("protocol %q is not available", protocol)} + } + if endpoint == "" { + return "", &UnsupportedError{Message: fmt.Sprintf("provider %q does not support protocol %q", provider.ID, protocol)} + } + return endpoint, nil +} + func FindProvider(name string) (Provider, error) { provider, ok := providers[strings.ToLower(name)] if !ok { diff --git a/.agents/skills/encrypted-dns-skill/internal/edns/providers_test.go b/.agents/skills/encrypted-dns-skill/internal/edns/providers_test.go index 6810153..71d87c3 100644 --- a/.agents/skills/encrypted-dns-skill/internal/edns/providers_test.go +++ b/.agents/skills/encrypted-dns-skill/internal/edns/providers_test.go @@ -1,6 +1,10 @@ package edns -import "testing" +import ( + "net/url" + "testing" + "time" +) func TestBuiltInProvidersHaveStrictEndpoints(t *testing.T) { for _, name := range []string{"cloudflare", "google", "quad9", "adguard"} { @@ -11,8 +15,47 @@ func TestBuiltInProvidersHaveStrictEndpoints(t *testing.T) { if provider.DoHURL == "" || provider.DoTAddr == "" || provider.DoTName == "" { t.Fatalf("provider %s is incomplete: %#v", name, provider) } + source, err := url.ParseRequestURI(provider.SourceURL) + if err != nil || source.Scheme != "https" || source.Host == "" { + t.Fatalf("provider %s has invalid official source URL %q: %v", name, provider.SourceURL, err) + } + if _, err := time.Parse(time.DateOnly, provider.VerifiedDate); err != nil { + t.Fatalf("provider %s has invalid verification date %q: %v", name, provider.VerifiedDate, err) + } } if _, err := FindProvider("custom"); err == nil { t.Fatal("unapproved custom provider was accepted") } } + +func TestProviderProtocolMatrix(t *testing.T) { + tests := []struct { + provider string + protocol string + allowed bool + }{ + {provider: "cloudflare", protocol: "doh3", allowed: true}, + {provider: "google", protocol: "doh3", allowed: true}, + {provider: "adguard", protocol: "doq", allowed: true}, + {provider: "adguard", protocol: "dnscrypt", allowed: true}, + {provider: "cloudflare", protocol: "doq", allowed: false}, + {provider: "cloudflare", protocol: "dnscrypt", allowed: false}, + {provider: "quad9", protocol: "doh3", allowed: false}, + {provider: "adguard", protocol: "doh3", allowed: false}, + } + for _, test := range tests { + t.Run(test.provider+"/"+test.protocol, func(t *testing.T) { + provider, err := FindProvider(test.provider) + if err != nil { + t.Fatalf("find provider: %v", err) + } + _, err = provider.Endpoint(test.protocol) + if test.allowed && err != nil { + t.Fatalf("supported endpoint rejected: %v", err) + } + if !test.allowed && err == nil { + t.Fatal("unsupported endpoint was inferred") + } + }) + } +} diff --git a/.agents/skills/encrypted-dns-skill/internal/edns/query.go b/.agents/skills/encrypted-dns-skill/internal/edns/query.go index 16b5015..069f5ff 100644 --- a/.agents/skills/encrypted-dns-skill/internal/edns/query.go +++ b/.agents/skills/encrypted-dns-skill/internal/edns/query.go @@ -2,11 +2,17 @@ package edns import ( "context" + "encoding/binary" "fmt" - "net/url" ) +type queryExchange func(context.Context, Provider, []byte, QueryOptions) ([]byte, TransportInfo, dnsCryptPeerInfo, error) + func Query(ctx context.Context, options QueryOptions) Result { + return queryWithExchange(ctx, options, exchangeProtocol) +} + +func queryWithExchange(ctx context.Context, options QueryOptions, exchange queryExchange) Result { wire, query, transactionID, err := BuildQuery(options.Name, options.RecordType) result := Result{ SchemaVersion: 1, @@ -30,41 +36,76 @@ func Query(ctx context.Context, options QueryOptions) Result { result.Error = &ErrorInfo{Class: "input", Message: err.Error()} return result } - if options.EndpointURL != "" { - if options.Protocol != "doh" { - result.Error = &ErrorInfo{Class: "input", Message: "custom --url applies only to DoH"} - return result - } - parsed, err := url.Parse(options.EndpointURL) - if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" { - result.Error = &ErrorInfo{Class: "input", Message: fmt.Sprintf("invalid DoH endpoint URL %q", options.EndpointURL)} - return result - } - provider = Provider{ID: "custom", Profile: "custom", DoHURL: options.EndpointURL} + if err := ValidateProxyURL(options.Proxy); err != nil { + result.Error = &ErrorInfo{Class: "input", Message: err.Error()} + return result + } + if options.Proxy != "" && options.Protocol != "doh" && options.Protocol != "dot" { + result.Error = &ErrorInfo{Class: "unsupported", Message: fmt.Sprintf("proxying is not available for protocol %q", options.Protocol)} + return result } result.Resolver = ResolverInfo{Provider: provider.ID, Profile: provider.Profile} - - var response []byte - switch options.Protocol { - case "doh": - result.Resolver.Endpoint = provider.DoHURL - response, result.Transport, err = exchangeDoH(ctx, provider, wire, options.Method) - case "dot": - result.Resolver.Endpoint = provider.DoTAddr - response, result.Transport, err = exchangeDoT(ctx, provider, wire) - default: - err = fmt.Errorf("protocol %q is not available; run ednsdiag capabilities", options.Protocol) + endpoint, err := provider.Endpoint(options.Protocol) + if err != nil { + class := "input" + if IsUnsupported(err) { + class = "unsupported" + } + result.Error = &ErrorInfo{Class: class, Message: err.Error()} + return result } + result.Resolver.Endpoint = endpoint + if options.Protocol == "doh" || options.Protocol == "doh3" || options.Protocol == "doq" { + binary.BigEndian.PutUint16(wire[:2], 0) + transactionID = 0 + } + + response, transport, peer, err := exchange(ctx, provider, wire, options) + result.Transport = transport if err != nil { result.Error = &ErrorInfo{Class: "transport", Message: err.Error()} return result } + if options.Protocol == "dnscrypt" { + result.Resolver.Endpoint = peer.ServerAddress + result.Resolver.AuthenticationName = peer.ProviderName + result.Resolver.CertificateSerial = peer.CertificateSerial + } result.DNS, err = ParseResponse(response, transactionID, query) if err != nil { result.Error = &ErrorInfo{Class: "protocol", Message: err.Error()} return result } + applyHTTPAge(&result.DNS, result.Transport.HTTPAgeSeconds) result.Completed = true return result } + +func exchangeProtocol(ctx context.Context, provider Provider, wire []byte, options QueryOptions) ([]byte, TransportInfo, dnsCryptPeerInfo, error) { + var response []byte + var transport TransportInfo + var peer dnsCryptPeerInfo + var err error + switch options.Protocol { + case "doh": + response, transport, err = exchangeDoH(ctx, provider, wire, options.Method, options.Proxy) + case "dot": + response, transport, err = exchangeDoT(ctx, provider, wire, options.Proxy) + case "doq": + response, transport, err = exchangeDoQ(ctx, provider, wire) + case "doh3": + response, transport, err = exchangeDoH3(ctx, provider, wire, options.Method) + case "dnscrypt": + response, transport, peer, err = exchangeDNSCrypt(ctx, provider.DNSCryptStamp, wire) + default: + err = fmt.Errorf("protocol %q is not available; run ednsdiag capabilities", options.Protocol) + } + return response, transport, peer, err +} + +func Probe(ctx context.Context, options QueryOptions) Result { + result := Query(ctx, options) + result.Operation = "probe" + return result +} diff --git a/.agents/skills/encrypted-dns-skill/references/providers.md b/.agents/skills/encrypted-dns-skill/references/providers.md index 83873ae..4260c9b 100644 --- a/.agents/skills/encrypted-dns-skill/references/providers.md +++ b/.agents/skills/encrypted-dns-skill/references/providers.md @@ -6,12 +6,12 @@ below were verified on 2026-08-13. ## Candidate providers -| Provider | DoH endpoint | DoT endpoint / authentication name | Official documentation | Profile | -| --- | --- | --- | --- | --- | -| Cloudflare | `https://cloudflare-dns.com/dns-query` | `one.one.one.one:853` | [DoH](https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/) / [DoT](https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-tls/) | Unfiltered | -| Google | `https://dns.google/dns-query` | `dns.google:853` | [DoH](https://developers.google.com/speed/public-dns/docs/doh) / [DoT](https://developers.google.com/speed/public-dns/docs/dns-over-tls) | Unfiltered | -| Quad9 | `https://dns.quad9.net/dns-query` | `dns.quad9.net:853` | [Quad9 services](https://docs.quad9.net/services/) | Security filtered; HTTP/2 required | -| AdGuard | `https://dns.adguard-dns.com/dns-query` | `dns.adguard-dns.com:853` | [AdGuard providers](https://adguard-dns.io/kb/general/dns-providers/) | Ads, tracking, and security filtered | +| Provider | DoH endpoint | DoT endpoint / authentication name | QUIC support | DNSCrypt | Official documentation | Profile | +| --- | --- | --- | --- | --- | --- | --- | +| Cloudflare | `https://cloudflare-dns.com/dns-query` | `one.one.one.one:853` | DoH3 at the DoH endpoint | No verified built-in stamp | [DoH and HTTP/3](https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/) / [DoT](https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-tls/) | Unfiltered | +| Google | `https://dns.google/dns-query` | `dns.google:853` | DoH3 at the DoH endpoint | No verified built-in stamp | [Secure transports](https://developers.google.com/speed/public-dns/docs/secure-transports) | Unfiltered | +| Quad9 | `https://dns.quad9.net/dns-query` | `dns.quad9.net:853` | Not enabled without an official endpoint statement | No verified built-in stamp | [Quad9 services](https://docs.quad9.net/services/) | Security filtered; HTTP/2 required | +| AdGuard | `https://dns.adguard-dns.com/dns-query` | `dns.adguard-dns.com:853` | DoQ at `dns.adguard-dns.com:853` | Official stamp for `2.dnscrypt.default.ns1.adguard.com` at `94.140.14.14:5443` | [AdGuard dnsproxy example](https://github.com/AdguardTeam/dnsproxy#encrypted-upstreams) / [AdGuard public DNS](https://adguard-dns.io/kb/en/public-dns/overview/) | Ads, tracking, and security filtered | ## Registry requirements @@ -25,6 +25,11 @@ Each built-in provider entry must include: - official source URL; - last verification date. +The runtime `Provider` registry stores the official source URL and verification +date alongside each endpoint. Update both fields whenever an endpoint or +capability is re-verified; the markdown table alone is not authoritative for +runtime metadata. + Do not infer one protocol endpoint from another. Do not treat filtering and non-filtering services as interchangeable. Provider comparison results must remain separate. diff --git a/.agents/skills/encrypted-dns-skill/references/security.md b/.agents/skills/encrypted-dns-skill/references/security.md index feab645..912b727 100644 --- a/.agents/skills/encrypted-dns-skill/references/security.md +++ b/.agents/skills/encrypted-dns-skill/references/security.md @@ -8,7 +8,7 @@ validation, fallback, or result claims. 1. Never silently downgrade to plaintext DNS. 2. Validate certificates and authentication domain names. DoT follows the strict privacy profile in [RFC 8310](https://www.rfc-editor.org/rfc/rfc8310.html). -3. Treat certificate, hostname, SNI, and negotiated ALPN mismatches as hard +3. Treat certificate, hostname, SNI, and any non-empty negotiated ALPN mismatch as hard failures, not fallback opportunities. 4. Bound response sizes, per-attempt timeouts, total time, redirects, and the number of attempts. @@ -19,12 +19,62 @@ validation, fallback, or result claims. 7. Do not enable AXFR, IXFR, or ANY queries. 8. Do not persist full query names or client identifiers by default. +## Proxy policy + +DoH and DoT may use an explicit `--proxy` or the standard Go +[`ProxyFromEnvironment`](https://pkg.go.dev/net/http#ProxyFromEnvironment) +selection rules for `HTTPS_PROXY` and `NO_PROXY`. An explicit URL takes +precedence and must use `http` or `https`. DoT establishes an HTTP CONNECT +tunnel and then performs the normal resolver TLS handshake inside it; the +proxy never substitutes for resolver certificate, authentication-domain, SNI, +or ALPN validation. + +Proxy credentials may be sent as HTTP Basic authentication when embedded in +the URL, but must never appear in result JSON or diagnostic errors. Result +metadata contains only a sanitized proxy endpoint. An HTTP(S) proxy can observe +the resolver destination, connection timing, and traffic volume even though it +cannot read the resolver TLS payload. + +DoH3, DoQ, and the current DNSCrypt transport use UDP or QUIC and do not use a +TCP HTTP CONNECT proxy. Reject an explicit proxy for those protocols instead of +silently connecting directly. Proxy failures are transport failures and never +trigger plaintext DNS or an undisclosed direct connection. + ## DoT ALPN policy -The client advertises the `dot` ALPN identifier. RFC 7858 and RFC 8310 do not -require a DoT server on its dedicated port to select an ALPN protocol, so an -empty negotiated ALPN is permitted and reported as empty. If a server selects -a non-empty protocol other than `dot`, abort before sending the DNS query. +The client advertises the IANA-registered `dot` ALPN identifier. An explicit +selection other than `dot` is a hard failure before the DNS query is sent. An +empty selection is permitted and reported because RFC 7858 and RFC 8310 do not +make ALPN negotiation part of DoT server authentication; the dedicated port, +PKIX chain, SNI, and configured authentication domain still identify the +service. This deliberately restores the policy from PR #9 and supersedes the +stricter empty-ALPN rejection introduced by PR #11. + +## QUIC transport policy + +DoQ requires TLS 1.3 and an exact `doq` ALPN selection. DoH3 requires TLS 1.3, +HTTP/3, and an exact `h3` ALPN selection. Certificate, authentication-domain, +SNI, and ALPN failures abort before a DNS query is sent. The initial +implementation does not send 0-RTT data or enable session resumption because +their replay and linkability properties require a separate policy decision. + +Each DoQ query uses one client-initiated bidirectional stream, a two-octet +length prefix, DNS Message ID 0, and STREAM FIN. Truncated frames, extra +responses, non-zero response IDs, unexpected streams, and missing FIN are +protocol failures; they never trigger plaintext or cross-protocol fallback. + +Reject DNS messages with the TC bit set on every encrypted transport. In +particular, the UDP-only DNSCrypt implementation must not expose a partial +answer as a completed lookup. + +## DNSCrypt transport policy + +Accept only allowlisted DNSCrypt v2 stamps. Validate the stamp type, provider +public key, provider name, resolver certificate signature, validity interval, +and encrypted response. Report the stamp IP bootstrap path, provider +authentication name, certificate serial, and crypto construction. Certificate +or response-authentication failures are hard failures and never trigger +plaintext or cross-protocol fallback. Anonymized DNSCrypt remains unavailable. ## Bootstrap transparency diff --git a/.agents/skills/encrypted-dns-skill/references/standards.md b/.agents/skills/encrypted-dns-skill/references/standards.md index 133a112..74ed7eb 100644 --- a/.agents/skills/encrypted-dns-skill/references/standards.md +++ b/.agents/skills/encrypted-dns-skill/references/standards.md @@ -7,16 +7,20 @@ standard, not on summaries or provider-specific JSON APIs. | --- | --- | --- | | Agent Skills package | [Agent Skills specification](https://agentskills.io/specification) | Required package format | | OMP discovery | [OMP Skills documentation](https://github.com/can1357/oh-my-pi/blob/main/docs/skills.md) | Supported host | -| DoH | [RFC 8484](https://www.rfc-editor.org/rfc/rfc8484.html) | Planned | -| DoT | [RFC 7858](https://www.rfc-editor.org/rfc/rfc7858.html) | Planned | +| DoH | [RFC 8484](https://www.rfc-editor.org/rfc/rfc8484.html) | Implemented over HTTP/1.1 and HTTP/2 | +| DoH3 | [RFC 8484](https://www.rfc-editor.org/rfc/rfc8484.html) over [RFC 9114](https://www.rfc-editor.org/rfc/rfc9114.html) | Implemented | +| DoT | [RFC 7858](https://www.rfc-editor.org/rfc/rfc7858.html) | Implemented | | DoT authentication profiles | [RFC 8310](https://www.rfc-editor.org/rfc/rfc8310.html) | Strict privacy only | -| DoQ | [RFC 9250](https://www.rfc-editor.org/rfc/rfc9250.html) | Planned | +| DoT ALPN identifier | [IANA TLS ALPN registry](https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values.xhtml#alpn-protocol-ids) | Advertise `dot`; reject a different selection; permit and report no selection | +| DoQ | [RFC 9250](https://www.rfc-editor.org/rfc/rfc9250.html) | Implemented for single-response queries | | ODoH | [RFC 9230](https://www.rfc-editor.org/rfc/rfc9230.html) | Research until a maintained implementation is selected | | DNS privacy operations | [RFC 8932](https://www.rfc-editor.org/rfc/rfc8932.html) | Security and privacy guidance | | EDNS(0) padding | [RFC 7830](https://www.rfc-editor.org/rfc/rfc7830.html) and [RFC 8467](https://www.rfc-editor.org/rfc/rfc8467.html) | Evaluate per transport | -| DNSCrypt | [DNSCrypt protocol specification](https://github.com/DNSCrypt/dnscrypt-protocol) | Planned, non-IETF | +| DNSCrypt | [DNSCrypt protocol specification](https://github.com/DNSCrypt/dnscrypt-protocol) | DNSCrypt v2 implemented over UDP; non-IETF | | Anonymized DNSCrypt | [Anonymized DNSCrypt specification](https://github.com/DNSCrypt/dnscrypt-protocol/blob/master/ANONYMIZED-DNSCRYPT.txt) | Research | | Go DNS wire and IDNA support | [Go x/net module](https://pkg.go.dev/golang.org/x/net) | Pinned to v0.58.0; use `dnsmessage` and `idna` | +| Go QUIC and HTTP/3 support | [quic-go documentation](https://quic-go.net/docs/) | Pinned to v0.61.0 | +| Go DNSCrypt support | [ameshkov/dnscrypt](https://github.com/ameshkov/dnscrypt) | Pinned to v2.4.0; stamp, certificate, and protocol implementation | ## Deliberate exclusions @@ -38,3 +42,13 @@ For DoH, accept and send `application/dns-message`. Keep HTTP status separate from the DNS RCODE: a valid NXDOMAIN or SERVFAIL response still uses HTTP 2xx. For DoT, use the strict privacy profile and verify both the PKIX chain and the configured authentication domain name. + +For DoQ, use ALPN `doq`, UDP port 853, a separate client-initiated bidirectional +stream per query, the two-octet DNS-over-TCP length field, STREAM FIN, and DNS +Message ID 0. DoH3 retains RFC 8484 message and HTTP semantics and requires +HTTP/3 with ALPN `h3`. + +For DNSCrypt v2, parse only DNSCrypt stamps, fetch the provider TXT +certificate, validate its Ed25519 signature and validity interval against the +stamp key, and authenticate encrypted responses. The stamp-supplied provider +name and IP endpoint are part of the authenticated resolver identity. diff --git a/.agents/skills/encrypted-dns-skill/schemas/result-v1.schema.json b/.agents/skills/encrypted-dns-skill/schemas/result-v1.schema.json index 22ea9d0..66f1122 100644 --- a/.agents/skills/encrypted-dns-skill/schemas/result-v1.schema.json +++ b/.agents/skills/encrypted-dns-skill/schemas/result-v1.schema.json @@ -1,34 +1,36 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/windyboy/encrypted-dns-skill/schemas/result-v1.schema.json", - "title": "Encrypted DNS diagnostic result", - "type": "object", - "required": ["schema_version", "operation", "completed", "query", "transport", "dns"], - "properties": { - "schema_version": { "const": 1 }, - "operation": { "enum": ["query", "probe", "compare"] }, - "completed": { "type": "boolean" }, + "title": "Encrypted DNS diagnostic result v1", + "oneOf": [ + { "$ref": "#/$defs/singleResult" }, + { "$ref": "#/$defs/compareResult" } + ], + "$defs": { "query": { "type": "object", "required": ["name", "type"], "properties": { "name": { "type": "string" }, - "type": { "type": "string" } + "type": { "enum": ["A", "AAAA", "CNAME", "MX", "TXT", "NS", "SOA", "CAA", "SRV", "PTR", "HTTPS", "SVCB"] } }, "additionalProperties": false }, "resolver": { "type": "object", + "required": ["provider", "endpoint", "profile"], "properties": { "provider": { "type": "string" }, "endpoint": { "type": "string" }, - "profile": { "type": "string" } + "profile": { "type": "string" }, + "authentication_name": { "type": "string" }, + "certificate_serial": { "type": "integer", "minimum": 0 } }, "additionalProperties": false }, "transport": { "type": "object", - "required": ["protocol", "encrypted", "server_authenticated"], + "required": ["protocol", "encrypted", "server_authenticated", "elapsed_ms", "bootstrap"], "properties": { "protocol": { "enum": ["doh", "dot", "doq", "doh3", "dnscrypt", "odoh", "anonymized-dnscrypt"] }, "encrypted": { "type": "boolean" }, @@ -37,46 +39,229 @@ "bootstrap": { "type": "string" }, "tls_version": { "type": "string" }, "alpn": { "type": "string" }, - "http_version": { "type": "string" } + "http_version": { "type": "string" }, + "http_age_seconds": { "type": "integer", "minimum": 0 }, + "quic_version": { "type": "string" }, + "crypto_construction": { "type": "string" }, + "proxy": { "type": "string" } }, - "additionalProperties": true + "additionalProperties": false + }, + "answerBaseProperties": { + "name": { "type": "string" }, + "ttl": { "type": "integer", "minimum": 0 } + }, + "addressAnswer": { + "type": "object", + "required": ["name", "type", "ttl", "address"], + "properties": { + "name": { "$ref": "#/$defs/answerBaseProperties/name" }, + "type": { "enum": ["A", "AAAA"] }, + "ttl": { "$ref": "#/$defs/answerBaseProperties/ttl" }, + "address": { "type": "string" } + }, + "additionalProperties": false + }, + "targetAnswer": { + "type": "object", + "required": ["name", "type", "ttl", "target"], + "properties": { + "name": { "$ref": "#/$defs/answerBaseProperties/name" }, + "type": { "enum": ["CNAME", "PTR"] }, + "ttl": { "$ref": "#/$defs/answerBaseProperties/ttl" }, + "target": { "type": "string" } + }, + "additionalProperties": false + }, + "mxAnswer": { + "type": "object", + "required": ["name", "type", "ttl", "priority", "exchange"], + "properties": { + "name": { "$ref": "#/$defs/answerBaseProperties/name" }, + "type": { "const": "MX" }, + "ttl": { "$ref": "#/$defs/answerBaseProperties/ttl" }, + "priority": { "type": "integer", "minimum": 0, "maximum": 65535 }, + "exchange": { "type": "string" } + }, + "additionalProperties": false + }, + "txtAnswer": { + "type": "object", + "required": ["name", "type", "ttl", "strings"], + "properties": { + "name": { "$ref": "#/$defs/answerBaseProperties/name" }, + "type": { "const": "TXT" }, + "ttl": { "$ref": "#/$defs/answerBaseProperties/ttl" }, + "strings": { "type": "array", "items": { "type": "string" } } + }, + "additionalProperties": false + }, + "nsAnswer": { + "type": "object", + "required": ["name", "type", "ttl", "host"], + "properties": { + "name": { "$ref": "#/$defs/answerBaseProperties/name" }, + "type": { "const": "NS" }, + "ttl": { "$ref": "#/$defs/answerBaseProperties/ttl" }, + "host": { "type": "string" } + }, + "additionalProperties": false + }, + "soaAnswer": { + "type": "object", + "required": ["name", "type", "ttl", "primary_ns", "responsible_mailbox", "serial", "refresh", "retry", "expire", "minimum_ttl"], + "properties": { + "name": { "$ref": "#/$defs/answerBaseProperties/name" }, + "type": { "const": "SOA" }, + "ttl": { "$ref": "#/$defs/answerBaseProperties/ttl" }, + "primary_ns": { "type": "string" }, + "responsible_mailbox": { "type": "string" }, + "serial": { "type": "integer", "minimum": 0 }, + "refresh": { "type": "integer", "minimum": 0 }, + "retry": { "type": "integer", "minimum": 0 }, + "expire": { "type": "integer", "minimum": 0 }, + "minimum_ttl": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": false + }, + "caaAnswer": { + "type": "object", + "required": ["name", "type", "ttl", "flags", "tag", "value"], + "properties": { + "name": { "$ref": "#/$defs/answerBaseProperties/name" }, + "type": { "const": "CAA" }, + "ttl": { "$ref": "#/$defs/answerBaseProperties/ttl" }, + "flags": { "type": "integer", "minimum": 0, "maximum": 255 }, + "tag": { "type": "string" }, + "value": { "type": "string" } + }, + "additionalProperties": false + }, + "srvAnswer": { + "type": "object", + "required": ["name", "type", "ttl", "priority", "weight", "port", "target"], + "properties": { + "name": { "$ref": "#/$defs/answerBaseProperties/name" }, + "type": { "const": "SRV" }, + "ttl": { "$ref": "#/$defs/answerBaseProperties/ttl" }, + "priority": { "type": "integer", "minimum": 0, "maximum": 65535 }, + "weight": { "type": "integer", "minimum": 0, "maximum": 65535 }, + "port": { "type": "integer", "minimum": 0, "maximum": 65535 }, + "target": { "type": "string" } + }, + "additionalProperties": false + }, + "svcbParam": { + "type": "object", + "required": ["key", "key_value", "value_base64"], + "properties": { + "key": { "type": "string" }, + "key_value": { "type": "integer", "minimum": 0, "maximum": 65535 }, + "value_base64": { "type": "string" } + }, + "additionalProperties": false + }, + "svcbAnswer": { + "type": "object", + "required": ["name", "type", "ttl", "priority", "target", "params"], + "properties": { + "name": { "$ref": "#/$defs/answerBaseProperties/name" }, + "type": { "enum": ["SVCB", "HTTPS"] }, + "ttl": { "$ref": "#/$defs/answerBaseProperties/ttl" }, + "priority": { "type": "integer", "minimum": 0, "maximum": 65535 }, + "target": { "type": "string" }, + "params": { "type": "array", "items": { "$ref": "#/$defs/svcbParam" } } + }, + "additionalProperties": false + }, + "answer": { + "oneOf": [ + { "$ref": "#/$defs/addressAnswer" }, + { "$ref": "#/$defs/targetAnswer" }, + { "$ref": "#/$defs/mxAnswer" }, + { "$ref": "#/$defs/txtAnswer" }, + { "$ref": "#/$defs/nsAnswer" }, + { "$ref": "#/$defs/soaAnswer" }, + { "$ref": "#/$defs/caaAnswer" }, + { "$ref": "#/$defs/srvAnswer" }, + { "$ref": "#/$defs/svcbAnswer" } + ] }, "dns": { "type": "object", - "required": ["rcode", "rcode_value", "answers"], + "required": ["rcode", "rcode_value", "resolver_reports_dnssec_authenticated", "client_validated_dnssec", "answers"], "properties": { "rcode": { "type": "string" }, "rcode_value": { "type": "integer", "minimum": 0 }, "resolver_reports_dnssec_authenticated": { "type": "boolean" }, - "client_validated_dnssec": { "type": "boolean" }, - "answers": { - "type": "array", - "items": { - "type": "object", - "required": ["name", "type", "ttl"], - "properties": { - "name": { "type": "string" }, - "type": { "type": "string" }, - "ttl": { "type": "integer", "minimum": 0 } - }, - "additionalProperties": true - } - } + "client_validated_dnssec": { "const": false }, + "answers": { "type": "array", "items": { "$ref": "#/$defs/answer" } } }, - "additionalProperties": true + "additionalProperties": false }, - "fallback_used": { "type": "boolean" }, - "attempts": { "type": "array", "items": { "type": "object" } }, - "warnings": { "type": "array", "items": { "type": "string" } }, "error": { "type": "object", "required": ["class", "message"], "properties": { - "class": { "enum": ["input", "transport", "protocol", "internal"] }, + "class": { "enum": ["input", "transport", "protocol", "unsupported", "internal"] }, "message": { "type": "string" } }, "additionalProperties": false + }, + "singleResult": { + "type": "object", + "required": ["schema_version", "operation", "completed", "query", "resolver", "transport", "dns"], + "properties": { + "schema_version": { "const": 1 }, + "operation": { "enum": ["query", "probe"] }, + "completed": { "type": "boolean" }, + "query": { "$ref": "#/$defs/query" }, + "resolver": { "$ref": "#/$defs/resolver" }, + "transport": { "$ref": "#/$defs/transport" }, + "dns": { "$ref": "#/$defs/dns" }, + "warnings": { "type": "array", "items": { "type": "string" } }, + "error": { "$ref": "#/$defs/error" } + }, + "allOf": [ + { + "if": { "properties": { "completed": { "const": true } }, "required": ["completed"] }, + "then": { "not": { "required": ["error"] } }, + "else": { "required": ["error"] } + } + ], + "additionalProperties": false + }, + "summary": { + "type": "object", + "required": ["total", "completed", "failed", "unsupported"], + "properties": { + "total": { "type": "integer", "minimum": 0 }, + "completed": { "type": "integer", "minimum": 0 }, + "failed": { "type": "integer", "minimum": 0 }, + "unsupported": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": false + }, + "compareResult": { + "type": "object", + "required": ["schema_version", "operation", "completed", "query", "attempts", "summary"], + "properties": { + "schema_version": { "const": 1 }, + "operation": { "const": "compare" }, + "completed": { "type": "boolean" }, + "query": { "$ref": "#/$defs/query" }, + "attempts": { "type": "array", "items": { "$ref": "#/$defs/singleResult" }, "maxItems": 8 }, + "summary": { "$ref": "#/$defs/summary" }, + "error": { "$ref": "#/$defs/error" } + }, + "allOf": [ + { + "if": { "properties": { "completed": { "const": true } }, "required": ["completed"] }, + "then": { "not": { "required": ["error"] } }, + "else": { "required": ["error"] } + } + ], + "additionalProperties": false } - }, - "additionalProperties": true + } }