initial commit

This commit is contained in:
developer committed 2026-09-27 11:20:34 +00:00
1 parent 5b935301e5
commit 85453852d5
105 files changed
+24846

No files matched your search

+848
View File
@@ -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()
}
}
+56
View File
@@ -0,0 +1,56 @@
package server
import "sync/atomic"
// HandlerStats aggregates the per-transport counters plus the engine's own
// view. It is the "what is this thing doing right now" summary the admin API
// and the health endpoint read.
type HandlerStats struct {
// Queries is every query the server accepted across all transports.
Queries uint64 `json:"queries"`
// Blocked is queries answered by a blocklist rule.
Blocked uint64 `json:"blocked"`
// Cached is queries answered from the cache.
Cached uint64 `json:"cached"`
// Errors is queries answered with SERVFAIL, dropped or malformed.
Errors uint64 `json:"errors"`
// Dropped is queries answered with silence.
Dropped uint64 `json:"dropped"`
// Inflight is the current in-flight count.
Inflight int64 `json:"inflight"`
}
// ByTransport returns a per-transport breakdown for the UI's transport tab.
func (s *Server) ByTransport() map[string]TransportStats {
out := map[string]TransportStats{}
for _, t := range []string{"udp", "tcp", "dot", "doh"} {
ts := TransportStats{Transport: t}
ts.Queries = s.load(t, "queries")
ts.Blocked = s.load(t, "blocked")
ts.Cached = s.load(t, "cached")
ts.Errors = s.load(t, "errors")
ts.Truncated = s.load(t, "truncated")
ts.Sent = s.load(t, "sent")
out[t] = ts
}
return out
}
func (s *Server) load(transport, field string) uint64 {
v, ok := s.counters.Load(transport + "." + field)
if !ok {
return 0
}
return v.(*atomic.Uint64).Load()
}
// TransportStats is a per-transport counter set.
type TransportStats struct {
Transport string `json:"transport"`
Queries uint64 `json:"queries"`
Blocked uint64 `json:"blocked"`
Cached uint64 `json:"cached"`
Errors uint64 `json:"errors"`
Truncated uint64 `json:"truncated"`
Sent uint64 `json:"sent"`
}