resolver

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 30, 2026 License: Apache-2.0 Imports: 15 Imported by: 0

Documentation

Overview

Package resolver answers a DNS query using a cache and a set of upstreams.

It is the layer that turns the pieces below it into a resolver: it decides when a cached answer will do, when to ask upstream, what to do when the upstream cannot be reached, and how to shape the reply for the client that asked. Everything it uses arrives as an interface, so it opens no sockets and holds no files of its own.

Deduplication is the point

Two hundred devices behind one gateway wake up and ask for the same name in the same second. Without deduplication that is two hundred upstream queries for one answer, and it is not a rare shape — it is what a cache miss on a popular name looks like on any busy network, and what a cold start looks like on every network.

Resolver collapses concurrent identical questions into one upstream query and gives every caller the result. The saving is not merely bandwidth: a stampede is also how a rate limit gets hit, and hitting one turns a slow answer into no answer for everybody.

The subtle part is whose context the shared query runs under. If it inherits the first caller's, then that caller navigating away cancels a query two hundred others are still waiting on, and they all fail for a reason none of them caused. The shared query therefore runs under a context that outlives any single participant and ends only when the last one leaves — see [singleflight] for the mechanism.

Stale answers are the caller's decision, and here that caller is us

The cache can tell that an entry has expired but not whether the upstream is reachable, so it hands back an expired entry labelled cache.StatusStale and leaves the judgement to whoever has just tried the network. That is this package. The rule it applies is RFC 8767's: a stale answer is served only after an upstream attempt has actually failed, never in place of one.

What it does not do

It does not filter, block, or apply per-device policy — that is milestone 7, and it enters through Middleware rather than through changes here. It does not listen on a socket; a server hands it decoded queries. It does not recurse from the root: this is a forwarding resolver, and the distinction matters because a forwarder trusts its upstream to have done the walking and so must be correspondingly careful about what it accepts, which is why validation lives down in the transport where the bytes arrive.

Index

Constants

View Source
const DefaultBlockTTL uint32 = 60

DefaultBlockTTL is how long a synthesised policy answer may be cached.

A minute is short enough that unblocking a name takes effect while the person who asked for it is still watching, and long enough that a device retrying a blocked name every second does not cost a policy evaluation each time.

View Source
const DefaultTimeout = 5 * time.Second

DefaultTimeout bounds one resolution when the caller sets no deadline. Five seconds is longer than any healthy path through cache, upstream and failover, and shorter than the patience of every stub resolver in common use — which is what matters, because an answer the client has stopped waiting for is not an answer.

Variables

View Source
var (
	// ErrNoQuestion reports a query with an empty question section, which
	// cannot be resolved or cached and is not worth an upstream round trip.
	ErrNoQuestion = errors.New("resolver: query has no question")

	// ErrNoUpstream reports a resolver constructed without anywhere to ask.
	ErrNoUpstream = errors.New("resolver: no upstream configured")

	// ErrRefusedOpcode reports a query this resolver does not implement, such
	// as UPDATE or NOTIFY. It is distinct from a failure so that a server can
	// answer NOTIMP rather than SERVFAIL.
	ErrRefusedOpcode = errors.New("resolver: unsupported opcode")
)

Errors this package returns.

View Source
var ErrTypeMismatch = errors.New("resolver: answer type does not match the question")

ErrTypeMismatch reports RDATA whose type does not answer the question asked.

Functions

func Answer

func Answer(q *dnsmsg.Message, ttl uint32, rdata ...dnsmsg.RData) (*dnsmsg.Message, error)

Answer builds a positive reply to q carrying rdata.

It is what a custom record or a block-to-an-address rule produces. Every payload must answer the question that was asked: returning an A record for an AAAA query is a malformed answer that a client will either ignore or misinterpret, and it is an easy mistake to make from a configuration file where the address and the type are written separately.

func Blocked

func Blocked(q *dnsmsg.Message, zone dnsmsg.Name, ttl uint32, reason string) *dnsmsg.Message

Blocked answers q as a policy denial, with the Extended DNS Error that says so.

The RCODE is a real choice. NXDOMAIN is the most compatible: every client understands it and stops. It is also a lie — the name usually does exist — and a client that tries another resolver will get a different answer and may conclude this one is broken. The Extended DNS Error is what makes it honest to a client that reads one, which is why it is attached rather than optional.

func NXDomain

func NXDomain(q *dnsmsg.Message, zone dnsmsg.Name, ttl uint32) *dnsmsg.Message

NXDomain answers q with a cacheable name error.

zone names the authority the denial is attributed to; pass the queried name when nothing better is known. ttl is both the SOA's TTL and its MINIMUM, which is what RFC 2308 section 5 makes the negative caching lifetime.

