feat(ednsdiag): add DoQ/DoH3/DNSCrypt transports, proxy support, probe & compare
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -14,6 +15,20 @@ import (
|
||||
|
||||
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"`
|
||||
@@ -51,9 +66,9 @@ func run(args []string, stdout, stderr io.Writer) int {
|
||||
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: "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"},
|
||||
},
|
||||
@@ -68,7 +83,7 @@ func run(args []string, stdout, stderr io.Writer) int {
|
||||
fmt.Fprintln(stdout, version)
|
||||
return 0
|
||||
|
||||
case "query":
|
||||
case "query", "probe":
|
||||
options, timeout, err := parseQueryArgs(args[1:])
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
@@ -77,21 +92,31 @@ func run(args []string, stdout, stderr io.Writer) int {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
result := edns.Query(ctx, options)
|
||||
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
|
||||
}
|
||||
if result.Completed {
|
||||
return 0
|
||||
}
|
||||
if result.Error != nil && result.Error.Class == "input" {
|
||||
return 2
|
||||
}
|
||||
return 3
|
||||
return resultExitCode(result.Completed, result.Error)
|
||||
|
||||
case "probe", "compare":
|
||||
fmt.Fprintf(stderr, "%s is not implemented in %s; run ednsdiag capabilities\n", args[0], version)
|
||||
return 4
|
||||
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])
|
||||
@@ -129,8 +154,6 @@ func parseQueryArgs(args []string) (edns.QueryOptions, time.Duration, error) {
|
||||
options.Protocol = strings.ToLower(value)
|
||||
case "provider":
|
||||
options.Provider = strings.ToLower(value)
|
||||
case "url":
|
||||
options.EndpointURL = value
|
||||
case "method":
|
||||
options.Method = strings.ToLower(value)
|
||||
case "timeout":
|
||||
@@ -139,6 +162,8 @@ func parseQueryArgs(args []string) (edns.QueryOptions, time.Duration, error) {
|
||||
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)
|
||||
}
|
||||
@@ -153,18 +178,191 @@ func parseQueryArgs(args []string) (edns.QueryOptions, time.Duration, error) {
|
||||
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 !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 == "dot" && options.Method != "post" {
|
||||
return options, 0, fmt.Errorf("--method applies only to DoH")
|
||||
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("", " ")
|
||||
@@ -180,5 +378,9 @@ func writeUsage(writer io.Writer) {
|
||||
}
|
||||
|
||||
func writeQueryUsage(writer io.Writer) {
|
||||
fmt.Fprintln(writer, "usage: ednsdiag query <domain> [type] [--protocol doh|dot] [--provider cloudflare|google|quad9|adguard] [--url https://host/dns-query] [--method post|get] [--timeout 5s]")
|
||||
fmt.Fprintln(writer, "usage: ednsdiag <query|probe> <domain> [type] [--protocol doh|dot|doq|doh3|dnscrypt|odoh|anonymized-dnscrypt] [--provider cloudflare|google|quad9|adguard] [--method post|get] [--proxy http://host:port] [--timeout 5s]")
|
||||
}
|
||||
|
||||
func writeCompareUsage(writer io.Writer) {
|
||||
fmt.Fprintln(writer, "usage: ednsdiag compare <domain> [type] --target protocol:provider[:method] --target protocol:provider[:method] [--proxy http://host:port] [--attempt-timeout 5s] [--timeout 30s] [--max-attempts 4]")
|
||||
}
|
||||
|
||||
@@ -2,10 +2,16 @@ 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) {
|
||||
@@ -21,62 +27,259 @@ func TestCapabilities(t *testing.T) {
|
||||
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")
|
||||
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"] {
|
||||
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)
|
||||
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 TestReservedCommandIsNotImplemented(t *testing.T) {
|
||||
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{"probe"}, &stdout, &stderr)
|
||||
if code != 4 {
|
||||
t.Fatalf("run probe returned %d, want 4", code)
|
||||
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 !strings.Contains(stderr.String(), "not implemented") {
|
||||
t.Fatalf("stderr = %q, want not implemented message", 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 TestParseQueryArgsAllowsInterspersedOptions(t *testing.T) {
|
||||
options, timeout, err := parseQueryArgs([]string{"example.com", "MX", "--protocol", "dot", "--provider=quad9", "--timeout", "3s"})
|
||||
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 query args: %v", err)
|
||||
t.Fatalf("parse compare proxy: %v", err)
|
||||
}
|
||||
if options.Name != "example.com" || options.RecordType != "MX" || options.Protocol != "dot" || options.Provider != "quad9" {
|
||||
t.Fatalf("unexpected options: %#v", options)
|
||||
if options.Proxy != "https://proxy.example:8443" {
|
||||
t.Fatalf("proxy = %q", options.Proxy)
|
||||
}
|
||||
if timeout != 3*time.Second {
|
||||
t.Fatalf("timeout = %v, want 3s", timeout)
|
||||
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
|
||||
|
||||
code := run([]string{"unknown"}, &stdout, &stderr)
|
||||
if code != 2 {
|
||||
t.Fatalf("run unknown returned %d, want 2", code)
|
||||
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") {
|
||||
t.Fatalf("stderr = %q, want unknown command message", stderr.String())
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user