initial commit
This commit is contained in:
1 parent
5b935301e5
commit
85453852d5
105 files changed
+24846
No files matched your search
@@ -0,0 +1,848 @@
|
||||
// Package server runs the DNS listeners: plain DNS on UDP/TCP, DNS-over-TLS
|
||||
// (RFC 7858) and DNS-over-HTTPS (RFC 8484).
|
||||
//
|
||||
// All four transports funnel into one handler, so a query behaves identically
|
||||
// whichever way it arrived. The transports are deliberately thin: parse, hand
|
||||
// to the engine, write. Anything heavier belongs behind the engine.
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
"golang.org/x/net/http2"
|
||||
"golang.org/x/net/http2/h2c"
|
||||
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/engine"
|
||||
)
|
||||
|
||||
// Handler is the interface the transports use to answer a query.
|
||||
type Handler interface {
|
||||
Handle(ctx context.Context, req engine.Request) engine.Response
|
||||
Inflight() int64
|
||||
Flush()
|
||||
}
|
||||
|
||||
// identityFunc resolves a client IP to its reverse-DNS names. The app installs
|
||||
// it so the server does not depend on the engine's cache directly.
|
||||
type identityFunc func(ip string) []string
|
||||
|
||||
// tagFunc derives ACL tags from a client IP.
|
||||
type tagFunc func(ip string) []string
|
||||
|
||||
// Server owns every DNS listener.
|
||||
type Server struct {
|
||||
eng Handler
|
||||
cfg *config.Store
|
||||
log *slog.Logger
|
||||
version string
|
||||
|
||||
names atomic.Pointer[identityFunc]
|
||||
tags atomic.Pointer[tagFunc]
|
||||
|
||||
mu sync.Mutex
|
||||
started bool
|
||||
shutdown context.CancelFunc
|
||||
wait sync.WaitGroup
|
||||
listeners []listener
|
||||
// counters is keyed by "<transport>.<field>".
|
||||
counters sync.Map
|
||||
// active is the listener snapshot the API serves.
|
||||
active atomic.Pointer[[]ListenerInfo]
|
||||
}
|
||||
|
||||
// listener couples a close function with the info the API reports for it.
|
||||
type listener struct {
|
||||
close func() error
|
||||
info ListenerInfo
|
||||
}
|
||||
|
||||
// ListenerInfo describes one bound listener.
|
||||
type ListenerInfo struct {
|
||||
Transport string `json:"transport"`
|
||||
Addr string `json:"addr"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Running bool `json:"running"`
|
||||
Err string `json:"err,omitempty"`
|
||||
}
|
||||
|
||||
// Options configures a Server.
|
||||
type Options struct {
|
||||
// Engine handles queries.
|
||||
Engine Handler
|
||||
// Config is the hot-reloadable config store; the server subscribes to it.
|
||||
Config *config.Store
|
||||
// Logger is the structured logger.
|
||||
Logger *slog.Logger
|
||||
// Version is reported in the DoH server header and the admin API.
|
||||
Version string
|
||||
}
|
||||
|
||||
// New builds a Server.
|
||||
func New(opt Options) *Server {
|
||||
log := opt.Logger
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Server{
|
||||
eng: opt.Engine,
|
||||
cfg: opt.Config,
|
||||
log: log.With("component", "dns"),
|
||||
version: opt.Version,
|
||||
}
|
||||
}
|
||||
|
||||
// SetIdentity installs the client reverse-name and tag resolvers.
|
||||
func (s *Server) SetIdentity(names identityFunc, tags tagFunc) {
|
||||
s.names.Store(&names)
|
||||
s.tags.Store(&tags)
|
||||
}
|
||||
|
||||
// Start binds every enabled listener and begins serving. It returns once all
|
||||
// listeners have been bound, so a bind failure surfaces synchronously.
|
||||
func (s *Server) Start(ctx context.Context) error {
|
||||
cfg := s.cfg.Get()
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
s.mu.Lock()
|
||||
if s.started {
|
||||
s.mu.Unlock()
|
||||
cancel()
|
||||
return errors.New("server: already started")
|
||||
}
|
||||
s.started = true
|
||||
s.shutdown = cancel
|
||||
s.mu.Unlock()
|
||||
|
||||
var failures []error
|
||||
for _, addr := range cfg.DNS.UDP {
|
||||
if err := s.serveUDP(ctx, addr); err != nil {
|
||||
failures = append(failures, err)
|
||||
}
|
||||
}
|
||||
for _, addr := range cfg.DNS.TCP {
|
||||
if err := s.serveTCP(ctx, addr); err != nil {
|
||||
failures = append(failures, err)
|
||||
}
|
||||
}
|
||||
if cfg.DNS.DoT.Enabled {
|
||||
if err := s.serveDoT(ctx, cfg); err != nil {
|
||||
failures = append(failures, err)
|
||||
}
|
||||
}
|
||||
if cfg.DNS.DoH.Enabled {
|
||||
if err := s.serveDoH(ctx, cfg); err != nil {
|
||||
failures = append(failures, err)
|
||||
}
|
||||
}
|
||||
s.publish()
|
||||
if err := errors.Join(failures...); err != nil {
|
||||
s.log.Warn("some listeners failed to start", "error", err)
|
||||
}
|
||||
if s.count() == 0 && len(failures) > 0 {
|
||||
cancel()
|
||||
return fmt.Errorf("server: no listener started: %w", errors.Join(failures...))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) count() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.listeners)
|
||||
}
|
||||
|
||||
func (s *Server) add(l listener) {
|
||||
s.mu.Lock()
|
||||
s.listeners = append(s.listeners, l)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) publish() {
|
||||
s.mu.Lock()
|
||||
infos := make([]ListenerInfo, 0, len(s.listeners))
|
||||
for _, l := range s.listeners {
|
||||
infos = append(infos, l.info)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
s.active.Store(&infos)
|
||||
}
|
||||
|
||||
// Listeners returns the current listener snapshot.
|
||||
func (s *Server) Listeners() []ListenerInfo {
|
||||
if p := s.active.Load(); p != nil {
|
||||
return *p
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop shuts every listener down and waits for their goroutines.
|
||||
func (s *Server) Stop(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
if !s.started {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
s.started = false
|
||||
shutdown := s.shutdown
|
||||
ls := s.listeners
|
||||
s.listeners = nil
|
||||
s.mu.Unlock()
|
||||
|
||||
if shutdown != nil {
|
||||
shutdown()
|
||||
}
|
||||
var errs []error
|
||||
for _, l := range ls {
|
||||
if err := l.close(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
s.wait.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
errs = append(errs, ctx.Err())
|
||||
}
|
||||
s.publish()
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// serveUDP binds a UDP listener. One short-lived goroutine per packet keeps
|
||||
// lock hold times at zero; a large socket buffer absorbs bursts instead of
|
||||
// dropping them.
|
||||
func (s *Server) serveUDP(ctx context.Context, addr string) error {
|
||||
pc, err := net.ListenPacket("udp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bind udp %s: %w", addr, err)
|
||||
}
|
||||
if uc, ok := pc.(*net.UDPConn); ok {
|
||||
_ = uc.SetReadBuffer(4 << 20)
|
||||
_ = uc.SetWriteBuffer(4 << 20)
|
||||
}
|
||||
// The read deadline is what lets the loop notice cancellation promptly;
|
||||
// without it a blocked ReadFrom would outlive shutdown. It has to be reset
|
||||
// on every iteration: a deadline set once expires for good and turns the
|
||||
// loop into a spin that never reads another packet.
|
||||
const readTick = 500 * time.Millisecond
|
||||
s.add(listener{close: pc.Close, info: ListenerInfo{
|
||||
Transport: "udp", Addr: pc.LocalAddr().String(), Enabled: true, Running: true,
|
||||
}})
|
||||
s.log.Info("dns listener up", "transport", "udp", "addr", pc.LocalAddr().String())
|
||||
|
||||
s.wait.Add(1)
|
||||
go func() {
|
||||
defer s.wait.Done()
|
||||
buf := make([]byte, dns.MaxMsgSize)
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
_ = pc.SetReadDeadline(time.Now().Add(readTick))
|
||||
n, from, err := pc.ReadFrom(buf)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if isTimeout(err) {
|
||||
continue
|
||||
}
|
||||
// A closed listener during shutdown lands here.
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return
|
||||
}
|
||||
s.log.Debug("udp read", "error", err)
|
||||
continue
|
||||
}
|
||||
pkt := make([]byte, n)
|
||||
copy(pkt, buf[:n])
|
||||
addr, _ := net.ResolveUDPAddr("udp", from.String())
|
||||
go s.servePacket(ctx, pc, pkt, from, addr)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func isTimeout(err error) bool {
|
||||
var ne net.Error
|
||||
return errors.As(err, &ne) && ne.Timeout()
|
||||
}
|
||||
|
||||
// servePacket answers one UDP query. The listener is passed in so the reply
|
||||
// goes out on the very same socket the query arrived on: a fresh socket per
|
||||
// answer would burn an ephemeral port and a connect handshake per query.
|
||||
func (s *Server) servePacket(ctx context.Context, pc net.PacketConn, pkt []byte, from net.Addr, to *net.UDPAddr) {
|
||||
m := new(dns.Msg)
|
||||
if err := m.Unpack(pkt); err != nil {
|
||||
// An unparsable message cannot be replied to: there is no ID and no
|
||||
// question to echo. Dropping it is the only correct response.
|
||||
s.bump("udp", "errors")
|
||||
return
|
||||
}
|
||||
// A message with no question is still answerable (FORMERR), so it goes
|
||||
// through the engine's validation rather than being dropped here.
|
||||
// Cap the client's advertised buffer: nobody needs 64 KiB of DNS, and an
|
||||
// unbounded value is a cheap amplification vector.
|
||||
if o := m.IsEdns0(); o != nil && o.UDPSize() > 4096 {
|
||||
o.SetUDPSize(4096)
|
||||
}
|
||||
req := s.request(ctx, m, from, "udp")
|
||||
res := s.eng.Handle(ctx, req)
|
||||
s.bump("udp", "queries")
|
||||
s.classify("udp", res)
|
||||
if res.Drop || res.Msg == nil {
|
||||
s.bump("udp", "dropped")
|
||||
return
|
||||
}
|
||||
if to == nil {
|
||||
return
|
||||
}
|
||||
out := s.fitUDP(res.Msg, m, 512)
|
||||
if out == nil {
|
||||
return
|
||||
}
|
||||
if c, ok := pc.(interface{ SetWriteDeadline(time.Time) error }); ok {
|
||||
_ = c.SetWriteDeadline(time.Now().Add(2 * time.Second))
|
||||
}
|
||||
s.bump("udp", "sent")
|
||||
_, _ = pc.WriteTo(out, to)
|
||||
}
|
||||
|
||||
// fitUDP packs a reply into a UDP datagram, setting TC and returning a
|
||||
// question-only response when it does not fit (RFC 1035 §4.2.1, RFC 2181 §9).
|
||||
func (s *Server) fitUDP(res *dns.Msg, q *dns.Msg, floor int) []byte {
|
||||
limit := floor
|
||||
if o := q.IsEdns0(); o != nil {
|
||||
if n := int(o.UDPSize()); n >= 512 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
if limit > 4096 {
|
||||
limit = 4096
|
||||
}
|
||||
out, err := res.Pack()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if len(out) <= limit {
|
||||
return out
|
||||
}
|
||||
tc := new(dns.Msg)
|
||||
tc.SetReply(q)
|
||||
tc.Rcode = res.Rcode
|
||||
tc.Truncated = true
|
||||
tc.AuthenticatedData = res.AuthenticatedData
|
||||
if o := q.IsEdns0(); o != nil {
|
||||
tc.SetEdns0(uint16(limit), o.Do())
|
||||
}
|
||||
out, err = tc.Pack()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
s.bump("udp", "truncated")
|
||||
return out
|
||||
}
|
||||
|
||||
// request builds an engine.Request, filling in the client's identity.
|
||||
func (s *Server) request(ctx context.Context, m *dns.Msg, from net.Addr, transport string) engine.Request {
|
||||
ip := addrToAddr(from)
|
||||
key := ip.String()
|
||||
req := engine.Request{
|
||||
Msg: m,
|
||||
Remote: ip,
|
||||
Transport: transport,
|
||||
Start: time.Now(),
|
||||
}
|
||||
if f := s.names.Load(); f != nil {
|
||||
req.ClientNames = (*f)(key)
|
||||
}
|
||||
if f := s.tags.Load(); f != nil {
|
||||
req.ClientTags = (*f)(key)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// classify records per-transport outcome counters.
|
||||
func (s *Server) classify(transport string, res engine.Response) {
|
||||
switch res.Action {
|
||||
case "block":
|
||||
s.bump(transport, "blocked")
|
||||
case "cached":
|
||||
s.bump(transport, "cached")
|
||||
case "error", "drop":
|
||||
s.bump(transport, "errors")
|
||||
}
|
||||
}
|
||||
|
||||
// serveTCP binds a TCP listener for plain DNS.
|
||||
func (s *Server) serveTCP(ctx context.Context, addr string) error {
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bind tcp %s: %w", addr, err)
|
||||
}
|
||||
return s.serveStream(ctx, ln, "tcp", nil, 0)
|
||||
}
|
||||
|
||||
// serveStream runs the accept loop shared by TCP and DoT.
|
||||
func (s *Server) serveStream(ctx context.Context, ln net.Listener, transport string, tlsCfg *tls.Config, idleTimeout time.Duration) error {
|
||||
if tlsCfg != nil {
|
||||
ln = tls.NewListener(ln, tlsCfg)
|
||||
}
|
||||
s.add(listener{close: ln.Close, info: ListenerInfo{
|
||||
Transport: transport, Addr: ln.Addr().String(), Enabled: true, Running: true,
|
||||
}})
|
||||
s.log.Info("dns listener up", "transport", transport, "addr", ln.Addr().String())
|
||||
s.wait.Add(1)
|
||||
go func() {
|
||||
defer s.wait.Done()
|
||||
var wg sync.WaitGroup
|
||||
defer wg.Wait()
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if isTimeout(err) {
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
s.serveConn(ctx, conn, transport, idleTimeout)
|
||||
}()
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// serveConn handles one stream connection with RFC 1035 §4.2.2 length
|
||||
// prefixes, so a client may pipeline several queries.
|
||||
func (s *Server) serveConn(ctx context.Context, conn net.Conn, transport string, idleTimeout time.Duration) {
|
||||
defer func() { _ = conn.Close() }()
|
||||
if idleTimeout > 0 {
|
||||
_ = conn.SetDeadline(time.Now().Add(idleTimeout))
|
||||
}
|
||||
co := &dns.Conn{Conn: conn}
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if idleTimeout > 0 {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(idleTimeout))
|
||||
}
|
||||
m, err := co.ReadMsg()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(m.Question) == 0 {
|
||||
s.bump(transport, "errors")
|
||||
return
|
||||
}
|
||||
req := s.request(ctx, m, conn.RemoteAddr(), transport)
|
||||
req.TCP = true
|
||||
res := s.eng.Handle(ctx, req)
|
||||
s.bump(transport, "queries")
|
||||
s.classify(transport, res)
|
||||
if res.Drop || res.Msg == nil {
|
||||
s.bump(transport, "dropped")
|
||||
return
|
||||
}
|
||||
if idleTimeout > 0 {
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(idleTimeout))
|
||||
}
|
||||
if err := co.WriteMsg(res.Msg); err != nil {
|
||||
return
|
||||
}
|
||||
s.bump(transport, "sent")
|
||||
}
|
||||
}
|
||||
|
||||
// serveDoT binds the DNS-over-TLS listener.
|
||||
func (s *Server) serveDoT(ctx context.Context, cfg *config.Config) error {
|
||||
tlsCfg, err := tlsConfig(cfg.DNS.DoT.CertFile, cfg.DNS.DoT.KeyFile, cfg.DNS.DoT.ClientCA, cfg.DNS.DoT.MinVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dot: %w", err)
|
||||
}
|
||||
if tlsCfg == nil {
|
||||
return errors.New("dot: enabled but no certificate configured")
|
||||
}
|
||||
tlsCfg.NextProtos = []string{"dot"}
|
||||
timeout := cfg.Server.Timeouts.TLS.D()
|
||||
var errs []error
|
||||
for _, addr := range cfg.DNS.DoT.Listen {
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("bind dot %s: %w", addr, err))
|
||||
continue
|
||||
}
|
||||
if err := s.serveStream(ctx, ln, "dot", tlsCfg, timeout); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// serveDoH binds the DNS-over-HTTPS listener. GET (with ?dns=) and POST
|
||||
// (application/dns-message) are both supported per RFC 8484, and the handler
|
||||
// speaks h2 with an h2c fallback so it also works on a plain TCP listener.
|
||||
func (s *Server) serveDoH(ctx context.Context, cfg *config.Config) error {
|
||||
base := normaliseBase(cfg.DNS.DoH.BasePath)
|
||||
mux := http.NewServeMux()
|
||||
h := s.doHHandler(base, cfg.DNS.DoH.AllowPost)
|
||||
mux.Handle(base, h)
|
||||
// RFC 8484 §4.1 reserves /.well-known/dns-query as an alias.
|
||||
if base != "/dns-query" {
|
||||
mux.Handle("/.well-known/dns-query", h)
|
||||
}
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
})
|
||||
|
||||
h2s := &http2.Server{IdleTimeout: 90 * time.Second}
|
||||
handler := h2c.NewHandler(mux, h2s)
|
||||
|
||||
tlsCfg, tlsErr := tlsConfig(cfg.DNS.DoH.CertFile, cfg.DNS.DoH.KeyFile, "", cfg.DNS.DoH.MinVersion)
|
||||
if tlsCfg != nil {
|
||||
tlsCfg.NextProtos = []string{"h2", "http/1.1"}
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for _, addr := range cfg.DNS.DoH.Listen {
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("bind doh %s: %w", addr, err))
|
||||
continue
|
||||
}
|
||||
actual := ln.Addr().String()
|
||||
srv := &http.Server{
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
IdleTimeout: 90 * time.Second,
|
||||
// No WriteTimeout: a long-lived h2 stream should not be cut off,
|
||||
// and ReadHeaderTimeout plus IdleTimeout already bound the damage.
|
||||
}
|
||||
closeFn := ln.Close
|
||||
if tlsCfg != nil {
|
||||
srv.TLSConfig = tlsCfg
|
||||
ln = tls.NewListener(ln, tlsCfg)
|
||||
closeFn = ln.Close
|
||||
}
|
||||
s.add(listener{close: closeFn, info: ListenerInfo{
|
||||
Transport: "doh", Addr: actual, Enabled: true, Running: true,
|
||||
}})
|
||||
if tlsErr != nil {
|
||||
s.log.Warn("doh: running without TLS", "error", tlsErr)
|
||||
}
|
||||
s.log.Info("dns listener up", "transport", "doh", "addr", actual, "path", base)
|
||||
s.wait.Add(1)
|
||||
go func() {
|
||||
defer s.wait.Done()
|
||||
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
s.log.Debug("doh server stopped", "error", err)
|
||||
}
|
||||
}()
|
||||
s.wait.Add(1)
|
||||
go func() {
|
||||
defer s.wait.Done()
|
||||
<-ctx.Done()
|
||||
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(shutCtx)
|
||||
}()
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
const contentTypeDNS = "application/dns-message"
|
||||
|
||||
// maxGETResponse bounds a DoH GET response so no intermediary truncates it.
|
||||
const maxGETResponse = 65535
|
||||
|
||||
// doHHandler builds the RFC 8484 handler.
|
||||
func (s *Server) doHHandler(base string, allowPost bool) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var (
|
||||
payload []byte
|
||||
err error
|
||||
)
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// RFC 8484 §4.1 defines exactly one parameter, "dns", holding
|
||||
// unpadded base64url. An unknown parameter is a 400 rather than a
|
||||
// 404 so a client can tell its own mistake from a wrong path.
|
||||
q := r.URL.Query()
|
||||
if !q.Has("dns") {
|
||||
writeDoHError(w, http.StatusBadRequest, "missing dns parameter")
|
||||
return
|
||||
}
|
||||
if len(q) > 1 {
|
||||
for k := range q {
|
||||
if k != "dns" {
|
||||
writeDoHError(w, http.StatusBadRequest, "unsupported parameter "+k)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
payload, err = decodeBase64URL(q.Get("dns"))
|
||||
if err != nil {
|
||||
writeDoHError(w, http.StatusBadRequest, "malformed dns parameter")
|
||||
return
|
||||
}
|
||||
case http.MethodPost:
|
||||
if !allowPost {
|
||||
w.Header().Set("Allow", "GET")
|
||||
http.Error(w, "POST not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); ct != contentTypeDNS {
|
||||
writeDoHError(w, http.StatusUnsupportedMediaType, "content-type must be "+contentTypeDNS)
|
||||
return
|
||||
}
|
||||
payload, err = readCapped(r, dns.MaxMsgSize)
|
||||
if err != nil {
|
||||
writeDoHError(w, http.StatusBadRequest, "cannot read body")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
m := new(dns.Msg)
|
||||
if err := m.Unpack(payload); err != nil {
|
||||
writeDoHError(w, http.StatusBadRequest, "malformed DNS message")
|
||||
return
|
||||
}
|
||||
if len(m.Question) == 0 {
|
||||
writeDoHError(w, http.StatusBadRequest, "no question")
|
||||
return
|
||||
}
|
||||
req := s.request(r.Context(), m, httpRemoteAddr(r), "doh")
|
||||
req.TCP = true
|
||||
req.DoHPath = r.URL.Path
|
||||
res := s.eng.Handle(r.Context(), req)
|
||||
s.bump("doh", "queries")
|
||||
s.classify("doh", res)
|
||||
if res.Drop || res.Msg == nil {
|
||||
// A dropped query still needs a body: unlike UDP, this is a
|
||||
// well-formed HTTP exchange, so hanging up would read as a
|
||||
// transport error rather than a policy decision.
|
||||
res.Msg = new(dns.Msg)
|
||||
res.Msg.SetRcode(m, dns.RcodeSuccess)
|
||||
}
|
||||
wire, err := res.Msg.Pack()
|
||||
if err != nil {
|
||||
http.Error(w, "pack failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", contentTypeDNS)
|
||||
w.Header().Set("Cache-Control", "max-age=0")
|
||||
if !cfgHideIdentity(s) {
|
||||
w.Header().Set("Server", "fancy-dns/"+s.version)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(wire)
|
||||
})
|
||||
}
|
||||
|
||||
func cfgHideIdentity(s *Server) bool {
|
||||
if s.cfg == nil {
|
||||
return false
|
||||
}
|
||||
return s.cfg.Get().DNS.HideIdentity
|
||||
}
|
||||
|
||||
func writeDoHError(w http.ResponseWriter, code int, msg string) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
http.Error(w, msg, code)
|
||||
}
|
||||
|
||||
// tlsConfig loads a certificate for the encrypted listeners. It returns
|
||||
// (nil, nil) when no certificate is configured, which means "run plaintext".
|
||||
func tlsConfig(certFile, keyFile, clientCA, minVersion string) (*tls.Config, error) {
|
||||
if certFile == "" || keyFile == "" {
|
||||
return nil, nil
|
||||
}
|
||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load certificate: %w", err)
|
||||
}
|
||||
cfg := &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
MinVersion: parseTLSVersion(minVersion),
|
||||
}
|
||||
if clientCA != "" {
|
||||
pem, err := os.ReadFile(clientCA)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read client CA: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, errors.New("client CA: no certificates found")
|
||||
}
|
||||
cfg.ClientCAs = pool
|
||||
cfg.ClientAuth = tls.RequireAndVerifyClientCert
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func parseTLSVersion(s string) uint16 {
|
||||
switch s {
|
||||
case "1.3":
|
||||
return tls.VersionTLS13
|
||||
case "1.0":
|
||||
return tls.VersionTLS10
|
||||
default:
|
||||
return tls.VersionTLS12
|
||||
}
|
||||
}
|
||||
|
||||
func normaliseBase(p string) string {
|
||||
if p == "" {
|
||||
return "/dns-query"
|
||||
}
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
p = "/" + p
|
||||
}
|
||||
for len(p) > 1 && p[len(p)-1] == '/' {
|
||||
p = p[:len(p)-1]
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func readCapped(r *http.Request, max int64) ([]byte, error) {
|
||||
if r.ContentLength > max {
|
||||
return nil, fmt.Errorf("body too large: %d", r.ContentLength)
|
||||
}
|
||||
// LimitReader plus one extra byte is how you detect "too large" without
|
||||
// trusting Content-Length, which a client may lie about.
|
||||
body := io.LimitReader(r.Body, max+1)
|
||||
buf, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(buf)) > max {
|
||||
return nil, errors.New("body too large")
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
func decodeBase64URL(s string) ([]byte, error) {
|
||||
// RFC 8484 requires unpadded base64url, but every major client sends
|
||||
// padding, so accept both rather than fail a legitimate request.
|
||||
if b, err := base64.RawURLEncoding.DecodeString(s); err == nil {
|
||||
return b, nil
|
||||
}
|
||||
return base64.URLEncoding.DecodeString(s)
|
||||
}
|
||||
|
||||
// httpRemoteAddr turns an http.Request's RemoteAddr into a net.Addr so the
|
||||
// shared request builder can be used. Over plain HTTP this is the socket peer;
|
||||
// behind a reverse proxy it is the proxy, which is why the caller must trust
|
||||
// X-Forwarded-For before that matters (handled in the API layer).
|
||||
func httpRemoteAddr(r *http.Request) net.Addr {
|
||||
if r.RemoteAddr == "" {
|
||||
return &net.TCPAddr{}
|
||||
}
|
||||
ap, err := netip.ParseAddrPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return &net.TCPAddr{}
|
||||
}
|
||||
return net.UDPAddrFromAddrPort(ap)
|
||||
}
|
||||
|
||||
// addrToAddr extracts a netip.Addr from a net.Addr.
|
||||
func addrToAddr(a net.Addr) netip.Addr {
|
||||
switch v := a.(type) {
|
||||
case *net.UDPAddr:
|
||||
return v.AddrPort().Addr().Unmap()
|
||||
case *net.TCPAddr:
|
||||
return v.AddrPort().Addr().Unmap()
|
||||
default:
|
||||
ap, err := netip.ParseAddrPort(a.String())
|
||||
if err == nil {
|
||||
return ap.Addr().Unmap()
|
||||
}
|
||||
if ip, err := netip.ParseAddr(a.String()); err == nil {
|
||||
return ip.Unmap()
|
||||
}
|
||||
return netip.Addr{}
|
||||
}
|
||||
}
|
||||
|
||||
// Stats aggregates the per-transport counters plus the engine's own view.
|
||||
func (s *Server) Stats() HandlerStats {
|
||||
var out HandlerStats
|
||||
s.counters.Range(func(k, v any) bool {
|
||||
key, _ := k.(string)
|
||||
n := v.(*atomic.Uint64).Load()
|
||||
dot := strings.IndexByte(key, '.')
|
||||
if dot < 0 {
|
||||
return true
|
||||
}
|
||||
switch key[dot+1:] {
|
||||
case "queries":
|
||||
out.Queries += n
|
||||
case "blocked":
|
||||
out.Blocked += n
|
||||
case "cached":
|
||||
out.Cached += n
|
||||
case "errors":
|
||||
out.Errors += n
|
||||
case "dropped":
|
||||
out.Dropped += n
|
||||
}
|
||||
return true
|
||||
})
|
||||
if s.eng != nil {
|
||||
out.Inflight = s.eng.Inflight()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) bump(transport, field string) {
|
||||
if transport == "" {
|
||||
transport = "udp"
|
||||
}
|
||||
key := transport + "." + field
|
||||
v, _ := s.counters.LoadOrStore(key, new(atomic.Uint64))
|
||||
v.(*atomic.Uint64).Add(1)
|
||||
}
|
||||
|
||||
// flusher is implemented by the engine: it pushes buffered cache and log state
|
||||
// to disk on demand (used by the admin API's "flush" action).
|
||||
type flusher interface{ Flush() }
|
||||
|
||||
// Flush pushes buffered state to disk.
|
||||
func (s *Server) Flush() {
|
||||
if f, ok := s.eng.(flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user