diff --git a/.agents/skills/encrypted-dns-skill/.github/workflows/ci.yml b/.agents/skills/encrypted-dns-skill/.github/workflows/ci.yml new file mode 100644 index 0000000..957c6f9 --- /dev/null +++ b/.agents/skills/encrypted-dns-skill/.github/workflows/ci.yml @@ -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 ./... diff --git a/.agents/skills/encrypted-dns-skill/.gitignore b/.agents/skills/encrypted-dns-skill/.gitignore new file mode 100644 index 0000000..96af939 --- /dev/null +++ b/.agents/skills/encrypted-dns-skill/.gitignore @@ -0,0 +1,10 @@ +# Go build outputs +/bin/ +/dist/ +*.exe +*.test +*.out + +# Local development +.env +.DS_Store diff --git a/.agents/skills/encrypted-dns-skill/LICENSE b/.agents/skills/encrypted-dns-skill/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/.agents/skills/encrypted-dns-skill/LICENSE @@ -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. diff --git a/.agents/skills/encrypted-dns-skill/README.md b/.agents/skills/encrypted-dns-skill/README.md new file mode 100644 index 0000000..c82be37 --- /dev/null +++ b/.agents/skills/encrypted-dns-skill/README.md @@ -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 [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). diff --git a/.agents/skills/encrypted-dns-skill/SKILL.md b/.agents/skills/encrypted-dns-skill/SKILL.md new file mode 100644 index 0000000..e6a41aa --- /dev/null +++ b/.agents/skills/encrypted-dns-skill/SKILL.md @@ -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 [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. diff --git a/.agents/skills/encrypted-dns-skill/cmd/ednsdiag/main.go b/.agents/skills/encrypted-dns-skill/cmd/ednsdiag/main.go new file mode 100644 index 0000000..f6761d0 --- /dev/null +++ b/.agents/skills/encrypted-dns-skill/cmd/ednsdiag/main.go @@ -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 ") +} + +func writeQueryUsage(writer io.Writer) { + fmt.Fprintln(writer, "usage: ednsdiag query [type] [--protocol doh|dot] [--provider cloudflare|google|quad9|adguard] [--method post|get] [--timeout 5s]") +} diff --git a/.agents/skills/encrypted-dns-skill/cmd/ednsdiag/main_test.go b/.agents/skills/encrypted-dns-skill/cmd/ednsdiag/main_test.go new file mode 100644 index 0000000..8bd58f1 --- /dev/null +++ b/.agents/skills/encrypted-dns-skill/cmd/ednsdiag/main_test.go @@ -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()) + } +} diff --git a/.agents/skills/encrypted-dns-skill/go.mod b/.agents/skills/encrypted-dns-skill/go.mod new file mode 100644 index 0000000..a665ce0 --- /dev/null +++ b/.agents/skills/encrypted-dns-skill/go.mod @@ -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 diff --git a/.agents/skills/encrypted-dns-skill/go.sum b/.agents/skills/encrypted-dns-skill/go.sum new file mode 100644 index 0000000..cc9727d --- /dev/null +++ b/.agents/skills/encrypted-dns-skill/go.sum @@ -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= diff --git a/.agents/skills/encrypted-dns-skill/internal/edns/dns.go b/.agents/skills/encrypted-dns-skill/internal/edns/dns.go new file mode 100644 index 0000000..ceaf24a --- /dev/null +++ b/.agents/skills/encrypted-dns-skill/internal/edns/dns.go @@ -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) +} diff --git a/.agents/skills/encrypted-dns-skill/internal/edns/dns_test.go b/.agents/skills/encrypted-dns-skill/internal/edns/dns_test.go new file mode 100644 index 0000000..5573c81 --- /dev/null +++ b/.agents/skills/encrypted-dns-skill/internal/edns/dns_test.go @@ -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") + } +} diff --git a/.agents/skills/encrypted-dns-skill/internal/edns/doh.go b/.agents/skills/encrypted-dns-skill/internal/edns/doh.go new file mode 100644 index 0000000..24f3c24 --- /dev/null +++ b/.agents/skills/encrypted-dns-skill/internal/edns/doh.go @@ -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) + } +} diff --git a/.agents/skills/encrypted-dns-skill/internal/edns/doh_test.go b/.agents/skills/encrypted-dns-skill/internal/edns/doh_test.go new file mode 100644 index 0000000..3ac2579 --- /dev/null +++ b/.agents/skills/encrypted-dns-skill/internal/edns/doh_test.go @@ -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") + } +} diff --git a/.agents/skills/encrypted-dns-skill/internal/edns/dot.go b/.agents/skills/encrypted-dns-skill/internal/edns/dot.go new file mode 100644 index 0000000..0829151 --- /dev/null +++ b/.agents/skills/encrypted-dns-skill/internal/edns/dot.go @@ -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 +} diff --git a/.agents/skills/encrypted-dns-skill/internal/edns/dot_test.go b/.agents/skills/encrypted-dns-skill/internal/edns/dot_test.go new file mode 100644 index 0000000..b827184 --- /dev/null +++ b/.agents/skills/encrypted-dns-skill/internal/edns/dot_test.go @@ -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 +} diff --git a/.agents/skills/encrypted-dns-skill/internal/edns/model.go b/.agents/skills/encrypted-dns-skill/internal/edns/model.go new file mode 100644 index 0000000..a3cea57 --- /dev/null +++ b/.agents/skills/encrypted-dns-skill/internal/edns/model.go @@ -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"` +} diff --git a/.agents/skills/encrypted-dns-skill/internal/edns/providers.go b/.agents/skills/encrypted-dns-skill/internal/edns/providers.go new file mode 100644 index 0000000..798916b --- /dev/null +++ b/.agents/skills/encrypted-dns-skill/internal/edns/providers.go @@ -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 +} diff --git a/.agents/skills/encrypted-dns-skill/internal/edns/providers_test.go b/.agents/skills/encrypted-dns-skill/internal/edns/providers_test.go new file mode 100644 index 0000000..6810153 --- /dev/null +++ b/.agents/skills/encrypted-dns-skill/internal/edns/providers_test.go @@ -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") + } +} diff --git a/.agents/skills/encrypted-dns-skill/internal/edns/query.go b/.agents/skills/encrypted-dns-skill/internal/edns/query.go new file mode 100644 index 0000000..f98dc48 --- /dev/null +++ b/.agents/skills/encrypted-dns-skill/internal/edns/query.go @@ -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 +} diff --git a/.agents/skills/encrypted-dns-skill/internal/edns/test_helpers_test.go b/.agents/skills/encrypted-dns-skill/internal/edns/test_helpers_test.go new file mode 100644 index 0000000..84501c0 --- /dev/null +++ b/.agents/skills/encrypted-dns-skill/internal/edns/test_helpers_test.go @@ -0,0 +1,7 @@ +package edns + +import "encoding/base64" + +func decodeGETQuery(value string) ([]byte, error) { + return base64.RawURLEncoding.DecodeString(value) +} diff --git a/.agents/skills/encrypted-dns-skill/references/providers.md b/.agents/skills/encrypted-dns-skill/references/providers.md new file mode 100644 index 0000000..83873ae --- /dev/null +++ b/.agents/skills/encrypted-dns-skill/references/providers.md @@ -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. diff --git a/.agents/skills/encrypted-dns-skill/references/security.md b/.agents/skills/encrypted-dns-skill/references/security.md new file mode 100644 index 0000000..feab645 --- /dev/null +++ b/.agents/skills/encrypted-dns-skill/references/security.md @@ -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. diff --git a/.agents/skills/encrypted-dns-skill/references/standards.md b/.agents/skills/encrypted-dns-skill/references/standards.md new file mode 100644 index 0000000..133a112 --- /dev/null +++ b/.agents/skills/encrypted-dns-skill/references/standards.md @@ -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. diff --git a/.agents/skills/encrypted-dns-skill/schemas/result-v1.schema.json b/.agents/skills/encrypted-dns-skill/schemas/result-v1.schema.json new file mode 100644 index 0000000..22ea9d0 --- /dev/null +++ b/.agents/skills/encrypted-dns-skill/schemas/result-v1.schema.json @@ -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 +}