diff --git a/.agents/skills/encrypted-dns-skill/.github/workflows/ci.yml b/.agents/skills/encrypted-dns-skill/.github/workflows/ci.yml deleted file mode 100644 index 6f4d9e9..0000000 --- a/.agents/skills/encrypted-dns-skill/.github/workflows/ci.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -permissions: - contents: read - -jobs: - quality: - runs-on: ubuntu-latest - steps: - - name: Check out repository - uses: actions/checkout@v6 - - name: Set up Go - uses: actions/setup-go@v7 - with: - go-version-file: go.mod - cache: true - - name: Check formatting - run: | - files="$(gofmt -l .)" - if [ -n "$files" ]; then - echo "Unformatted Go files:" - echo "$files" - exit 1 - fi - - name: Check module files - run: go mod tidy && git diff --exit-code - - name: Validate Agent Skill package - run: python3 scripts/validate_skill.py . - - name: Static analysis - run: go run honnef.co/go/tools/cmd/staticcheck@v0.7.0 ./... - - name: Vulnerability scan - run: go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./... - - test: - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - runs-on: ${{ matrix.os }} - steps: - - name: Check out repository - uses: actions/checkout@v6 - - name: Set up Go - uses: actions/setup-go@v7 - with: - go-version-file: go.mod - cache: true - - name: Resolve modules - run: go mod download - - name: Test with race detector - run: go test -race ./... - - name: DNSCrypt interoperability - if: matrix.os == 'ubuntu-latest' - env: - EDNSDIAG_DNSCRYPT_INTEROP: "1" - run: go test ./internal/edns -run '^TestDNSCryptAdGuardInteroperability$' -count=1 - - name: Public DoH and DoT interoperability - if: matrix.os == 'ubuntu-latest' - env: - EDNSDIAG_PUBLIC_INTEROP: "1" - run: go test ./internal/edns -run '^TestPublicCloudflareDo[HT]Interoperability$' -count=1 -v - - name: Vet - run: go vet ./... diff --git a/.agents/skills/encrypted-dns-skill/.gitignore b/.agents/skills/encrypted-dns-skill/.gitignore deleted file mode 100644 index 96af939..0000000 --- a/.agents/skills/encrypted-dns-skill/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -# 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 deleted file mode 100644 index d645695..0000000 --- a/.agents/skills/encrypted-dns-skill/LICENSE +++ /dev/null @@ -1,202 +0,0 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/.agents/skills/encrypted-dns-skill/README.md b/.agents/skills/encrypted-dns-skill/README.md deleted file mode 100644 index cbe8517..0000000 --- a/.agents/skills/encrypted-dns-skill/README.md +++ /dev/null @@ -1,233 +0,0 @@ -# Encrypted DNS Skill - -[![CI](https://github.com/windyboy/encrypted-dns-skill/actions/workflows/ci.yml/badge.svg)](https://github.com/windyboy/encrypted-dns-skill/actions/workflows/ci.yml) - -An [Agent Skill](https://agentskills.io/specification) and deterministic Go CLI -for querying and diagnosing encrypted DNS resolvers. - -The skill tells an agent when and how to perform encrypted DNS diagnostics; -`ednsdiag` performs the protocol exchange. Agents do not need to construct DoH -URLs, TLS sessions, or DNS wire messages themselves. - -## Status - -| Protocol | Status | Standard | -| --- | --- | --- | -| DNS over HTTPS (DoH) | Available (GET and POST) | [RFC 8484](https://www.rfc-editor.org/rfc/rfc8484.html) | -| DNS over TLS (DoT) | Available (strict authentication) | [RFC 7858](https://www.rfc-editor.org/rfc/rfc7858.html), [RFC 8310](https://www.rfc-editor.org/rfc/rfc8310.html) | -| DNS over QUIC (DoQ) | Available | [RFC 9250](https://www.rfc-editor.org/rfc/rfc9250.html) | -| DoH over HTTP/3 (DoH3) | Available (GET and POST) | RFC 8484 over HTTP/3 | -| DNSCrypt | Available (v2 over UDP) | [DNSCrypt protocol specification](https://github.com/DNSCrypt/dnscrypt-protocol) | -| Oblivious DoH (ODoH) | Research | [RFC 9230](https://www.rfc-editor.org/rfc/rfc9230.html) | -| Anonymized DNSCrypt | Research | [Anonymized DNSCrypt specification](https://github.com/DNSCrypt/dnscrypt-protocol/blob/master/ANONYMIZED-DNSCRYPT.txt) | - -Run `ednsdiag capabilities` instead of assuming a protocol is implemented. - -## Why a Skill and a CLI? - -- `SKILL.md` provides compact instructions, safety boundaries, and result - interpretation for an AI agent. -- `ednsdiag` provides repeatable wire-format DNS, HTTP, TLS, input validation, - and structured JSON output. -- Reference files keep protocol, provider, and security details grounded in - authoritative sources without bloating the agent's active context. - -The CLI never silently downgrades to plaintext DNS, and it does not connect to -addresses returned in DNS answers. - -## Requirements - -- Go 1.26.6 or later when running or building from source -- Network access to the selected encrypted DNS resolver -- A host that supports the [Agent Skills package format](https://agentskills.io/specification) when using the repository as a Skill - -## Install the Skill - -Clone or copy this repository into a skill discovery directory supported by -your agent host. Keep the repository layout intact so `SKILL.md`, `references/`, -`schemas/`, and the Go source remain together. - -For example, in a host that discovers project-local skills from `.agents/skills`: - -```bash -git clone https://github.com/windyboy/encrypted-dns-skill.git \ - .agents/skills/encrypted-dns-skill -``` - -Discovery paths differ between hosts. Follow the host's documentation rather -than moving only `SKILL.md`. - -## Run from Source - -No precompiled executable is required. Go can compile and run the command from -the repository root: - -```bash -go run ./cmd/ednsdiag capabilities -go run ./cmd/ednsdiag query example.com A --protocol doh --provider cloudflare -go run ./cmd/ednsdiag query gmail.com MX --protocol dot --provider google --timeout 5s -HTTPS_PROXY=http://127.0.0.1:8080 go run ./cmd/ednsdiag query example.com A --protocol doh -go run ./cmd/ednsdiag query example.com A --protocol dot --proxy http://127.0.0.1:8080 -go run ./cmd/ednsdiag query example.com AAAA --protocol doq --provider adguard -go run ./cmd/ednsdiag query example.com HTTPS --protocol doh3 --provider cloudflare -go run ./cmd/ednsdiag query example.com A --protocol dnscrypt --provider adguard -go run ./cmd/ednsdiag probe example.com A --protocol dot --provider cloudflare -go run ./cmd/ednsdiag compare example.com A \ - --target doh:cloudflare --target dot:google -``` - -The first run may download the modules pinned in `go.mod` and `go.sum`. - -To build a reusable local executable: - -```bash -go build -o ./bin/ednsdiag ./cmd/ednsdiag -./bin/ednsdiag capabilities -``` - -Do not download or execute an unverified third-party binary. This repository -does not currently publish release binaries. - -## Usage - -```text -ednsdiag capabilities -ednsdiag version -ednsdiag query [type] \ - [--protocol doh|dot|doq|doh3|dnscrypt|odoh|anonymized-dnscrypt] \ - [--provider cloudflare|google|quad9|adguard] \ - [--method post|get] \ - [--proxy http://host:port] \ - [--timeout 5s] -ednsdiag probe [type] [query options] -ednsdiag compare [type] \ - --target protocol:provider[:method] \ - --target protocol:provider[:method] \ - [--proxy http://host:port] \ - [--attempt-timeout 5s] [--timeout 30s] [--max-attempts 4] -``` - -Defaults are `A`, `doh`, `cloudflare`, `post`, and `5s`. `--method` applies -only to DoH and DoH3. The timeout must be between `250ms` and `30s`. -Research protocols are accepted as inputs so automation receives a structured -`unsupported` result and exit code `4`; they are never silently substituted. - -DoH and DoT honor Go's standard `HTTPS_PROXY`/`https_proxy` and -`NO_PROXY`/`no_proxy` environment variables. `--proxy` overrides environment -selection and accepts an `http://` or `https://` proxy URL, including optional -Basic-auth userinfo. DoT uses HTTP CONNECT before its resolver TLS handshake. -DoH3, DoQ, and DNSCrypt are UDP/QUIC transports and cannot use this TCP CONNECT -proxy; an explicit proxy combined with one of those protocols is rejected. -The same `--proxy` is shared by every DoH/DoT target in a `compare` operation. -See Go's official [`ProxyFromEnvironment` documentation](https://pkg.go.dev/net/http#ProxyFromEnvironment) -for environment-variable and `NO_PROXY` matching rules. - -`compare` accepts 2–8 unique, allowlisted targets, bounded by `--max-attempts`. -Its total timeout is `250ms`–`60s`; each attempt timeout is `250ms`–`30s` and -cannot exceed the total. Comparison targets start concurrently, results retain -the requested target order, and answers are never merged. - -Supported record types are `A`, `AAAA`, `CNAME`, `MX`, `TXT`, `NS`, `SOA`, -`CAA`, `SRV`, `PTR`, `HTTPS`, and `SVCB`. For `PTR`, pass an IP address; the CLI -constructs the reverse name. Other IP literals and local names remain blocked. - -Built-in resolver profiles: - -| Provider | Profile | DoH | DoT | DoQ | DoH3 | DNSCrypt | -| --- | --- | --- | --- | --- | --- | --- | -| Cloudflare | Unfiltered | Yes | Yes | No | Yes | No | -| Google | Unfiltered | Yes | Yes | No | Yes | No | -| Quad9 | Security-filtered | Yes | Yes | No | No | No | -| AdGuard | Ad- and security-filtered | Yes | Yes | Yes | No | Yes | - -Filtering policies can affect DNS answers. Results always identify the -provider and profile used. - -## Result Semantics - -Every query returns structured JSON compatible with -[`schemas/result-v1.schema.json`](schemas/result-v1.schema.json). - -- `completed: true` means the encrypted protocol exchange completed; it does - not mean the DNS response was `NOERROR`. -- `dns.rcode` is the DNS result. `NXDOMAIN`, `SERVFAIL`, and `REFUSED` are DNS - outcomes, not transport failures. -- Empty `dns.answers` with `NOERROR` means NODATA. -- `transport.server_authenticated` reports resolver endpoint authentication. -- `dns.resolver_reports_dnssec_authenticated` reflects the resolver's AD bit; - it is not local DNSSEC validation. -- `transport.bootstrap: system_resolver` means the operating system resolver - was used to locate the encrypted resolver endpoint. -- `transport.proxy`, when present, is the HTTP(S) proxy endpoint actually - selected for DoH or DoT. Embedded credentials are never reported. -- DNSCrypt reports `bootstrap: stamp_ip`, the authenticated provider name, - resolver certificate serial, and selected crypto construction. -- DoH and DoH3 subtract a valid HTTP `Age` value from returned answer TTLs and - report it as `transport.http_age_seconds`. -- Truncated or non-representable DNS answers are protocol failures rather than - partial `completed: true` results. - -Human-readable usage errors go to stderr. Machine-readable operational results -go to stdout. Stable exit codes are: - -| Code | Meaning | -| --- | --- | -| `0` | The requested encrypted DNS operation completed; inspect `dns.rcode`. | -| `1` | Local or internal failure. | -| `2` | Invalid input or CLI usage. | -| `3` | Transport or DNS protocol failure. | -| `4` | Known but unsupported capability or provider/protocol combination. | - -See [`references/contracts.md`](references/contracts.md) for the complete v1 -command and result contract. - -## Security Model - -- DoH uses standard `application/dns-message` wire messages. -- DoT verifies the PKIX certificate chain and configured authentication domain. -- DoT advertises ALPN `dot`; an empty selection is accepted and reported, while - selection of a different application protocol is rejected. -- DNSCrypt validates the resolver stamp, Ed25519-signed certificate, validity - interval, provider identity, and encrypted response before accepting DNS data. -- Plaintext fallback is prohibited. -- DNS errors are not retried through another protocol as transport failures. -- Provider and protocol results remain separate. -- DNS answers are data only; the tool does not make application connections to - returned addresses. - -See [`references/security.md`](references/security.md) for the complete threat -model and privacy boundaries. - -## Development - -```bash -go test ./... -go test -race ./... -go vet ./... -go run honnef.co/go/tools/cmd/staticcheck@v0.7.0 ./... -go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./... -python3 scripts/validate_skill.py . -``` - -Public endpoint interoperability tests are opt-in and skip cleanly when the -host cannot reach the network: - -```bash -EDNSDIAG_PUBLIC_INTEROP=1 go test ./internal/edns \ - -run '^TestPublicCloudflareDo[HT]Interoperability$' -count=1 -v -``` - -Protocol behavior must remain aligned with -[`references/standards.md`](references/standards.md), provider changes with -[`references/providers.md`](references/providers.md), and output with the v1 -JSON schema. - -## Scope - -This project targets client-to-recursive encrypted DNS diagnostics. It is not a -system stub resolver, an authoritative DNS server, a hosted DNS record manager, -or a zone-transfer tool. - -## License - -Licensed under the [Apache License 2.0](LICENSE). diff --git a/.agents/skills/encrypted-dns-skill/SKILL.md b/.agents/skills/encrypted-dns-skill/SKILL.md deleted file mode 100644 index 6e47b79..0000000 --- a/.agents/skills/encrypted-dns-skill/SKILL.md +++ /dev/null @@ -1,114 +0,0 @@ ---- -name: encrypted-dns-skill -description: Query, probe, and compare DNS resolution through supported encrypted transports. Use for encrypted DNS record lookups, resolver connectivity tests, TLS and QUIC diagnostics, protocol comparisons, DNSSEC status inspection, and troubleshooting DoH, DoT, DoQ, DoH3, or DNSCrypt resolver endpoints. ---- - -# Encrypted DNS Diagnostics - -Use `ednsdiag` for encrypted DNS work. Do not assemble protocol requests with -`curl`, `openssl`, or ad-hoc scripts when `ednsdiag` supports the operation. -The executable requires network access. - -Prefer an installed `ednsdiag` executable. When it is unavailable and Go 1.26.6+ -is installed, run the source from the skill root with: - -```bash -go run ./cmd/ednsdiag [arguments] -``` - -Do not download or execute an unverified binary automatically. Building from -source may require permission to download pinned Go modules. - -## Check capabilities - -Before attempting an operation, run: - -```bash -ednsdiag capabilities -``` - -Only use a protocol when its reported status is `available`. Never describe a -`planned` or `experimental` capability as implemented. - -## Commands - -```bash -ednsdiag query example.com A --protocol doh --provider cloudflare -ednsdiag query gmail.com MX --protocol dot --provider google --timeout 5s -ednsdiag query example.com AAAA --protocol doq --provider adguard -ednsdiag query example.com HTTPS --protocol doh3 --provider cloudflare -ednsdiag query example.com A --protocol dnscrypt --provider adguard -ednsdiag probe example.com A --protocol dot --provider cloudflare -ednsdiag compare example.com A --target doh:cloudflare --target dot:google -ednsdiag capabilities -ednsdiag version -``` - -Use `--method get` or `--method post` only with DoH or DoH3. The default is POST. -Built-in providers are `cloudflare`, `google`, `quad9`, and `adguard`. Provider -protocol support and filtering policies differ and are included in the result. -Run `capabilities` and do not infer an unsupported endpoint. `probe` executes -one diagnostic query while labeling the operation for automation. `compare` -requires two or more explicit `protocol:provider[:method]` targets and preserves -each result independently. - -For a user-requested HTTP(S) proxy, pass `--proxy http://host:port`. Without -that flag, DoH and DoT honor `HTTPS_PROXY` and `NO_PROXY`. Only DoH and DoT can -use this CONNECT proxy; do not add `--proxy` to DoH3, DoQ, or DNSCrypt commands. -Never expose proxy credentials when quoting a command or interpreting output. - -## Required behavior - -- Use standard DNS wire messages for DoH, not provider-specific JSON APIs. -- Apply strict certificate and authentication-domain validation. -- Never silently downgrade to plaintext DNS. -- Do not retry `NXDOMAIN`, `NODATA`, `SERVFAIL`, or `REFUSED` through another - protocol as though they were transport failures. -- Keep results from different providers and protocols separate. -- Report every fallback attempt and its reason. -- Treat the DNS `AD` bit as validation reported by the selected resolver, not - as local DNSSEC validation. -- Do not connect to addresses returned in DNS answers. - -## Result interpretation - -- `completed: true` means a protocol exchange completed. It does not imply - `NOERROR`. -- Read `dns.rcode` for the DNS outcome. -- Read `transport.server_authenticated` separately from DNSSEC fields. -- Read `transport.bootstrap`; `system_resolver` means resolving the encrypted - resolver endpoint itself used the operating system resolver. -- If `transport.proxy` is present, the exchange used that sanitized proxy - endpoint; credentials are deliberately omitted. -- For DNSCrypt, `stamp_ip` means the authenticated resolver stamp supplied the - connection address; verify `resolver.authentication_name` and certificate - metadata in the result. -- Empty answers with `NOERROR` represent NODATA. -- Treat truncated or non-representable answers as protocol failures; never - infer a partial result from an incomplete exchange. -- For DoH and DoH3, `transport.http_age_seconds` is already subtracted from - answer TTLs when an HTTP cache reports an age. -- A filtering resolver may synthesize `NXDOMAIN`; disclose the provider. - -## References - -- Read [references/standards.md](references/standards.md) before changing - protocol behavior. -- Read [references/security.md](references/security.md) before changing TLS, - bootstrap, fallback, endpoint, or privacy behavior. -- Read [references/providers.md](references/providers.md) before adding or - modifying a built-in provider. -- Keep output compatible with - [schemas/result-v1.schema.json](schemas/result-v1.schema.json). -- Read [references/contracts.md](references/contracts.md) when integrating the - CLI with an agent or changing command, exit-code, or JSON behavior. - -## Scope - -The target scope is widely deployed client-to-recursive encrypted DNS: -DoH, DoT, DoQ, DoH3, and DNSCrypt. ODoH and Anonymized DNSCrypt remain -research capabilities until explicitly marked available. - -Do not use this skill for DNS-over-DTLS, zone transfers, authoritative-server -operation, changing hosted DNS records, or replacing the operating system's -stub resolver. diff --git a/.agents/skills/encrypted-dns-skill/cmd/ednsdiag/main.go b/.agents/skills/encrypted-dns-skill/cmd/ednsdiag/main.go deleted file mode 100644 index 2e8f01f..0000000 --- a/.agents/skills/encrypted-dns-skill/cmd/ednsdiag/main.go +++ /dev/null @@ -1,386 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "io" - "os" - "strconv" - "strings" - "time" - - "github.com/windyboy/encrypted-dns-skill/internal/edns" -) - -const version = "0.1.0-dev" - -const ( - exitSuccess = 0 - exitLocal = 1 - exitUsage = 2 - exitTransport = 3 - exitUnsupported = 4 -) - -var ( - runQuery = edns.Query - runProbe = edns.Probe - runCompare = edns.Compare -) - -type capability struct { - Protocol string `json:"protocol"` - Status string `json:"status"` - Standard string `json:"standard,omitempty"` - Note string `json:"note,omitempty"` -} - -type capabilitiesResult struct { - SchemaVersion int `json:"schema_version"` - Command string `json:"command"` - Version string `json:"version"` - Capabilities []capability `json:"capabilities"` -} - -func main() { - os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) -} - -func run(args []string, stdout, stderr io.Writer) int { - if len(args) == 0 { - writeUsage(stderr) - return 2 - } - - switch args[0] { - case "capabilities": - if len(args) != 1 { - fmt.Fprintln(stderr, "capabilities does not accept arguments") - return 2 - } - result := capabilitiesResult{ - SchemaVersion: 1, - Command: "capabilities", - Version: version, - Capabilities: []capability{ - {Protocol: "doh", Status: "available", Standard: "RFC 8484", Note: "RFC wire format over HTTP GET or POST"}, - {Protocol: "dot", Status: "available", Standard: "RFC 7858 and RFC 8310", Note: "strict PKIX and authentication-domain validation"}, - {Protocol: "doq", Status: "available", Standard: "RFC 9250", Note: "RFC wire format over dedicated QUIC streams"}, - {Protocol: "doh3", Status: "available", Standard: "RFC 8484 over HTTP/3", Note: "RFC wire format over HTTP/3 GET or POST"}, - {Protocol: "dnscrypt", Status: "available", Standard: "DNSCrypt protocol specification", Note: "DNSCrypt v2 with authenticated resolver certificates"}, - {Protocol: "odoh", Status: "research", Standard: "RFC 9230", Note: "No maintained Go dependency has been selected."}, - {Protocol: "anonymized-dnscrypt", Status: "research", Standard: "Anonymized DNSCrypt specification"}, - }, - } - return writeJSON(stdout, stderr, result) - - case "version": - if len(args) != 1 { - fmt.Fprintln(stderr, "version does not accept arguments") - return 2 - } - fmt.Fprintln(stdout, version) - return 0 - - case "query", "probe": - options, timeout, err := parseQueryArgs(args[1:]) - if err != nil { - fmt.Fprintln(stderr, err) - writeQueryUsage(stderr) - return 2 - } - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - var result edns.Result - if args[0] == "probe" { - result = runProbe(ctx, options) - } else { - result = runQuery(ctx, options) - } - if code := writeJSON(stdout, stderr, result); code != 0 { - return code - } - return resultExitCode(result.Completed, result.Error) - - case "compare": - options, timeout, err := parseCompareArgs(args[1:]) - if err != nil { - fmt.Fprintln(stderr, err) - writeCompareUsage(stderr) - return exitUsage - } - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - result := runCompare(ctx, options) - if code := writeJSON(stdout, stderr, result); code != 0 { - return code - } - return resultExitCode(result.Completed, result.Error) - - default: - fmt.Fprintf(stderr, "unknown command %q\n", args[0]) - writeUsage(stderr) - return 2 - } -} - -func parseQueryArgs(args []string) (edns.QueryOptions, time.Duration, error) { - options := edns.QueryOptions{ - RecordType: "A", - Protocol: "doh", - Provider: "cloudflare", - Method: "post", - } - timeout := 5 * time.Second - positionals := make([]string, 0, 2) - - for index := 0; index < len(args); index++ { - argument := args[index] - if !strings.HasPrefix(argument, "--") { - positionals = append(positionals, argument) - continue - } - key, value, found := strings.Cut(strings.TrimPrefix(argument, "--"), "=") - if !found { - index++ - if index >= len(args) { - return options, 0, fmt.Errorf("--%s requires a value", key) - } - value = args[index] - } - switch key { - case "protocol": - options.Protocol = strings.ToLower(value) - case "provider": - options.Provider = strings.ToLower(value) - case "method": - options.Method = strings.ToLower(value) - case "timeout": - parsed, err := time.ParseDuration(value) - if err != nil { - return options, 0, fmt.Errorf("invalid timeout %q: %w", value, err) - } - timeout = parsed - case "proxy": - options.Proxy = value - default: - return options, 0, fmt.Errorf("unknown query option --%s", key) - } - } - if len(positionals) < 1 || len(positionals) > 2 { - return options, 0, fmt.Errorf("query requires a domain and optional record type") - } - if timeout < 250*time.Millisecond || timeout > 30*time.Second { - return options, 0, fmt.Errorf("timeout must be between 250ms and 30s") - } - options.Name = positionals[0] - if len(positionals) == 2 { - options.RecordType = strings.ToUpper(positionals[1]) - } - if !knownRecordType(options.RecordType) { - return options, 0, fmt.Errorf("unsupported record type %q", options.RecordType) - } - if !knownProtocol(options.Protocol) { - return options, 0, fmt.Errorf("unknown protocol %q", options.Protocol) - } - if _, err := edns.FindProvider(options.Provider); err != nil { - return options, 0, err - } - if options.Method != "get" && options.Method != "post" { - return options, 0, fmt.Errorf("DoH method must be get or post") - } - if options.Protocol != "doh" && options.Protocol != "doh3" && options.Method != "post" { - return options, 0, fmt.Errorf("--method applies only to DoH and DoH3") - } - if err := edns.ValidateProxyURL(options.Proxy); err != nil { - return options, 0, err - } - if options.Proxy != "" && options.Protocol != "doh" && options.Protocol != "dot" { - return options, 0, fmt.Errorf("--proxy applies only to DoH and DoT") - } - return options, timeout, nil -} - -func parseCompareArgs(args []string) (edns.CompareOptions, time.Duration, error) { - options := edns.CompareOptions{RecordType: "A", AttemptTimeout: 5 * time.Second, MaxAttempts: 4} - totalTimeout := 30 * time.Second - positionals := make([]string, 0, 2) - seenTargets := map[string]bool{} - - for index := 0; index < len(args); index++ { - argument := args[index] - if !strings.HasPrefix(argument, "--") { - positionals = append(positionals, argument) - continue - } - key, value, found := strings.Cut(strings.TrimPrefix(argument, "--"), "=") - if !found { - index++ - if index >= len(args) { - return options, 0, fmt.Errorf("--%s requires a value", key) - } - value = args[index] - } - switch key { - case "target": - target, err := parseCompareTarget(value) - if err != nil { - return options, 0, err - } - identity := target.Protocol + ":" + target.Provider + ":" + target.Method - if seenTargets[identity] { - return options, 0, fmt.Errorf("duplicate comparison target %q", value) - } - seenTargets[identity] = true - options.Targets = append(options.Targets, target) - case "timeout": - parsed, err := time.ParseDuration(value) - if err != nil { - return options, 0, fmt.Errorf("invalid timeout %q: %w", value, err) - } - totalTimeout = parsed - case "attempt-timeout": - parsed, err := time.ParseDuration(value) - if err != nil { - return options, 0, fmt.Errorf("invalid attempt timeout %q: %w", value, err) - } - options.AttemptTimeout = parsed - case "max-attempts": - parsed, err := strconv.Atoi(value) - if err != nil { - return options, 0, fmt.Errorf("invalid max attempts %q", value) - } - options.MaxAttempts = parsed - case "proxy": - options.Proxy = value - default: - return options, 0, fmt.Errorf("unknown compare option --%s", key) - } - } - - if len(positionals) < 1 || len(positionals) > 2 { - return options, 0, fmt.Errorf("compare requires a domain and optional record type") - } - options.Name = positionals[0] - if len(positionals) == 2 { - options.RecordType = strings.ToUpper(positionals[1]) - } - if !knownRecordType(options.RecordType) { - return options, 0, fmt.Errorf("unsupported record type %q", options.RecordType) - } - if len(options.Targets) < 2 { - return options, 0, fmt.Errorf("compare requires at least two --target values") - } - if options.MaxAttempts < 2 || options.MaxAttempts > 8 { - return options, 0, fmt.Errorf("max attempts must be between 2 and 8") - } - if len(options.Targets) > options.MaxAttempts { - return options, 0, fmt.Errorf("comparison targets exceed max attempts") - } - if totalTimeout < 250*time.Millisecond || totalTimeout > 60*time.Second { - return options, 0, fmt.Errorf("compare timeout must be between 250ms and 60s") - } - if options.AttemptTimeout < 250*time.Millisecond || options.AttemptTimeout > 30*time.Second { - return options, 0, fmt.Errorf("attempt timeout must be between 250ms and 30s") - } - if options.AttemptTimeout > totalTimeout { - return options, 0, fmt.Errorf("attempt timeout cannot exceed compare timeout") - } - if err := edns.ValidateProxyURL(options.Proxy); err != nil { - return options, 0, err - } - if options.Proxy != "" { - for _, target := range options.Targets { - if target.Protocol != "doh" && target.Protocol != "dot" { - return options, 0, fmt.Errorf("--proxy cannot be used with %s comparison targets", target.Protocol) - } - } - } - return options, totalTimeout, nil -} - -func parseCompareTarget(value string) (edns.CompareTarget, error) { - parts := strings.Split(value, ":") - if len(parts) < 2 || len(parts) > 3 { - return edns.CompareTarget{}, fmt.Errorf("target %q must be protocol:provider[:method]", value) - } - target := edns.CompareTarget{Protocol: strings.ToLower(parts[0]), Provider: strings.ToLower(parts[1]), Method: "post"} - if len(parts) == 3 { - target.Method = strings.ToLower(parts[2]) - } - if !knownProtocol(target.Protocol) { - return target, fmt.Errorf("unknown protocol %q", target.Protocol) - } - if _, err := edns.FindProvider(target.Provider); err != nil { - return target, err - } - if target.Method != "get" && target.Method != "post" { - return target, fmt.Errorf("target method must be get or post") - } - if target.Protocol != "doh" && target.Protocol != "doh3" && target.Method != "post" { - return target, fmt.Errorf("GET method applies only to DoH and DoH3 targets") - } - return target, nil -} - -func knownProtocol(protocol string) bool { - switch protocol { - case "doh", "dot", "doq", "doh3", "dnscrypt", "odoh", "anonymized-dnscrypt": - return true - default: - return false - } -} - -func knownRecordType(recordType string) bool { - switch recordType { - case "A", "AAAA", "CNAME", "MX", "TXT", "NS", "SOA", "CAA", "SRV", "PTR", "HTTPS", "SVCB": - return true - default: - return false - } -} - -func resultExitCode(completed bool, resultError *edns.ErrorInfo) int { - if completed { - return exitSuccess - } - if resultError == nil { - return exitLocal - } - switch resultError.Class { - case "internal": - return exitLocal - case "input": - return exitUsage - case "unsupported": - return exitUnsupported - case "transport", "protocol": - return exitTransport - default: - return exitLocal - } -} - -func writeJSON(stdout, stderr io.Writer, value any) int { - encoder := json.NewEncoder(stdout) - encoder.SetIndent("", " ") - if err := encoder.Encode(value); err != nil { - fmt.Fprintf(stderr, "encode JSON result: %v\n", err) - return 1 - } - return 0 -} - -func writeUsage(writer io.Writer) { - fmt.Fprintln(writer, "usage: ednsdiag ") -} - -func writeQueryUsage(writer io.Writer) { - fmt.Fprintln(writer, "usage: ednsdiag [type] [--protocol doh|dot|doq|doh3|dnscrypt|odoh|anonymized-dnscrypt] [--provider cloudflare|google|quad9|adguard] [--method post|get] [--proxy http://host:port] [--timeout 5s]") -} - -func writeCompareUsage(writer io.Writer) { - fmt.Fprintln(writer, "usage: ednsdiag compare [type] --target protocol:provider[:method] --target protocol:provider[:method] [--proxy http://host:port] [--attempt-timeout 5s] [--timeout 30s] [--max-attempts 4]") -} diff --git a/.agents/skills/encrypted-dns-skill/cmd/ednsdiag/main_test.go b/.agents/skills/encrypted-dns-skill/cmd/ednsdiag/main_test.go deleted file mode 100644 index e16b897..0000000 --- a/.agents/skills/encrypted-dns-skill/cmd/ednsdiag/main_test.go +++ /dev/null @@ -1,285 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/santhosh-tekuri/jsonschema/v6" - "github.com/windyboy/encrypted-dns-skill/internal/edns" -) - -func TestCapabilities(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - code := run([]string{"capabilities"}, &stdout, &stderr) - if code != 0 { - t.Fatalf("run capabilities returned %d; stderr=%q", code, stderr.String()) - } - - var result capabilitiesResult - if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { - t.Fatalf("decode capabilities: %v", err) - } - if result.SchemaVersion != 1 || result.Command != "capabilities" { - t.Fatalf("unexpected capabilities envelope: %#v", result) - } - available := map[string]bool{} - for _, item := range result.Capabilities { - available[item.Protocol] = item.Status == "available" - } - if !available["doh"] || !available["dot"] || !available["doq"] || !available["doh3"] || !available["dnscrypt"] { - t.Fatalf("DoH, DoT, DoQ, DoH3, and DNSCrypt must be available: %#v", available) - } -} - -func TestQueryProbeAndCompareGoldenContracts(t *testing.T) { - originalQuery, originalProbe, originalCompare := runQuery, runProbe, runCompare - t.Cleanup(func() { - runQuery, runProbe, runCompare = originalQuery, originalProbe, originalCompare - }) - - queryCalls := 0 - probeCalls := 0 - runQuery = func(_ context.Context, options edns.QueryOptions) edns.Result { - queryCalls++ - return successfulResult("query", options.Protocol, options.Provider, "203.0.113.10") - } - runProbe = func(_ context.Context, options edns.QueryOptions) edns.Result { - probeCalls++ - return successfulResult("probe", options.Protocol, options.Provider, "203.0.113.10") - } - runCompare = func(_ context.Context, _ edns.CompareOptions) edns.CompareResult { - first := successfulResult("query", "doh", "cloudflare", "203.0.113.10") - second := successfulResult("query", "dot", "google", "203.0.113.20") - return edns.CompareResult{ - SchemaVersion: 1, - Operation: "compare", - Completed: true, - Query: edns.QueryInfo{Name: "example.com", Type: "A"}, - Attempts: []edns.Result{first, second}, - Summary: edns.CompareSummary{Total: 2, Completed: 2}, - } - } - - tests := []struct { - name string - args []string - golden string - }{ - {name: "query", args: []string{"query", "example.com", "A"}, golden: "query.golden.json"}, - {name: "probe", args: []string{"probe", "example.com", "A"}, golden: "probe.golden.json"}, - {name: "compare", args: []string{"compare", "example.com", "A", "--target", "doh:cloudflare", "--target", "dot:google"}, golden: "compare.golden.json"}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - if code := run(test.args, &stdout, &stderr); code != exitSuccess { - t.Fatalf("run returned %d; stderr=%q", code, stderr.String()) - } - if stderr.Len() != 0 { - t.Fatalf("stderr = %q, want empty", stderr.String()) - } - want, err := os.ReadFile(filepath.Join("testdata", test.golden)) - if err != nil { - t.Fatalf("read golden: %v", err) - } - want = bytes.ReplaceAll(want, []byte("\r\n"), []byte("\n")) - if !bytes.Equal(stdout.Bytes(), want) { - t.Fatalf("stdout does not match %s\nwant:\n%s\ngot:\n%s", test.golden, want, stdout.Bytes()) - } - validateResultSchema(t, stdout.Bytes()) - }) - } - - if queryCalls != 1 || probeCalls != 1 { - t.Fatalf("query calls = %d, probe calls = %d; each operation must invoke only its own runner", queryCalls, probeCalls) - } -} - -func TestUnsupportedProtocolReturnsStableJSONAndExitCode(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - code := run([]string{"query", "example.com", "A", "--protocol", "odoh", "--provider", "cloudflare"}, &stdout, &stderr) - if code != exitUnsupported { - t.Fatalf("run returned %d, want %d; stderr=%q", code, exitUnsupported, stderr.String()) - } - if stderr.Len() != 0 { - t.Fatalf("stderr = %q, want empty for a structured operational result", stderr.String()) - } - var result edns.Result - if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { - t.Fatalf("decode result: %v", err) - } - if result.Error == nil || result.Error.Class != "unsupported" { - t.Fatalf("unexpected error result: %#v", result) - } - validateResultSchema(t, stdout.Bytes()) -} - -func TestUsageDiagnosticsStayOnStderr(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - code := run([]string{"compare", "example.com", "--target", "doh:cloudflare"}, &stdout, &stderr) - if code != exitUsage { - t.Fatalf("run returned %d, want %d", code, exitUsage) - } - if stdout.Len() != 0 || !strings.Contains(stderr.String(), "at least two") { - t.Fatalf("stdout=%q stderr=%q", stdout.String(), stderr.String()) - } -} - -func TestParseQueryArgsAllowsSupportedAndResearchProtocols(t *testing.T) { - for _, protocol := range []string{"dot", "doq", "doh3", "dnscrypt", "odoh", "anonymized-dnscrypt"} { - t.Run(protocol, func(t *testing.T) { - options, timeout, err := parseQueryArgs([]string{"example.com", "MX", "--protocol", protocol, "--provider=quad9", "--timeout", "3s"}) - if err != nil { - t.Fatalf("parse query args: %v", err) - } - if options.Protocol != protocol || timeout != 3*time.Second { - t.Fatalf("unexpected options=%#v timeout=%v", options, timeout) - } - }) - } -} - -func TestParseQueryArgsAcceptsProxyForDoHAndDoTOnly(t *testing.T) { - for _, protocol := range []string{"doh", "dot"} { - options, _, err := parseQueryArgs([]string{"example.com", "--protocol", protocol, "--proxy", "http://proxy.example:8080"}) - if err != nil { - t.Fatalf("parse %s proxy: %v", protocol, err) - } - if options.Proxy != "http://proxy.example:8080" { - t.Fatalf("proxy = %q", options.Proxy) - } - } - if _, _, err := parseQueryArgs([]string{"example.com", "--protocol", "doq", "--provider", "adguard", "--proxy", "http://proxy.example:8080"}); err == nil { - t.Fatal("DoQ accepted an HTTP proxy") - } - if _, _, err := parseQueryArgs([]string{"example.com", "--proxy", "socks5://proxy.example:1080"}); err == nil { - t.Fatal("unsupported proxy scheme was accepted") - } -} - -func TestParseCompareArgsAcceptsSharedProxyForTCPAndHTTPTargets(t *testing.T) { - options, _, err := parseCompareArgs([]string{ - "example.com", "--target", "doh:cloudflare", "--target", "dot:google", "--proxy", "https://proxy.example:8443", - }) - if err != nil { - t.Fatalf("parse compare proxy: %v", err) - } - if options.Proxy != "https://proxy.example:8443" { - t.Fatalf("proxy = %q", options.Proxy) - } - if _, _, err := parseCompareArgs([]string{ - "example.com", "--target", "doh:cloudflare", "--target", "doq:adguard", "--proxy", "http://proxy.example:8080", - }); err == nil { - t.Fatal("compare accepted a proxy with a QUIC target") - } -} - -func TestParseCompareArgsRejectsDuplicatesAndLimits(t *testing.T) { - for _, args := range [][]string{ - {"example.com", "--target", "doh:cloudflare", "--target", "doh:cloudflare"}, - {"example.com", "--target", "doh:cloudflare", "--target", "dot:google", "--max-attempts", "1"}, - {"example.com", "--target", "doh:cloudflare", "--target", "dot:google", "--attempt-timeout", "10s", "--timeout", "5s"}, - } { - if _, _, err := parseCompareArgs(args); err == nil { - t.Fatalf("parseCompareArgs(%q) succeeded, want error", args) - } - } -} - -func TestResultExitCodes(t *testing.T) { - tests := []struct { - completed bool - class string - want int - }{ - {completed: true, want: exitSuccess}, - {class: "internal", want: exitLocal}, - {class: "input", want: exitUsage}, - {class: "transport", want: exitTransport}, - {class: "protocol", want: exitTransport}, - {class: "unsupported", want: exitUnsupported}, - } - for _, test := range tests { - var resultError *edns.ErrorInfo - if test.class != "" { - resultError = &edns.ErrorInfo{Class: test.class} - } - if got := resultExitCode(test.completed, resultError); got != test.want { - t.Fatalf("resultExitCode(%v, %q) = %d, want %d", test.completed, test.class, got, test.want) - } - } -} - -func TestUnknownCommand(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - if code := run([]string{"unknown"}, &stdout, &stderr); code != exitUsage { - t.Fatalf("run unknown returned %d, want %d", code, exitUsage) - } - if !strings.Contains(stderr.String(), "unknown command") || stdout.Len() != 0 { - t.Fatalf("stdout=%q stderr=%q", stdout.String(), stderr.String()) - } -} - -func successfulResult(operation, protocol, provider, address string) edns.Result { - return edns.Result{ - SchemaVersion: 1, - Operation: operation, - Completed: true, - Query: edns.QueryInfo{Name: "example.com", Type: "A"}, - Resolver: edns.ResolverInfo{Provider: provider, Endpoint: provider + ".example:443", Profile: "test"}, - Transport: edns.TransportInfo{ - Protocol: protocol, - Encrypted: true, - ServerAuthenticated: true, - ElapsedMS: 12, - Bootstrap: "test_fixture", - }, - DNS: edns.DNSInfo{ - RCode: "NOERROR", - RCodeValue: 0, - Answers: []edns.AnswerRecord{{ - "name": "example.com", "type": "A", "ttl": float64(60), "address": address, - }}, - }, - } -} - -func validateResultSchema(t *testing.T, document []byte) { - t.Helper() - schemaBytes, err := os.ReadFile(filepath.Join("..", "..", "schemas", "result-v1.schema.json")) - if err != nil { - t.Fatalf("read result schema: %v", err) - } - var schemaDocument any - if err := json.Unmarshal(schemaBytes, &schemaDocument); err != nil { - t.Fatalf("decode result schema: %v", err) - } - compiler := jsonschema.NewCompiler() - if err := compiler.AddResource("result-v1.schema.json", schemaDocument); err != nil { - t.Fatalf("add result schema: %v", err) - } - schema, err := compiler.Compile("result-v1.schema.json") - if err != nil { - t.Fatalf("compile result schema: %v", err) - } - var value any - if err := json.Unmarshal(document, &value); err != nil { - t.Fatalf("decode result JSON: %v", err) - } - if err := schema.Validate(value); err != nil { - t.Fatalf("result does not validate against result-v1: %v", err) - } -} diff --git a/.agents/skills/encrypted-dns-skill/go.mod b/.agents/skills/encrypted-dns-skill/go.mod deleted file mode 100644 index 81312a1..0000000 --- a/.agents/skills/encrypted-dns-skill/go.mod +++ /dev/null @@ -1,25 +0,0 @@ -module github.com/windyboy/encrypted-dns-skill - -go 1.26.6 - -require golang.org/x/net v0.58.0 - -require ( - github.com/ameshkov/dnscrypt/v2 v2.4.0 - github.com/miekg/dns v1.1.72 - github.com/quic-go/quic-go v0.61.0 - github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 -) - -require ( - github.com/AdguardTeam/golibs v0.32.7 // indirect - github.com/ameshkov/dnsstamps v1.0.3 // indirect - github.com/quic-go/qpack v0.6.0 // indirect - golang.org/x/crypto v0.55.0 // indirect - golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect - golang.org/x/mod v0.38.0 // indirect - golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.41.0 // indirect - golang.org/x/tools v0.48.0 // indirect -) diff --git a/.agents/skills/encrypted-dns-skill/go.sum b/.agents/skills/encrypted-dns-skill/go.sum deleted file mode 100644 index ac7ca3e..0000000 --- a/.agents/skills/encrypted-dns-skill/go.sum +++ /dev/null @@ -1,46 +0,0 @@ -github.com/AdguardTeam/golibs v0.32.7 h1:3dmGlAVgmvquCCwHsvEl58KKcRAK3z1UnjMnwSIeDH4= -github.com/AdguardTeam/golibs v0.32.7/go.mod h1:bE8KV1zqTzgZjmjFyBJ9f9O5DEKO717r7e57j1HclJA= -github.com/ameshkov/dnscrypt/v2 v2.4.0 h1:if6ZG2cuQmcP2TwSY+D0+8+xbPfoatufGlOQTMNkI9o= -github.com/ameshkov/dnscrypt/v2 v2.4.0/go.mod h1:WpEFV2uhebXb8Jhes/5/fSdpmhGV8TL22RDaeWwV6hI= -github.com/ameshkov/dnsstamps v1.0.3 h1:Srzik+J9mivH1alRACTbys2xOxs0lRH9qnTA7Y1OYVo= -github.com/ameshkov/dnsstamps v1.0.3/go.mod h1:Ii3eUu73dx4Vw5O4wjzmT5+lkCwovjzaEZZ4gKyIH5A= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= -github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= -github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= -github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= -github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= -github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= -github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA= -github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= -go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= -golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= -golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= -golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= -golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= -golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= -golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= -golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= -golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/.agents/skills/encrypted-dns-skill/internal/edns/dns.go b/.agents/skills/encrypted-dns-skill/internal/edns/dns.go deleted file mode 100644 index d389365..0000000 --- a/.agents/skills/encrypted-dns-skill/internal/edns/dns.go +++ /dev/null @@ -1,289 +0,0 @@ -package edns - -import ( - "crypto/rand" - "encoding/base64" - "encoding/binary" - "fmt" - "net" - "net/netip" - "strings" - - "golang.org/x/net/dns/dnsmessage" - "golang.org/x/net/idna" -) - -var recordTypes = map[string]dnsmessage.Type{ - "A": dnsmessage.TypeA, - "AAAA": dnsmessage.TypeAAAA, - "CNAME": dnsmessage.TypeCNAME, - "MX": dnsmessage.TypeMX, - "TXT": dnsmessage.TypeTXT, - "NS": dnsmessage.TypeNS, - "SOA": dnsmessage.TypeSOA, - "CAA": dnsmessage.Type(257), - "SRV": dnsmessage.TypeSRV, - "SVCB": dnsmessage.TypeSVCB, - "HTTPS": dnsmessage.TypeHTTPS, - "PTR": dnsmessage.TypePTR, -} - -func BuildQuery(name, recordType string) ([]byte, QueryInfo, uint16, error) { - typeName := strings.ToUpper(recordType) - qtype, ok := recordTypes[typeName] - if !ok { - return nil, QueryInfo{}, 0, fmt.Errorf("unsupported record type %q", recordType) - } - - var canonical string - var err error - if typeName == "PTR" { - address, parseErr := netip.ParseAddr(strings.TrimSpace(name)) - if parseErr != nil { - return nil, QueryInfo{}, 0, fmt.Errorf("PTR queries require an IPv4 or IPv6 address") - } - canonical = reverseName(address.Unmap()) - } else { - canonical, err = canonicalName(name) - } - if err != nil { - return nil, QueryInfo{}, 0, err - } - - dnsName, err := dnsmessage.NewName(canonical + ".") - if err != nil { - return nil, QueryInfo{}, 0, fmt.Errorf("encode domain name: %w", err) - } - - var randomID [2]byte - if _, err := rand.Read(randomID[:]); err != nil { - return nil, QueryInfo{}, 0, fmt.Errorf("generate DNS transaction ID: %w", err) - } - id := binary.BigEndian.Uint16(randomID[:]) - message := dnsmessage.Message{ - Header: dnsmessage.Header{ID: id, RecursionDesired: true}, - Questions: []dnsmessage.Question{{ - Name: dnsName, - Type: qtype, - Class: dnsmessage.ClassINET, - }}, - } - wire, err := message.Pack() - if err != nil { - return nil, QueryInfo{}, 0, fmt.Errorf("pack DNS query: %w", err) - } - return wire, QueryInfo{Name: canonical, Type: typeName}, id, nil -} - -func reverseName(address netip.Addr) string { - if address.Is4() { - bytes := address.As4() - return fmt.Sprintf("%d.%d.%d.%d.in-addr.arpa", bytes[3], bytes[2], bytes[1], bytes[0]) - } - - bytes := address.As16() - var builder strings.Builder - // Each IPv6 nibble is emitted from least to most significant per RFC 3596. - for index := len(bytes) - 1; index >= 0; index-- { - fmt.Fprintf(&builder, "%x.%x.", bytes[index]&0x0f, bytes[index]>>4) - } - builder.WriteString("ip6.arpa") - return builder.String() -} - -func ParseResponse(wire []byte, expectedID uint16, query QueryInfo) (DNSInfo, error) { - var message dnsmessage.Message - if err := message.Unpack(wire); err != nil { - return DNSInfo{}, fmt.Errorf("unpack DNS response: %w", err) - } - if !message.Header.Response { - return DNSInfo{}, fmt.Errorf("received a DNS query instead of a response") - } - if message.Header.OpCode != 0 { - return DNSInfo{}, fmt.Errorf("DNS response uses unexpected opcode %d", message.Header.OpCode) - } - if message.Header.Truncated { - return DNSInfo{}, fmt.Errorf("DNS response is truncated") - } - if message.Header.ID != expectedID { - return DNSInfo{}, fmt.Errorf("DNS transaction ID mismatch") - } - if len(message.Questions) != 1 { - return DNSInfo{}, fmt.Errorf("DNS response contains %d questions, want 1", len(message.Questions)) - } - wantType := recordTypes[query.Type] - question := message.Questions[0] - if trimRoot(question.Name.String()) != query.Name || question.Type != wantType || question.Class != dnsmessage.ClassINET { - return DNSInfo{}, fmt.Errorf("DNS response question does not match request") - } - - answers := make([]AnswerRecord, 0, len(message.Answers)) - for _, resource := range message.Answers { - if resource.Header.Class != dnsmessage.ClassINET { - return DNSInfo{}, fmt.Errorf("DNS answer %q uses unsupported class %d", trimRoot(resource.Header.Name.String()), resource.Header.Class) - } - answer, err := normalizeAnswer(resource) - if err != nil { - return DNSInfo{}, err - } - answers = append(answers, answer) - } - - return DNSInfo{ - RCode: rcodeName(message.Header.RCode), - RCodeValue: int(message.Header.RCode), - ResolverReportsDNSSECAuthenticated: message.Header.AuthenticData, - ClientValidatedDNSSEC: false, - Answers: answers, - }, nil -} - -func canonicalName(input string) (string, error) { - name := strings.TrimSuffix(strings.TrimSpace(input), ".") - if name == "" { - return "", fmt.Errorf("domain name is empty") - } - if net.ParseIP(name) != nil { - return "", fmt.Errorf("IP literals are not accepted as domain names") - } - - ascii, err := idna.Lookup.ToASCII(name) - if err != nil { - return "", fmt.Errorf("convert domain name to IDNA ASCII: %w", err) - } - ascii = strings.ToLower(ascii) - if len(ascii) > 253 { - return "", fmt.Errorf("domain name exceeds 253 bytes") - } - for _, label := range strings.Split(ascii, ".") { - if label == "" || len(label) > 63 { - return "", fmt.Errorf("domain name contains an invalid label") - } - } - - blocked := []string{"localhost", ".local", ".internal", ".lan", ".arpa"} - for _, suffix := range blocked { - if ascii == strings.TrimPrefix(suffix, ".") || strings.HasSuffix(ascii, suffix) { - return "", fmt.Errorf("domain name is blocked by the local-name policy") - } - } - return ascii, nil -} - -func normalizeAnswer(resource dnsmessage.Resource) (AnswerRecord, error) { - record := AnswerRecord{ - "name": trimRoot(resource.Header.Name.String()), - "type": typeName(resource.Header.Type), - "ttl": resource.Header.TTL, - } - - switch body := resource.Body.(type) { - case *dnsmessage.AResource: - record["address"] = net.IP(body.A[:]).String() - case *dnsmessage.AAAAResource: - record["address"] = net.IP(body.AAAA[:]).String() - case *dnsmessage.CNAMEResource: - record["target"] = trimRoot(body.CNAME.String()) - case *dnsmessage.MXResource: - record["priority"] = body.Pref - record["exchange"] = trimRoot(body.MX.String()) - case *dnsmessage.TXTResource: - record["strings"] = body.TXT - case *dnsmessage.NSResource: - record["host"] = trimRoot(body.NS.String()) - case *dnsmessage.PTRResource: - record["target"] = trimRoot(body.PTR.String()) - case *dnsmessage.SOAResource: - record["primary_ns"] = trimRoot(body.NS.String()) - record["responsible_mailbox"] = trimRoot(body.MBox.String()) - record["serial"] = body.Serial - record["refresh"] = body.Refresh - record["retry"] = body.Retry - record["expire"] = body.Expire - record["minimum_ttl"] = body.MinTTL - case *dnsmessage.SRVResource: - record["priority"] = body.Priority - record["weight"] = body.Weight - record["port"] = body.Port - record["target"] = trimRoot(body.Target.String()) - case *dnsmessage.SVCBResource: - addSVCBFields(record, body.Priority, body.Target, body.Params) - case *dnsmessage.HTTPSResource: - addSVCBFields(record, body.Priority, body.Target, body.Params) - case *dnsmessage.UnknownResource: - if resource.Header.Type == dnsmessage.Type(257) && len(body.Data) >= 2 { - record["flags"] = body.Data[0] - tagLength := int(body.Data[1]) - if 2+tagLength <= len(body.Data) { - record["tag"] = string(body.Data[2 : 2+tagLength]) - record["value"] = string(body.Data[2+tagLength:]) - } else { - return nil, fmt.Errorf("CAA answer contains a truncated tag") - } - } else { - return nil, fmt.Errorf("DNS answer type %s cannot be represented by result-v1", typeName(resource.Header.Type)) - } - default: - return nil, fmt.Errorf("DNS answer type %s has an unexpected wire representation", typeName(resource.Header.Type)) - } - return record, nil -} - -func applyHTTPAge(info *DNSInfo, ageSeconds int64) { - if ageSeconds <= 0 { - return - } - for _, answer := range info.Answers { - ttl, ok := answer["ttl"].(uint32) - if !ok { - continue - } - if ageSeconds >= int64(ttl) { - answer["ttl"] = uint32(0) - } else { - answer["ttl"] = ttl - uint32(ageSeconds) - } - } -} - -func addSVCBFields(record AnswerRecord, priority uint16, target dnsmessage.Name, params []dnsmessage.SVCParam) { - record["priority"] = priority - record["target"] = trimRoot(target.String()) - values := make([]map[string]any, 0, len(params)) - for _, param := range params { - values = append(values, map[string]any{ - "key": param.Key.String(), - "key_value": uint16(param.Key), - "value_base64": base64.StdEncoding.EncodeToString(param.Value), - }) - } - record["params"] = values -} - -func trimRoot(name string) string { - return strings.TrimSuffix(strings.ToLower(name), ".") -} - -func typeName(recordType dnsmessage.Type) string { - for name, value := range recordTypes { - if value == recordType { - return name - } - } - return fmt.Sprintf("TYPE%d", recordType) -} - -func rcodeName(rcode dnsmessage.RCode) string { - names := map[dnsmessage.RCode]string{ - dnsmessage.RCodeSuccess: "NOERROR", - dnsmessage.RCodeFormatError: "FORMERR", - dnsmessage.RCodeServerFailure: "SERVFAIL", - dnsmessage.RCodeNameError: "NXDOMAIN", - dnsmessage.RCodeNotImplemented: "NOTIMP", - dnsmessage.RCodeRefused: "REFUSED", - } - if name, ok := names[rcode]; ok { - return name - } - return fmt.Sprintf("RCODE%d", rcode) -} diff --git a/.agents/skills/encrypted-dns-skill/internal/edns/dns_test.go b/.agents/skills/encrypted-dns-skill/internal/edns/dns_test.go deleted file mode 100644 index 0ba3c25..0000000 --- a/.agents/skills/encrypted-dns-skill/internal/edns/dns_test.go +++ /dev/null @@ -1,304 +0,0 @@ -package edns - -import ( - "encoding/binary" - "reflect" - "strings" - "testing" - - "golang.org/x/net/dns/dnsmessage" -) - -func TestBuildAndParseResponse(t *testing.T) { - queryWire, query, transactionID, err := BuildQuery("Example.COM.", "A") - if err != nil { - t.Fatalf("build query: %v", err) - } - if query.Name != "example.com" || query.Type != "A" { - t.Fatalf("canonical query = %#v", query) - } - - var request dnsmessage.Message - if err := request.Unpack(queryWire); err != nil { - t.Fatalf("unpack query: %v", err) - } - response := dnsmessage.Message{ - Header: dnsmessage.Header{ - ID: transactionID, - Response: true, - RecursionDesired: true, - RecursionAvailable: true, - AuthenticData: true, - }, - Questions: request.Questions, - Answers: []dnsmessage.Resource{{ - Header: dnsmessage.ResourceHeader{Name: request.Questions[0].Name, Class: dnsmessage.ClassINET, TTL: 60}, - Body: &dnsmessage.AResource{A: [4]byte{192, 0, 2, 1}}, - }}, - } - responseWire, err := response.Pack() - if err != nil { - t.Fatalf("pack response: %v", err) - } - - dnsResult, err := ParseResponse(responseWire, transactionID, query) - if err != nil { - t.Fatalf("parse response: %v", err) - } - if dnsResult.RCode != "NOERROR" || !dnsResult.ResolverReportsDNSSECAuthenticated { - t.Fatalf("unexpected DNS result: %#v", dnsResult) - } - if got := dnsResult.Answers[0]["address"]; got != "192.0.2.1" { - t.Fatalf("address = %v, want 192.0.2.1", got) - } -} - -func TestBuildQueryIDNAAndBlockedNames(t *testing.T) { - _, query, _, err := BuildQuery("bücher.example", "AAAA") - if err != nil { - t.Fatalf("build IDNA query: %v", err) - } - if query.Name != "xn--bcher-kva.example" { - t.Fatalf("IDNA name = %q", query.Name) - } - - blocked := []string{"localhost", "router.local", "service.internal", "host.lan", "1.0.0.127.in-addr.arpa", "127.0.0.1"} - for _, name := range blocked { - if _, _, _, err := BuildQuery(name, "A"); err == nil { - t.Errorf("BuildQuery(%q) succeeded, want policy error", name) - } - } -} - -func TestBuildPTRQueryFromIPAddress(t *testing.T) { - _, ipv4, _, err := BuildQuery("192.0.2.1", "PTR") - if err != nil { - t.Fatalf("build IPv4 PTR query: %v", err) - } - if ipv4.Name != "1.2.0.192.in-addr.arpa" || ipv4.Type != "PTR" { - t.Fatalf("unexpected IPv4 PTR query: %#v", ipv4) - } - _, ipv6, _, err := BuildQuery("2001:db8::1", "PTR") - if err != nil { - t.Fatalf("build IPv6 PTR query: %v", err) - } - if ipv6.Name != "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa" { - t.Fatalf("unexpected IPv6 PTR name: %q", ipv6.Name) - } - _, mappedIPv4, _, err := BuildQuery("::ffff:192.0.2.1", "PTR") - if err != nil { - t.Fatalf("build IPv4-mapped PTR query: %v", err) - } - if mappedIPv4.Name != ipv4.Name { - t.Fatalf("IPv4-mapped PTR name = %q, want %q", mappedIPv4.Name, ipv4.Name) - } - if _, _, _, err := BuildQuery("example.com", "PTR"); err == nil { - t.Fatal("PTR query accepted a non-IP input") - } -} - -func TestNormalizeSupportedAnswerTypes(t *testing.T) { - name := dnsmessage.MustNewName("example.com.") - target := dnsmessage.MustNewName("target.example.") - resources := []dnsmessage.Resource{ - {Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeAAAA, TTL: 60}, Body: &dnsmessage.AAAAResource{AAAA: [16]byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}}}, - {Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeCNAME, TTL: 60}, Body: &dnsmessage.CNAMEResource{CNAME: target}}, - {Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeMX, TTL: 60}, Body: &dnsmessage.MXResource{Pref: 10, MX: target}}, - {Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeTXT, TTL: 60}, Body: &dnsmessage.TXTResource{TXT: []string{"one", "two"}}}, - {Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeNS, TTL: 60}, Body: &dnsmessage.NSResource{NS: target}}, - {Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeSOA, TTL: 60}, Body: &dnsmessage.SOAResource{NS: target, MBox: target, Serial: 1}}, - {Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeSRV, TTL: 60}, Body: &dnsmessage.SRVResource{Priority: 1, Weight: 2, Port: 443, Target: target}}, - {Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypePTR, TTL: 60}, Body: &dnsmessage.PTRResource{PTR: target}}, - {Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeSVCB, TTL: 60}, Body: &dnsmessage.SVCBResource{Priority: 1, Target: target}}, - {Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeHTTPS, TTL: 60}, Body: &dnsmessage.HTTPSResource{SVCBResource: dnsmessage.SVCBResource{Priority: 1, Target: target}}}, - } - wantTypes := []string{"AAAA", "CNAME", "MX", "TXT", "NS", "SOA", "SRV", "PTR", "SVCB", "HTTPS"} - wantFields := []map[string]any{ - {"address": "2001:db8::1"}, - {"target": "target.example"}, - {"priority": uint16(10), "exchange": "target.example"}, - {"strings": []string{"one", "two"}}, - {"host": "target.example"}, - {"primary_ns": "target.example", "responsible_mailbox": "target.example", "serial": uint32(1)}, - {"priority": uint16(1), "weight": uint16(2), "port": uint16(443), "target": "target.example"}, - {"target": "target.example"}, - {"priority": uint16(1), "target": "target.example", "params": []map[string]any{}}, - {"priority": uint16(1), "target": "target.example", "params": []map[string]any{}}, - } - for index, resource := range resources { - record, err := normalizeAnswer(resource) - if err != nil { - t.Fatalf("normalize %s: %v", wantTypes[index], err) - } - if record["type"] != wantTypes[index] || record["name"] != "example.com" || record["ttl"] != uint32(60) { - t.Fatalf("unexpected %s normalization: %#v", wantTypes[index], record) - } - for field, want := range wantFields[index] { - if got := record[field]; !reflect.DeepEqual(got, want) { - t.Fatalf("%s field %s = %#v, want %#v", wantTypes[index], field, got, want) - } - } - } -} - -func TestNormalizeCAA(t *testing.T) { - name := dnsmessage.MustNewName("example.com.") - data := append([]byte{0, 5}, []byte("issueletsencrypt.org")...) - record, err := normalizeAnswer(dnsmessage.Resource{ - Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.Type(257), Class: dnsmessage.ClassINET, TTL: 300}, - Body: &dnsmessage.UnknownResource{Type: dnsmessage.Type(257), Data: data}, - }) - if err != nil { - t.Fatalf("normalize CAA: %v", err) - } - if record["tag"] != "issue" || record["value"] != "letsencrypt.org" { - t.Fatalf("unexpected CAA normalization: %#v", record) - } -} - -func TestParseResponseRejectsTransactionMismatch(t *testing.T) { - name := dnsmessage.MustNewName("example.com.") - message := dnsmessage.Message{ - Header: dnsmessage.Header{ID: 2, Response: true}, - Questions: []dnsmessage.Question{{Name: name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET}}, - } - wire, err := message.Pack() - if err != nil { - t.Fatalf("pack response: %v", err) - } - if _, err := ParseResponse(wire, 1, QueryInfo{Name: "example.com", Type: "A"}); err == nil { - t.Fatal("transaction mismatch was accepted") - } - - if binary.BigEndian.Uint16(wire[:2]) != 2 { - t.Fatal("test response ID was not encoded") - } -} - -func TestParseResponseRejectsIncompleteOrNonStandardMessages(t *testing.T) { - queryWire, query, transactionID, err := BuildQuery("example.com", "A") - if err != nil { - t.Fatalf("build query: %v", err) - } - var request dnsmessage.Message - if err := request.Unpack(queryWire); err != nil { - t.Fatalf("unpack query: %v", err) - } - validAnswer := dnsmessage.Resource{ - Header: dnsmessage.ResourceHeader{Name: request.Questions[0].Name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET, TTL: 60}, - Body: &dnsmessage.AResource{A: [4]byte{192, 0, 2, 1}}, - } - - tests := []struct { - name string - message dnsmessage.Message - want string - }{ - { - name: "truncated", - message: dnsmessage.Message{Header: dnsmessage.Header{ID: transactionID, Response: true, Truncated: true}, - Questions: request.Questions}, - want: "truncated", - }, - { - name: "unexpected opcode", - message: dnsmessage.Message{Header: dnsmessage.Header{ID: transactionID, Response: true, OpCode: 1}, - Questions: request.Questions}, - want: "opcode", - }, - { - name: "non-IN question", - message: dnsmessage.Message{Header: dnsmessage.Header{ID: transactionID, Response: true}, Questions: []dnsmessage.Question{{ - Name: request.Questions[0].Name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassCHAOS, - }}}, - want: "question does not match", - }, - { - name: "non-IN answer", - message: dnsmessage.Message{Header: dnsmessage.Header{ID: transactionID, Response: true}, Questions: request.Questions, Answers: []dnsmessage.Resource{{ - Header: dnsmessage.ResourceHeader{Name: request.Questions[0].Name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassCHAOS, TTL: 60}, - Body: &dnsmessage.AResource{A: [4]byte{192, 0, 2, 1}}, - }}}, - want: "unsupported class", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - wire, err := test.message.Pack() - if err != nil { - t.Fatalf("pack response: %v", err) - } - if _, err := ParseResponse(wire, transactionID, query); err == nil || !strings.Contains(err.Error(), test.want) { - t.Fatalf("error = %v, want substring %q", err, test.want) - } - }) - } - - valid := dnsmessage.Message{Header: dnsmessage.Header{ID: transactionID, Response: true}, Questions: request.Questions, Answers: []dnsmessage.Resource{validAnswer}} - if _, err := valid.Pack(); err != nil { - t.Fatalf("valid fixture does not pack: %v", err) - } -} - -func TestParseResponseRejectsNonRepresentableAnswers(t *testing.T) { - queryWire, query, transactionID, err := BuildQuery("example.com", "CAA") - if err != nil { - t.Fatalf("build query: %v", err) - } - var request dnsmessage.Message - if err := request.Unpack(queryWire); err != nil { - t.Fatalf("unpack query: %v", err) - } - - tests := []struct { - name string - record dnsmessage.Resource - contains string - }{ - { - name: "truncated CAA tag", - record: dnsmessage.Resource{ - Header: dnsmessage.ResourceHeader{Name: request.Questions[0].Name, Type: dnsmessage.Type(257), Class: dnsmessage.ClassINET, TTL: 60}, - Body: &dnsmessage.UnknownResource{Type: dnsmessage.Type(257), Data: []byte{0, 5, 'i'}}, - }, - contains: "truncated tag", - }, - { - name: "unknown answer type", - record: dnsmessage.Resource{ - Header: dnsmessage.ResourceHeader{Name: request.Questions[0].Name, Type: dnsmessage.Type(99), Class: dnsmessage.ClassINET, TTL: 60}, - Body: &dnsmessage.UnknownResource{Type: dnsmessage.Type(99), Data: []byte{1, 2}}, - }, - contains: "cannot be represented", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - message := dnsmessage.Message{ - Header: dnsmessage.Header{ID: transactionID, Response: true}, - Questions: request.Questions, - Answers: []dnsmessage.Resource{test.record}, - } - wire, err := message.Pack() - if err != nil { - t.Fatalf("pack response: %v", err) - } - if _, err := ParseResponse(wire, transactionID, query); err == nil || !strings.Contains(err.Error(), test.contains) { - t.Fatalf("error = %v, want substring %q", err, test.contains) - } - }) - } -} - -func TestApplyHTTPAgeClampsAnswerTTL(t *testing.T) { - info := DNSInfo{Answers: []AnswerRecord{ - {"ttl": uint32(120)}, - {"ttl": uint32(30)}, - }} - applyHTTPAge(&info, 45) - if info.Answers[0]["ttl"] != uint32(75) || info.Answers[1]["ttl"] != uint32(0) { - t.Fatalf("unexpected aged TTLs: %#v", info.Answers) - } -} diff --git a/.agents/skills/encrypted-dns-skill/internal/edns/doh.go b/.agents/skills/encrypted-dns-skill/internal/edns/doh.go deleted file mode 100644 index 893b2f4..0000000 --- a/.agents/skills/encrypted-dns-skill/internal/edns/doh.go +++ /dev/null @@ -1,169 +0,0 @@ -package edns - -import ( - "bytes" - "context" - "crypto/tls" - "encoding/base64" - "fmt" - "io" - "mime" - "net" - "net/http" - "net/url" - "strconv" - "strings" - "time" -) - -const maxDNSMessageSize = 65535 - -func exchangeDoH(ctx context.Context, provider Provider, wire []byte, method, explicitProxy string) ([]byte, TransportInfo, error) { - client, proxyLabel, err := newDoHClient(provider.DoHURL, explicitProxy) - if err != nil { - return nil, TransportInfo{Protocol: "doh", Encrypted: true, Bootstrap: "system_resolver"}, err - } - response, info, err := exchangeDoHWithClient(ctx, client, provider.DoHURL, wire, method) - info.Proxy = proxyLabel - return response, info, err -} - -func newDoHClient(endpoint, explicitProxy string) (*http.Client, string, error) { - return newDoHClientWithTLSConfig(endpoint, explicitProxy, &tls.Config{MinVersion: tls.VersionTLS12}) -} - -func newDoHClientWithTLSConfig(endpoint, explicitProxy string, tlsConfig *tls.Config) (*http.Client, string, error) { - origin, err := url.Parse(endpoint) - if err != nil { - return nil, "", fmt.Errorf("parse DoH endpoint: %w", err) - } - proxyURL, err := resolveProxy(origin, explicitProxy) - if err != nil { - return nil, "", fmt.Errorf("select DoH proxy: %w", err) - } - transport := &http.Transport{ - ForceAttemptHTTP2: true, - DialContext: (&net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}).DialContext, - TLSClientConfig: tlsConfig.Clone(), - TLSHandshakeTimeout: 5 * time.Second, - } - if proxyURL != nil { - transport.Proxy = http.ProxyURL(proxyURL) - } - client := &http.Client{ - Transport: transport, - CheckRedirect: func(request *http.Request, via []*http.Request) error { - if len(via) >= 3 { - return fmt.Errorf("too many DoH redirects") - } - if request.URL.Scheme != "https" { - return fmt.Errorf("DoH redirect changed to a non-HTTPS scheme") - } - if !strings.EqualFold(request.URL.Hostname(), origin.Hostname()) { - return fmt.Errorf("DoH redirect changed authentication domain") - } - return nil - }, - } - return client, proxyDisplayURL(proxyURL), nil -} - -func exchangeDoHWithClient(ctx context.Context, client *http.Client, endpoint string, wire []byte, method string) ([]byte, TransportInfo, error) { - return exchangeHTTPSDNSWithClient(ctx, client, endpoint, wire, method, "doh") -} - -func exchangeHTTPSDNSWithClient(ctx context.Context, client *http.Client, endpoint string, wire []byte, method, protocol string) ([]byte, TransportInfo, error) { - started := time.Now() - info := TransportInfo{ - Protocol: protocol, - Encrypted: true, - Bootstrap: "system_resolver", - } - - requestURL := endpoint - var body io.Reader - switch strings.ToLower(method) { - case "get": - parsed, err := url.Parse(endpoint) - if err != nil { - return nil, info, fmt.Errorf("parse DoH endpoint: %w", err) - } - query := parsed.Query() - query.Set("dns", base64.RawURLEncoding.EncodeToString(wire)) - parsed.RawQuery = query.Encode() - requestURL = parsed.String() - case "post", "": - method = "post" - body = bytes.NewReader(wire) - default: - return nil, info, fmt.Errorf("unsupported DoH method %q", method) - } - - request, err := http.NewRequestWithContext(ctx, strings.ToUpper(method), requestURL, body) - if err != nil { - return nil, info, fmt.Errorf("create DoH request: %w", err) - } - request.Header.Set("Accept", "application/dns-message") - if strings.EqualFold(method, "post") { - request.Header.Set("Content-Type", "application/dns-message") - } - request.Header.Set("User-Agent", "ednsdiag/0.1.0-dev") - - response, err := client.Do(request) - info.ElapsedMS = time.Since(started).Milliseconds() - if err != nil { - return nil, info, fmt.Errorf("perform DoH exchange: %w", err) - } - defer response.Body.Close() - - info.HTTPVersion = response.Proto - if age := response.Header.Get("Age"); age != "" { - parsedAge, err := strconv.ParseInt(age, 10, 64) - if err != nil || parsedAge < 0 { - return nil, info, fmt.Errorf("DoH server returned invalid Age header %q", age) - } - info.HTTPAgeSeconds = parsedAge - } - if response.TLS == nil || len(response.TLS.VerifiedChains) == 0 { - return nil, info, fmt.Errorf("DoH server TLS identity was not verified") - } - info.ServerAuthenticated = true - info.TLSVersion = tlsVersionName(response.TLS.Version) - info.ALPN = response.TLS.NegotiatedProtocol - if protocol == "doh3" { - if response.ProtoMajor != 3 { - return nil, info, fmt.Errorf("DoH3 server used unexpected HTTP version %q", response.Proto) - } - if info.ALPN != "h3" { - return nil, info, fmt.Errorf("DoH3 server negotiated unexpected ALPN protocol %q", info.ALPN) - } - } - - if response.StatusCode < 200 || response.StatusCode > 299 { - return nil, info, fmt.Errorf("DoH server returned HTTP status %d", response.StatusCode) - } - mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type")) - if err != nil || !strings.EqualFold(mediaType, "application/dns-message") { - return nil, info, fmt.Errorf("DoH server returned unsupported content type %q", response.Header.Get("Content-Type")) - } - - payload, err := io.ReadAll(io.LimitReader(response.Body, maxDNSMessageSize+1)) - if err != nil { - return nil, info, fmt.Errorf("read DoH response: %w", err) - } - if len(payload) > maxDNSMessageSize { - return nil, info, fmt.Errorf("DoH response exceeds %d bytes", maxDNSMessageSize) - } - return payload, info, nil -} - -func tlsVersionName(version uint16) string { - switch version { - case tls.VersionTLS13: - return "TLS1.3" - case tls.VersionTLS12: - return "TLS1.2" - default: - return fmt.Sprintf("0x%04x", version) - } -} diff --git a/.agents/skills/encrypted-dns-skill/internal/edns/doh_test.go b/.agents/skills/encrypted-dns-skill/internal/edns/doh_test.go deleted file mode 100644 index 6bf7aaf..0000000 --- a/.agents/skills/encrypted-dns-skill/internal/edns/doh_test.go +++ /dev/null @@ -1,142 +0,0 @@ -package edns - -import ( - "io" - "net/http" - "net/http/httptest" - "net/url" - "testing" - - "golang.org/x/net/dns/dnsmessage" -) - -func TestExchangeDoHGETAndPOST(t *testing.T) { - server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - var payload []byte - var err error - if request.Method == http.MethodGet { - payload, err = decodeGETQuery(request.URL.Query().Get("dns")) - } else { - payload, err = io.ReadAll(request.Body) - } - if err != nil { - http.Error(writer, err.Error(), http.StatusBadRequest) - return - } - if request.Header.Get("Accept") != "application/dns-message" { - http.Error(writer, "missing accept", http.StatusNotAcceptable) - return - } - - var query dnsmessage.Message - if err := query.Unpack(payload); err != nil { - http.Error(writer, err.Error(), http.StatusBadRequest) - return - } - response := dnsmessage.Message{ - Header: dnsmessage.Header{ID: query.Header.ID, Response: true, RecursionAvailable: true}, - Questions: query.Questions, - } - responseWire, err := response.Pack() - if err != nil { - http.Error(writer, err.Error(), http.StatusInternalServerError) - return - } - writer.Header().Set("Content-Type", "application/dns-message") - writer.Header().Set("Age", "10") - _, _ = writer.Write(responseWire) - })) - defer server.Close() - - wire, _, _, err := BuildQuery("example.com", "A") - if err != nil { - t.Fatalf("build query: %v", err) - } - for _, method := range []string{"get", "post"} { - t.Run(method, func(t *testing.T) { - response, info, err := exchangeDoHWithClient(t.Context(), server.Client(), server.URL, wire, method) - if err != nil { - t.Fatalf("exchange DoH: %v", err) - } - if len(response) == 0 || !info.Encrypted || !info.ServerAuthenticated || info.HTTPAgeSeconds != 10 { - t.Fatalf("unexpected result: response=%d info=%#v", len(response), info) - } - }) - } -} - -func TestExchangeDoHThroughHTTPConnectProxy(t *testing.T) { - server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - payload, err := io.ReadAll(request.Body) - if err != nil { - http.Error(writer, err.Error(), http.StatusBadRequest) - return - } - var query dnsmessage.Message - if err := query.Unpack(payload); err != nil { - http.Error(writer, err.Error(), http.StatusBadRequest) - return - } - response := dnsmessage.Message{ - Header: dnsmessage.Header{ID: query.Header.ID, Response: true}, - Questions: query.Questions, - } - responseWire, err := response.Pack() - if err != nil { - http.Error(writer, err.Error(), http.StatusInternalServerError) - return - } - writer.Header().Set("Content-Type", "application/dns-message") - _, _ = writer.Write(responseWire) - })) - defer server.Close() - - endpoint, err := url.Parse(server.URL) - if err != nil { - t.Fatalf("parse test endpoint: %v", err) - } - proxyURL, proxyError := startConnectProxy(t, endpoint.Host, "Basic dXNlcjpzZWNyZXQ=") - testTransport := server.Client().Transport.(*http.Transport) - client, proxyLabel, err := newDoHClientWithTLSConfig(server.URL, proxyURL, testTransport.TLSClientConfig) - if err != nil { - t.Fatalf("create proxied DoH client: %v", err) - } - if proxyLabel == "" || proxyLabel == proxyURL { - t.Fatalf("proxy label = %q, want sanitized URL", proxyLabel) - } - wire, _, _, err := BuildQuery("example.com", "A") - if err != nil { - t.Fatalf("build query: %v", err) - } - if _, _, err := exchangeDoHWithClient(t.Context(), client, server.URL, wire, "post"); err != nil { - t.Fatalf("exchange DoH through proxy: %v", err) - } - client.CloseIdleConnections() - if err := <-proxyError; err != nil { - t.Fatalf("serve CONNECT proxy: %v", err) - } -} - -func TestExchangeDoHRejectsInvalidAge(t *testing.T) { - server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - writer.Header().Set("Content-Type", "application/dns-message") - writer.Header().Set("Age", "invalid") - _, _ = writer.Write([]byte{1}) - })) - defer server.Close() - - if _, _, err := exchangeDoHWithClient(t.Context(), server.Client(), server.URL, []byte{1}, "post"); err == nil { - t.Fatal("invalid HTTP Age was accepted") - } -} - -func TestExchangeDoHRejectsHTTPError(t *testing.T) { - server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - http.Error(writer, "unavailable", http.StatusServiceUnavailable) - })) - defer server.Close() - - if _, _, err := exchangeDoHWithClient(t.Context(), server.Client(), server.URL, []byte{1}, "post"); err == nil { - t.Fatal("HTTP error was accepted") - } -} diff --git a/.agents/skills/encrypted-dns-skill/internal/edns/dot.go b/.agents/skills/encrypted-dns-skill/internal/edns/dot.go deleted file mode 100644 index cbc5a8b..0000000 --- a/.agents/skills/encrypted-dns-skill/internal/edns/dot.go +++ /dev/null @@ -1,96 +0,0 @@ -package edns - -import ( - "context" - "crypto/tls" - "fmt" - "io" - "net/url" - "time" -) - -func exchangeDoT(ctx context.Context, provider Provider, wire []byte, explicitProxy string) ([]byte, TransportInfo, error) { - return exchangeDoTWithTLSConfigAndProxy(ctx, provider, wire, &tls.Config{ - ServerName: provider.DoTName, - MinVersion: tls.VersionTLS12, - NextProtos: []string{"dot"}, - }, explicitProxy) -} - -func exchangeDoTWithTLSConfig(ctx context.Context, provider Provider, wire []byte, tlsConfig *tls.Config) ([]byte, TransportInfo, error) { - return exchangeDoTWithTLSConfigAndProxy(ctx, provider, wire, tlsConfig, "") -} - -func exchangeDoTWithTLSConfigAndProxy(ctx context.Context, provider Provider, wire []byte, tlsConfig *tls.Config, explicitProxy string) ([]byte, TransportInfo, error) { - started := time.Now() - info := TransportInfo{ - Protocol: "dot", - Encrypted: true, - Bootstrap: "system_resolver", - } - - endpoint := &url.URL{Scheme: "https", Host: provider.DoTAddr} - proxyURL, err := resolveProxy(endpoint, explicitProxy) - if err != nil { - return nil, info, fmt.Errorf("select DoT proxy: %w", err) - } - info.Proxy = proxyDisplayURL(proxyURL) - rawConnection, err := dialTCP(ctx, provider.DoTAddr, proxyURL) - if err != nil { - info.ElapsedMS = time.Since(started).Milliseconds() - return nil, info, fmt.Errorf("connect to DoT server: %w", err) - } - defer rawConnection.Close() - if deadline, ok := ctx.Deadline(); ok { - if err := rawConnection.SetDeadline(deadline); err != nil { - return nil, info, fmt.Errorf("set DoT deadline: %w", err) - } - } - - tlsConfig = tlsConfig.Clone() - tlsConfig.ServerName = provider.DoTName - tlsConnection := tls.Client(rawConnection, tlsConfig) - if err := tlsConnection.HandshakeContext(ctx); err != nil { - info.ElapsedMS = time.Since(started).Milliseconds() - return nil, info, fmt.Errorf("authenticate DoT server: %w", err) - } - state := tlsConnection.ConnectionState() - if len(state.VerifiedChains) == 0 { - return nil, info, fmt.Errorf("DoT server TLS identity was not verified") - } - info.ServerAuthenticated = true - info.TLSVersion = tlsVersionName(state.Version) - info.ALPN = state.NegotiatedProtocol - if info.ALPN != "" && info.ALPN != "dot" { - info.ElapsedMS = time.Since(started).Milliseconds() - return nil, info, fmt.Errorf("DoT server negotiated unexpected ALPN protocol %q", info.ALPN) - } - - response, err := exchangeTCPFrame(tlsConnection, wire) - info.ElapsedMS = time.Since(started).Milliseconds() - if err != nil { - return nil, info, fmt.Errorf("perform DoT exchange: %w", err) - } - return response, info, nil -} - -func exchangeTCPFrame(connection io.ReadWriter, wire []byte) ([]byte, error) { - if err := writeDNSFrame(connection, wire); err != nil { - return nil, fmt.Errorf("write framed DNS query: %w", err) - } - return readDNSFrame(connection) -} - -func writeAll(writer io.Writer, payload []byte) error { - for len(payload) > 0 { - written, err := writer.Write(payload) - if err != nil { - return err - } - if written == 0 { - return io.ErrShortWrite - } - payload = payload[written:] - } - return nil -} diff --git a/.agents/skills/encrypted-dns-skill/internal/edns/dot_test.go b/.agents/skills/encrypted-dns-skill/internal/edns/dot_test.go deleted file mode 100644 index 35c525f..0000000 --- a/.agents/skills/encrypted-dns-skill/internal/edns/dot_test.go +++ /dev/null @@ -1,314 +0,0 @@ -package edns - -import ( - "bytes" - "context" - "crypto/ed25519" - "crypto/rand" - "crypto/tls" - "crypto/x509" - "encoding/binary" - "io" - "math/big" - "net" - "strings" - "testing" - "time" - - "golang.org/x/net/dns/dnsmessage" -) - -type scriptedReadWriter struct { - read *bytes.Reader - written bytes.Buffer -} - -func (stream *scriptedReadWriter) Read(payload []byte) (int, error) { - return stream.read.Read(payload) -} - -func (stream *scriptedReadWriter) Write(payload []byte) (int, error) { - return stream.written.Write(payload) -} - -func TestExchangeTCPFrame(t *testing.T) { - responsePayload := []byte{9, 8, 7} - framedResponse := make([]byte, 2+len(responsePayload)) - binary.BigEndian.PutUint16(framedResponse[:2], uint16(len(responsePayload))) - copy(framedResponse[2:], responsePayload) - stream := &scriptedReadWriter{read: bytes.NewReader(framedResponse)} - - query := []byte{1, 2, 3, 4} - response, err := exchangeTCPFrame(stream, query) - if err != nil { - t.Fatalf("exchange TCP frame: %v", err) - } - if !bytes.Equal(response, responsePayload) { - t.Fatalf("response = %v, want %v", response, responsePayload) - } - written := stream.written.Bytes() - if int(binary.BigEndian.Uint16(written[:2])) != len(query) || !bytes.Equal(written[2:], query) { - t.Fatalf("invalid query frame: %v", written) - } -} - -func TestExchangeDoTAuthenticatesServer(t *testing.T) { - certificate, roots := newTestCertificate(t, "resolver.test") - listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{ - Certificates: []tls.Certificate{certificate}, - MinVersion: tls.VersionTLS12, - NextProtos: []string{"dot"}, - }) - if err != nil { - t.Fatalf("listen for DoT: %v", err) - } - defer listener.Close() - - serverError := make(chan error, 1) - go func() { - connection, err := listener.Accept() - if err != nil { - serverError <- err - return - } - defer connection.Close() - response, err := serveOneDoTQuery(connection) - if err == nil { - err = writeAll(connection, response) - } - serverError <- err - }() - - queryWire, query, transactionID, err := BuildQuery("example.com", "A") - if err != nil { - t.Fatalf("build query: %v", err) - } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - response, info, err := exchangeDoTWithTLSConfig(ctx, Provider{DoTAddr: listener.Addr().String(), DoTName: "resolver.test"}, queryWire, &tls.Config{ - RootCAs: roots, - MinVersion: tls.VersionTLS12, - NextProtos: []string{"dot"}, - }) - if err != nil { - t.Fatalf("exchange DoT: %v", err) - } - if err := <-serverError; err != nil { - t.Fatalf("serve DoT: %v", err) - } - if !info.ServerAuthenticated || info.ALPN != "dot" { - t.Fatalf("unexpected transport info: %#v", info) - } - if _, err := ParseResponse(response, transactionID, query); err != nil { - t.Fatalf("parse response: %v", err) - } -} - -func TestExchangeDoTThroughHTTPConnectProxy(t *testing.T) { - certificate, roots := newTestCertificate(t, "resolver.test") - listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{ - Certificates: []tls.Certificate{certificate}, - MinVersion: tls.VersionTLS12, - NextProtos: []string{"dot"}, - }) - if err != nil { - t.Fatalf("listen for DoT: %v", err) - } - defer listener.Close() - - serverError := make(chan error, 1) - go func() { - connection, err := listener.Accept() - if err != nil { - serverError <- err - return - } - defer connection.Close() - response, err := serveOneDoTQuery(connection) - if err == nil { - err = writeAll(connection, response) - } - serverError <- err - }() - - proxyURL, proxyError := startConnectProxy(t, listener.Addr().String(), "Basic dXNlcjpzZWNyZXQ=") - queryWire, _, _, err := BuildQuery("example.com", "A") - if err != nil { - t.Fatalf("build query: %v", err) - } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - _, info, err := exchangeDoTWithTLSConfigAndProxy(ctx, Provider{DoTAddr: listener.Addr().String(), DoTName: "resolver.test"}, queryWire, &tls.Config{ - RootCAs: roots, - MinVersion: tls.VersionTLS12, - NextProtos: []string{"dot"}, - }, proxyURL) - if err != nil { - t.Fatalf("exchange DoT through proxy: %v", err) - } - if info.Proxy == "" || strings.Contains(info.Proxy, "secret") || strings.Contains(info.Proxy, "user") { - t.Fatalf("proxy metadata was missing or exposed credentials: %#v", info) - } - if err := <-serverError; err != nil { - t.Fatalf("serve DoT: %v", err) - } - if err := <-proxyError; err != nil { - t.Fatalf("serve CONNECT proxy: %v", err) - } -} - -func TestExchangeDoTAllowsMissingALPN(t *testing.T) { - certificate, roots := newTestCertificate(t, "resolver.test") - listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{ - Certificates: []tls.Certificate{certificate}, - MinVersion: tls.VersionTLS12, - }) - if err != nil { - t.Fatalf("listen for DoT: %v", err) - } - defer listener.Close() - - serverError := make(chan error, 1) - go func() { - connection, err := listener.Accept() - if err != nil { - serverError <- err - return - } - defer connection.Close() - response, err := serveOneDoTQuery(connection) - if err == nil { - err = writeAll(connection, response) - } - serverError <- err - }() - - queryWire, _, _, err := BuildQuery("example.com", "A") - if err != nil { - t.Fatalf("build query: %v", err) - } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - response, info, err := exchangeDoTWithTLSConfig(ctx, Provider{DoTAddr: listener.Addr().String(), DoTName: "resolver.test"}, queryWire, &tls.Config{ - RootCAs: roots, - MinVersion: tls.VersionTLS12, - NextProtos: []string{"dot"}, - }) - if err != nil { - t.Fatalf("exchange DoT without ALPN: %v", err) - } - if err := <-serverError; err != nil { - t.Fatalf("serve DoT: %v", err) - } - if !info.ServerAuthenticated || info.ALPN != "" { - t.Fatalf("unexpected transport info: %#v", info) - } - if _, err := ParseResponse(response, binary.BigEndian.Uint16(queryWire[:2]), QueryInfo{Name: "example.com", Type: "A"}); err != nil { - t.Fatalf("parse response without ALPN: %v", err) - } -} - -func TestExchangeDoTRejectsUnexpectedALPN(t *testing.T) { - certificate, roots := newTestCertificate(t, "resolver.test") - listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{ - Certificates: []tls.Certificate{certificate}, - MinVersion: tls.VersionTLS12, - NextProtos: []string{"http/1.1"}, - }) - if err != nil { - t.Fatalf("listen for TLS: %v", err) - } - defer listener.Close() - - serverError := make(chan error, 1) - go func() { - connection, err := listener.Accept() - if err != nil { - serverError <- err - return - } - defer connection.Close() - serverError <- connection.(*tls.Conn).Handshake() - }() - - queryWire, _, _, err := BuildQuery("example.com", "A") - if err != nil { - t.Fatalf("build query: %v", err) - } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - _, info, err := exchangeDoTWithTLSConfig(ctx, Provider{DoTAddr: listener.Addr().String(), DoTName: "resolver.test"}, queryWire, &tls.Config{ - RootCAs: roots, - MinVersion: tls.VersionTLS12, - NextProtos: []string{"http/1.1"}, - }) - if err == nil || !strings.Contains(err.Error(), "unexpected ALPN protocol") { - t.Fatalf("exchange DoT error = %v, want unexpected ALPN error", err) - } - if err := <-serverError; err != nil { - t.Fatalf("complete TLS handshake: %v", err) - } - if !info.ServerAuthenticated || info.ALPN != "http/1.1" { - t.Fatalf("unexpected transport info: %#v", info) - } -} - -func newTestCertificate(t *testing.T, name string) (tls.Certificate, *x509.CertPool) { - t.Helper() - publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatalf("generate key: %v", err) - } - template := &x509.Certificate{ - SerialNumber: big.NewInt(1), - NotBefore: time.Now().Add(-time.Hour), - NotAfter: time.Now().Add(time.Hour), - KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - IsCA: true, - BasicConstraintsValid: true, - } - if address := net.ParseIP(name); address != nil { - template.IPAddresses = []net.IP{address} - } else { - template.DNSNames = []string{name} - } - der, err := x509.CreateCertificate(rand.Reader, template, template, publicKey, privateKey) - if err != nil { - t.Fatalf("create certificate: %v", err) - } - parsed, err := x509.ParseCertificate(der) - if err != nil { - t.Fatalf("parse certificate: %v", err) - } - roots := x509.NewCertPool() - roots.AddCert(parsed) - return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: privateKey}, roots -} - -func serveOneDoTQuery(connection net.Conn) ([]byte, error) { - var lengthBytes [2]byte - if _, err := io.ReadFull(connection, lengthBytes[:]); err != nil { - return nil, err - } - wire := make([]byte, int(binary.BigEndian.Uint16(lengthBytes[:]))) - if _, err := io.ReadFull(connection, wire); err != nil { - return nil, err - } - var query dnsmessage.Message - if err := query.Unpack(wire); err != nil { - return nil, err - } - response := dnsmessage.Message{ - Header: dnsmessage.Header{ID: query.Header.ID, Response: true, RecursionAvailable: true}, - Questions: query.Questions, - } - responseWire, err := response.Pack() - if err != nil { - return nil, err - } - framed := make([]byte, 2+len(responseWire)) - binary.BigEndian.PutUint16(framed[:2], uint16(len(responseWire))) - copy(framed[2:], responseWire) - return framed, nil -} diff --git a/.agents/skills/encrypted-dns-skill/internal/edns/model.go b/.agents/skills/encrypted-dns-skill/internal/edns/model.go deleted file mode 100644 index 901d2f3..0000000 --- a/.agents/skills/encrypted-dns-skill/internal/edns/model.go +++ /dev/null @@ -1,99 +0,0 @@ -package edns - -import "time" - -type QueryOptions struct { - Name string - RecordType string - Protocol string - Provider string - Method string - Proxy string -} - -type CompareTarget struct { - Protocol string `json:"protocol"` - Provider string `json:"provider"` - Method string `json:"method,omitempty"` -} - -type CompareOptions struct { - Name string - RecordType string - Targets []CompareTarget - AttemptTimeout time.Duration - MaxAttempts int - Proxy string -} - -type Result struct { - SchemaVersion int `json:"schema_version"` - Operation string `json:"operation"` - Completed bool `json:"completed"` - Query QueryInfo `json:"query"` - Resolver ResolverInfo `json:"resolver"` - Transport TransportInfo `json:"transport"` - DNS DNSInfo `json:"dns"` - Warnings []string `json:"warnings,omitempty"` - Error *ErrorInfo `json:"error,omitempty"` -} - -type CompareResult struct { - SchemaVersion int `json:"schema_version"` - Operation string `json:"operation"` - Completed bool `json:"completed"` - Query QueryInfo `json:"query"` - Attempts []Result `json:"attempts"` - Summary CompareSummary `json:"summary"` - Error *ErrorInfo `json:"error,omitempty"` -} - -type CompareSummary struct { - Total int `json:"total"` - Completed int `json:"completed"` - Failed int `json:"failed"` - Unsupported int `json:"unsupported"` -} - -type QueryInfo struct { - Name string `json:"name"` - Type string `json:"type"` -} - -type ResolverInfo struct { - Provider string `json:"provider"` - Endpoint string `json:"endpoint"` - Profile string `json:"profile"` - AuthenticationName string `json:"authentication_name,omitempty"` - CertificateSerial uint32 `json:"certificate_serial,omitempty"` -} - -type TransportInfo struct { - Protocol string `json:"protocol"` - Encrypted bool `json:"encrypted"` - ServerAuthenticated bool `json:"server_authenticated"` - ElapsedMS int64 `json:"elapsed_ms"` - Bootstrap string `json:"bootstrap"` - TLSVersion string `json:"tls_version,omitempty"` - ALPN string `json:"alpn,omitempty"` - HTTPVersion string `json:"http_version,omitempty"` - HTTPAgeSeconds int64 `json:"http_age_seconds,omitempty"` - QUICVersion string `json:"quic_version,omitempty"` - CryptoConstruction string `json:"crypto_construction,omitempty"` - Proxy string `json:"proxy,omitempty"` -} - -type DNSInfo struct { - RCode string `json:"rcode"` - RCodeValue int `json:"rcode_value"` - ResolverReportsDNSSECAuthenticated bool `json:"resolver_reports_dnssec_authenticated"` - ClientValidatedDNSSEC bool `json:"client_validated_dnssec"` - Answers []AnswerRecord `json:"answers"` -} - -type AnswerRecord map[string]any - -type ErrorInfo struct { - Class string `json:"class"` - Message string `json:"message"` -} diff --git a/.agents/skills/encrypted-dns-skill/internal/edns/providers.go b/.agents/skills/encrypted-dns-skill/internal/edns/providers.go deleted file mode 100644 index 65872df..0000000 --- a/.agents/skills/encrypted-dns-skill/internal/edns/providers.go +++ /dev/null @@ -1,106 +0,0 @@ -package edns - -import ( - "errors" - "fmt" - "strings" -) - -type UnsupportedError struct { - Message string -} - -func (err *UnsupportedError) Error() string { return err.Message } - -func IsUnsupported(err error) bool { - var unsupported *UnsupportedError - return errors.As(err, &unsupported) -} - -type Provider struct { - ID string - Profile string - SourceURL string - VerifiedDate string - DoHURL string - DoTAddr string - DoTName string - DoQAddr string - DoQName string - DoH3URL string - DNSCryptStamp string -} - -var providers = map[string]Provider{ - "cloudflare": { - ID: "cloudflare", - Profile: "unfiltered", - SourceURL: "https://developers.cloudflare.com/1.1.1.1/encryption/", - VerifiedDate: "2026-08-13", - DoHURL: "https://cloudflare-dns.com/dns-query", - DoTAddr: "one.one.one.one:853", - DoTName: "one.one.one.one", - DoH3URL: "https://cloudflare-dns.com/dns-query", - }, - "google": { - ID: "google", - Profile: "unfiltered", - SourceURL: "https://developers.google.com/speed/public-dns/docs/secure-transports", - VerifiedDate: "2026-08-13", - DoHURL: "https://dns.google/dns-query", - DoTAddr: "dns.google:853", - DoTName: "dns.google", - DoH3URL: "https://dns.google/dns-query", - }, - "quad9": { - ID: "quad9", - Profile: "security-filtered", - SourceURL: "https://docs.quad9.net/services/", - VerifiedDate: "2026-08-13", - DoHURL: "https://dns.quad9.net/dns-query", - DoTAddr: "dns.quad9.net:853", - DoTName: "dns.quad9.net", - }, - "adguard": { - ID: "adguard", - Profile: "ad-and-security-filtered", - SourceURL: "https://adguard-dns.io/kb/en/public-dns/overview/", - VerifiedDate: "2026-08-13", - DoHURL: "https://dns.adguard-dns.com/dns-query", - DoTAddr: "dns.adguard-dns.com:853", - DoTName: "dns.adguard-dns.com", - DoQAddr: "dns.adguard-dns.com:853", - DoQName: "dns.adguard-dns.com", - DNSCryptStamp: "sdns://AQMAAAAAAAAAETk0LjE0MC4xNC4xNDo1NDQzINErR_JS3PLCu_iZEIbq95zkSV2LFsigxDIuUso_OQhzIjIuZG5zY3J5cHQuZGVmYXVsdC5uczEuYWRndWFyZC5jb20", - }, -} - -func (provider Provider) Endpoint(protocol string) (string, error) { - var endpoint string - switch strings.ToLower(protocol) { - case "doh": - endpoint = provider.DoHURL - case "dot": - endpoint = provider.DoTAddr - case "doq": - endpoint = provider.DoQAddr - case "doh3": - endpoint = provider.DoH3URL - case "dnscrypt": - endpoint = provider.DNSCryptStamp - default: - return "", &UnsupportedError{Message: fmt.Sprintf("protocol %q is not available", protocol)} - } - if endpoint == "" { - return "", &UnsupportedError{Message: fmt.Sprintf("provider %q does not support protocol %q", provider.ID, protocol)} - } - return endpoint, nil -} - -func FindProvider(name string) (Provider, error) { - provider, ok := providers[strings.ToLower(name)] - if !ok { - return Provider{}, fmt.Errorf("unknown provider %q", name) - } - return provider, nil -} diff --git a/.agents/skills/encrypted-dns-skill/internal/edns/providers_test.go b/.agents/skills/encrypted-dns-skill/internal/edns/providers_test.go deleted file mode 100644 index 71d87c3..0000000 --- a/.agents/skills/encrypted-dns-skill/internal/edns/providers_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package edns - -import ( - "net/url" - "testing" - "time" -) - -func TestBuiltInProvidersHaveStrictEndpoints(t *testing.T) { - for _, name := range []string{"cloudflare", "google", "quad9", "adguard"} { - provider, err := FindProvider(name) - if err != nil { - t.Fatalf("find provider %s: %v", name, err) - } - if provider.DoHURL == "" || provider.DoTAddr == "" || provider.DoTName == "" { - t.Fatalf("provider %s is incomplete: %#v", name, provider) - } - source, err := url.ParseRequestURI(provider.SourceURL) - if err != nil || source.Scheme != "https" || source.Host == "" { - t.Fatalf("provider %s has invalid official source URL %q: %v", name, provider.SourceURL, err) - } - if _, err := time.Parse(time.DateOnly, provider.VerifiedDate); err != nil { - t.Fatalf("provider %s has invalid verification date %q: %v", name, provider.VerifiedDate, err) - } - } - if _, err := FindProvider("custom"); err == nil { - t.Fatal("unapproved custom provider was accepted") - } -} - -func TestProviderProtocolMatrix(t *testing.T) { - tests := []struct { - provider string - protocol string - allowed bool - }{ - {provider: "cloudflare", protocol: "doh3", allowed: true}, - {provider: "google", protocol: "doh3", allowed: true}, - {provider: "adguard", protocol: "doq", allowed: true}, - {provider: "adguard", protocol: "dnscrypt", allowed: true}, - {provider: "cloudflare", protocol: "doq", allowed: false}, - {provider: "cloudflare", protocol: "dnscrypt", allowed: false}, - {provider: "quad9", protocol: "doh3", allowed: false}, - {provider: "adguard", protocol: "doh3", allowed: false}, - } - for _, test := range tests { - t.Run(test.provider+"/"+test.protocol, func(t *testing.T) { - provider, err := FindProvider(test.provider) - if err != nil { - t.Fatalf("find provider: %v", err) - } - _, err = provider.Endpoint(test.protocol) - if test.allowed && err != nil { - t.Fatalf("supported endpoint rejected: %v", err) - } - if !test.allowed && err == nil { - t.Fatal("unsupported endpoint was inferred") - } - }) - } -} diff --git a/.agents/skills/encrypted-dns-skill/internal/edns/query.go b/.agents/skills/encrypted-dns-skill/internal/edns/query.go deleted file mode 100644 index 069f5ff..0000000 --- a/.agents/skills/encrypted-dns-skill/internal/edns/query.go +++ /dev/null @@ -1,111 +0,0 @@ -package edns - -import ( - "context" - "encoding/binary" - "fmt" -) - -type queryExchange func(context.Context, Provider, []byte, QueryOptions) ([]byte, TransportInfo, dnsCryptPeerInfo, error) - -func Query(ctx context.Context, options QueryOptions) Result { - return queryWithExchange(ctx, options, exchangeProtocol) -} - -func queryWithExchange(ctx context.Context, options QueryOptions, exchange queryExchange) Result { - wire, query, transactionID, err := BuildQuery(options.Name, options.RecordType) - result := Result{ - SchemaVersion: 1, - Operation: "query", - Query: query, - Transport: TransportInfo{ - Protocol: options.Protocol, - Encrypted: true, - Bootstrap: "system_resolver", - }, - DNS: DNSInfo{Answers: []AnswerRecord{}}, - } - if err != nil { - result.Query = QueryInfo{Name: options.Name, Type: options.RecordType} - result.Error = &ErrorInfo{Class: "input", Message: err.Error()} - return result - } - - provider, err := FindProvider(options.Provider) - if err != nil { - result.Error = &ErrorInfo{Class: "input", Message: err.Error()} - return result - } - if err := ValidateProxyURL(options.Proxy); err != nil { - result.Error = &ErrorInfo{Class: "input", Message: err.Error()} - return result - } - if options.Proxy != "" && options.Protocol != "doh" && options.Protocol != "dot" { - result.Error = &ErrorInfo{Class: "unsupported", Message: fmt.Sprintf("proxying is not available for protocol %q", options.Protocol)} - return result - } - result.Resolver = ResolverInfo{Provider: provider.ID, Profile: provider.Profile} - endpoint, err := provider.Endpoint(options.Protocol) - if err != nil { - class := "input" - if IsUnsupported(err) { - class = "unsupported" - } - result.Error = &ErrorInfo{Class: class, Message: err.Error()} - return result - } - result.Resolver.Endpoint = endpoint - if options.Protocol == "doh" || options.Protocol == "doh3" || options.Protocol == "doq" { - binary.BigEndian.PutUint16(wire[:2], 0) - transactionID = 0 - } - - response, transport, peer, err := exchange(ctx, provider, wire, options) - result.Transport = transport - if err != nil { - result.Error = &ErrorInfo{Class: "transport", Message: err.Error()} - return result - } - if options.Protocol == "dnscrypt" { - result.Resolver.Endpoint = peer.ServerAddress - result.Resolver.AuthenticationName = peer.ProviderName - result.Resolver.CertificateSerial = peer.CertificateSerial - } - - result.DNS, err = ParseResponse(response, transactionID, query) - if err != nil { - result.Error = &ErrorInfo{Class: "protocol", Message: err.Error()} - return result - } - applyHTTPAge(&result.DNS, result.Transport.HTTPAgeSeconds) - result.Completed = true - return result -} - -func exchangeProtocol(ctx context.Context, provider Provider, wire []byte, options QueryOptions) ([]byte, TransportInfo, dnsCryptPeerInfo, error) { - var response []byte - var transport TransportInfo - var peer dnsCryptPeerInfo - var err error - switch options.Protocol { - case "doh": - response, transport, err = exchangeDoH(ctx, provider, wire, options.Method, options.Proxy) - case "dot": - response, transport, err = exchangeDoT(ctx, provider, wire, options.Proxy) - case "doq": - response, transport, err = exchangeDoQ(ctx, provider, wire) - case "doh3": - response, transport, err = exchangeDoH3(ctx, provider, wire, options.Method) - case "dnscrypt": - response, transport, peer, err = exchangeDNSCrypt(ctx, provider.DNSCryptStamp, wire) - default: - err = fmt.Errorf("protocol %q is not available; run ednsdiag capabilities", options.Protocol) - } - return response, transport, peer, err -} - -func Probe(ctx context.Context, options QueryOptions) Result { - result := Query(ctx, options) - result.Operation = "probe" - return result -} 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 deleted file mode 100644 index 84501c0..0000000 --- a/.agents/skills/encrypted-dns-skill/internal/edns/test_helpers_test.go +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index 4260c9b..0000000 --- a/.agents/skills/encrypted-dns-skill/references/providers.md +++ /dev/null @@ -1,35 +0,0 @@ -# Built-in provider policy - -Provider endpoints and capabilities must be verified against the provider's -official documentation before they are added or changed. The built-in entries -below were verified on 2026-08-13. - -## Candidate providers - -| Provider | DoH endpoint | DoT endpoint / authentication name | QUIC support | DNSCrypt | Official documentation | Profile | -| --- | --- | --- | --- | --- | --- | --- | -| Cloudflare | `https://cloudflare-dns.com/dns-query` | `one.one.one.one:853` | DoH3 at the DoH endpoint | No verified built-in stamp | [DoH and HTTP/3](https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/) / [DoT](https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-tls/) | Unfiltered | -| Google | `https://dns.google/dns-query` | `dns.google:853` | DoH3 at the DoH endpoint | No verified built-in stamp | [Secure transports](https://developers.google.com/speed/public-dns/docs/secure-transports) | Unfiltered | -| Quad9 | `https://dns.quad9.net/dns-query` | `dns.quad9.net:853` | Not enabled without an official endpoint statement | No verified built-in stamp | [Quad9 services](https://docs.quad9.net/services/) | Security filtered; HTTP/2 required | -| AdGuard | `https://dns.adguard-dns.com/dns-query` | `dns.adguard-dns.com:853` | DoQ at `dns.adguard-dns.com:853` | Official stamp for `2.dnscrypt.default.ns1.adguard.com` at `94.140.14.14:5443` | [AdGuard dnsproxy example](https://github.com/AdguardTeam/dnsproxy#encrypted-upstreams) / [AdGuard public DNS](https://adguard-dns.io/kb/en/public-dns/overview/) | Ads, tracking, and security filtered | - -## Registry requirements - -Each built-in provider entry must include: - -- stable provider identifier; -- protocol and endpoint; -- authentication domain name; -- bootstrap addresses only when officially published; -- filtering/ECS profile; -- official source URL; -- last verification date. - -The runtime `Provider` registry stores the official source URL and verification -date alongside each endpoint. Update both fields whenever an endpoint or -capability is re-verified; the markdown table alone is not authoritative for -runtime metadata. - -Do not infer one protocol endpoint from another. Do not treat filtering and -non-filtering services as interchangeable. Provider comparison results must -remain separate. diff --git a/.agents/skills/encrypted-dns-skill/references/security.md b/.agents/skills/encrypted-dns-skill/references/security.md deleted file mode 100644 index 912b727..0000000 --- a/.agents/skills/encrypted-dns-skill/references/security.md +++ /dev/null @@ -1,108 +0,0 @@ -# Security and privacy requirements - -Read this file before changing transports, bootstrap behavior, endpoint -validation, fallback, or result claims. - -## Non-negotiable rules - -1. Never silently downgrade to plaintext DNS. -2. Validate certificates and authentication domain names. DoT follows the - strict privacy profile in [RFC 8310](https://www.rfc-editor.org/rfc/rfc8310.html). -3. Treat certificate, hostname, SNI, and any non-empty negotiated ALPN mismatch as hard - failures, not fallback opportunities. -4. Bound response sizes, per-attempt timeouts, total time, redirects, and the - number of attempts. -5. Do not expose an unrestricted endpoint parameter to an Agent. Built-in - providers are allowlisted; private or custom endpoints require explicit - user intent and policy approval. -6. Do not connect to addresses returned in DNS answers. -7. Do not enable AXFR, IXFR, or ANY queries. -8. Do not persist full query names or client identifiers by default. - -## Proxy policy - -DoH and DoT may use an explicit `--proxy` or the standard Go -[`ProxyFromEnvironment`](https://pkg.go.dev/net/http#ProxyFromEnvironment) -selection rules for `HTTPS_PROXY` and `NO_PROXY`. An explicit URL takes -precedence and must use `http` or `https`. DoT establishes an HTTP CONNECT -tunnel and then performs the normal resolver TLS handshake inside it; the -proxy never substitutes for resolver certificate, authentication-domain, SNI, -or ALPN validation. - -Proxy credentials may be sent as HTTP Basic authentication when embedded in -the URL, but must never appear in result JSON or diagnostic errors. Result -metadata contains only a sanitized proxy endpoint. An HTTP(S) proxy can observe -the resolver destination, connection timing, and traffic volume even though it -cannot read the resolver TLS payload. - -DoH3, DoQ, and the current DNSCrypt transport use UDP or QUIC and do not use a -TCP HTTP CONNECT proxy. Reject an explicit proxy for those protocols instead of -silently connecting directly. Proxy failures are transport failures and never -trigger plaintext DNS or an undisclosed direct connection. - -## DoT ALPN policy - -The client advertises the IANA-registered `dot` ALPN identifier. An explicit -selection other than `dot` is a hard failure before the DNS query is sent. An -empty selection is permitted and reported because RFC 7858 and RFC 8310 do not -make ALPN negotiation part of DoT server authentication; the dedicated port, -PKIX chain, SNI, and configured authentication domain still identify the -service. This deliberately restores the policy from PR #9 and supersedes the -stricter empty-ALPN rejection introduced by PR #11. - -## QUIC transport policy - -DoQ requires TLS 1.3 and an exact `doq` ALPN selection. DoH3 requires TLS 1.3, -HTTP/3, and an exact `h3` ALPN selection. Certificate, authentication-domain, -SNI, and ALPN failures abort before a DNS query is sent. The initial -implementation does not send 0-RTT data or enable session resumption because -their replay and linkability properties require a separate policy decision. - -Each DoQ query uses one client-initiated bidirectional stream, a two-octet -length prefix, DNS Message ID 0, and STREAM FIN. Truncated frames, extra -responses, non-zero response IDs, unexpected streams, and missing FIN are -protocol failures; they never trigger plaintext or cross-protocol fallback. - -Reject DNS messages with the TC bit set on every encrypted transport. In -particular, the UDP-only DNSCrypt implementation must not expose a partial -answer as a completed lookup. - -## DNSCrypt transport policy - -Accept only allowlisted DNSCrypt v2 stamps. Validate the stamp type, provider -public key, provider name, resolver certificate signature, validity interval, -and encrypted response. Report the stamp IP bootstrap path, provider -authentication name, certificate serial, and crypto construction. Certificate -or response-authentication failures are hard failures and never trigger -plaintext or cross-protocol fallback. Anonymized DNSCrypt remains unavailable. - -## Bootstrap transparency - -Connecting to a resolver hostname may require an initial DNS lookup. Report -whether the endpoint was reached using a configured bootstrap address, the -system resolver, or an already-known IP. Do not claim that a query avoided the -system resolver when bootstrap used it. - -## DNS status and fallback - -An HTTP, TLS, or QUIC exchange can succeed while DNS returns `NXDOMAIN`, -`SERVFAIL`, or `REFUSED`. Those are DNS outcomes and must not be converted into -transport errors. Cross-provider or cross-protocol fallback is permitted only -for explicitly classified transport failures and must be disclosed. - -## DNSSEC language - -The AD bit means the selected recursive resolver reports authenticated data. -It is not proof that this client validated the DNSSEC chain. Use separate -fields for resolver-reported and locally validated DNSSEC state. - -## Privacy language - -Encrypted transport protects the path between the client and the selected -resolver. The resolver can still observe the query. Provider policy, logging, -filtering, ECS behavior, and jurisdiction remain relevant. See -[RFC 8932](https://www.rfc-editor.org/rfc/rfc8932.html). - -ODoH and Anonymized DNSCrypt add relay models but do not justify claims of -absolute anonymity. Their proxy, relay, and target roles must be reported -separately. diff --git a/.agents/skills/encrypted-dns-skill/references/standards.md b/.agents/skills/encrypted-dns-skill/references/standards.md deleted file mode 100644 index 74ed7eb..0000000 --- a/.agents/skills/encrypted-dns-skill/references/standards.md +++ /dev/null @@ -1,54 +0,0 @@ -# Standards and authoritative sources - -Verified on 2026-08-13. Protocol behavior must be based on the published -standard, not on summaries or provider-specific JSON APIs. - -| Capability | Authority | Project scope | -| --- | --- | --- | -| Agent Skills package | [Agent Skills specification](https://agentskills.io/specification) | Required package format | -| OMP discovery | [OMP Skills documentation](https://github.com/can1357/oh-my-pi/blob/main/docs/skills.md) | Supported host | -| DoH | [RFC 8484](https://www.rfc-editor.org/rfc/rfc8484.html) | Implemented over HTTP/1.1 and HTTP/2 | -| DoH3 | [RFC 8484](https://www.rfc-editor.org/rfc/rfc8484.html) over [RFC 9114](https://www.rfc-editor.org/rfc/rfc9114.html) | Implemented | -| DoT | [RFC 7858](https://www.rfc-editor.org/rfc/rfc7858.html) | Implemented | -| DoT authentication profiles | [RFC 8310](https://www.rfc-editor.org/rfc/rfc8310.html) | Strict privacy only | -| DoT ALPN identifier | [IANA TLS ALPN registry](https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values.xhtml#alpn-protocol-ids) | Advertise `dot`; reject a different selection; permit and report no selection | -| DoQ | [RFC 9250](https://www.rfc-editor.org/rfc/rfc9250.html) | Implemented for single-response queries | -| ODoH | [RFC 9230](https://www.rfc-editor.org/rfc/rfc9230.html) | Research until a maintained implementation is selected | -| DNS privacy operations | [RFC 8932](https://www.rfc-editor.org/rfc/rfc8932.html) | Security and privacy guidance | -| EDNS(0) padding | [RFC 7830](https://www.rfc-editor.org/rfc/rfc7830.html) and [RFC 8467](https://www.rfc-editor.org/rfc/rfc8467.html) | Evaluate per transport | -| DNSCrypt | [DNSCrypt protocol specification](https://github.com/DNSCrypt/dnscrypt-protocol) | DNSCrypt v2 implemented over UDP; non-IETF | -| Anonymized DNSCrypt | [Anonymized DNSCrypt specification](https://github.com/DNSCrypt/dnscrypt-protocol/blob/master/ANONYMIZED-DNSCRYPT.txt) | Research | -| Go DNS wire and IDNA support | [Go x/net module](https://pkg.go.dev/golang.org/x/net) | Pinned to v0.58.0; use `dnsmessage` and `idna` | -| Go QUIC and HTTP/3 support | [quic-go documentation](https://quic-go.net/docs/) | Pinned to v0.61.0 | -| Go DNSCrypt support | [ameshkov/dnscrypt](https://github.com/ameshkov/dnscrypt) | Pinned to v2.4.0; stamp, certificate, and protocol implementation | - -## Deliberate exclusions - -- DNS-over-DTLS ([RFC 8094](https://www.rfc-editor.org/rfc/rfc8094.html)) - is experimental and is not a target transport. -- DNS zone transfer over TLS - ([RFC 9103](https://www.rfc-editor.org/rfc/rfc9103.html)) is outside the - client-to-recursive diagnostic scope. -- Recursive-to-authoritative encryption and resolver/server operation are - outside the initial scope. - -## Terminology - -DoH3 means RFC 8484 semantics carried over HTTP/3. It is not a separate DNS -message format. DNSCrypt is an encrypted DNS protocol with its own -specification; do not label it as an IETF RFC. - -For DoH, accept and send `application/dns-message`. Keep HTTP status separate -from the DNS RCODE: a valid NXDOMAIN or SERVFAIL response still uses HTTP 2xx. -For DoT, use the strict privacy profile and verify both the PKIX chain and the -configured authentication domain name. - -For DoQ, use ALPN `doq`, UDP port 853, a separate client-initiated bidirectional -stream per query, the two-octet DNS-over-TCP length field, STREAM FIN, and DNS -Message ID 0. DoH3 retains RFC 8484 message and HTTP semantics and requires -HTTP/3 with ALPN `h3`. - -For DNSCrypt v2, parse only DNSCrypt stamps, fetch the provider TXT -certificate, validate its Ed25519 signature and validity interval against the -stamp key, and authenticate encrypted responses. The stamp-supplied provider -name and IP endpoint are part of the authenticated resolver identity. diff --git a/.agents/skills/encrypted-dns-skill/schemas/result-v1.schema.json b/.agents/skills/encrypted-dns-skill/schemas/result-v1.schema.json deleted file mode 100644 index 66f1122..0000000 --- a/.agents/skills/encrypted-dns-skill/schemas/result-v1.schema.json +++ /dev/null @@ -1,267 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/windyboy/encrypted-dns-skill/schemas/result-v1.schema.json", - "title": "Encrypted DNS diagnostic result v1", - "oneOf": [ - { "$ref": "#/$defs/singleResult" }, - { "$ref": "#/$defs/compareResult" } - ], - "$defs": { - "query": { - "type": "object", - "required": ["name", "type"], - "properties": { - "name": { "type": "string" }, - "type": { "enum": ["A", "AAAA", "CNAME", "MX", "TXT", "NS", "SOA", "CAA", "SRV", "PTR", "HTTPS", "SVCB"] } - }, - "additionalProperties": false - }, - "resolver": { - "type": "object", - "required": ["provider", "endpoint", "profile"], - "properties": { - "provider": { "type": "string" }, - "endpoint": { "type": "string" }, - "profile": { "type": "string" }, - "authentication_name": { "type": "string" }, - "certificate_serial": { "type": "integer", "minimum": 0 } - }, - "additionalProperties": false - }, - "transport": { - "type": "object", - "required": ["protocol", "encrypted", "server_authenticated", "elapsed_ms", "bootstrap"], - "properties": { - "protocol": { "enum": ["doh", "dot", "doq", "doh3", "dnscrypt", "odoh", "anonymized-dnscrypt"] }, - "encrypted": { "type": "boolean" }, - "server_authenticated": { "type": "boolean" }, - "elapsed_ms": { "type": "integer", "minimum": 0 }, - "bootstrap": { "type": "string" }, - "tls_version": { "type": "string" }, - "alpn": { "type": "string" }, - "http_version": { "type": "string" }, - "http_age_seconds": { "type": "integer", "minimum": 0 }, - "quic_version": { "type": "string" }, - "crypto_construction": { "type": "string" }, - "proxy": { "type": "string" } - }, - "additionalProperties": false - }, - "answerBaseProperties": { - "name": { "type": "string" }, - "ttl": { "type": "integer", "minimum": 0 } - }, - "addressAnswer": { - "type": "object", - "required": ["name", "type", "ttl", "address"], - "properties": { - "name": { "$ref": "#/$defs/answerBaseProperties/name" }, - "type": { "enum": ["A", "AAAA"] }, - "ttl": { "$ref": "#/$defs/answerBaseProperties/ttl" }, - "address": { "type": "string" } - }, - "additionalProperties": false - }, - "targetAnswer": { - "type": "object", - "required": ["name", "type", "ttl", "target"], - "properties": { - "name": { "$ref": "#/$defs/answerBaseProperties/name" }, - "type": { "enum": ["CNAME", "PTR"] }, - "ttl": { "$ref": "#/$defs/answerBaseProperties/ttl" }, - "target": { "type": "string" } - }, - "additionalProperties": false - }, - "mxAnswer": { - "type": "object", - "required": ["name", "type", "ttl", "priority", "exchange"], - "properties": { - "name": { "$ref": "#/$defs/answerBaseProperties/name" }, - "type": { "const": "MX" }, - "ttl": { "$ref": "#/$defs/answerBaseProperties/ttl" }, - "priority": { "type": "integer", "minimum": 0, "maximum": 65535 }, - "exchange": { "type": "string" } - }, - "additionalProperties": false - }, - "txtAnswer": { - "type": "object", - "required": ["name", "type", "ttl", "strings"], - "properties": { - "name": { "$ref": "#/$defs/answerBaseProperties/name" }, - "type": { "const": "TXT" }, - "ttl": { "$ref": "#/$defs/answerBaseProperties/ttl" }, - "strings": { "type": "array", "items": { "type": "string" } } - }, - "additionalProperties": false - }, - "nsAnswer": { - "type": "object", - "required": ["name", "type", "ttl", "host"], - "properties": { - "name": { "$ref": "#/$defs/answerBaseProperties/name" }, - "type": { "const": "NS" }, - "ttl": { "$ref": "#/$defs/answerBaseProperties/ttl" }, - "host": { "type": "string" } - }, - "additionalProperties": false - }, - "soaAnswer": { - "type": "object", - "required": ["name", "type", "ttl", "primary_ns", "responsible_mailbox", "serial", "refresh", "retry", "expire", "minimum_ttl"], - "properties": { - "name": { "$ref": "#/$defs/answerBaseProperties/name" }, - "type": { "const": "SOA" }, - "ttl": { "$ref": "#/$defs/answerBaseProperties/ttl" }, - "primary_ns": { "type": "string" }, - "responsible_mailbox": { "type": "string" }, - "serial": { "type": "integer", "minimum": 0 }, - "refresh": { "type": "integer", "minimum": 0 }, - "retry": { "type": "integer", "minimum": 0 }, - "expire": { "type": "integer", "minimum": 0 }, - "minimum_ttl": { "type": "integer", "minimum": 0 } - }, - "additionalProperties": false - }, - "caaAnswer": { - "type": "object", - "required": ["name", "type", "ttl", "flags", "tag", "value"], - "properties": { - "name": { "$ref": "#/$defs/answerBaseProperties/name" }, - "type": { "const": "CAA" }, - "ttl": { "$ref": "#/$defs/answerBaseProperties/ttl" }, - "flags": { "type": "integer", "minimum": 0, "maximum": 255 }, - "tag": { "type": "string" }, - "value": { "type": "string" } - }, - "additionalProperties": false - }, - "srvAnswer": { - "type": "object", - "required": ["name", "type", "ttl", "priority", "weight", "port", "target"], - "properties": { - "name": { "$ref": "#/$defs/answerBaseProperties/name" }, - "type": { "const": "SRV" }, - "ttl": { "$ref": "#/$defs/answerBaseProperties/ttl" }, - "priority": { "type": "integer", "minimum": 0, "maximum": 65535 }, - "weight": { "type": "integer", "minimum": 0, "maximum": 65535 }, - "port": { "type": "integer", "minimum": 0, "maximum": 65535 }, - "target": { "type": "string" } - }, - "additionalProperties": false - }, - "svcbParam": { - "type": "object", - "required": ["key", "key_value", "value_base64"], - "properties": { - "key": { "type": "string" }, - "key_value": { "type": "integer", "minimum": 0, "maximum": 65535 }, - "value_base64": { "type": "string" } - }, - "additionalProperties": false - }, - "svcbAnswer": { - "type": "object", - "required": ["name", "type", "ttl", "priority", "target", "params"], - "properties": { - "name": { "$ref": "#/$defs/answerBaseProperties/name" }, - "type": { "enum": ["SVCB", "HTTPS"] }, - "ttl": { "$ref": "#/$defs/answerBaseProperties/ttl" }, - "priority": { "type": "integer", "minimum": 0, "maximum": 65535 }, - "target": { "type": "string" }, - "params": { "type": "array", "items": { "$ref": "#/$defs/svcbParam" } } - }, - "additionalProperties": false - }, - "answer": { - "oneOf": [ - { "$ref": "#/$defs/addressAnswer" }, - { "$ref": "#/$defs/targetAnswer" }, - { "$ref": "#/$defs/mxAnswer" }, - { "$ref": "#/$defs/txtAnswer" }, - { "$ref": "#/$defs/nsAnswer" }, - { "$ref": "#/$defs/soaAnswer" }, - { "$ref": "#/$defs/caaAnswer" }, - { "$ref": "#/$defs/srvAnswer" }, - { "$ref": "#/$defs/svcbAnswer" } - ] - }, - "dns": { - "type": "object", - "required": ["rcode", "rcode_value", "resolver_reports_dnssec_authenticated", "client_validated_dnssec", "answers"], - "properties": { - "rcode": { "type": "string" }, - "rcode_value": { "type": "integer", "minimum": 0 }, - "resolver_reports_dnssec_authenticated": { "type": "boolean" }, - "client_validated_dnssec": { "const": false }, - "answers": { "type": "array", "items": { "$ref": "#/$defs/answer" } } - }, - "additionalProperties": false - }, - "error": { - "type": "object", - "required": ["class", "message"], - "properties": { - "class": { "enum": ["input", "transport", "protocol", "unsupported", "internal"] }, - "message": { "type": "string" } - }, - "additionalProperties": false - }, - "singleResult": { - "type": "object", - "required": ["schema_version", "operation", "completed", "query", "resolver", "transport", "dns"], - "properties": { - "schema_version": { "const": 1 }, - "operation": { "enum": ["query", "probe"] }, - "completed": { "type": "boolean" }, - "query": { "$ref": "#/$defs/query" }, - "resolver": { "$ref": "#/$defs/resolver" }, - "transport": { "$ref": "#/$defs/transport" }, - "dns": { "$ref": "#/$defs/dns" }, - "warnings": { "type": "array", "items": { "type": "string" } }, - "error": { "$ref": "#/$defs/error" } - }, - "allOf": [ - { - "if": { "properties": { "completed": { "const": true } }, "required": ["completed"] }, - "then": { "not": { "required": ["error"] } }, - "else": { "required": ["error"] } - } - ], - "additionalProperties": false - }, - "summary": { - "type": "object", - "required": ["total", "completed", "failed", "unsupported"], - "properties": { - "total": { "type": "integer", "minimum": 0 }, - "completed": { "type": "integer", "minimum": 0 }, - "failed": { "type": "integer", "minimum": 0 }, - "unsupported": { "type": "integer", "minimum": 0 } - }, - "additionalProperties": false - }, - "compareResult": { - "type": "object", - "required": ["schema_version", "operation", "completed", "query", "attempts", "summary"], - "properties": { - "schema_version": { "const": 1 }, - "operation": { "const": "compare" }, - "completed": { "type": "boolean" }, - "query": { "$ref": "#/$defs/query" }, - "attempts": { "type": "array", "items": { "$ref": "#/$defs/singleResult" }, "maxItems": 8 }, - "summary": { "$ref": "#/$defs/summary" }, - "error": { "$ref": "#/$defs/error" } - }, - "allOf": [ - { - "if": { "properties": { "completed": { "const": true } }, "required": ["completed"] }, - "then": { "not": { "required": ["error"] } }, - "else": { "required": ["error"] } - } - ], - "additionalProperties": false - } - } -}