tools(skills): add encrypted-dns-skill (ednsdiag CLI + agent skill)

This commit is contained in:
windyboy
2026-08-13 12:52:20 +08:00
parent 95ec2350af
commit c0cf82d4af
24 changed files with 2086 additions and 0 deletions
@@ -0,0 +1,52 @@
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@v4
- name: Set up Go
uses: actions/setup-go@v5
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
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@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Resolve modules
run: go mod download
- name: Test
run: go test ./...
- name: Vet
run: go vet ./...
@@ -0,0 +1,10 @@
# Go build outputs
/bin/
/dist/
*.exe
*.test
*.out
# Local development
.env
.DS_Store
+202
View File
@@ -0,0 +1,202 @@
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.
@@ -0,0 +1,159 @@
# 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) | Planned | [RFC 9250](https://www.rfc-editor.org/rfc/rfc9250.html) |
| DoH over HTTP/3 (DoH3) | Planned | RFC 8484 over HTTP/3 |
| DNSCrypt | Planned | [DNSCrypt protocol specification](https://github.com/DNSCrypt/dnscrypt-protocol) |
| 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 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
```
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] \
[--provider cloudflare|google|quad9|adguard] \
[--method post|get] \
[--timeout 5s]
```
Defaults are `A`, `doh`, `cloudflare`, `post`, and `5s`. `--method` applies
only to DoH. The timeout must be between `250ms` and `30s`.
Built-in resolver profiles:
| Provider | Profile |
| --- | --- |
| Cloudflare | Unfiltered |
| Google | Unfiltered |
| Quad9 | Security-filtered |
| AdGuard | Ad- and security-filtered |
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.
## Security Model
- DoH uses standard `application/dns-message` wire messages.
- DoT verifies the PKIX certificate chain and configured authentication domain.
- 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 vet ./...
```
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).
@@ -0,0 +1,90 @@
---
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+
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 capabilities
ednsdiag version
```
Use `--method get` or `--method post` only with DoH. The default is POST.
Built-in providers are `cloudflare`, `google`, `quad9`, and `adguard`. Provider
filtering policies differ and are included in the result. `probe` and `compare`
remain reserved until their capabilities are implemented.
## 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.
- Empty answers with `NOERROR` represent NODATA.
- 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).
## 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.
@@ -0,0 +1,182 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/windyboy/encrypted-dns-skill/internal/edns"
)
const version = "0.1.0-dev"
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: "planned", Standard: "RFC 9250"},
{Protocol: "doh3", Status: "planned", Standard: "RFC 8484 over HTTP/3"},
{Protocol: "dnscrypt", Status: "planned", Standard: "DNSCrypt protocol specification"},
{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":
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()
result := edns.Query(ctx, options)
if code := writeJSON(stdout, stderr, result); code != 0 {
return code
}
if result.Completed {
return 0
}
if result.Error != nil && result.Error.Class == "input" {
return 2
}
return 3
case "probe", "compare":
fmt.Fprintf(stderr, "%s is not implemented in %s; run ednsdiag capabilities\n", args[0], version)
return 4
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
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 options.Protocol != "doh" && options.Protocol != "dot" {
return options, 0, fmt.Errorf("protocol %q is not available", options.Protocol)
}
if options.Method != "get" && options.Method != "post" {
return options, 0, fmt.Errorf("DoH method must be get or post")
}
if options.Protocol == "dot" && options.Method != "post" {
return options, 0, fmt.Errorf("--method applies only to DoH")
}
return options, timeout, nil
}
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 <domain> [type] [--protocol doh|dot] [--provider cloudflare|google|quad9|adguard] [--method post|get] [--timeout 5s]")
}
@@ -0,0 +1,82 @@
package main
import (
"bytes"
"encoding/json"
"strings"
"testing"
"time"
)
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 {
t.Fatalf("schema version = %d, want 1", result.SchemaVersion)
}
if result.Command != "capabilities" {
t.Fatalf("command = %q, want capabilities", result.Command)
}
if len(result.Capabilities) == 0 {
t.Fatal("capabilities list is empty")
}
available := map[string]bool{}
for _, item := range result.Capabilities {
available[item.Protocol] = item.Status == "available"
}
if !available["doh"] || !available["dot"] {
t.Fatalf("DoH and DoT must be available: %#v", available)
}
if available["doq"] || available["doh3"] || available["dnscrypt"] {
t.Fatalf("planned transports must not be available: %#v", available)
}
}
func TestReservedCommandIsNotImplemented(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := run([]string{"probe"}, &stdout, &stderr)
if code != 4 {
t.Fatalf("run probe returned %d, want 4", code)
}
if !strings.Contains(stderr.String(), "not implemented") {
t.Fatalf("stderr = %q, want not implemented message", stderr.String())
}
}
func TestParseQueryArgsAllowsInterspersedOptions(t *testing.T) {
options, timeout, err := parseQueryArgs([]string{"example.com", "MX", "--protocol", "dot", "--provider=quad9", "--timeout", "3s"})
if err != nil {
t.Fatalf("parse query args: %v", err)
}
if options.Name != "example.com" || options.RecordType != "MX" || options.Protocol != "dot" || options.Provider != "quad9" {
t.Fatalf("unexpected options: %#v", options)
}
if timeout != 3*time.Second {
t.Fatalf("timeout = %v, want 3s", timeout)
}
}
func TestUnknownCommand(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := run([]string{"unknown"}, &stdout, &stderr)
if code != 2 {
t.Fatalf("run unknown returned %d, want 2", code)
}
if !strings.Contains(stderr.String(), "unknown command") {
t.Fatalf("stderr = %q, want unknown command message", stderr.String())
}
}
@@ -0,0 +1,7 @@
module github.com/windyboy/encrypted-dns-skill
go 1.26.0
require golang.org/x/net v0.58.0
require golang.org/x/text v0.41.0 // indirect
@@ -0,0 +1,4 @@
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
@@ -0,0 +1,229 @@
package edns
import (
"crypto/rand"
"encoding/base64"
"encoding/binary"
"fmt"
"net"
"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,
}
func BuildQuery(name, recordType string) ([]byte, QueryInfo, uint16, error) {
canonical, err := canonicalName(name)
if err != nil {
return nil, QueryInfo{}, 0, err
}
typeName := strings.ToUpper(recordType)
qtype, ok := recordTypes[typeName]
if !ok {
return nil, QueryInfo{}, 0, fmt.Errorf("unsupported record type %q", recordType)
}
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 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.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 {
return DNSInfo{}, fmt.Errorf("DNS response question does not match request")
}
answers := make([]AnswerRecord, 0, len(message.Answers))
for _, resource := range message.Answers {
answers = append(answers, normalizeAnswer(resource))
}
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 {
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 {
record["rdata_base64"] = base64.StdEncoding.EncodeToString(body.Data)
}
} else {
record["rdata_base64"] = base64.StdEncoding.EncodeToString(body.Data)
}
}
return record
}
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)
}
@@ -0,0 +1,100 @@
package edns
import (
"encoding/binary"
"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 TestNormalizeCAA(t *testing.T) {
name := dnsmessage.MustNewName("example.com.")
data := append([]byte{0, 5}, []byte("issueletsencrypt.org")...)
record := 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 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")
}
}
@@ -0,0 +1,131 @@
package edns
import (
"bytes"
"context"
"crypto/tls"
"encoding/base64"
"fmt"
"io"
"mime"
"net"
"net/http"
"net/url"
"strings"
"time"
)
const maxDNSMessageSize = 65535
func exchangeDoH(ctx context.Context, provider Provider, wire []byte, method string) ([]byte, TransportInfo, error) {
client := newDoHClient(provider.DoHURL)
return exchangeDoHWithClient(ctx, client, provider.DoHURL, wire, method)
}
func newDoHClient(endpoint string) *http.Client {
origin, _ := url.Parse(endpoint)
transport := &http.Transport{
ForceAttemptHTTP2: true,
DialContext: (&net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
},
TLSHandshakeTimeout: 5 * time.Second,
}
return &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
},
}
}
func exchangeDoHWithClient(ctx context.Context, client *http.Client, endpoint string, wire []byte, method string) ([]byte, TransportInfo, error) {
started := time.Now()
info := TransportInfo{
Protocol: "doh",
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 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 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)
}
}
@@ -0,0 +1,75 @@
package edns
import (
"io"
"net/http"
"net/http/httptest"
"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.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 {
t.Fatalf("unexpected result: response=%d info=%#v", len(response), info)
}
})
}
}
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")
}
}
@@ -0,0 +1,106 @@
package edns
import (
"context"
"crypto/tls"
"encoding/binary"
"fmt"
"io"
"net"
"time"
)
func exchangeDoT(ctx context.Context, provider Provider, wire []byte) ([]byte, TransportInfo, error) {
return exchangeDoTWithTLSConfig(ctx, provider, wire, &tls.Config{
ServerName: provider.DoTName,
MinVersion: tls.VersionTLS12,
NextProtos: []string{"dot"},
})
}
func exchangeDoTWithTLSConfig(ctx context.Context, provider Provider, wire []byte, tlsConfig *tls.Config) ([]byte, TransportInfo, error) {
started := time.Now()
info := TransportInfo{
Protocol: "dot",
Encrypted: true,
Bootstrap: "system_resolver",
}
rawConnection, err := (&net.Dialer{}).DialContext(ctx, "tcp", provider.DoTAddr)
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 len(wire) == 0 || len(wire) > maxDNSMessageSize {
return nil, fmt.Errorf("invalid DNS message length %d", len(wire))
}
frame := make([]byte, 2+len(wire))
binary.BigEndian.PutUint16(frame[:2], uint16(len(wire)))
copy(frame[2:], wire)
if err := writeAll(connection, frame); err != nil {
return nil, fmt.Errorf("write framed DNS query: %w", err)
}
var lengthBytes [2]byte
if _, err := io.ReadFull(connection, lengthBytes[:]); err != nil {
return nil, fmt.Errorf("read DNS response length: %w", err)
}
length := int(binary.BigEndian.Uint16(lengthBytes[:]))
if length == 0 {
return nil, fmt.Errorf("DoT server returned an empty DNS message")
}
response := make([]byte, length)
if _, err := io.ReadFull(connection, response); err != nil {
return nil, fmt.Errorf("read DNS response: %w", err)
}
return response, nil
}
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
}
@@ -0,0 +1,254 @@
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 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()
_, info, err := exchangeDoTWithTLSConfig(ctx, Provider{DoTAddr: listener.Addr().String(), DoTName: "resolver.test"}, queryWire, &tls.Config{
RootCAs: roots,
MinVersion: tls.VersionTLS12,
NextProtos: []string{"dot"},
})
if err != nil {
t.Fatalf("exchange DoT without server ALPN: %v", err)
}
if err := <-serverError; err != nil {
t.Fatalf("serve DoT: %v", err)
}
if !info.ServerAuthenticated || info.ALPN != "" {
t.Fatalf("unexpected transport info: %#v", info)
}
}
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),
DNSNames: []string{name},
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,
}
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
}
@@ -0,0 +1,58 @@
package edns
type QueryOptions struct {
Name string
RecordType string
Protocol string
Provider string
Method 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 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"`
}
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"`
}
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"`
}
@@ -0,0 +1,53 @@
package edns
import (
"fmt"
"strings"
)
type Provider struct {
ID string
Profile string
DoHURL string
DoTAddr string
DoTName string
}
var providers = map[string]Provider{
"cloudflare": {
ID: "cloudflare",
Profile: "unfiltered",
DoHURL: "https://cloudflare-dns.com/dns-query",
DoTAddr: "one.one.one.one:853",
DoTName: "one.one.one.one",
},
"google": {
ID: "google",
Profile: "unfiltered",
DoHURL: "https://dns.google/dns-query",
DoTAddr: "dns.google:853",
DoTName: "dns.google",
},
"quad9": {
ID: "quad9",
Profile: "security-filtered",
DoHURL: "https://dns.quad9.net/dns-query",
DoTAddr: "dns.quad9.net:853",
DoTName: "dns.quad9.net",
},
"adguard": {
ID: "adguard",
Profile: "ad-and-security-filtered",
DoHURL: "https://dns.adguard-dns.com/dns-query",
DoTAddr: "dns.adguard-dns.com:853",
DoTName: "dns.adguard-dns.com",
},
}
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
}
@@ -0,0 +1,18 @@
package edns
import "testing"
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)
}
}
if _, err := FindProvider("custom"); err == nil {
t.Fatal("unapproved custom provider was accepted")
}
}
@@ -0,0 +1,57 @@
package edns
import (
"context"
"fmt"
)
func Query(ctx context.Context, options QueryOptions) 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
}
result.Resolver = ResolverInfo{Provider: provider.ID, Profile: provider.Profile}
var response []byte
switch options.Protocol {
case "doh":
result.Resolver.Endpoint = provider.DoHURL
response, result.Transport, err = exchangeDoH(ctx, provider, wire, options.Method)
case "dot":
result.Resolver.Endpoint = provider.DoTAddr
response, result.Transport, err = exchangeDoT(ctx, provider, wire)
default:
err = fmt.Errorf("protocol %q is not available; run ednsdiag capabilities", options.Protocol)
}
if err != nil {
result.Error = &ErrorInfo{Class: "transport", Message: err.Error()}
return result
}
result.DNS, err = ParseResponse(response, transactionID, query)
if err != nil {
result.Error = &ErrorInfo{Class: "protocol", Message: err.Error()}
return result
}
result.Completed = true
return result
}
@@ -0,0 +1,7 @@
package edns
import "encoding/base64"
func decodeGETQuery(value string) ([]byte, error) {
return base64.RawURLEncoding.DecodeString(value)
}
@@ -0,0 +1,30 @@
# 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 | Official documentation | Profile |
| --- | --- | --- | --- | --- |
| Cloudflare | `https://cloudflare-dns.com/dns-query` | `one.one.one.one:853` | [DoH](https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/) / [DoT](https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-tls/) | Unfiltered |
| Google | `https://dns.google/dns-query` | `dns.google:853` | [DoH](https://developers.google.com/speed/public-dns/docs/doh) / [DoT](https://developers.google.com/speed/public-dns/docs/dns-over-tls) | Unfiltered |
| Quad9 | `https://dns.quad9.net/dns-query` | `dns.quad9.net:853` | [Quad9 services](https://docs.quad9.net/services/) | Security filtered; HTTP/2 required |
| AdGuard | `https://dns.adguard-dns.com/dns-query` | `dns.adguard-dns.com:853` | [AdGuard providers](https://adguard-dns.io/kb/general/dns-providers/) | Ads, tracking, and security filtered |
## 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.
Do not infer one protocol endpoint from another. Do not treat filtering and
non-filtering services as interchangeable. Provider comparison results must
remain separate.
@@ -0,0 +1,58 @@
# 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 negotiated ALPN mismatches 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.
## DoT ALPN policy
The client advertises the `dot` ALPN identifier. RFC 7858 and RFC 8310 do not
require a DoT server on its dedicated port to select an ALPN protocol, so an
empty negotiated ALPN is permitted and reported as empty. If a server selects
a non-empty protocol other than `dot`, abort before sending the DNS query.
## 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.
@@ -0,0 +1,40 @@
# 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) | Planned |
| DoT | [RFC 7858](https://www.rfc-editor.org/rfc/rfc7858.html) | Planned |
| DoT authentication profiles | [RFC 8310](https://www.rfc-editor.org/rfc/rfc8310.html) | Strict privacy only |
| DoQ | [RFC 9250](https://www.rfc-editor.org/rfc/rfc9250.html) | Planned |
| ODoH | [RFC 9230](https://www.rfc-editor.org/rfc/rfc9230.html) | Research until a maintained implementation is selected |
| DNS privacy operations | [RFC 8932](https://www.rfc-editor.org/rfc/rfc8932.html) | Security and privacy guidance |
| EDNS(0) padding | [RFC 7830](https://www.rfc-editor.org/rfc/rfc7830.html) and [RFC 8467](https://www.rfc-editor.org/rfc/rfc8467.html) | Evaluate per transport |
| DNSCrypt | [DNSCrypt protocol specification](https://github.com/DNSCrypt/dnscrypt-protocol) | Planned, non-IETF |
| 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` |
## 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.
@@ -0,0 +1,82 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/windyboy/encrypted-dns-skill/schemas/result-v1.schema.json",
"title": "Encrypted DNS diagnostic result",
"type": "object",
"required": ["schema_version", "operation", "completed", "query", "transport", "dns"],
"properties": {
"schema_version": { "const": 1 },
"operation": { "enum": ["query", "probe", "compare"] },
"completed": { "type": "boolean" },
"query": {
"type": "object",
"required": ["name", "type"],
"properties": {
"name": { "type": "string" },
"type": { "type": "string" }
},
"additionalProperties": false
},
"resolver": {
"type": "object",
"properties": {
"provider": { "type": "string" },
"endpoint": { "type": "string" },
"profile": { "type": "string" }
},
"additionalProperties": false
},
"transport": {
"type": "object",
"required": ["protocol", "encrypted", "server_authenticated"],
"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" }
},
"additionalProperties": true
},
"dns": {
"type": "object",
"required": ["rcode", "rcode_value", "answers"],
"properties": {
"rcode": { "type": "string" },
"rcode_value": { "type": "integer", "minimum": 0 },
"resolver_reports_dnssec_authenticated": { "type": "boolean" },
"client_validated_dnssec": { "type": "boolean" },
"answers": {
"type": "array",
"items": {
"type": "object",
"required": ["name", "type", "ttl"],
"properties": {
"name": { "type": "string" },
"type": { "type": "string" },
"ttl": { "type": "integer", "minimum": 0 }
},
"additionalProperties": true
}
}
},
"additionalProperties": true
},
"fallback_used": { "type": "boolean" },
"attempts": { "type": "array", "items": { "type": "object" } },
"warnings": { "type": "array", "items": { "type": "string" } },
"error": {
"type": "object",
"required": ["class", "message"],
"properties": {
"class": { "enum": ["input", "transport", "protocol", "internal"] },
"message": { "type": "string" }
},
"additionalProperties": false
}
},
"additionalProperties": true
}