init
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user