feat(ednsdiag): add DoQ/DoH3/DNSCrypt transports, proxy support, probe & compare

This commit is contained in:
windyboy
2026-08-14 18:17:26 +08:00
parent 8303d78caf
commit 70aea6cd72
21 changed files with 1665 additions and 236 deletions
+22 -6
View File
@@ -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 ./...
+86 -12
View File
@@ -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 <domain> [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 <domain> [type] [query options]
ednsdiag compare <domain> [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 28 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
+28 -4
View File
@@ -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
@@ -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 <domain> [type] [--protocol doh|dot] [--provider cloudflare|google|quad9|adguard] [--url https://host/dns-query] [--method post|get] [--timeout 5s]")
fmt.Fprintln(writer, "usage: ednsdiag <query|probe> <domain> [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 <domain> [type] --target protocol:provider[:method] --target protocol:provider[:method] [--proxy http://host:port] [--attempt-timeout 5s] [--timeout 30s] [--max-attempts 4]")
}
@@ -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)
}
}
+20 -2
View File
@@ -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
)
+42
View File
@@ -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=
@@ -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) {
@@ -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)
}
}
@@ -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)
@@ -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)
@@ -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 {
@@ -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)
@@ -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 {
@@ -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 {
@@ -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")
}
})
}
}
@@ -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
}
@@ -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.
@@ -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
@@ -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.
@@ -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
}
}