func NoData

func NoData(q *dnsmsg.Message, zone dnsmsg.Name, ttl uint32) *dnsmsg.Message

NoData answers q with NOERROR and no answers: the name exists, this type does not. It is the correct denial for a name that has other record types, and using NXDOMAIN there would deny the whole name.

func NotImplemented

func NotImplemented(q *dnsmsg.Message) *dnsmsg.Message

NotImplemented answers q with NOTIMP, which is what a query this resolver does not implement — UPDATE, NOTIFY — is owed. It is not a failure and should not be reported as one.

func Refused

func Refused(q *dnsmsg.Message) *dnsmsg.Message

Refused answers q with REFUSED: this resolver will not serve this client or this name. It is the honest code for a policy decision about the ASKER, where NXDOMAIN would be a lie about the name.

func ServFail

func ServFail(q *dnsmsg.Message) *dnsmsg.Message

ServFail answers q with SERVFAIL: we could not resolve it.

It carries no Extended DNS Error by default because the useful text differs per cause; use WithEDE to add one. SERVFAIL is deliberately not cached by this project's cache, since caching a transient failure turns a moment's upstream trouble into an outage of our own making.

func SyntheticSOA

func SyntheticSOA(zone dnsmsg.Name, ttl uint32) dnsmsg.RR

SyntheticSOA builds the SOA a synthesised negative answer needs.

RFC 2308 section 3 requires an SOA in the authority section of a negative answer, and gives its MINIMUM field as the negative caching lifetime. Without one the answer has no authorised lifetime at all: every downstream cache invents its own, and this project's cache refuses to store it. A blocked name answered without an SOA is therefore re-evaluated on every single query.

The zone is the name itself rather than a guessed parent, because guessing which label is the zone cut requires knowing the delegation, and being wrong puts an authority record there for a zone that does not exist.

func Truncate

func Truncate(m *dnsmsg.Message, maxSize int) (*dnsmsg.Message, bool, error)

Truncate returns m shortened to fit maxSize octets, with the TC bit set if anything was dropped.

The size is the SERVER's decision because only the server knows the transport: the same answer needs truncating over UDP to a 512-octet client and not at all over TCP. The work is dnsmsg.Message.AppendPack's, which already sheds records in the order RFC 2181 section 9 requires and keeps the OPT record RFC 6891 section 7 requires be kept — reimplementing that here would be a second, worse copy of a hard thing.

It reports whether truncation occurred, so a caller can count it.

func WithEDE

func WithEDE(m *dnsmsg.Message, code dnsmsg.ExtendedErrorCode, text string) *dnsmsg.Message

WithEDE attaches an Extended DNS Error (RFC 8914) to m and returns it.

It is how a synthesised answer explains itself: a client that receives NXDOMAIN for a name it can reach from another resolver is owed the distinction between "no such name" and "your gateway blocked this". The option is only attached when the query carried an OPT record, because [reply] only builds one then; adding it otherwise would violate RFC 6891 section 6.1.1.

Types

type Client

type Client struct {
	// Addr is the client's address and port, as the server observed it. It is
	// the one piece of identity that cannot be forged by the query itself.
	//
	// It is [netip.AddrPort] rather than a string because everything that reads
	// it compares or classifies an address — is this client on the local link,
	// which device is at this address, is this a source that may ask us at all
	// — and every one of those from a "192.168.4.20:53214" string means parsing
	// it again on a path that runs tens of thousands of times a second, with a
	// parse failure nobody has a sensible answer for.
	Addr netip.AddrPort
	// ID is a stable identifier for the device, assigned by whatever above us
	// knows about devices. Empty when nothing does.
	ID string
	// Transport names how the query reached us: "udp", "tcp", "tls", "https".
	Transport string
}

Client identifies who is asking.

It is carried through the pipeline from the first milestone that has one so that the policy engine and the query log do not need the resolver to change shape when they arrive. The resolver itself uses only Addr, and only to decide what to log; it applies no policy of its own.

type Handler

type Handler interface {
	Resolve(ctx context.Context, q *dnsmsg.Message, c Client) (*dnsmsg.Message, Result, error)
}

Handler resolves one query.

type HandlerFunc

type HandlerFunc func(ctx context.Context, q *dnsmsg.Message, c Client) (*dnsmsg.Message, Result, error)

HandlerFunc adapts a function to Handler.

func (HandlerFunc) Resolve

Resolve implements Handler.

type Middleware

type Middleware func(Handler) Handler

Middleware wraps the resolution of one query.

This is the seam the policy engine, the query log and any third-party plugin enter through, and it exists now — before there is anything to put in it — so that milestone 7 adds a middleware rather than a special case inside this package. A middleware may answer the query itself, rewrite the question, inspect the reply, or do nothing.

