chore(skills): remove vendored encrypted-dns-skill (installed globally via skills CLI)

This commit is contained in:
windyboy
2026-08-15 12:35:55 +08:00
parent 1ec9246156
commit 047ac03346
24 changed files with 0 additions and 3531 deletions
@@ -1,68 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
quality:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Check formatting
run: |
files="$(gofmt -l .)"
if [ -n "$files" ]; then
echo "Unformatted Go files:"
echo "$files"
exit 1
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:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Check out repository
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Resolve modules
run: go mod download
- 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 ./...
@@ -1,10 +0,0 @@
# Go build outputs
/bin/
/dist/
*.exe
*.test
*.out
# Local development
.env
.DS_Store
-202
View File
@@ -1,202 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@@ -1,233 +0,0 @@
# Encrypted DNS Skill
[![CI](https://github.com/windyboy/encrypted-dns-skill/actions/workflows/ci.yml/badge.svg)](https://github.com/windyboy/encrypted-dns-skill/actions/workflows/ci.yml)
An [Agent Skill](https://agentskills.io/specification) and deterministic Go CLI
for querying and diagnosing encrypted DNS resolvers.
The skill tells an agent when and how to perform encrypted DNS diagnostics;
`ednsdiag` performs the protocol exchange. Agents do not need to construct DoH
URLs, TLS sessions, or DNS wire messages themselves.
## Status
| Protocol | Status | Standard |
| --- | --- | --- |
| 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) | 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) |
Run `ednsdiag capabilities` instead of assuming a protocol is implemented.
## Why a Skill and a CLI?
- `SKILL.md` provides compact instructions, safety boundaries, and result
interpretation for an AI agent.
- `ednsdiag` provides repeatable wire-format DNS, HTTP, TLS, input validation,
and structured JSON output.
- Reference files keep protocol, provider, and security details grounded in
authoritative sources without bloating the agent's active context.
The CLI never silently downgrades to plaintext DNS, and it does not connect to
addresses returned in DNS answers.
## Requirements
- 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
## Install the Skill
Clone or copy this repository into a skill discovery directory supported by
your agent host. Keep the repository layout intact so `SKILL.md`, `references/`,
`schemas/`, and the Go source remain together.
For example, in a host that discovers project-local skills from `.agents/skills`:
```bash
git clone https://github.com/windyboy/encrypted-dns-skill.git \
.agents/skills/encrypted-dns-skill
```
Discovery paths differ between hosts. Follow the host's documentation rather
than moving only `SKILL.md`.
## Run from Source
No precompiled executable is required. Go can compile and run the command from
the repository root:
```bash
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`.
To build a reusable local executable:
```bash
go build -o ./bin/ednsdiag ./cmd/ednsdiag
./bin/ednsdiag capabilities
```
Do not download or execute an unverified third-party binary. This repository
does not currently publish release binaries.
## Usage
```text
ednsdiag capabilities
ednsdiag version
ednsdiag query <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]
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 and DoH3. The timeout must be between `250ms` and `30s`.
Research protocols are accepted as inputs so automation receives a structured
`unsupported` result and exit code `4`; they are never silently substituted.
DoH and DoT honor Go's standard `HTTPS_PROXY`/`https_proxy` and
`NO_PROXY`/`no_proxy` environment variables. `--proxy` overrides environment
selection and accepts an `http://` or `https://` proxy URL, including optional
Basic-auth userinfo. DoT uses HTTP CONNECT before its resolver TLS handshake.
DoH3, DoQ, and DNSCrypt are UDP/QUIC transports and cannot use this TCP CONNECT
proxy; an explicit proxy combined with one of those protocols is rejected.
The same `--proxy` is shared by every DoH/DoT target in a `compare` operation.
See Go's official [`ProxyFromEnvironment` documentation](https://pkg.go.dev/net/http#ProxyFromEnvironment)
for environment-variable and `NO_PROXY` matching rules.
`compare` accepts 28 unique, allowlisted targets, bounded by `--max-attempts`.
Its total timeout is `250ms``60s`; each attempt timeout is `250ms``30s` and
cannot exceed the total. Comparison targets start concurrently, results retain
the requested target order, and answers are never merged.
Supported record types are `A`, `AAAA`, `CNAME`, `MX`, `TXT`, `NS`, `SOA`,
`CAA`, `SRV`, `PTR`, `HTTPS`, and `SVCB`. For `PTR`, pass an IP address; the CLI
constructs the reverse name. Other IP literals and local names remain blocked.
Built-in resolver profiles:
| Provider | Profile | 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.
## Result Semantics
Every query returns structured JSON compatible with
[`schemas/result-v1.schema.json`](schemas/result-v1.schema.json).
- `completed: true` means the encrypted protocol exchange completed; it does
not mean the DNS response was `NOERROR`.
- `dns.rcode` is the DNS result. `NXDOMAIN`, `SERVFAIL`, and `REFUSED` are DNS
outcomes, not transport failures.
- Empty `dns.answers` with `NOERROR` means NODATA.
- `transport.server_authenticated` reports resolver endpoint authentication.
- `dns.resolver_reports_dnssec_authenticated` reflects the resolver's AD bit;
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.
- DNS answers are data only; the tool does not make application connections to
returned addresses.
See [`references/security.md`](references/security.md) for the complete threat
model and privacy boundaries.
## Development
```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
[`references/standards.md`](references/standards.md), provider changes with
[`references/providers.md`](references/providers.md), and output with the v1
JSON schema.
## Scope
This project targets client-to-recursive encrypted DNS diagnostics. It is not a
system stub resolver, an authoritative DNS server, a hosted DNS record manager,
or a zone-transfer tool.
## License
Licensed under the [Apache License 2.0](LICENSE).
-114
View File
@@ -1,114 +0,0 @@
---
name: encrypted-dns-skill
description: Query, probe, and compare DNS resolution through supported encrypted transports. Use for encrypted DNS record lookups, resolver connectivity tests, TLS and QUIC diagnostics, protocol comparisons, DNSSEC status inspection, and troubleshooting DoH, DoT, DoQ, DoH3, or DNSCrypt resolver endpoints.
---
# Encrypted DNS Diagnostics
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.6+
is installed, run the source from the skill root with:
```bash
go run ./cmd/ednsdiag <command> [arguments]
```
Do not download or execute an unverified binary automatically. Building from
source may require permission to download pinned Go modules.
## Check capabilities
Before attempting an operation, run:
```bash
ednsdiag capabilities
```
Only use a protocol when its reported status is `available`. Never describe a
`planned` or `experimental` capability as implemented.
## Commands
```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 or DoH3. The default is POST.
Built-in providers are `cloudflare`, `google`, `quad9`, and `adguard`. Provider
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
- Use standard DNS wire messages for DoH, not provider-specific JSON APIs.
- Apply strict certificate and authentication-domain validation.
- Never silently downgrade to plaintext DNS.
- Do not retry `NXDOMAIN`, `NODATA`, `SERVFAIL`, or `REFUSED` through another
protocol as though they were transport failures.
- Keep results from different providers and protocols separate.
- Report every fallback attempt and its reason.
- Treat the DNS `AD` bit as validation reported by the selected resolver, not
as local DNSSEC validation.
- Do not connect to addresses returned in DNS answers.
## Result interpretation
- `completed: true` means a protocol exchange completed. It does not imply
`NOERROR`.
- Read `dns.rcode` for the DNS outcome.
- 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
- Read [references/standards.md](references/standards.md) before changing
protocol behavior.
- Read [references/security.md](references/security.md) before changing TLS,
bootstrap, fallback, endpoint, or privacy behavior.
- Read [references/providers.md](references/providers.md) before adding or
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
The target scope is widely deployed client-to-recursive encrypted DNS:
DoH, DoT, DoQ, DoH3, and DNSCrypt. ODoH and Anonymized DNSCrypt remain
research capabilities until explicitly marked available.
Do not use this skill for DNS-over-DTLS, zone transfers, authoritative-server
operation, changing hosted DNS records, or replacing the operating system's
stub resolver.
@@ -1,386 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
"github.com/windyboy/encrypted-dns-skill/internal/edns"
)
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"`
Standard string `json:"standard,omitempty"`
Note string `json:"note,omitempty"`
}
type capabilitiesResult struct {
SchemaVersion int `json:"schema_version"`
Command string `json:"command"`
Version string `json:"version"`
Capabilities []capability `json:"capabilities"`
}
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
func run(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
writeUsage(stderr)
return 2
}
switch args[0] {
case "capabilities":
if len(args) != 1 {
fmt.Fprintln(stderr, "capabilities does not accept arguments")
return 2
}
result := capabilitiesResult{
SchemaVersion: 1,
Command: "capabilities",
Version: version,
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: "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"},
},
}
return writeJSON(stdout, stderr, result)
case "version":
if len(args) != 1 {
fmt.Fprintln(stderr, "version does not accept arguments")
return 2
}
fmt.Fprintln(stdout, version)
return 0
case "query", "probe":
options, timeout, err := parseQueryArgs(args[1:])
if err != nil {
fmt.Fprintln(stderr, err)
writeQueryUsage(stderr)
return 2
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
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
}
return resultExitCode(result.Completed, result.Error)
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])
writeUsage(stderr)
return 2
}
}
func parseQueryArgs(args []string) (edns.QueryOptions, time.Duration, error) {
options := edns.QueryOptions{
RecordType: "A",
Protocol: "doh",
Provider: "cloudflare",
Method: "post",
}
timeout := 5 * time.Second
positionals := make([]string, 0, 2)
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 "protocol":
options.Protocol = strings.ToLower(value)
case "provider":
options.Provider = strings.ToLower(value)
case "method":
options.Method = strings.ToLower(value)
case "timeout":
parsed, err := time.ParseDuration(value)
if err != nil {
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)
}
}
if len(positionals) < 1 || len(positionals) > 2 {
return options, 0, fmt.Errorf("query requires a domain and optional record type")
}
if timeout < 250*time.Millisecond || timeout > 30*time.Second {
return options, 0, fmt.Errorf("timeout must be between 250ms and 30s")
}
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 !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 != "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("", " ")
if err := encoder.Encode(value); err != nil {
fmt.Fprintf(stderr, "encode JSON result: %v\n", err)
return 1
}
return 0
}
func writeUsage(writer io.Writer) {
fmt.Fprintln(writer, "usage: ednsdiag <capabilities|version|query|probe|compare>")
}
func writeQueryUsage(writer io.Writer) {
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]")
}
@@ -1,285 +0,0 @@
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) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := run([]string{"capabilities"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("run capabilities returned %d; stderr=%q", code, stderr.String())
}
var result capabilitiesResult
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
t.Fatalf("decode capabilities: %v", err)
}
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"] || !available["doq"] || !available["doh3"] || !available["dnscrypt"] {
t.Fatalf("DoH, DoT, DoQ, DoH3, and DNSCrypt must be available: %#v", available)
}
}
func TestQueryProbeAndCompareGoldenContracts(t *testing.T) {
originalQuery, originalProbe, originalCompare := runQuery, runProbe, runCompare
t.Cleanup(func() {
runQuery, runProbe, runCompare = originalQuery, originalProbe, originalCompare
})
queryCalls := 0
probeCalls := 0
runQuery = func(_ context.Context, options edns.QueryOptions) edns.Result {
queryCalls++
return successfulResult("query", options.Protocol, options.Provider, "203.0.113.10")
}
runProbe = func(_ context.Context, options edns.QueryOptions) edns.Result {
probeCalls++
return successfulResult("probe", options.Protocol, options.Provider, "203.0.113.10")
}
runCompare = func(_ context.Context, _ edns.CompareOptions) edns.CompareResult {
first := successfulResult("query", "doh", "cloudflare", "203.0.113.10")
second := successfulResult("query", "dot", "google", "203.0.113.20")
return edns.CompareResult{
SchemaVersion: 1,
Operation: "compare",
Completed: true,
Query: edns.QueryInfo{Name: "example.com", Type: "A"},
Attempts: []edns.Result{first, second},
Summary: edns.CompareSummary{Total: 2, Completed: 2},
}
}
tests := []struct {
name string
args []string
golden string
}{
{name: "query", args: []string{"query", "example.com", "A"}, golden: "query.golden.json"},
{name: "probe", args: []string{"probe", "example.com", "A"}, golden: "probe.golden.json"},
{name: "compare", args: []string{"compare", "example.com", "A", "--target", "doh:cloudflare", "--target", "dot:google"}, golden: "compare.golden.json"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
if code := run(test.args, &stdout, &stderr); code != exitSuccess {
t.Fatalf("run returned %d; stderr=%q", code, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
want, err := os.ReadFile(filepath.Join("testdata", test.golden))
if err != nil {
t.Fatalf("read golden: %v", err)
}
want = bytes.ReplaceAll(want, []byte("\r\n"), []byte("\n"))
if !bytes.Equal(stdout.Bytes(), want) {
t.Fatalf("stdout does not match %s\nwant:\n%s\ngot:\n%s", test.golden, want, stdout.Bytes())
}
validateResultSchema(t, stdout.Bytes())
})
}
if queryCalls != 1 || probeCalls != 1 {
t.Fatalf("query calls = %d, probe calls = %d; each operation must invoke only its own runner", queryCalls, probeCalls)
}
}
func TestUnsupportedProtocolReturnsStableJSONAndExitCode(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := run([]string{"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.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)
}
}
}
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())
}
}
func successfulResult(operation, protocol, provider, address string) edns.Result {
return edns.Result{
SchemaVersion: 1,
Operation: operation,
Completed: true,
Query: edns.QueryInfo{Name: "example.com", Type: "A"},
Resolver: edns.ResolverInfo{Provider: provider, Endpoint: provider + ".example:443", Profile: "test"},
Transport: edns.TransportInfo{
Protocol: protocol,
Encrypted: true,
ServerAuthenticated: true,
ElapsedMS: 12,
Bootstrap: "test_fixture",
},
DNS: edns.DNSInfo{
RCode: "NOERROR",
RCodeValue: 0,
Answers: []edns.AnswerRecord{{
"name": "example.com", "type": "A", "ttl": float64(60), "address": address,
}},
},
}
}
func validateResultSchema(t *testing.T, document []byte) {
t.Helper()
schemaBytes, err := os.ReadFile(filepath.Join("..", "..", "schemas", "result-v1.schema.json"))
if err != nil {
t.Fatalf("read result schema: %v", err)
}
var schemaDocument any
if err := json.Unmarshal(schemaBytes, &schemaDocument); err != nil {
t.Fatalf("decode result schema: %v", err)
}
compiler := jsonschema.NewCompiler()
if err := compiler.AddResource("result-v1.schema.json", schemaDocument); err != nil {
t.Fatalf("add result schema: %v", err)
}
schema, err := compiler.Compile("result-v1.schema.json")
if err != nil {
t.Fatalf("compile result schema: %v", err)
}
var value any
if err := json.Unmarshal(document, &value); err != nil {
t.Fatalf("decode result JSON: %v", err)
}
if err := schema.Validate(value); err != nil {
t.Fatalf("result does not validate against result-v1: %v", err)
}
}
-25
View File
@@ -1,25 +0,0 @@
module github.com/windyboy/encrypted-dns-skill
go 1.26.6
require golang.org/x/net v0.58.0
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
)
-46
View File
@@ -1,46 +0,0 @@
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=
@@ -1,289 +0,0 @@
package edns
import (
"crypto/rand"
"encoding/base64"
"encoding/binary"
"fmt"
"net"
"net/netip"
"strings"
"golang.org/x/net/dns/dnsmessage"
"golang.org/x/net/idna"
)
var recordTypes = map[string]dnsmessage.Type{
"A": dnsmessage.TypeA,
"AAAA": dnsmessage.TypeAAAA,
"CNAME": dnsmessage.TypeCNAME,
"MX": dnsmessage.TypeMX,
"TXT": dnsmessage.TypeTXT,
"NS": dnsmessage.TypeNS,
"SOA": dnsmessage.TypeSOA,
"CAA": dnsmessage.Type(257),
"SRV": dnsmessage.TypeSRV,
"SVCB": dnsmessage.TypeSVCB,
"HTTPS": dnsmessage.TypeHTTPS,
"PTR": dnsmessage.TypePTR,
}
func BuildQuery(name, recordType string) ([]byte, QueryInfo, uint16, error) {
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)
}
var randomID [2]byte
if _, err := rand.Read(randomID[:]); err != nil {
return nil, QueryInfo{}, 0, fmt.Errorf("generate DNS transaction ID: %w", err)
}
id := binary.BigEndian.Uint16(randomID[:])
message := dnsmessage.Message{
Header: dnsmessage.Header{ID: id, RecursionDesired: true},
Questions: []dnsmessage.Question{{
Name: dnsName,
Type: qtype,
Class: dnsmessage.ClassINET,
}},
}
wire, err := message.Pack()
if err != nil {
return nil, QueryInfo{}, 0, fmt.Errorf("pack DNS query: %w", err)
}
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 {
return DNSInfo{}, fmt.Errorf("unpack DNS response: %w", err)
}
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")
}
if len(message.Questions) != 1 {
return DNSInfo{}, fmt.Errorf("DNS response contains %d questions, want 1", len(message.Questions))
}
wantType := recordTypes[query.Type]
question := message.Questions[0]
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 {
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{
RCode: rcodeName(message.Header.RCode),
RCodeValue: int(message.Header.RCode),
ResolverReportsDNSSECAuthenticated: message.Header.AuthenticData,
ClientValidatedDNSSEC: false,
Answers: answers,
}, nil
}
func canonicalName(input string) (string, error) {
name := strings.TrimSuffix(strings.TrimSpace(input), ".")
if name == "" {
return "", fmt.Errorf("domain name is empty")
}
if net.ParseIP(name) != nil {
return "", fmt.Errorf("IP literals are not accepted as domain names")
}
ascii, err := idna.Lookup.ToASCII(name)
if err != nil {
return "", fmt.Errorf("convert domain name to IDNA ASCII: %w", err)
}
ascii = strings.ToLower(ascii)
if len(ascii) > 253 {
return "", fmt.Errorf("domain name exceeds 253 bytes")
}
for _, label := range strings.Split(ascii, ".") {
if label == "" || len(label) > 63 {
return "", fmt.Errorf("domain name contains an invalid label")
}
}
blocked := []string{"localhost", ".local", ".internal", ".lan", ".arpa"}
for _, suffix := range blocked {
if ascii == strings.TrimPrefix(suffix, ".") || strings.HasSuffix(ascii, suffix) {
return "", fmt.Errorf("domain name is blocked by the local-name policy")
}
}
return ascii, nil
}
func normalizeAnswer(resource dnsmessage.Resource) (AnswerRecord, error) {
record := AnswerRecord{
"name": trimRoot(resource.Header.Name.String()),
"type": typeName(resource.Header.Type),
"ttl": resource.Header.TTL,
}
switch body := resource.Body.(type) {
case *dnsmessage.AResource:
record["address"] = net.IP(body.A[:]).String()
case *dnsmessage.AAAAResource:
record["address"] = net.IP(body.AAAA[:]).String()
case *dnsmessage.CNAMEResource:
record["target"] = trimRoot(body.CNAME.String())
case *dnsmessage.MXResource:
record["priority"] = body.Pref
record["exchange"] = trimRoot(body.MX.String())
case *dnsmessage.TXTResource:
record["strings"] = body.TXT
case *dnsmessage.NSResource:
record["host"] = trimRoot(body.NS.String())
case *dnsmessage.PTRResource:
record["target"] = trimRoot(body.PTR.String())
case *dnsmessage.SOAResource:
record["primary_ns"] = trimRoot(body.NS.String())
record["responsible_mailbox"] = trimRoot(body.MBox.String())
record["serial"] = body.Serial
record["refresh"] = body.Refresh
record["retry"] = body.Retry
record["expire"] = body.Expire
record["minimum_ttl"] = body.MinTTL
case *dnsmessage.SRVResource:
record["priority"] = body.Priority
record["weight"] = body.Weight
record["port"] = body.Port
record["target"] = trimRoot(body.Target.String())
case *dnsmessage.SVCBResource:
addSVCBFields(record, body.Priority, body.Target, body.Params)
case *dnsmessage.HTTPSResource:
addSVCBFields(record, body.Priority, body.Target, body.Params)
case *dnsmessage.UnknownResource:
if resource.Header.Type == dnsmessage.Type(257) && len(body.Data) >= 2 {
record["flags"] = body.Data[0]
tagLength := int(body.Data[1])
if 2+tagLength <= len(body.Data) {
record["tag"] = string(body.Data[2 : 2+tagLength])
record["value"] = string(body.Data[2+tagLength:])
} else {
return nil, fmt.Errorf("CAA answer contains a truncated tag")
}
} else {
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)
}
}
}
func addSVCBFields(record AnswerRecord, priority uint16, target dnsmessage.Name, params []dnsmessage.SVCParam) {
record["priority"] = priority
record["target"] = trimRoot(target.String())
values := make([]map[string]any, 0, len(params))
for _, param := range params {
values = append(values, map[string]any{
"key": param.Key.String(),
"key_value": uint16(param.Key),
"value_base64": base64.StdEncoding.EncodeToString(param.Value),
})
}
record["params"] = values
}
func trimRoot(name string) string {
return strings.TrimSuffix(strings.ToLower(name), ".")
}
func typeName(recordType dnsmessage.Type) string {
for name, value := range recordTypes {
if value == recordType {
return name
}
}
return fmt.Sprintf("TYPE%d", recordType)
}
func rcodeName(rcode dnsmessage.RCode) string {
names := map[dnsmessage.RCode]string{
dnsmessage.RCodeSuccess: "NOERROR",
dnsmessage.RCodeFormatError: "FORMERR",
dnsmessage.RCodeServerFailure: "SERVFAIL",
dnsmessage.RCodeNameError: "NXDOMAIN",
dnsmessage.RCodeNotImplemented: "NOTIMP",
dnsmessage.RCodeRefused: "REFUSED",
}
if name, ok := names[rcode]; ok {
return name
}
return fmt.Sprintf("RCODE%d", rcode)
}
@@ -1,304 +0,0 @@
package edns
import (
"encoding/binary"
"reflect"
"strings"
"testing"
"golang.org/x/net/dns/dnsmessage"
)
func TestBuildAndParseResponse(t *testing.T) {
queryWire, query, transactionID, err := BuildQuery("Example.COM.", "A")
if err != nil {
t.Fatalf("build query: %v", err)
}
if query.Name != "example.com" || query.Type != "A" {
t.Fatalf("canonical query = %#v", query)
}
var request dnsmessage.Message
if err := request.Unpack(queryWire); err != nil {
t.Fatalf("unpack query: %v", err)
}
response := dnsmessage.Message{
Header: dnsmessage.Header{
ID: transactionID,
Response: true,
RecursionDesired: true,
RecursionAvailable: true,
AuthenticData: true,
},
Questions: request.Questions,
Answers: []dnsmessage.Resource{{
Header: dnsmessage.ResourceHeader{Name: request.Questions[0].Name, Class: dnsmessage.ClassINET, TTL: 60},
Body: &dnsmessage.AResource{A: [4]byte{192, 0, 2, 1}},
}},
}
responseWire, err := response.Pack()
if err != nil {
t.Fatalf("pack response: %v", err)
}
dnsResult, err := ParseResponse(responseWire, transactionID, query)
if err != nil {
t.Fatalf("parse response: %v", err)
}
if dnsResult.RCode != "NOERROR" || !dnsResult.ResolverReportsDNSSECAuthenticated {
t.Fatalf("unexpected DNS result: %#v", dnsResult)
}
if got := dnsResult.Answers[0]["address"]; got != "192.0.2.1" {
t.Fatalf("address = %v, want 192.0.2.1", got)
}
}
func TestBuildQueryIDNAAndBlockedNames(t *testing.T) {
_, query, _, err := BuildQuery("bücher.example", "AAAA")
if err != nil {
t.Fatalf("build IDNA query: %v", err)
}
if query.Name != "xn--bcher-kva.example" {
t.Fatalf("IDNA name = %q", query.Name)
}
blocked := []string{"localhost", "router.local", "service.internal", "host.lan", "1.0.0.127.in-addr.arpa", "127.0.0.1"}
for _, name := range blocked {
if _, _, _, err := BuildQuery(name, "A"); err == nil {
t.Errorf("BuildQuery(%q) succeeded, want policy error", name)
}
}
}
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, 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)
}
}
func TestParseResponseRejectsTransactionMismatch(t *testing.T) {
name := dnsmessage.MustNewName("example.com.")
message := dnsmessage.Message{
Header: dnsmessage.Header{ID: 2, Response: true},
Questions: []dnsmessage.Question{{Name: name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET}},
}
wire, err := message.Pack()
if err != nil {
t.Fatalf("pack response: %v", err)
}
if _, err := ParseResponse(wire, 1, QueryInfo{Name: "example.com", Type: "A"}); err == nil {
t.Fatal("transaction mismatch was accepted")
}
if binary.BigEndian.Uint16(wire[:2]) != 2 {
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)
}
}
@@ -1,169 +0,0 @@
package edns
import (
"bytes"
"context"
"crypto/tls"
"encoding/base64"
"fmt"
"io"
"mime"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const maxDNSMessageSize = 65535
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, 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: tlsConfig.Clone(),
TLSHandshakeTimeout: 5 * time.Second,
}
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 {
return fmt.Errorf("too many DoH redirects")
}
if request.URL.Scheme != "https" {
return fmt.Errorf("DoH redirect changed to a non-HTTPS scheme")
}
if !strings.EqualFold(request.URL.Hostname(), origin.Hostname()) {
return fmt.Errorf("DoH redirect changed authentication domain")
}
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: protocol,
Encrypted: true,
Bootstrap: "system_resolver",
}
requestURL := endpoint
var body io.Reader
switch strings.ToLower(method) {
case "get":
parsed, err := url.Parse(endpoint)
if err != nil {
return nil, info, fmt.Errorf("parse DoH endpoint: %w", err)
}
query := parsed.Query()
query.Set("dns", base64.RawURLEncoding.EncodeToString(wire))
parsed.RawQuery = query.Encode()
requestURL = parsed.String()
case "post", "":
method = "post"
body = bytes.NewReader(wire)
default:
return nil, info, fmt.Errorf("unsupported DoH method %q", method)
}
request, err := http.NewRequestWithContext(ctx, strings.ToUpper(method), requestURL, body)
if err != nil {
return nil, info, fmt.Errorf("create DoH request: %w", err)
}
request.Header.Set("Accept", "application/dns-message")
if strings.EqualFold(method, "post") {
request.Header.Set("Content-Type", "application/dns-message")
}
request.Header.Set("User-Agent", "ednsdiag/0.1.0-dev")
response, err := client.Do(request)
info.ElapsedMS = time.Since(started).Milliseconds()
if err != nil {
return nil, info, fmt.Errorf("perform DoH exchange: %w", err)
}
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)
}
mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type"))
if err != nil || !strings.EqualFold(mediaType, "application/dns-message") {
return nil, info, fmt.Errorf("DoH server returned unsupported content type %q", response.Header.Get("Content-Type"))
}
payload, err := io.ReadAll(io.LimitReader(response.Body, maxDNSMessageSize+1))
if err != nil {
return nil, info, fmt.Errorf("read DoH response: %w", err)
}
if len(payload) > maxDNSMessageSize {
return nil, info, fmt.Errorf("DoH response exceeds %d bytes", maxDNSMessageSize)
}
return payload, info, nil
}
func tlsVersionName(version uint16) string {
switch version {
case tls.VersionTLS13:
return "TLS1.3"
case tls.VersionTLS12:
return "TLS1.2"
default:
return fmt.Sprintf("0x%04x", version)
}
}
@@ -1,142 +0,0 @@
package edns
import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"golang.org/x/net/dns/dnsmessage"
)
func TestExchangeDoHGETAndPOST(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
var payload []byte
var err error
if request.Method == http.MethodGet {
payload, err = decodeGETQuery(request.URL.Query().Get("dns"))
} else {
payload, err = io.ReadAll(request.Body)
}
if err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest)
return
}
if request.Header.Get("Accept") != "application/dns-message" {
http.Error(writer, "missing accept", http.StatusNotAcceptable)
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, RecursionAvailable: 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.Header().Set("Age", "10")
_, _ = writer.Write(responseWire)
}))
defer server.Close()
wire, _, _, err := BuildQuery("example.com", "A")
if err != nil {
t.Fatalf("build query: %v", err)
}
for _, method := range []string{"get", "post"} {
t.Run(method, func(t *testing.T) {
response, info, err := exchangeDoHWithClient(t.Context(), server.Client(), server.URL, wire, method)
if err != nil {
t.Fatalf("exchange DoH: %v", err)
}
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)
}))
defer server.Close()
if _, _, err := exchangeDoHWithClient(t.Context(), server.Client(), server.URL, []byte{1}, "post"); err == nil {
t.Fatal("HTTP error was accepted")
}
}
@@ -1,96 +0,0 @@
package edns
import (
"context"
"crypto/tls"
"fmt"
"io"
"net/url"
"time"
)
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",
Encrypted: true,
Bootstrap: "system_resolver",
}
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)
}
defer rawConnection.Close()
if deadline, ok := ctx.Deadline(); ok {
if err := rawConnection.SetDeadline(deadline); err != nil {
return nil, info, fmt.Errorf("set DoT deadline: %w", err)
}
}
tlsConfig = tlsConfig.Clone()
tlsConfig.ServerName = provider.DoTName
tlsConnection := tls.Client(rawConnection, tlsConfig)
if err := tlsConnection.HandshakeContext(ctx); err != nil {
info.ElapsedMS = time.Since(started).Milliseconds()
return nil, info, fmt.Errorf("authenticate DoT server: %w", err)
}
state := tlsConnection.ConnectionState()
if len(state.VerifiedChains) == 0 {
return nil, info, fmt.Errorf("DoT server TLS identity was not verified")
}
info.ServerAuthenticated = true
info.TLSVersion = tlsVersionName(state.Version)
info.ALPN = state.NegotiatedProtocol
if info.ALPN != "" && info.ALPN != "dot" {
info.ElapsedMS = time.Since(started).Milliseconds()
return nil, info, fmt.Errorf("DoT server negotiated unexpected ALPN protocol %q", info.ALPN)
}
response, err := exchangeTCPFrame(tlsConnection, wire)
info.ElapsedMS = time.Since(started).Milliseconds()
if err != nil {
return nil, info, fmt.Errorf("perform DoT exchange: %w", err)
}
return response, info, nil
}
func exchangeTCPFrame(connection io.ReadWriter, wire []byte) ([]byte, error) {
if err := writeDNSFrame(connection, wire); err != nil {
return nil, fmt.Errorf("write framed DNS query: %w", err)
}
return readDNSFrame(connection)
}
func writeAll(writer io.Writer, payload []byte) error {
for len(payload) > 0 {
written, err := writer.Write(payload)
if err != nil {
return err
}
if written == 0 {
return io.ErrShortWrite
}
payload = payload[written:]
}
return nil
}
@@ -1,314 +0,0 @@
package edns
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"encoding/binary"
"io"
"math/big"
"net"
"strings"
"testing"
"time"
"golang.org/x/net/dns/dnsmessage"
)
type scriptedReadWriter struct {
read *bytes.Reader
written bytes.Buffer
}
func (stream *scriptedReadWriter) Read(payload []byte) (int, error) {
return stream.read.Read(payload)
}
func (stream *scriptedReadWriter) Write(payload []byte) (int, error) {
return stream.written.Write(payload)
}
func TestExchangeTCPFrame(t *testing.T) {
responsePayload := []byte{9, 8, 7}
framedResponse := make([]byte, 2+len(responsePayload))
binary.BigEndian.PutUint16(framedResponse[:2], uint16(len(responsePayload)))
copy(framedResponse[2:], responsePayload)
stream := &scriptedReadWriter{read: bytes.NewReader(framedResponse)}
query := []byte{1, 2, 3, 4}
response, err := exchangeTCPFrame(stream, query)
if err != nil {
t.Fatalf("exchange TCP frame: %v", err)
}
if !bytes.Equal(response, responsePayload) {
t.Fatalf("response = %v, want %v", response, responsePayload)
}
written := stream.written.Bytes()
if int(binary.BigEndian.Uint16(written[:2])) != len(query) || !bytes.Equal(written[2:], query) {
t.Fatalf("invalid query frame: %v", written)
}
}
func TestExchangeDoTAuthenticatesServer(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
}()
queryWire, query, transactionID, 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()
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: %v", err)
}
if err := <-serverError; err != nil {
t.Fatalf("serve DoT: %v", err)
}
if !info.ServerAuthenticated || info.ALPN != "dot" {
t.Fatalf("unexpected transport info: %#v", info)
}
if _, err := ParseResponse(response, transactionID, query); err != nil {
t.Fatalf("parse response: %v", err)
}
}
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{
Certificates: []tls.Certificate{certificate},
MinVersion: tls.VersionTLS12,
})
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
}()
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()
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 ALPN: %v", err)
}
if err := <-serverError; err != nil {
t.Fatalf("serve DoT: %v", err)
}
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) {
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{"http/1.1"},
})
if err != nil {
t.Fatalf("listen for TLS: %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()
serverError <- connection.(*tls.Conn).Handshake()
}()
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 := exchangeDoTWithTLSConfig(ctx, Provider{DoTAddr: listener.Addr().String(), DoTName: "resolver.test"}, queryWire, &tls.Config{
RootCAs: roots,
MinVersion: tls.VersionTLS12,
NextProtos: []string{"http/1.1"},
})
if err == nil || !strings.Contains(err.Error(), "unexpected ALPN protocol") {
t.Fatalf("exchange DoT error = %v, want unexpected ALPN error", err)
}
if err := <-serverError; err != nil {
t.Fatalf("complete TLS handshake: %v", err)
}
if !info.ServerAuthenticated || info.ALPN != "http/1.1" {
t.Fatalf("unexpected transport info: %#v", info)
}
}
func newTestCertificate(t *testing.T, name string) (tls.Certificate, *x509.CertPool) {
t.Helper()
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
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)
}
parsed, err := x509.ParseCertificate(der)
if err != nil {
t.Fatalf("parse certificate: %v", err)
}
roots := x509.NewCertPool()
roots.AddCert(parsed)
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: privateKey}, roots
}
func serveOneDoTQuery(connection net.Conn) ([]byte, error) {
var lengthBytes [2]byte
if _, err := io.ReadFull(connection, lengthBytes[:]); err != nil {
return nil, err
}
wire := make([]byte, int(binary.BigEndian.Uint16(lengthBytes[:])))
if _, err := io.ReadFull(connection, wire); err != nil {
return nil, err
}
var query dnsmessage.Message
if err := query.Unpack(wire); err != nil {
return nil, err
}
response := dnsmessage.Message{
Header: dnsmessage.Header{ID: query.Header.ID, Response: true, RecursionAvailable: true},
Questions: query.Questions,
}
responseWire, err := response.Pack()
if err != nil {
return nil, err
}
framed := make([]byte, 2+len(responseWire))
binary.BigEndian.PutUint16(framed[:2], uint16(len(responseWire)))
copy(framed[2:], responseWire)
return framed, nil
}
@@ -1,99 +0,0 @@
package edns
import "time"
type QueryOptions struct {
Name string
RecordType string
Protocol string
Provider string
Method 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 {
SchemaVersion int `json:"schema_version"`
Operation string `json:"operation"`
Completed bool `json:"completed"`
Query QueryInfo `json:"query"`
Resolver ResolverInfo `json:"resolver"`
Transport TransportInfo `json:"transport"`
DNS DNSInfo `json:"dns"`
Warnings []string `json:"warnings,omitempty"`
Error *ErrorInfo `json:"error,omitempty"`
}
type CompareResult struct {
SchemaVersion int `json:"schema_version"`
Operation string `json:"operation"`
Completed bool `json:"completed"`
Query QueryInfo `json:"query"`
Attempts []Result `json:"attempts"`
Summary CompareSummary `json:"summary"`
Error *ErrorInfo `json:"error,omitempty"`
}
type CompareSummary struct {
Total int `json:"total"`
Completed int `json:"completed"`
Failed int `json:"failed"`
Unsupported int `json:"unsupported"`
}
type QueryInfo struct {
Name string `json:"name"`
Type string `json:"type"`
}
type ResolverInfo struct {
Provider string `json:"provider"`
Endpoint string `json:"endpoint"`
Profile string `json:"profile"`
AuthenticationName string `json:"authentication_name,omitempty"`
CertificateSerial uint32 `json:"certificate_serial,omitempty"`
}
type TransportInfo struct {
Protocol string `json:"protocol"`
Encrypted bool `json:"encrypted"`
ServerAuthenticated bool `json:"server_authenticated"`
ElapsedMS int64 `json:"elapsed_ms"`
Bootstrap string `json:"bootstrap"`
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 {
RCode string `json:"rcode"`
RCodeValue int `json:"rcode_value"`
ResolverReportsDNSSECAuthenticated bool `json:"resolver_reports_dnssec_authenticated"`
ClientValidatedDNSSEC bool `json:"client_validated_dnssec"`
Answers []AnswerRecord `json:"answers"`
}
type AnswerRecord map[string]any
type ErrorInfo struct {
Class string `json:"class"`
Message string `json:"message"`
}
@@ -1,106 +0,0 @@
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",
},
"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 {
return Provider{}, fmt.Errorf("unknown provider %q", name)
}
return provider, nil
}
@@ -1,61 +0,0 @@
package edns
import (
"net/url"
"testing"
"time"
)
func TestBuiltInProvidersHaveStrictEndpoints(t *testing.T) {
for _, name := range []string{"cloudflare", "google", "quad9", "adguard"} {
provider, err := FindProvider(name)
if err != nil {
t.Fatalf("find provider %s: %v", name, err)
}
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")
}
})
}
}
@@ -1,111 +0,0 @@
package edns
import (
"context"
"encoding/binary"
"fmt"
)
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,
Operation: "query",
Query: query,
Transport: TransportInfo{
Protocol: options.Protocol,
Encrypted: true,
Bootstrap: "system_resolver",
},
DNS: DNSInfo{Answers: []AnswerRecord{}},
}
if err != nil {
result.Query = QueryInfo{Name: options.Name, Type: options.RecordType}
result.Error = &ErrorInfo{Class: "input", Message: err.Error()}
return result
}
provider, err := FindProvider(options.Provider)
if err != nil {
result.Error = &ErrorInfo{Class: "input", Message: err.Error()}
return result
}
if err := ValidateProxyURL(options.Proxy); err != nil {
result.Error = &ErrorInfo{Class: "input", Message: err.Error()}
return result
}
if options.Proxy != "" && options.Protocol != "doh" && options.Protocol != "dot" {
result.Error = &ErrorInfo{Class: "unsupported", Message: fmt.Sprintf("proxying is not available for protocol %q", options.Protocol)}
return result
}
result.Resolver = ResolverInfo{Provider: provider.ID, Profile: provider.Profile}
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
}
@@ -1,7 +0,0 @@
package edns
import "encoding/base64"
func decodeGETQuery(value string) ([]byte, error) {
return base64.RawURLEncoding.DecodeString(value)
}
@@ -1,35 +0,0 @@
# Built-in provider policy
Provider endpoints and capabilities must be verified against the provider's
official documentation before they are added or changed. The built-in entries
below were verified on 2026-08-13.
## Candidate providers
| 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
Each built-in provider entry must include:
- stable provider identifier;
- protocol and endpoint;
- authentication domain name;
- bootstrap addresses only when officially published;
- filtering/ECS profile;
- 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.
@@ -1,108 +0,0 @@
# Security and privacy requirements
Read this file before changing transports, bootstrap behavior, endpoint
validation, fallback, or result claims.
## Non-negotiable rules
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 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.
5. Do not expose an unrestricted endpoint parameter to an Agent. Built-in
providers are allowlisted; private or custom endpoints require explicit
user intent and policy approval.
6. Do not connect to addresses returned in DNS answers.
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 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
Connecting to a resolver hostname may require an initial DNS lookup. Report
whether the endpoint was reached using a configured bootstrap address, the
system resolver, or an already-known IP. Do not claim that a query avoided the
system resolver when bootstrap used it.
## DNS status and fallback
An HTTP, TLS, or QUIC exchange can succeed while DNS returns `NXDOMAIN`,
`SERVFAIL`, or `REFUSED`. Those are DNS outcomes and must not be converted into
transport errors. Cross-provider or cross-protocol fallback is permitted only
for explicitly classified transport failures and must be disclosed.
## DNSSEC language
The AD bit means the selected recursive resolver reports authenticated data.
It is not proof that this client validated the DNSSEC chain. Use separate
fields for resolver-reported and locally validated DNSSEC state.
## Privacy language
Encrypted transport protects the path between the client and the selected
resolver. The resolver can still observe the query. Provider policy, logging,
filtering, ECS behavior, and jurisdiction remain relevant. See
[RFC 8932](https://www.rfc-editor.org/rfc/rfc8932.html).
ODoH and Anonymized DNSCrypt add relay models but do not justify claims of
absolute anonymity. Their proxy, relay, and target roles must be reported
separately.
@@ -1,54 +0,0 @@
# Standards and authoritative sources
Verified on 2026-08-13. Protocol behavior must be based on the published
standard, not on summaries or provider-specific JSON APIs.
| Capability | Authority | Project scope |
| --- | --- | --- |
| 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) | 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 |
| 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) | 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
- DNS-over-DTLS ([RFC 8094](https://www.rfc-editor.org/rfc/rfc8094.html))
is experimental and is not a target transport.
- DNS zone transfer over TLS
([RFC 9103](https://www.rfc-editor.org/rfc/rfc9103.html)) is outside the
client-to-recursive diagnostic scope.
- Recursive-to-authoritative encryption and resolver/server operation are
outside the initial scope.
## Terminology
DoH3 means RFC 8484 semantics carried over HTTP/3. It is not a separate DNS
message format. DNSCrypt is an encrypted DNS protocol with its own
specification; do not label it as an IETF RFC.
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,267 +0,0 @@
{
"$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 v1",
"oneOf": [
{ "$ref": "#/$defs/singleResult" },
{ "$ref": "#/$defs/compareResult" }
],
"$defs": {
"query": {
"type": "object",
"required": ["name", "type"],
"properties": {
"name": { "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" },
"authentication_name": { "type": "string" },
"certificate_serial": { "type": "integer", "minimum": 0 }
},
"additionalProperties": false
},
"transport": {
"type": "object",
"required": ["protocol", "encrypted", "server_authenticated", "elapsed_ms", "bootstrap"],
"properties": {
"protocol": { "enum": ["doh", "dot", "doq", "doh3", "dnscrypt", "odoh", "anonymized-dnscrypt"] },
"encrypted": { "type": "boolean" },
"server_authenticated": { "type": "boolean" },
"elapsed_ms": { "type": "integer", "minimum": 0 },
"bootstrap": { "type": "string" },
"tls_version": { "type": "string" },
"alpn": { "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": 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", "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": { "const": false },
"answers": { "type": "array", "items": { "$ref": "#/$defs/answer" } }
},
"additionalProperties": false
},
"error": {
"type": "object",
"required": ["class", "message"],
"properties": {
"class": { "enum": ["input", "transport", "protocol", "unsupported", "internal"] },
"message": { "type": "string" }
},
"additionalProperties": false
},
"singleResult": {
"type": "object",
"required": ["schema_version", "operation", "completed", "query", "resolver", "transport", "dns"],
"properties": {
"schema_version": { "const": 1 },
"operation": { "enum": ["query", "probe"] },
"completed": { "type": "boolean" },
"query": { "$ref": "#/$defs/query" },
"resolver": { "$ref": "#/$defs/resolver" },
"transport": { "$ref": "#/$defs/transport" },
"dns": { "$ref": "#/$defs/dns" },
"warnings": { "type": "array", "items": { "type": "string" } },
"error": { "$ref": "#/$defs/error" }
},
"allOf": [
{
"if": { "properties": { "completed": { "const": true } }, "required": ["completed"] },
"then": { "not": { "required": ["error"] } },
"else": { "required": ["error"] }
}
],
"additionalProperties": false
},
"summary": {
"type": "object",
"required": ["total", "completed", "failed", "unsupported"],
"properties": {
"total": { "type": "integer", "minimum": 0 },
"completed": { "type": "integer", "minimum": 0 },
"failed": { "type": "integer", "minimum": 0 },
"unsupported": { "type": "integer", "minimum": 0 }
},
"additionalProperties": false
},
"compareResult": {
"type": "object",
"required": ["schema_version", "operation", "completed", "query", "attempts", "summary"],
"properties": {
"schema_version": { "const": 1 },
"operation": { "const": "compare" },
"completed": { "type": "boolean" },
"query": { "$ref": "#/$defs/query" },
"attempts": { "type": "array", "items": { "$ref": "#/$defs/singleResult" }, "maxItems": 8 },
"summary": { "$ref": "#/$defs/summary" },
"error": { "$ref": "#/$defs/error" }
},
"allOf": [
{
"if": { "properties": { "completed": { "const": true } }, "required": ["completed"] },
"then": { "not": { "required": ["error"] } },
"else": { "required": ["error"] }
}
],
"additionalProperties": false
}
}
}