Skip to content

feat: Introduce dns.RawResolver interface#594

Draft
fortuna wants to merge 5 commits into
mainfrom
fortuna/raw-resolver
Draft

feat: Introduce dns.RawResolver interface#594
fortuna wants to merge 5 commits into
mainfrom
fortuna/raw-resolver

Conversation

@fortuna
Copy link
Copy Markdown
Contributor

@fortuna fortuna commented Apr 1, 2026

Motivation

The main goal here is to decouple this foundational piece from the DNS parsing library. I've had a few issues with dns.Resolver while working on ECH research:

  • dnsmessage fails to parse DNS labels with dots in it, which is used in the email field of SOA records.
  • dnsmessage may not support the latest record types. In my case, it didn't support the HTTPS and SVCB records.

I ended up forced to use miekg's library instead of the SDK for that work.

This decoupling will allow the user to use whatever parsing library they prefer.

It also streamlines the implementation of the system resolver, removing unnecessary transformations when we fetch a []byte response from the system APIs. See #592 for where I'm going.

The new interface takes the output buffer as input, which lets the caller control the lifecycle of the buffer and avoid unnecessary allocations.

I also want this to be a building block for the dnstt implementation.

Changes

The new API includes:

  • RawResolver (takes QNAME + QTYPE + output buffer)
  • FuncRawResolver - convenience func -> RawResolver adaptor
  • RawToResolver - convenience adaptor to map to the current interface.

Extracts the RawResolver interface, FuncRawResolver, RawToResolver, and their tests out of PR #592 to decouple DNS querying from response parsing.
@fortuna fortuna changed the title Introduce dns.RawResolver interface feat: Introduce dns.RawResolver interface Apr 1, 2026
@fortuna fortuna marked this pull request as draft April 1, 2026 17:34
Copy link
Copy Markdown
Contributor Author

@fortuna fortuna left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some API questions to consider

Comment thread dns/resolver.go Outdated
// allowing callers to parse the response with any library — including those that support
// record types not yet recognized by golang.org/x/net/dns/dnsmessage.
type RawResolver interface {
QueryRaw(ctx context.Context, name string, qtype uint16) ([]byte, error)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to decide on the qname format. Could be the native []byte format with length-prefixed labels, or [][]byte (slice of labels), or a escaped string ("foo.bar.com").

While this doesn't affect regular usage, it can help use cases like DNS tunneling.

Comment thread dns/resolver.go Outdated
Comment thread dns/resolver.go Outdated
fortuna added 2 commits April 1, 2026 18:26
This commit modifies RawResolver's QueryRaw to take an arbitrary buf []byte to enable zero-allocation DNS wire-format pipelines logic within queryDatagram and queryStream.
@fortuna fortuna force-pushed the fortuna/raw-resolver branch from c48ea1c to 918b795 Compare April 1, 2026 22:35
fortuna added 2 commits April 1, 2026 18:46
…dAll

Extract body read loop to readAllInto, taking ContentLength into consideration to pre-allocate capacity.
Copy link
Copy Markdown
Contributor Author

@fortuna fortuna left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some thoughts.

Comment thread dns/resolver.go
// this adapter only unpacks the result.
func RawToResolver(r RawResolver) Resolver {
return FuncResolver(func(ctx context.Context, q dnsmessage.Question) (*dnsmessage.Message, error) {
buf := make([]byte, 0, maxUDPMessageSize)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What should the size be? I'm not convinced this is the best approach.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps we should set the max message size in the resolver object. Each implementation will have different parameters.
We may want to specify OPT options.

Also see https://datatracker.ietf.org/doc/html/rfc6891#section-6.2.5

Comment thread dns/resolver.go Outdated
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we read it into buf instead to avoid allocating another buffer?

Comment thread dns/doc.go
Comment on lines +53 to +55
1. Zero-dependency footprint: Bypassing rigid DNS parsing libraries allows Outline to pack esoteric labels natively natively (e.g. obscure DNS tunneling headers).
2. Future-proof compatibility: Callers can inject or parse the raw responses using any external library, including those supporting newer record types.
3. Zero-allocation cycles: Providing a reusable `buf []byte` hands memory management to the caller, removing slice allocations in the hot path.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's reorder:

  1. Future proof
  2. Zero Allocation
  3. BYO parsing

Comment thread dns/resolver.go
// allowing callers to parse the response with any library — including those that support
// record types not yet recognized by golang.org/x/net/dns/dnsmessage.
type RawResolver interface {
QueryRaw(ctx context.Context, name string, qtype uint16, buf []byte) ([]byte, error)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should explain that the returned buffer may or may not be a slice of the input. It depends on the implementation.

Or should we change that to guarantee it?

The main challenge is when we have dynamic lengths, as in TCP and HTTP

Comment thread dns/resolver.go

// RawToResolver wraps a [RawResolver] in a [Resolver] that parses the wire-format
// response bytes using golang.org/x/net/dns/dnsmessage.
// The underlying [RawResolver] is responsible for ID matching and returning valid bytes;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can remove this line.

Comment thread dns/resolver.go
respQName := rawResp[12 : 12+len(expectedName)]

// Case-insensitive comparison, as the server may echo back randomized caps (e.g. 0x20 encoding)
for i := 0; i < len(expectedName); i++ {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move to a function. Also, need to compare the lengths!

Comment thread dns/resolver.go
}
}

respQType := binary.BigEndian.Uint16(rawResp[12+len(expectedName):])
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should check type and class first.

Comment thread dns/resolver.go
func checkResponse(reqID uint16, reqQues dnsmessage.Question, respHdr dnsmessage.Header, respQs []dnsmessage.Question) error {
if !respHdr.Response {
// checkResponseRaw verifies the DNS response matches the request parameters directly on the wire bytes.
func checkResponseRaw(reqID uint16, name string, qtype uint16, rawResp []byte) error {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like we may need another abstraction layer. This kind of check is standard, and independent on how we exchange messages (though the message exchanges depend on the message id)

There's also things like padding, extended errors, client subnet, DNSSEC, and so on.... Where should that live?

Oh, and caching!

Comment thread dns/resolver.go
Comment on lines 402 to +403
// [DNS-over-UDP]: https://datatracker.ietf.org/doc/html/rfc1035#section-4.2.1
func NewUDPResolver(pd transport.PacketDialer, resolverAddr string) Resolver {
func NewUDPRawResolver(pd transport.PacketDialer, resolverAddr string) RawResolver {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think these factory functions work well. We should consider a way to specify configs, like the max packet length for UDP, and layer other functionality that are common across implementations.

Comment thread dns/resolver.go
}
if err := checkResponse(0, q, msg.Header, msg.Questions); err != nil {
// Ignore invalid packets that fail to parse. It could be injected.
if err := checkResponseRaw(0, name, qtype, buf); err != nil {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should match on ID first, then validate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant