Files
sing-vis/internal/dnsx/doh.go
T
2026-07-24 13:29:16 +00:00

170 lines
4.4 KiB
Go

// Package dnsx provides a DNS-over-HTTPS resolver used to resolve domains to IP
// addresses when route rules depend on the resolved IP (ip_cidr, IP rule sets)
// or when a rule's action is "resolve".
//
// It uses the DoH JSON API (https://developers.google.com/speed/public-dns/docs/doh/json,
// also implemented by Cloudflare) rather than the RFC 8484 wireformat. In a
// browser this matters: a JSON GET with `Accept: application/dns-json` is a CORS
// "simple request", so it avoids the preflight that a wireformat POST with a
// custom Content-Type would trigger, and it needs no DNS message packer.
package dnsx
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// Resolver resolves a hostname to A/AAAA records.
type Resolver interface {
// Resolve returns resolved addresses for name. strategy is one of
// "", "prefer_ipv4", "prefer_ipv6", "ipv4_only", "ipv6_only".
Resolve(ctx context.Context, name string, strategy string) (*Result, error)
Server() string
}
// Result holds resolved addresses and diagnostic info about the query.
type Result struct {
Name string `json:"name"`
IPv4 []string `json:"ipv4"`
IPv6 []string `json:"ipv6"`
Error string `json:"error,omitempty"`
}
// All returns v4+v6 addresses honoring the strategy ordering.
func (r *Result) All(strategy string) []string {
switch strategy {
case "ipv4_only":
return r.IPv4
case "ipv6_only":
return r.IPv6
case "prefer_ipv6":
return append(append([]string{}, r.IPv6...), r.IPv4...)
default: // prefer_ipv4 / unset
return append(append([]string{}, r.IPv4...), r.IPv6...)
}
}
// DoHResolver implements Resolver against a DoH JSON endpoint.
type DoHResolver struct {
server string
client *http.Client
mu sync.Mutex
cache map[string]*Result
}
// NewDoHResolver builds a resolver for the given DoH endpoint URL.
func NewDoHResolver(server string) *DoHResolver {
return &DoHResolver{
server: server,
client: &http.Client{Timeout: 10 * time.Second},
cache: map[string]*Result{},
}
}
func (d *DoHResolver) Server() string { return d.server }
// DNS record types used by the JSON API.
const (
typeA = 1
typeAAAA = 28
)
// Resolve queries A and AAAA records for name over DoH JSON, caching per resolver.
func (d *DoHResolver) Resolve(ctx context.Context, name string, strategy string) (*Result, error) {
name = strings.TrimSuffix(strings.ToLower(name), ".")
d.mu.Lock()
if r, ok := d.cache[name]; ok {
d.mu.Unlock()
return r, nil
}
d.mu.Unlock()
res := &Result{Name: name}
var firstErr error
if strategy != "ipv6_only" {
v4, err := d.query(ctx, name, typeA)
if err != nil {
firstErr = err
}
res.IPv4 = v4
}
if strategy != "ipv4_only" {
v6, err := d.query(ctx, name, typeAAAA)
if err != nil && firstErr == nil {
firstErr = err
}
res.IPv6 = v6
}
if len(res.IPv4) == 0 && len(res.IPv6) == 0 && firstErr != nil {
res.Error = firstErr.Error()
return res, firstErr
}
d.mu.Lock()
d.cache[name] = res
d.mu.Unlock()
return res, nil
}
// jsonResponse is the DoH JSON API response shape (Google/Cloudflare).
type jsonResponse struct {
Status int `json:"Status"`
Answer []struct {
Name string `json:"name"`
Type int `json:"type"`
Data string `json:"data"`
} `json:"Answer"`
Comment string `json:"Comment,omitempty"`
}
func (d *DoHResolver) query(ctx context.Context, name string, qtype int) ([]string, error) {
endpoint, err := url.Parse(d.server)
if err != nil {
return nil, fmt.Errorf("invalid DoH server %q: %w", d.server, err)
}
q := endpoint.Query()
q.Set("name", name)
q.Set("type", fmt.Sprint(qtype))
q.Set("ct", "application/dns-json")
endpoint.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, err
}
// Accept is a CORS-safelisted header, so this stays a simple request.
req.Header.Set("Accept", "application/dns-json")
resp, err := d.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("DoH status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var parsed jsonResponse
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, fmt.Errorf("parse DoH JSON: %w", err)
}
var out []string
for _, a := range parsed.Answer {
if a.Type == qtype {
out = append(out, a.Data)
}
}
return out, nil
}