// internal/client/client.go package client import ( "fmt" "io" "net" "net/url" "strconv" "strings" "time" "github.com/afonsofrancof/sdns-perf/internal/protocols/do53" "github.com/afonsofrancof/sdns-perf/internal/protocols/doh" // "github.com/afonsofrancof/sdns-perf/internal/protocols/doq" // "github.com/afonsofrancof/sdns-perf/internal/protocols/dot" "github.com/miekg/dns" ) // DNSClient defines the interface that all specific protocol clients must implement. type DNSClient interface { Query(domain string, queryType uint16) (*dns.Msg, error) Close() } // Options holds common configuration options for creating any DNS client. type Options struct { Timeout time.Duration DNSSEC bool KeyLogPath string // Path for TLS key logging } type protocolType int const ( protoUnknown protocolType = iota protoDo53 protoDoT protoDoH protoDoH3 protoDoQ ) // config holds the parsed details of an upstream server string. // This is internal to the client package. type config struct { original string protocol protocolType host string port string path string } // parseUpstream takes a user-provided upstream string and attempts to determine // the protocol, host, port, and path. (Internal helper) func parseUpstream(upstreamStr string) (config, error) { cfg := config{original: upstreamStr, protocol: protoUnknown} // Try parsing as a full URL first parsedURL, err := url.Parse(upstreamStr) if err == nil && parsedURL.Scheme != "" && parsedURL.Host != "" { cfg.host = parsedURL.Hostname() cfg.port = parsedURL.Port() cfg.path = parsedURL.Path if cfg.path == "" { cfg.path = "/" // Default path } switch strings.ToLower(parsedURL.Scheme) { case "https", "doh": cfg.protocol = protoDoH if cfg.port == "" { cfg.port = "443" } case "h3", "doh3": cfg.protocol = protoDoH3 if cfg.port == "" { cfg.port = "443" } case "tls", "dot": cfg.protocol = protoDoT if cfg.port == "" { cfg.port = "853" } case "quic", "doq": cfg.protocol = protoDoQ if cfg.port == "" { cfg.port = "853" } case "udp", "do53": cfg.protocol = protoDo53 if cfg.port == "" { cfg.port = "53" } default: return cfg, fmt.Errorf("unsupported URL scheme: %q", parsedURL.Scheme) } return cfg, nil } // If not a valid URL or no scheme, assume plain DNS (Do53 UDP) cfg.protocol = protoDo53 host, port, err := net.SplitHostPort(upstreamStr) if err == nil { cfg.host = host cfg.port = port if _, pErr := strconv.Atoi(port); pErr != nil { return cfg, fmt.Errorf("invalid port %q in upstream %q: %w", port, upstreamStr, pErr) } } else { cfg.host = upstreamStr cfg.port = "53" // Basic check for likely IPv6 without brackets and port if strings.Contains(cfg.host, ":") && !strings.Contains(cfg.host, "[") { _, resolveErr := net.ResolveUDPAddr("udp", net.JoinHostPort(cfg.host, cfg.port)) if resolveErr != nil { return cfg, fmt.Errorf("invalid upstream format; could not parse %q as host:port or resolve as host with default port 53: %w", upstreamStr, err) } } } if cfg.host == "" { return cfg, fmt.Errorf("could not extract host from upstream: %q", upstreamStr) } return cfg, nil } // New creates the appropriate DNS client based on the upstream string format. // It returns an uninitialized client (connections are lazy). func New(upstreamStr string, opts Options) (DNSClient, error) { cfg, err := parseUpstream(upstreamStr) if err != nil { return nil, fmt.Errorf("client: failed to parse upstream %q: %w", upstreamStr, err) } var client DNSClient var clientErr error switch cfg.protocol { case protoDo53: // Ensure do53.New matches this signature config := do53.Config{HostAndPort: net.JoinHostPort(cfg.host, cfg.port), DNSSEC: false} client, clientErr = do53.New(config) case protoDoH: // Ensure doh.New matches this signature config := doh.Config{Host: cfg.host, Port: cfg.port, Path: cfg.path, DNSSEC: false} client, clientErr = doh.New(config) case protoDoT: // Ensure dot.New matches this signature // client, clientErr = dot.New(cfg.hostPort(), opts.Timeout, opts.DNSSEC, opts.KeyLogPath) // if clientErr == nil && client == nil { // clientErr = fmt.Errorf("client: DoT package returned nil client without error") // } case protoDoQ: // Ensure doq.New matches this signature // client, clientErr = doq.New(cfg.hostPort(), cfg.path, opts.Timeout, opts.DNSSEC, opts.KeyLogPath) // if clientErr == nil && client == nil { // clientErr = fmt.Errorf("client: DoQ package returned nil client without error") // } case protoDoH3: // Decide on DoH3 handling (fallback or error) // Fallback example: // fmt.Fprintf(os.Stderr, "Warning: DoH3 protocol (h3://) detected for %s. Attempting connection using standard DoH (HTTPS).\n", cfg.original) // client, clientErr = doh.New(cfg.hostPort(), cfg.path, opts.Timeout, opts.DNSSEC, opts.KeyLogPath) // Error example: // clientErr = fmt.Errorf("client: DoH3 protocol (h3://) is not yet supported") default: clientErr = fmt.Errorf("client: unknown or unsupported protocol detected for upstream: %s", upstreamStr) } if clientErr != nil { return nil, fmt.Errorf("client: failed to create client for %s: %w", upstreamStr, clientErr) } if client == nil { // Should be caught by clientErr checks above, but as a safeguard return nil, fmt.Errorf("client: internal error - nil client returned for %s", upstreamStr) } return client, nil } // Helper function to close key log writer if needed (can be used by specific clients) func CloseKeyLogWriter(w io.WriteCloser) error { if w != nil { return w.Close() } return nil }