Skip to content

Instantly share code, notes, and snippets.

@qdm12
Last active February 12, 2024 01:17
Show Gist options
  • Save qdm12/43b98c1964a292e68e2bce27afe2395f to your computer and use it in GitHub Desktop.
Save qdm12/43b98c1964a292e68e2bce27afe2395f to your computer and use it in GitHub Desktop.
DNS over HTTPS server resolver under 300 lines of clean Go code
package main
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"io/ioutil"
"log"
"net"
"net/http"
"net/url"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/miekg/dns"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, os.Interrupt)
defer stop()
logger := log.New(os.Stdout, "", log.Ldate|log.Ltime|log.Lshortfile)
server, err := NewServer(ctx, logger, Cloudflare())
if err != nil {
logger.Println(err)
return
}
stopped := make(chan struct{})
go server.Run(ctx, stopped)
select {
case <-ctx.Done():
case <-stopped: // server crashed
}
stop() // stop catching OS signals to exit when receiving an OS signal
<-stopped
}
type Provider struct {
serverIPv4 net.IP
serverIPv6 net.IP
serverName string
dohURL url.URL
}
func Cloudflare() Provider {
return Provider{
serverIPv4: net.IP{1, 1, 1, 1},
serverIPv6: net.IP{0x26, 0x6, 0x47, 0x0, 0x47, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x11, 0x11},
serverName: "cloudflare-dns.com",
dohURL: url.URL{
Scheme: "https",
Host: "cloudflare-dns.com",
Path: "/dns-query",
},
}
}
type Server interface {
Run(ctx context.Context, stopped chan<- struct{})
}
type server struct {
dnsServer dns.Server
logger *log.Logger
}
func NewServer(ctx context.Context, logger *log.Logger, provider Provider) (
s Server, err error) {
handler, err := newDNSHandler(ctx, logger, provider)
if err != nil {
return nil, err
}
return &server{
dnsServer: dns.Server{
Addr: ":53",
Net: "udp",
Handler: handler,
},
logger: logger,
}, nil
}
func (s *server) Run(ctx context.Context, stopped chan<- struct{}) {
defer close(stopped)
go func() { // shutdown goroutine
<-ctx.Done()
const graceTime = 100 * time.Millisecond
ctx, cancel := context.WithTimeout(context.Background(), graceTime)
defer cancel()
if err := s.dnsServer.ShutdownContext(ctx); err != nil {
s.logger.Println("DNS server shutdown error: ", err)
}
}()
s.logger.Println("DNS server listening on :53")
if err := s.dnsServer.ListenAndServe(); err != nil {
s.logger.Println("DNS server crashed: ", err)
}
s.logger.Println("DNS server stopped")
}
var ErrNoIPWorking = errors.New("both IPv4 and IPv6 do not work")
func newDNSHandler(ctx context.Context, logger *log.Logger, provider Provider) (
handler dns.Handler, err error) {
ipv4, ipv6 := ipVersionsSupported(ctx)
if !ipv4 && !ipv6 {
return nil, ErrNoIPWorking
}
serverIP := provider.serverIPv4
if ipv6 {
// use IPv6 address by default
// if both IPv4 and IPv6 are supported.
serverIP = provider.serverIPv6
}
client := newDoTClient(serverIP, provider.serverName)
const httpTimeout = 3 * time.Second
client.Timeout = httpTimeout
httpBufferPool := &sync.Pool{
New: func() interface{} {
return bytes.NewBuffer(nil)
},
}
const udpPacketMaxSize = 512
udpBufferPool := &sync.Pool{
New: func() interface{} {
return make([]byte, udpPacketMaxSize)
},
}
return &dnsHandler{
ctx: ctx,
provider: provider,
client: client,
httpBufferPool: httpBufferPool,
udpBufferPool: udpBufferPool,
logger: logger,
}, nil
}
type dnsHandler struct {
ctx context.Context
provider Provider
client *http.Client
httpBufferPool *sync.Pool
udpBufferPool *sync.Pool
logger *log.Logger
}
func (h *dnsHandler) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
buffer := h.udpBufferPool.Get().([]byte)
// no need to reset buffer as PackBuffer takes care of slicing it down
wire, err := r.PackBuffer(buffer)
if err != nil {
h.logger.Printf("cannot pack message to wire format: %s\n", err)
_ = w.WriteMsg(new(dns.Msg).SetRcode(r, dns.RcodeServerFailure))
return
}
respWire, err := h.requestHTTP(h.ctx, wire)
// It's fine to copy the slice headers as long as we keep
// the underlying array of bytes.
h.udpBufferPool.Put(buffer) //nolint:staticcheck
if err != nil {
h.logger.Printf("HTTP request failed: %s\n", err)
_ = w.WriteMsg(new(dns.Msg).SetRcode(r, dns.RcodeServerFailure))
return
}
message := new(dns.Msg)
if err := message.Unpack(respWire); err != nil {
h.logger.Printf("cannot unpack message from wireformat: %s\n", err)
_ = w.WriteMsg(new(dns.Msg).SetRcode(r, dns.RcodeServerFailure))
return
}
message.SetReply(r)
if err := w.WriteMsg(message); err != nil {
h.logger.Printf("write dns message error: %s\n", err)
}
}
var (
ErrHTTPStatus = errors.New("bad HTTP status")
)
func (h *dnsHandler) requestHTTP(ctx context.Context, wire []byte) (respWire []byte, err error) {
buffer := h.httpBufferPool.Get().(*bytes.Buffer)
buffer.Reset()
defer h.httpBufferPool.Put(buffer)
_, err = buffer.Write(wire)
if err != nil {
return nil, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, h.provider.dohURL.String(), buffer)
if err != nil {
return nil, err
}
request.Header.Set("Content-Type", "application/dns-udpwireformat")
response, err := h.client.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%w: %s", ErrHTTPStatus, response.Status)
}
respWire, err = ioutil.ReadAll(response.Body)
if err != nil {
return nil, err
}
if err := response.Body.Close(); err != nil {
return nil, err
}
return respWire, nil
}
func ipVersionsSupported(ctx context.Context) (ipv4, ipv6 bool) {
dialer := &net.Dialer{}
_, err := dialer.DialContext(ctx, "tcp4", "127.0.0.1:0")
ipv4 = err.Error() == "dial tcp4 127.0.0.1:0: connect: connection refused"
_, err = dialer.DialContext(ctx, "tcp6", "[::1]:0")
ipv6 = err.Error() == "dial tcp6 [::1]:0: connect: connection refused"
return ipv4, ipv6
}
func newDoTClient(serverIP net.IP, serverName string) *http.Client {
httpTransport := http.DefaultTransport.(*http.Transport).Clone()
dialer := &net.Dialer{
Resolver: newOpportunisticDoTResolver(serverIP, serverName),
}
httpTransport.DialContext = dialer.DialContext
return &http.Client{
Transport: httpTransport,
}
}
func newOpportunisticDoTResolver(serverIP net.IP, serverName string) *net.Resolver {
const dialerTimeout = 5 * time.Second
dialer := &net.Dialer{
Timeout: dialerTimeout,
}
plainAddr := net.JoinHostPort(serverIP.String(), "53")
tlsAddr := net.JoinHostPort(serverIP.String(), "853")
tlsConf := &tls.Config{
MinVersion: tls.VersionTLS12,
ServerName: serverName,
}
return &net.Resolver{
PreferGo: true,
StrictErrors: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
conn, err := dialer.DialContext(ctx, "tcp", tlsAddr)
if err != nil {
// fallback on plain DNS if DoT does not work
return dialer.DialContext(ctx, "udp", plainAddr)
}
return tls.Client(conn, tlsConf), nil
},
}
}
@qdm12
Copy link
Author

