transport

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: 20 Imported by: 0

Documentation

Overview

Package transport carries a DNS query to an upstream resolver and brings back the reply.

It knows nothing about caching, policy or which upstream to prefer; it knows how to speak DNS over a wire. Four wires are implemented — plain UDP, plain TCP, DNS over TLS (RFC 7858) and DNS over HTTPS (RFC 8484) — behind one Exchanger interface, so that everything above this package is written once.

The security boundary

This is where untrusted bytes from the network first become a message the engine will act on, which makes response validation the most important code here. A UDP response is a datagram from anyone who can guess or observe a four-tuple and a sixteen-bit ID; accepting one that merely looks plausible is how a cache gets poisoned. Every reply is matched against the query it claims to answer before it is returned — see Validate for what is checked and why each check is there.

The defences are cheap and they compose: a random ID, a random source port, and optionally DNS-0x20 case randomisation (draft-vixie-dnsext-dns0x20), which turns the attacker's problem from guessing sixteen bits into guessing sixteen bits plus one bit per letter in the name.

Truncation

A UDP reply with the TC bit set is not an answer, it is an instruction to ask again over a stream. UDP does not do that itself, because the decision belongs to whoever chose UDP in the first place and may prefer to fail, and because a transport that silently opens TCP connections is a transport whose cost model surprises people. It returns ErrTruncated and the caller retries over TCP; Auto wires that pair together for callers who just want the conventional behaviour.

Connections

UDP is connectionless and each exchange is independent. TCP, DoT and DoH all reuse connections, because a handshake per query would dominate the latency of every one — a cold TLS 1.3 handshake costs more than the query it carries. Reuse means an idle connection is held open, and RFC 7766 and RFC 7828 govern how long; see StreamOptions.IdleTimeout.

Query pipelining is supported on streams (RFC 7766 section 6.2.1.1): several queries may be outstanding on one connection and replies may arrive out of order, which is why every stream transport demultiplexes on the message ID rather than assuming request-response lockstep.

Testability

Nothing here opens a socket it was not asked to. Every transport takes a Dialer, and the tests use one backed by an in-memory pipe, so the whole package is exercised without a network — including the error paths that are otherwise reachable only by unplugging something.

Index

Constants

View Source
const (
	// DefaultTimeout bounds one exchange when the caller's context has no
	// deadline. Two seconds is longer than any healthy upstream and shorter than
	// a stub resolver's own patience, which is what makes it useful: failing
	// before the client gives up is what lets a resolver try a second upstream.
	DefaultTimeout = 2 * time.Second

	// DefaultIdleTimeout is how long an unused stream connection is held.
	// RFC 7766 section 6.2.3 suggests a few seconds for a busy server; longer
	// amortises the handshake better, and thirty seconds is comfortably inside
	// what public resolvers tolerate.
	DefaultIdleTimeout = 30 * time.Second

	// DefaultUDPSize is the EDNS payload size advertised on UDP. 1232 avoids
	// IPv6 fragmentation, which is widely dropped, and is the DNS Flag Day 2020
	// consensus value.
	DefaultUDPSize = dnsmsg.DefaultUDPSize

	// DefaultMaxConns bounds concurrent connections to one upstream.
	DefaultMaxConns = 4
)

Default sizes and timeouts. Each is a starting point an operator can override; the reasoning is in the field documentation that uses it.

View Source
const DoHDefaultMethod = http.MethodPost

DoHDefaultMethod is the HTTP method NewDoH uses when none is configured.

POST is the default rather than GET because RFC 8484 section 4.1 allows both and they differ in exactly one respect that matters here: a GET carries the query in the URL, where every HTTP intermediary can cache it, log it and correlate it. That cacheability is the reason GET exists and the reason it is not the default — see DoHOptions.Method.

View Source
const DoHMediaType = "application/dns-message"

DoHMediaType is the media type RFC 8484 section 6 registers for a DNS message carried over HTTP.

It is sent as both Content-Type and Accept on every request, and a response that arrives with anything else is refused. That refusal is not pedantry: an endpoint answering a DoH request with text/html is a captive portal or an error page, and decoding its body as a DNS message is how a login page becomes an NXDOMAIN.

Variables

View Source
var (
	// ErrTruncated reports a UDP reply with the TC bit set. It is not a failure:
	// it is the upstream saying "ask me again over TCP", and the caller is
	// expected to do exactly that. See [Auto].
	ErrTruncated = errors.New("transport: response truncated, retry over a stream")

	// ErrMismatch reports a reply that does not answer the query that was sent.
	// On a datagram transport this is the signature of a spoofing attempt as
	// much as of a broken server, so it is never retried on the same exchange —
	// the legitimate reply may still be in flight behind the forgery.
	ErrMismatch = errors.New("transport: response does not match the query")

	// ErrClosed reports use of an Exchanger after Close.
	ErrClosed = errors.New("transport: exchanger is closed")

	// ErrNoUpstream reports an Exchanger constructed without a usable address.
	ErrNoUpstream = errors.New("transport: no upstream address configured")

	// ErrResponseTooLarge reports a reply larger than the transport permits.
	ErrResponseTooLarge = errors.New("transport: response exceeds the size limit")

	// ErrProtocol reports a reply that could not be decoded, or a stream framing
	// error. It means the peer is broken or is not a DNS server.
	ErrProtocol = errors.New("transport: malformed response")
)

Errors this package returns. Test with errors.Is; they are wrapped with the upstream's address for diagnosis.

Functions

func Validate

func Validate(query, reply *dnsmsg.Message, randomized bool) error

Validate reports whether reply is a legitimate answer to query.

This is the security boundary of the whole engine. Everything downstream — the cache, the policy engine, the client — treats what comes out of a transport as fact, so a forgery accepted here is a forgery served for as long as its TTL says. On UDP the attacker's task is to land a datagram from the right address before the real one arrives; every check below is a bit they also have to get right.

The checks, and what each one closes:

  • The ID must match. Sixteen bits, and the only defence RFC 1035 specified. Alone it is not enough against an attacker who can send many guesses per legitimate reply, which is the Kaminsky result.
  • The question count and the question must match. A reply that answers a different name is not an answer to this query, whatever its ID says.
  • The name comparison is case-SENSITIVE when the query was randomised, and case-insensitive otherwise. That is the whole of DNS-0x20: the randomised spelling is a shared secret between us and the legitimate upstream, worth about one bit per letter, and comparing it case-insensitively would throw the defence away while appearing to implement it.
  • The QR bit must be set. A query is not a reply.
  • The opcode must match, so a NOTIFY cannot be passed off as an answer to a standard query.

Source address and port are checked by the transport before the bytes reach here, because only it knows the socket. Validate is exported because a caller implementing its own Exchanger needs exactly these rules, and reimplementing them from the prose above is how they get subtly wrong.

Types

type Auto

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

Auto is the conventional pairing of a datagram transport with a stream one: ask over UDP, and when the answer comes back truncated, ask again over TCP.

RFC 1035 section 4.2.1 makes this the standard behaviour of a resolver, and RFC 7766 section 5 makes supporting it mandatory rather than optional, so almost every caller wants it. It lives in its own type instead of inside UDP for two reasons, both of which are about who gets to decide.

The first is cost. A transport that silently opens TCP connections has a cost model that surprises people: an operator who provisioned for datagrams suddenly holds sockets, pays handshakes, and — if the upstream is a DoT endpoint — pays TLS, all triggered by a bit set on the far side of the world. Making the fallback a visible object means nobody is billed for a connection they did not ask for.

The second is choice. Truncation is not always something to retry: a caller that already knows the answer is large may prefer to fail fast and try another upstream, and a caller doing its own DNSSEC work may want the truncated header rather than a second query. UDP therefore reports ErrTruncated and stops, and whoever chose UDP decides what that means.

An Auto is safe for concurrent use if the two transports it wraps are, which every implementation in this package is.

func (*Auto) Close

func (a *Auto) Close() error

Close closes both transports.

Both are closed even if the first fails, because a half-closed pair would go on holding the stream connections that Close exists to release, and the errors are joined so that neither is hidden by the other.

func (*Auto) Exchange

func (a *Auto) Exchange(ctx context.Context, q *dnsmsg.Message) (*dnsmsg.Message, error)

Exchange sends q over the datagram transport and, if the reply is truncated, sends it again over the stream transport.

The retry carries the caller's original message, never the one the datagram leg put on the wire. That matters: [prepare] gives each attempt a fresh random ID and its own DNS-0x20 spelling, and reusing the first attempt's would waste the second's entropy — worse, a query whose ID an attacker has already seen on the wire is a query he no longer has to guess sixteen bits of. Passing the original also lets the stream transport apply its own framing and size rules, which are not the datagram leg's.

The truncated datagram reply is not returned once the retry is made, even if the retry fails. It is a header and a question with the answer cut off; a caller that wants it can call UDP.Exchange directly, where it comes back alongside ErrTruncated.

Any other error from the datagram leg is returned as it stands. A timeout, a refusal or a forged-reply flood are not answered by opening a TCP connection to the same host, and retrying them here would double the work every failing upstream costs.

type Dialer

type Dialer interface {
	// DialContext opens a connection. network is "udp" or "tcp" as in the net
	// package.
	DialContext(ctx context.Context, network, address string) (net.Conn, error)
}

Dialer opens the connections a transport needs.

This is the seam that keeps the package testable and keeps the engine from owning an opinion about how sockets come into being. The zero net.Dialer satisfies it, a test satisfies it with an in-memory pipe, and an embedder that routes through a proxy or a specific interface satisfies it with whatever it likes.

type DialerFunc

type DialerFunc func(ctx context.Context, network, address string) (net.Conn, error)

DialerFunc adapts a function to Dialer.

func (DialerFunc) DialContext

func (f DialerFunc) DialContext(ctx context.Context, network, address string) (net.Conn, error)

DialContext implements Dialer.

type DoH

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

DoH exchanges queries over HTTPS as defined by RFC 8484.

It is the transport to reach for when the network between here and the resolver is the threat: a DoH exchange is indistinguishable from any other HTTPS request on the wire, which is a property DoT (RFC 7858) deliberately gives up by using a port of its own. What it costs is HTTP — a request line, headers and a status code around every query — and a dependence on the endpoint's HTTP behaviour, which is why every response is checked for status and media type before a single octet of it is decoded.

A DoH is safe for concurrent use and is meant to be shared: it holds a connection pool, and one per query would spend more time in handshakes than in DNS.

func NewDoH

func NewDoH(opts DoHOptions) (*DoH, error)

NewDoH returns a DoH transport for the endpoint in opts.

It opens nothing. The first connection is made by the first DoH.Exchange, through opts.Dialer, which is what lets a program hold a configured upstream it may never use and what lets this package be tested without a network.

func (*DoH) Close

func (d *DoH) Close() error

Close releases the idle connections this transport is holding.

It is idempotent, and a later Exchange returns ErrClosed rather than reopening the pool. An exchange already in flight is not interrupted: its connection is not idle, so it runs to completion or to its own deadline, which is what Exchanger promises. A client supplied through DoHOptions.HTTPClient is left alone entirely — it may be serving something other than DNS, and shutting down a caller's connection pool because one of its users went away would be a surprising thing for a DNS library to do.

func (*DoH) Exchange

func (d *DoH) Exchange(ctx context.Context, q *dnsmsg.Message) (*dnsmsg.Message, error)

Exchange sends q over HTTPS and returns the validated reply.

The message ID is zero on the wire. RFC 8484 section 4.1 asks for that precisely so that two identical queries produce two byte-identical requests and an HTTP cache can serve the second — a random ID would make every request unique and every cache useless. The caller's ID is restored on the reply before it is returned, so nothing above this package has to know.

That has a consequence worth being explicit about: the ID check inside Validate compares zero against zero and defends nothing here. It is not weakened for that — the query handed to Validate carries the ID that was actually sent — but the security of a DoH exchange rests entirely elsewhere: on TLS, which authenticates the endpoint and conceals the query, and on the HTTP request/response pairing, which is what tells us this body answers this query. An off-path attacker cannot forge either, which is why the sixteen bits that matter so much on UDP are free to be spent on cacheability here.

type DoHOptions

type DoHOptions struct {
	Options

	// Method is "POST" or "GET". Empty selects [DoHDefaultMethod].
	//
	// GET is worth having: RFC 8484 section 4.1 puts the query in a "dns"
	// parameter so the response is cacheable by ordinary HTTP machinery, which
	// is how a shared cache in front of a resolver serves a popular name
	// without a round trip. It is not the default because the same property is
	// a privacy leak — the query name ends up in the URL, and URLs are what
	// proxies log — and because a URL is bounded in ways a body is not.
	Method string `json:"method"`

	// HTTPClient replaces the client [NewDoH] would build.
	//
	// It exists for the embedder who already has a tuned client, and for tests,
	// which supply a [http.RoundTripper] that answers in memory and so exercise
	// this transport without a socket. When it is set, every knob below that
	// describes a connection — Dialer, TLS, MaxConns, IdleTimeout — belongs to
	// the client instead, and [DoHOptions.Validate] refuses the combination
	// rather than letting a setting be silently ignored.
	HTTPClient *http.Client `json:"-"`

	// MaxConns bounds concurrent connections to this endpoint. Zero selects
	// [DefaultMaxConns].
	//
	// It is a small number on purpose. Over HTTP/2 a single connection
	// multiplexes every outstanding query, so the pool exists for failover and
	// for the HTTP/1.1 fallback rather than for throughput, and a large pool
	// against a DoH endpoint mostly buys extra TLS handshakes.
	MaxConns int `json:"max_conns"`

	// IdleTimeout is how long an unused connection is kept. Zero selects
	// [DefaultIdleTimeout]; negative disables connection reuse entirely.
	//
	// Reuse matters more here than anywhere else in this package: a cold DoH
	// exchange is a TCP handshake, a TLS handshake and an HTTP/2 preface before
	// a single DNS octet moves, which is several round trips spent on a query
	// that would have taken one over UDP.
	IdleTimeout time.Duration `json:"idle_timeout"`

	// TLS configures the transport-level TLS. Nil takes the platform defaults
	// with a TLS 1.2 floor.
	//
	// The certificate is verified against the URL's host name, which is what
	// makes DoH's confidentiality claim real; an embedder wanting the pinning
	// that [StreamOptions.PinnedSPKI] provides for DoT installs a
	// VerifyPeerCertificate here. The value is cloned, because the standard
	// library's HTTP/2 setup writes NextProtos into whatever config it is given
	// and a caller's config is not ours to edit.
	TLS *tls.Config `json:"-"`

	// UserAgent is the User-Agent header. Empty omits the header entirely.
	//
	// Omitting it is the default because a User-Agent is a fingerprint attached
	// to every query, and this is a privacy transport; the standard library's
	// "Go-http-client/1.1" would announce the implementation to every endpoint
	// and every proxy in between. Operators whose provider requires one, or who
	// want their own traffic identifiable in their own logs, set it.
	UserAgent string `json:"user_agent"`

	// Header carries additional request headers, for the endpoints that
	// authenticate with one — a bearer token, or a provider's profile
	// identifier. The protocol's own headers cannot be set here;
	// [DoHOptions.Validate] rejects an attempt, because a Content-Type that
	// disagrees with the body is a request no endpoint can answer.
	Header http.Header `json:"headers"`
}

DoHOptions configures NewDoH.

The embedded Options carry the settings every transport shares, with one difference worth stating plainly: Addr is an absolute https URL, not a host and port, and Options.UDPSize is ignored because HTTP has no datagram to overflow.

func (DoHOptions) Validate

func (o DoHOptions) Validate() error

Validate reports every problem in o at once, joined with errors.Join.

Every problem, not the first: a DoH endpoint is typically configured once, by hand, from a provider's documentation page, and getting the scheme, the method and a header wrong together is one edit's worth of mistakes that should cost one restart to find.

NewDoH calls it, so a caller who does not validate separately is not skipping it.

type DoHStatusError

type DoHStatusError struct {
	// StatusCode is the HTTP status, as in [http.Response.StatusCode].
	StatusCode int
	// Status is the status line text, kept because a proxy's phrase often says
	// more than the code does.
	Status string
}

DoHStatusError reports a DoH endpoint that answered with a status other than 200.

RFC 8484 section 4.2.1 gives 200 the only meaning this transport can act on: the body is a DNS response. Everything else — a 403 from an endpoint that has stopped trusting us, a 429 from one we are querying too hard, a 502 from a proxy in front of one that is down — is an HTTP-layer failure with no DNS message in it, and the status is the whole of the diagnosis. It is a distinct type rather than a wrapped ErrProtocol because a resolver's reaction is different in kind: a 429 means back off, a 404 means the URL is wrong, and neither means the upstream is speaking a broken protocol.

func (*DoHStatusError) Error

func (e *DoHStatusError) Error() string

Error implements the error interface.

type DoT

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

DoT carries queries over DNS over TLS (RFC 7858).

It is TCP with the connection wrapped in TLS, and the framing, pooling and pipelining are literally the same code, because RFC 7858 section 3.3 says the session is a TCP session in every respect but its encryption. What TLS adds is worth being precise about: it removes the passive observer, who could otherwise read every name a client resolves off the wire, and it removes the off-path forger, for whom a plaintext DNS response is a datagram with sixteen bits of secret in it.

What TLS does not add by itself is any assurance about who answered. See StreamOptions.PinnedSPKI.

DoT is safe for concurrent use.

func NewDoT

func NewDoT(opts StreamOptions) (*DoT, error)

NewDoT returns a DNS over TLS transport for the upstream in opts.

The address may omit the port, in which case 853 is used. TLS 1.2 is the floor — every version below it has a published break, and RFC 8310 section 4.1 sets the same bar — and 1.3 is negotiated whenever the server offers it, which is the case worth optimising for because a 1.3 handshake costs one round trip where 1.2 costs two.

Sessions are cached, so a reconnection resumes rather than handshaking from cold. On an upstream a resolver reconnects to whenever an idle period expires, resumption is most of the difference between DoT costing a handshake per reconnect and costing almost nothing.

func (*DoT) Close

func (d *DoT) Close() error

Close implements Exchanger. Every pooled connection is closed and every query still waiting fails with ErrClosed; it is idempotent.

func (*DoT) Exchange

func (d *DoT) Exchange(ctx context.Context, q *dnsmsg.Message) (*dnsmsg.Message, error)

Exchange implements Exchanger. The reply has been matched to the query by Validate before it is returned.

type Error

type Error struct {
	// Addr is the upstream address, as configured.
	Addr string
	// Proto names the transport: "udp", "tcp", "tls" or "https".
	Proto string
	// Err is the underlying cause.
	Err error
}

Error annotates a failure with the upstream it came from.

A resolver holding six upstreams needs to know which one failed in order to mark it unhealthy, and a bare wrapped error forces it to parse a string to find out.

func (*Error) Error

func (e *Error) Error() string

Error implements the error interface.

func (*Error) Timeout

func (e *Error) Timeout() bool

Timeout reports whether the failure was a deadline, so a caller can distinguish "this upstream is slow" from "this upstream is wrong". It satisfies the net.Error convention.

func (*Error) Unwrap

func (e *Error) Unwrap() error

Unwrap returns the cause so errors.Is reaches the sentinels.

type Exchanger

type Exchanger interface {
	// Exchange sends q and returns the reply. The reply has been validated
	// against q; see [Validate].
	Exchange(ctx context.Context, q *dnsmsg.Message) (*dnsmsg.Message, error)

	// Close releases any connections held. It is idempotent, and an Exchange in
	// flight during a Close either completes or fails, never blocks forever.
	Close() error
}

Exchanger sends one query and returns the reply.

It is the only interface everything above this package needs. A resolver holds a set of Exchangers and does not care whether a given one is a datagram socket, a pooled TLS connection or an HTTP client.

Implementations must be safe for concurrent use: a server will have many queries in flight against one upstream at once, and requiring the caller to serialise them would throw away the pipelining that makes a stream transport worth having.

The returned message is the caller's to modify. The context bounds the whole exchange, including connection establishment.

func NewAuto

func NewAuto(udp, stream Exchanger) Exchanger

NewAuto pairs a datagram transport with a stream transport.

The two need not be UDP and TCP: any Exchanger that reports ErrTruncated works as the first leg, and any Exchanger at all works as the second, which is how a deployment retries over DoT instead of plain TCP without this type knowing anything about TLS. Either may be nil, in which case the pairing degrades to whichever is left rather than panicking on a query; both nil is an ErrNoUpstream on every exchange.

It returns the interface rather than the concrete type because Auto adds no method of its own: everything it is for is Exchanger, and callers that hold one alongside a UDP or a TCP should be holding all of them the same way.

func NewAutoUDPTCP

func NewAutoUDPTCP(opts StreamOptions) (Exchanger, error)

NewAutoUDPTCP builds the pairing most callers want — a UDP and a TCP to the same upstream — from one set of options.

The Options embedded in opts configure both legs, which is what makes this a convenience rather than a shorthand: the address, timeout, dialer and DNS-0x20 setting are properties of the upstream, not of the wire used to reach it, and having to state them twice is how the two legs drift apart. The stream-only fields govern the TCP leg alone, and Options.UDPSize the datagram leg alone.

type Options

type Options struct {
	// Addr is the upstream. Host and port for UDP, TCP and TLS; a URL for HTTPS.
	Addr string `json:"addr"`

	// Timeout bounds one exchange. Zero selects [DefaultTimeout]. It is applied
	// only when the caller's context has no earlier deadline, so a caller that
	// has budgeted its own time is never overruled by ours.
	Timeout time.Duration `json:"timeout"`

	// Dialer opens connections. Nil selects a [net.Dialer].
	Dialer Dialer `json:"-"`

	// Randomize enables DNS-0x20 case randomisation of the query name, and
	// requires the reply to echo the exact spelling.
	//
	// It costs nothing and adds roughly one bit of entropy per letter in the
	// name against off-path forgery. It is off by default only because a small
	// number of authoritative servers still normalise case in replies, and
	// enabling it against one of those breaks every query rather than
	// degrading; see [Validate].
	Randomize bool `json:"randomize"`

	// UDPSize is the EDNS payload size to advertise. Zero selects
	// [DefaultUDPSize]. Ignored by stream transports, which are not limited by
	// a datagram size.
	UDPSize uint16 `json:"udp_size"`
}

Options are the settings every transport shares.

type StreamOptions

type StreamOptions struct {
	Options

	// IdleTimeout is how long an unused connection is kept. Zero selects
	// [DefaultIdleTimeout]; negative disables reuse entirely, which is wasteful
	// but occasionally what a diagnostic wants.
	IdleTimeout time.Duration `json:"idle_timeout"`

	// MaxConns bounds concurrent connections to this upstream. Zero selects
	// [DefaultMaxConns]. Queries beyond it are pipelined onto existing
	// connections rather than queued, which RFC 7766 section 6.2.1.1 permits and
	// which is why a small number suffices.
	MaxConns int `json:"max_conns"`

	// TLS configures DNS over TLS. Ignored by plain TCP.
	TLS *tls.Config `json:"-"`

	// PinnedSPKI is a set of base64 SHA-256 SPKI digests. When non-empty, the
	// peer's certificate chain must present one of them.
	//
	// This is the "Strict Privacy" profile of RFC 8310 section 8.1, and it is
	// the only way to get a meaningful guarantee from DoT to an upstream
	// identified by address rather than by name: without a pin, an on-path
	// attacker with any certificate the system trusts is indistinguishable from
	// the real upstream.
	PinnedSPKI []string `json:"pinned_spki"`
}

StreamOptions extend Options for the connection-reusing transports.

type TCP

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

TCP carries queries over a pool of plain TCP connections.

RFC 7766 made TCP a required part of DNS rather than the fallback RFC 1035 treated it as, and this is what that requires in practice: connections are reused, several queries are outstanding on each of them at once, and replies are matched to queries by ID because RFC 7766 section 6.2.1.1 lets a server answer in whatever order it finishes. A transport that opened a connection per query would work, and would spend a round trip on a handshake for every one of them.

TCP is what UDP hands off to when a reply arrives truncated, and it is the only transport that carries an answer too large for a datagram. It is safe for concurrent use.

func NewTCP

func NewTCP(opts StreamOptions) (*TCP, error)

NewTCP returns a TCP transport for the upstream in opts.

The address may omit the port, in which case 53 is used. No connection is opened here: the first TCP.Exchange dials, which keeps constructing a resolver with a dozen configured upstreams from costing a dozen handshakes to servers most queries will never touch.

func (*TCP) Close

func (t *TCP) Close() error

Close implements Exchanger. Every pooled connection is closed and every query still waiting fails with ErrClosed; it is idempotent.

func (*TCP) Exchange

func (t *TCP) Exchange(ctx context.Context, q *dnsmsg.Message) (*dnsmsg.Message, error)

Exchange implements Exchanger. The reply has been matched to the query by Validate before it is returned, and carries the caller's own message ID rather than the one this connection happened to use.

type UDP

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

UDP exchanges queries over plain UDP, the transport of RFC 1035 section 4.2.1 and still the one that carries the overwhelming majority of DNS traffic.

It is the fast, cheap and forgeable wire: there is no handshake to amortise and no connection to keep, but there is also nothing between a reply and the engine except the checks in this package. What guards an exchange is the random ID, the random source port, optionally DNS-0x20 case randomisation, and the kernel's four-tuple filter — see Validate for what each is worth.

A UDP is safe for concurrent use and holds no connection between exchanges; the buffers and decoders it pools are the only state that outlives a query.

func NewUDP

func NewUDP(opts Options) (*UDP, error)

NewUDP returns a datagram transport that sends to opts.Addr.

Nothing is dialled here. Every exchange opens its own socket through opts.Dialer, which is what lets a resolver hold a transport per upstream without holding a file descriptor per upstream, and what lets the tests run this code with no network at all.

An address with no port gets 53, the same way the stream transports do it. Beyond that the address is the Dialer's to interpret, because the Dialer is what decides what an address means: the default one wants a host and a port, and one routing through a proxy or a test harness may want something this package has no business validating.

func (*UDP) Close

func (u *UDP) Close() error

Close makes every later Exchange fail with ErrClosed.

There is nothing to release: this transport holds no connection between exchanges, and one in flight owns its own socket and will close it. Close exists so that Exchanger means the same thing for every wire, and so that a resolver retiring an upstream can stop it being used without racing the queries already running on it. It is idempotent.

func (*UDP) Exchange

func (u *UDP) Exchange(ctx context.Context, q *dnsmsg.Message) (*dnsmsg.Message, error)

Exchange sends q and returns the validated reply.

One socket per query

Each exchange dials its own connected socket and closes it on the way out. That is deliberate, and it is the single most valuable thing this transport does for security: a Dial-style socket has the kernel choose an ephemeral source port and then drop every datagram not from the peer, so an off-path attacker must guess the port as well as the ID. That is roughly sixteen extra bits, and it is the defence a long-lived socket throws away for every query after the first — one observed exchange and the port is known for the rest of the socket's life, which is the setting in which the Kaminsky attack is practical rather than theoretical.

The cost is honest and it is not zero: a socket create, bind and close per query, which at high rates is a measurable share of the work and consumes ephemeral ports at the query rate. A pool of pre-bound sockets, rotated often enough that no port carries many queries, is the known way to buy some of that back; it is a future option rather than the default, because the default should be the safe one.

Late and forged datagrams

A datagram that fails Validate, arrives from the wrong address or overflows the advertised payload size is discarded and the read is retried until the deadline. Returning on the first bad datagram would be a gift to an attacker: he loses the race, his forgery arrives just ahead of the real reply, and the query fails anyway — a denial of service for the price of one packet, and one that a caller retrying would hand him another chance at. The number discarded is bounded by udpMaxIgnored so that a flood cannot spin here.

If the socket eventually fails or the deadline expires after datagrams were discarded, the reason the first one was discarded is returned rather than the read error. "The upstream answered, and wrongly" is a different operational fact from "the upstream said nothing", and this error value is the only channel the package has for telling them apart.

A reply with the TC bit set is returned alongside a wrapped ErrTruncated, so a caller that wants the partial answer — the RCODE and the question are both trustworthy by then — can use it without re-querying. Retrying over a stream is the caller's decision; see Auto.

Jump to

Keyboard shortcuts

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