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

This commit is contained in:
windyboy
2026-08-13 12:52:20 +08:00
parent 95ec2350af
commit c0cf82d4af
24 changed files with 2086 additions and 0 deletions
@@ -0,0 +1,182 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/windyboy/encrypted-dns-skill/internal/edns"
)
const version = "0.1.0-dev"
type capability struct {
Protocol string `json:"protocol"`
Status string `json:"status"`
Standard string `json:"standard,omitempty"`
Note string `json:"note,omitempty"`
}
type capabilitiesResult struct {
SchemaVersion int `json:"schema_version"`
Command string `json:"command"`
Version string `json:"version"`
Capabilities []capability `json:"capabilities"`
}
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
func run(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
writeUsage(stderr)
return 2
}
switch args[0] {
case "capabilities":
if len(args) != 1 {
fmt.Fprintln(stderr, "capabilities does not accept arguments")
return 2
}
result := capabilitiesResult{
SchemaVersion: 1,
Command: "capabilities",
Version: version,
Capabilities: []capability{
{Protocol: "doh", Status: "available", Standard: "RFC 8484", Note: "RFC wire format over HTTP GET or POST"},
{Protocol: "dot", Status: "available", Standard: "RFC 7858 and RFC 8310", Note: "strict PKIX and authentication-domain validation"},
{Protocol: "doq", Status: "planned", Standard: "RFC 9250"},
{Protocol: "doh3", Status: "planned", Standard: "RFC 8484 over HTTP/3"},
{Protocol: "dnscrypt", Status: "planned", Standard: "DNSCrypt protocol specification"},
{Protocol: "odoh", Status: "research", Standard: "RFC 9230", Note: "No maintained Go dependency has been selected."},
{Protocol: "anonymized-dnscrypt", Status: "research", Standard: "Anonymized DNSCrypt specification"},
},
}
return writeJSON(stdout, stderr, result)
case "version":
if len(args) != 1 {
fmt.Fprintln(stderr, "version does not accept arguments")
return 2
}
fmt.Fprintln(stdout, version)
return 0
case "query":
options, timeout, err := parseQueryArgs(args[1:])
if err != nil {
fmt.Fprintln(stderr, err)
writeQueryUsage(stderr)
return 2
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
result := edns.Query(ctx, options)
if code := writeJSON(stdout, stderr, result); code != 0 {
return code
}
if result.Completed {
return 0
}
if result.Error != nil && result.Error.Class == "input" {
return 2
}
return 3
case "probe", "compare":
fmt.Fprintf(stderr, "%s is not implemented in %s; run ednsdiag capabilities\n", args[0], version)
return 4
default:
fmt.Fprintf(stderr, "unknown command %q\n", args[0])
writeUsage(stderr)
return 2
}
}
func parseQueryArgs(args []string) (edns.QueryOptions, time.Duration, error) {
options := edns.QueryOptions{
RecordType: "A",
Protocol: "doh",
Provider: "cloudflare",
Method: "post",
}
timeout := 5 * time.Second
positionals := make([]string, 0, 2)
for index := 0; index < len(args); index++ {
argument := args[index]
if !strings.HasPrefix(argument, "--") {
positionals = append(positionals, argument)
continue
}
key, value, found := strings.Cut(strings.TrimPrefix(argument, "--"), "=")
if !found {
index++
if index >= len(args) {
return options, 0, fmt.Errorf("--%s requires a value", key)
}
value = args[index]
}
switch key {
case "protocol":
options.Protocol = strings.ToLower(value)
case "provider":
options.Provider = strings.ToLower(value)
case "method":
options.Method = strings.ToLower(value)
case "timeout":
parsed, err := time.ParseDuration(value)
if err != nil {
return options, 0, fmt.Errorf("invalid timeout %q: %w", value, err)
}
timeout = parsed
default:
return options, 0, fmt.Errorf("unknown query option --%s", key)
}
}
if len(positionals) < 1 || len(positionals) > 2 {
return options, 0, fmt.Errorf("query requires a domain and optional record type")
}
if timeout < 250*time.Millisecond || timeout > 30*time.Second {
return options, 0, fmt.Errorf("timeout must be between 250ms and 30s")
}
options.Name = positionals[0]
if len(positionals) == 2 {
options.RecordType = strings.ToUpper(positionals[1])
}
if options.Protocol != "doh" && options.Protocol != "dot" {
return options, 0, fmt.Errorf("protocol %q is not available", options.Protocol)
}
if options.Method != "get" && options.Method != "post" {
return options, 0, fmt.Errorf("DoH method must be get or post")
}
if options.Protocol == "dot" && options.Method != "post" {
return options, 0, fmt.Errorf("--method applies only to DoH")
}
return options, timeout, nil
}
func writeJSON(stdout, stderr io.Writer, value any) int {
encoder := json.NewEncoder(stdout)
encoder.SetIndent("", " ")
if err := encoder.Encode(value); err != nil {
fmt.Fprintf(stderr, "encode JSON result: %v\n", err)
return 1
}
return 0
}
func writeUsage(writer io.Writer) {
fmt.Fprintln(writer, "usage: ednsdiag <capabilities|version|query|probe|compare>")
}
func writeQueryUsage(writer io.Writer) {
fmt.Fprintln(writer, "usage: ednsdiag query <domain> [type] [--protocol doh|dot] [--provider cloudflare|google|quad9|adguard] [--method post|get] [--timeout 5s]")
}
@@ -0,0 +1,82 @@
package main
import (
"bytes"
"encoding/json"
"strings"
"testing"
"time"
)
func TestCapabilities(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := run([]string{"capabilities"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("run capabilities returned %d; stderr=%q", code, stderr.String())
}
var result capabilitiesResult
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
t.Fatalf("decode capabilities: %v", err)
}
if result.SchemaVersion != 1 {
t.Fatalf("schema version = %d, want 1", result.SchemaVersion)
}
if result.Command != "capabilities" {
t.Fatalf("command = %q, want capabilities", result.Command)
}
if len(result.Capabilities) == 0 {
t.Fatal("capabilities list is empty")
}
available := map[string]bool{}
for _, item := range result.Capabilities {
available[item.Protocol] = item.Status == "available"
}
if !available["doh"] || !available["dot"] {
t.Fatalf("DoH and DoT must be available: %#v", available)
}
if available["doq"] || available["doh3"] || available["dnscrypt"] {
t.Fatalf("planned transports must not be available: %#v", available)
}
}
func TestReservedCommandIsNotImplemented(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := run([]string{"probe"}, &stdout, &stderr)
if code != 4 {
t.Fatalf("run probe returned %d, want 4", code)
}
if !strings.Contains(stderr.String(), "not implemented") {
t.Fatalf("stderr = %q, want not implemented message", stderr.String())
}
}
func TestParseQueryArgsAllowsInterspersedOptions(t *testing.T) {
options, timeout, err := parseQueryArgs([]string{"example.com", "MX", "--protocol", "dot", "--provider=quad9", "--timeout", "3s"})
if err != nil {
t.Fatalf("parse query args: %v", err)
}
if options.Name != "example.com" || options.RecordType != "MX" || options.Protocol != "dot" || options.Provider != "quad9" {
t.Fatalf("unexpected options: %#v", options)
}
if timeout != 3*time.Second {
t.Fatalf("timeout = %v, want 3s", timeout)
}
}
func TestUnknownCommand(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := run([]string{"unknown"}, &stdout, &stderr)
if code != 2 {
t.Fatalf("run unknown returned %d, want 2", code)
}
if !strings.Contains(stderr.String(), "unknown command") {
t.Fatalf("stderr = %q, want unknown command message", stderr.String())
}
}