qdm12 commented Mar 19, 2021

DNS over HTTPS server resolver under 300 lines of clean Go code

Overview of how it works

  1. The IP versions your machine supports is found using ipVersionsSupported.
  2. An opportunistic resolver is created using newOpportunisticDoTResolver such that if first tries DNS over TLS and falls back on plain DNS, using the provider IP address for the version your machine supports. IPv6 takes priority if it is supported.
  3. An HTTP client using this opportunistic resolver is created with newDoTClient. Its only purpose is to resolve the DNS over HTTPS host.
  4. A DNS server is run with the handler created with newDNSHandler and using the miekg/dns Go library. For each DNS request coming in, an HTTPs request is done using requestHTTP, by simply forwarding the bytes in the right format to the upstream server (Cloudflare).

Why the opportunistic DoT resolver

  • If the system running the program uses another DNS than itself, it avoids leaking possibly plaintext DNS queries to resolve the DoH host, here being cloudflare-dns.com, depending on the other DNS server configured in your system.
  • If the system running the program has its DNS set to itself, it must be able to resolve the DoH host cloudflare-dns.com in order to do HTTP requests. Using DNS over TLS on the other hand uses IP addresses instead of hostnames (1.1.1.1) so it can be used without relying on a DNS server, so this one is used to resolve the DoH host.

IPv4 and IPv6 support

This implementation should support both IPv4 and/or IPv6.

  • DNS over TLS use IP addresses, so we use ipVersionsSupported to detect IP version supported by the system. According to it, the IPv4 or IPv6 IP address for the DoT provider is used for the DoT based HTTP client.
  • The DNS server listens on :53 which includes all interfaces, IPv4 and IPv6
  • HTTP requests to the DoH url will resolve to an IPv4 or IPv6 address automatically
  • The DoT and DoH clients will both return A and AAAA records properly, depending on the DNS request

Why the dependency miekg/dns

As much as I dislike dependencies, miekg/dns brings two things that are worth it:

  • easily run a DNS server, compared to running an UDP proxy
  • parse and interact with the DNS requests and responses, to extend the server with for example DNS filtering

Other references

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment