feat(ednsdiag): add DoQ/DoH3/DNSCrypt transports, proxy support, probe & compare
This commit is contained in:
+22
-6
@@ -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 ./...
|
||||
|
||||
@@ -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 2–8 unique, allowlisted targets, bounded by `--max-attempts`.
|
||||
Its total timeout is `250ms`–`60s`; each attempt timeout is `250ms`–`30s` and
|
||||
cannot exceed the total. Comparison targets start concurrently, results retain
|
||||
the requested target order, and answers are never merged.
|
||||
|
||||
Supported record types are `A`, `AAAA`, `CNAME`, `MX`, `TXT`, `NS`, `SOA`,
|
||||
`CAA`, `SRV`, `PTR`, `HTTPS`, and `SVCB`. For `PTR`, pass an IP address; the CLI
|
||||
constructs the reverse name. Other IP literals and local names remain blocked.
|
||||
|
||||
Built-in resolver profiles:
|
||||
|
||||
| Provider | Profile |
|
||||
| --- | --- |
|
||||
| Cloudflare | Unfiltered |
|
||||
| Google | Unfiltered |
|
||||
| Quad9 | Security-filtered |
|
||||
| AdGuard | Ad- and security-filtered |
|
||||
| Provider | Profile | DoH | DoT | DoQ | DoH3 | DNSCrypt |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| Cloudflare | Unfiltered | Yes | Yes | No | Yes | No |
|
||||
| Google | Unfiltered | Yes | Yes | No | Yes | No |
|
||||
| Quad9 | Security-filtered | Yes | Yes | No | No | No |
|
||||
| AdGuard | Ad- and security-filtered | Yes | Yes | Yes | No | Yes |
|
||||
|
||||
Filtering policies can affect DNS answers. Results always identify the
|
||||
provider and profile used.
|
||||
@@ -122,11 +158,37 @@ Every query returns structured JSON compatible with
|
||||
it is not local DNSSEC validation.
|
||||
- `transport.bootstrap: system_resolver` means the operating system resolver
|
||||
was used to locate the encrypted resolver endpoint.
|
||||
- `transport.proxy`, when present, is the HTTP(S) proxy endpoint actually
|
||||
selected for DoH or DoT. Embedded credentials are never reported.
|
||||
- DNSCrypt reports `bootstrap: stamp_ip`, the authenticated provider name,
|
||||
resolver certificate serial, and selected crypto construction.
|
||||
- DoH and DoH3 subtract a valid HTTP `Age` value from returned answer TTLs and
|
||||
report it as `transport.http_age_seconds`.
|
||||
- Truncated or non-representable DNS answers are protocol failures rather than
|
||||
partial `completed: true` results.
|
||||
|
||||
Human-readable usage errors go to stderr. Machine-readable operational results
|
||||
go to stdout. Stable exit codes are:
|
||||
|
||||
| Code | Meaning |
|
||||
| --- | --- |
|
||||
| `0` | The requested encrypted DNS operation completed; inspect `dns.rcode`. |
|
||||
| `1` | Local or internal failure. |
|
||||
| `2` | Invalid input or CLI usage. |
|
||||
| `3` | Transport or DNS protocol failure. |
|
||||
| `4` | Known but unsupported capability or provider/protocol combination. |
|
||||
|
||||
See [`references/contracts.md`](references/contracts.md) for the complete v1
|
||||
command and result contract.
|
||||
|
||||
## Security Model
|
||||
|
||||
- DoH uses standard `application/dns-message` wire messages.
|
||||
- DoT verifies the PKIX certificate chain and configured authentication domain.
|
||||
- DoT advertises ALPN `dot`; an empty selection is accepted and reported, while
|
||||
selection of a different application protocol is rejected.
|
||||
- DNSCrypt validates the resolver stamp, Ed25519-signed certificate, validity
|
||||
interval, provider identity, and encrypted response before accepting DNS data.
|
||||
- Plaintext fallback is prohibited.
|
||||
- DNS errors are not retried through another protocol as transport failures.
|
||||
- Provider and protocol results remain separate.
|
||||
@@ -140,7 +202,19 @@ model and privacy boundaries.
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go test -race ./...
|
||||
go vet ./...
|
||||
go run honnef.co/go/tools/cmd/staticcheck@v0.7.0 ./...
|
||||
go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./...
|
||||
python3 scripts/validate_skill.py .
|
||||
```
|
||||
|
||||
Public endpoint interoperability tests are opt-in and skip cleanly when the
|
||||
host cannot reach the network:
|
||||
|
||||
```bash
|
||||
EDNSDIAG_PUBLIC_INTEROP=1 go test ./internal/edns \
|
||||
-run '^TestPublicCloudflareDo[HT]Interoperability$' -count=1 -v
|
||||
```
|
||||
|
||||
Protocol behavior must remain aligned with
|
||||
|
||||
@@ -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
|
||||
|
||||
code := run([]string{"probe"}, &stdout, &stderr)
|
||||
if code != 4 {
|
||||
t.Fatalf("run probe returned %d, want 4", code)
|
||||
if code := run(test.args, &stdout, &stderr); code != exitSuccess {
|
||||
t.Fatalf("run returned %d; stderr=%q", code, 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", 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 TestParseQueryArgsAllowsInterspersedOptions(t *testing.T) {
|
||||
options, timeout, err := parseQueryArgs([]string{"example.com", "MX", "--protocol", "dot", "--provider=quad9", "--timeout", "3s"})
|
||||
func TestUnsupportedProtocolReturnsStableJSONAndExitCode(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
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 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 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.Name != "example.com" || options.RecordType != "MX" || options.Protocol != "dot" || options.Provider != "quad9" {
|
||||
t.Fatalf("unexpected options: %#v", options)
|
||||
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 compare proxy: %v", err)
|
||||
}
|
||||
if options.Proxy != "https://proxy.example:8443" {
|
||||
t.Fatalf("proxy = %q", options.Proxy)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if timeout != 3*time.Second {
|
||||
t.Fatalf("timeout = %v, want 3s", timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownCommand(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
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") || stdout.Len() != 0 {
|
||||
t.Fatalf("stdout=%q stderr=%q", stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
code := run([]string{"unknown"}, &stdout, &stderr)
|
||||
if code != 2 {
|
||||
t.Fatalf("run unknown returned %d, want 2", code)
|
||||
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,
|
||||
}},
|
||||
},
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "unknown command") {
|
||||
t.Fatalf("stderr = %q, want unknown command message", stderr.String())
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
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,6 +38,23 @@ 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"`
|
||||
@@ -30,6 +64,8 @@ type ResolverInfo struct {
|
||||
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,36 +1,62 @@
|
||||
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
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
@@ -38,12 +64,39 @@ var providers = map[string]Provider{
|
||||
"adguard": {
|
||||
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"}
|
||||
if err := ValidateProxyURL(options.Proxy); err != nil {
|
||||
result.Error = &ErrorInfo{Class: "input", Message: err.Error()}
|
||||
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)}
|
||||
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
|
||||
}
|
||||
provider = Provider{ID: "custom", Profile: "custom", DoHURL: options.EndpointURL}
|
||||
}
|
||||
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 }
|
||||
"client_validated_dnssec": { "const": false },
|
||||
"answers": { "type": "array", "items": { "$ref": "#/$defs/answer" } }
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
"additionalProperties": false
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"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
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user