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
- Variables
- func Answer(q *dnsmsg.Message, ttl uint32, rdata ...dnsmsg.RData) (*dnsmsg.Message, error)
- func Blocked(q *dnsmsg.Message, zone dnsmsg.Name, ttl uint32, reason string) *dnsmsg.Message
- func NXDomain(q *dnsmsg.Message, zone dnsmsg.Name, ttl uint32) *dnsmsg.Message
- func NoData(q *dnsmsg.Message, zone dnsmsg.Name, ttl uint32) *dnsmsg.Message
- func NotImplemented(q *dnsmsg.Message) *dnsmsg.Message
- func Refused(q *dnsmsg.Message) *dnsmsg.Message
- func ServFail(q *dnsmsg.Message) *dnsmsg.Message
- func SyntheticSOA(zone dnsmsg.Name, ttl uint32) dnsmsg.RR
- func Truncate(m *dnsmsg.Message, maxSize int) (*dnsmsg.Message, bool, error)
- func WithEDE(m *dnsmsg.Message, code dnsmsg.ExtendedErrorCode, text string) *dnsmsg.Message
- type Client
- type Handler
- type HandlerFunc
- type Middleware
- type Options
- type Resolver
- type Result
- type Source
Constants ¶
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.
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 ¶
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.
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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.
type Middleware ¶
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.
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 (*Resolver) Close ¶
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 ¶
InFlight reports how many distinct upstream queries are outstanding. It is a diagnostic: a number far below the query rate is deduplication working.
type Result ¶
type Result struct {
Source Source
// 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.