Middleware runs OUTSIDE the cache and outside deduplication, which is the only placement that lets a blocking rule work: a rule that ran after the cache would let a name blocked at noon go on being answered from a cache entry stored at eleven, and one that ran inside deduplication would apply the first caller's policy to every caller sharing the query.

type Options

type Options struct {
	// Upstream is where queries go on a cache miss. Required.
	//
	// It is a [transport.Exchanger] rather than a provider group so that a
	// caller may supply either, or something of its own: the resolver's needs
	// end at "send this and give me the reply".
	Upstream transport.Exchanger `json:"-"`

	// Cache holds answers. Nil disables caching entirely, which is a supported
	// configuration for a resolver sitting in front of another one.
	Cache *cache.Cache `json:"-"`

	// Middleware wraps resolution, outermost first.
	Middleware []Middleware `json:"-"`

	// Identify assigns a device identity to a client, before the middleware
	// chain and before anything is recorded. Nil leaves [Client.ID] as the
	// server set it, which is empty.
	//
	// It is a hook rather than a middleware because the chain takes Client by
	// value: a middleware that set an ID would inform every handler downstream
	// of it and nothing upstream, including the metrics and the query log —
	// so a device table wired in as middleware would filter correctly and
	// report every query as coming from nobody.
	//
	// It runs once per query on the query path, so it must be fast and must not
	// block. A panic in it is contained: an unknown device is a far smaller
	// loss than no DNS.
	Identify func(Client) Client `json:"-"`

	// Timeout bounds one resolution when the caller's context has no earlier
	// deadline. Zero selects [DefaultTimeout].
	Timeout time.Duration `json:"timeout"`

	// StripECS removes any EDNS Client Subnet option from an outbound query.
	//
	// It defaults to ON, and that is a privacy decision taken deliberately:
	// forwarding ECS discloses the client's network to every upstream on every
	// query, which is precisely the disclosure a private resolver exists to
	// prevent. An operator who wants the geo-accuracy it buys can turn it off.
	StripECS *bool `json:"strip_ecs,omitempty"`

	Clock   clock.Clock      `json:"-"`
	Metrics metrics.Recorder `json:"-"`
	Events  *events.Bus      `json:"-"`
	Logger  *slog.Logger     `json:"-"`
}

Options configure a Resolver.

func (Options) Validate

func (o Options) Validate() error

Validate reports every problem with o rather than the first, so an operator fixing a configuration sees the whole list in one pass.

type Resolver

type Resolver struct {
	// contains filtered or unexported fields
}

Resolver answers queries from a cache and a set of upstreams.

It is safe for concurrent use and expects to be: a server hands it every query it receives, from as many goroutines as it has in flight.

func New

func New(opts Options) (*Resolver, error)

New returns a Resolver configured by opts.

func (*Resolver) Close

func (r *Resolver) Close() error

Close releases the upstream. The cache is not closed, because the resolver did not open it and something else may still be using it.

func (*Resolver) InFlight

func (r *Resolver) InFlight() int

InFlight reports how many distinct upstream queries are outstanding. It is a diagnostic: a number far below the query rate is deduplication working.

func (*Resolver) Resolve

func (r *Resolver) Resolve(ctx context.Context, q *dnsmsg.Message, c Client) (*dnsmsg.Message, Result, error)

Resolve answers one query.

The reply belongs to the caller and is safe to modify; the caller's query is never modified.

func (*Resolver) Waiting

func (r *Resolver) Waiting() int

Waiting reports how many callers are attached to those queries. InFlight and Waiting together are the whole of what deduplication is doing: one and two hundred means one upstream query is answering two hundred clients.

type Result

type Result struct {
	Source Source
	// Shared reports that this answer came from an upstream query another
	// caller had already started. It is what makes deduplication visible in a
	// log rather than only in an upstream's bandwidth bill.
	Shared bool
	// Upstream names the provider that answered, empty when nothing was asked.
	Upstream string
	// Blocked reports that a middleware answered from policy rather than from
	// DNS data, and Rule names the rule that matched. They travel here rather
	// than being inferred from Source, because SourceLocal covers every
	// locally synthesised answer — a local zone and a block look identical
	// from outside, and a query log that guessed would mislabel both.
	Blocked bool
	Rule    string
	// Duration is the whole resolution, including any wait for a shared query.
	Duration time.Duration
}

Result describes how a query was answered, alongside the answer itself.

type Source

type Source uint8

Source says where an answer came from, which is the first thing anyone asks of a resolver that returned something surprising.

const (
	SourceUpstream Source = iota
	SourceCache
	SourceStale
	SourceLocal
)

Answer sources.

func (Source) String

func (s Source) String() string

String implements fmt.Stringer.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL