git » dnss » next » tree

[next] / internal / httpresolver / resolver.go

package httpresolver

import (
	"bytes"
	"context"
	"crypto/tls"
	"crypto/x509"
	"errors"
	"fmt"
	"io"
	"mime"
	"net"
	"net/http"
	"net/url"
	"os"
	"time"

	"blitiri.com.ar/go/dnss/internal/dnsserver"
	"blitiri.com.ar/go/dnss/internal/trace"

	"blitiri.com.ar/go/log"
	"github.com/miekg/dns"
)

// httpsResolver implements the dnsserver.Resolver interface by querying a
// server via DNS over HTTPS (DoH, RFC 8484).
type httpsResolver struct {
	Upstream *url.URL
	CAFile   string

	// net.Resolver that will contact the server at --fallback_upstream for
	// DNS resolutions.
	fallbackResolver *net.Resolver

	client *http.Client

	// Trace to keep track of client errors, for debugging.
	tr *trace.Trace
}

var errAppendingCerts = errors.New("error appending certificates")

func loadCertPool(caFile string) (*x509.CertPool, error) {
	pemData, err := os.ReadFile(caFile)
	if err != nil {
		return nil, err
	}

	pool := x509.NewCertPool()
	if !pool.AppendCertsFromPEM(pemData) {
		return nil, errAppendingCerts
	}

	return pool, nil
}

// NewDoH creates a new DoH resolver, which uses the given upstream
// URL to resolve queries.
func NewDoH(upstream *url.URL, caFile, fallback string) *httpsResolver {
	r := &httpsResolver{
		Upstream: upstream,
		CAFile:   caFile,
	}

	if fallback != "" {
		// Dial function that will always use the fallback address to contact
		// DNS.
		dialer := net.Dialer{}
		dialFallback := func(ctx context.Context, network, address string) (net.Conn, error) {
			return dialer.DialContext(ctx, network, fallback)
		}

		r.fallbackResolver = &net.Resolver{
			PreferGo: true, // Avoid the system resolver.
			Dial:     dialFallback,
		}
	}

	return r
}

func (r *httpsResolver) Init() error {
	// If CAFile is empty, we're ok with the defaults (use the system default
	// CA database).
	var tlsConfig *tls.Config
	if r.CAFile != "" {
		pool, err := loadCertPool(r.CAFile)
		if err != nil {
			return err
		}

		tlsConfig = &tls.Config{
			RootCAs: pool,
		}
	}

	r.client = newClient(tlsConfig, r.fallbackResolver)
	r.tr = trace.New("httpresolver.Client", r.Upstream.String())
	r.tr.Printf("Init complete")

	return nil
}

func newClient(tlsConfig *tls.Config, resolver *net.Resolver) *http.Client {
	// Start from the default transport, which has reasonable settings (e.g.
	// for timeouts, and taking proxy settings from the environment).
	transport := http.DefaultTransport.(*http.Transport).Clone()
	transport.TLSClientConfig = tlsConfig

	// Use our resolver to find the upstream's address.
	// Limit the dial timeout, as queries time out sooner anyway. Note dials
	// can outlive the queries that started them, so the connection may still
	// be useful for later queries.
	transport.DialContext = (&net.Dialer{
		Timeout:  10 * time.Second,
		Resolver: resolver,
	}).DialContext

	// Enable HTTP/2 health checks: if we haven't received anything on a
	// connection for 5s, send a ping, and close the connection if there is no
	// response within 3s.
	// This detects dead connections (e.g. when the network changes, or after
	// an outage), which otherwise the transport would keep using for a long
	// time.
	transport.HTTP2 = &http.HTTP2Config{
		SendPingTimeout: 5 * time.Second,
		PingTimeout:     3 * time.Second,
	}

	return &http.Client{
		// Give our HTTP requests 4 second timeouts: DNS usually doesn't wait
		// that long anyway, but this helps with slow connections.
		Timeout: 4 * time.Second,

		Transport: transport,
	}
}

// Maintain the resolver. There's nothing to do, as the HTTP transport takes
// care of the connections.
func (r *httpsResolver) Maintain(ctx context.Context) {}

func (r *httpsResolver) Query(req *dns.Msg, tr *trace.Trace) (*dns.Msg, error) {
	packed, err := req.Pack()
	if err != nil {
		return nil, fmt.Errorf("cannot pack query: %w", err)
	}

	if log.V(1) {
		tr.Printf("DoH POST %v", r.Upstream)
	}

	// TODO: Accept header.

	hr, err := r.client.Post(
		r.Upstream.String(),
		"application/dns-message",
		bytes.NewReader(packed))
	if err != nil {
		r.tr.Printf("Client error: %v", err)
		return nil, fmt.Errorf("POST failed: %w", err)
	}
	tr.Printf("%s  %s", hr.Proto, hr.Status)
	defer hr.Body.Close()

	if hr.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("Response status: %s", hr.Status)
	}

	// Read the HTTPS response, and parse the message.
	ct, _, err := mime.ParseMediaType(hr.Header.Get("Content-Type"))
	if err != nil {
		return nil, fmt.Errorf("failed to parse content type: %w", err)
	}

	if ct != "application/dns-message" {
		return nil, fmt.Errorf("unknown response content type %q", ct)
	}

	respRaw, err := io.ReadAll(io.LimitReader(hr.Body, 64*1024))
	if err != nil {
		return nil, fmt.Errorf("error reading from body: %w", err)
	}

	respDNS := &dns.Msg{}
	err = respDNS.Unpack(respRaw)
	if err != nil {
		return nil, fmt.Errorf("error unpacking response: %w", err)
	}

	return respDNS, nil
}

// Compile-time check that the implementation matches the interface.
var _ dnsserver.Resolver = &httpsResolver{}