Skip to content
This repository was archived by the owner on Jul 3, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions doquic.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
// DNS-over-QUIC implementation
//
// See // https://datatracker.ietf.org/doc/rfc9250/
//

package dnscore

import (
"context"
"crypto/tls"
"net"
"time"

"github.com/miekg/dns"
"github.com/quic-go/quic-go"
"github.com/rbmk-project/common/closepool"
)

func (t *Transport) queryQUIC(ctx context.Context, addr *ServerAddr, query *dns.Msg) (*dns.Msg, error) {
// 0. immediately fail if the context is already done, which
// is useful to write unit tests
if ctx.Err() != nil {
return nil, ctx.Err()
}

Check warning on line 27 in doquic.go

View check run for this annotation

Codecov / codecov/patch

doquic.go#L26-L27

Added lines #L26 - L27 were not covered by tests

// 1. Fill the TLS configuration
hostname, _, err := net.SplitHostPort(addr.Address)
if err != nil {
return nil, err
}

Check warning on line 33 in doquic.go

View check run for this annotation

Codecov / codecov/patch

doquic.go#L32-L33

Added lines #L32 - L33 were not covered by tests
tlsConfig := &tls.Config{
NextProtos: []string{"doq"},
ServerName: hostname,
RootCAs: t.RootCAs,
}

// 2. Create a connection pool to close all opened connections
// and ensure we don't leak resources by using defer.
connPool := &closepool.Pool{}
defer connPool.Close()

// TODO(bassosimone,roopeshsn): for TCP connections, we abstract
// this process of combining the DNS lookup and dialing a connection,
// which, in turn, allows for better unit testing and also allows
// rbmk-project/rbmk to use rbmk-project/x/netcore for dialing.
//
// We should probably see to create a similar dialing interface in
// rbmk-project/x/netcore for QUIC connections. We started discussing
// this in https://github.com/rbmk-project/dnscore/pull/18.

// 3. Open the UDP connection for supporting QUIC
listenConfig := &net.ListenConfig{}
udpConn, err := listenConfig.ListenPacket(ctx, "udp", ":0")
if err != nil {
return nil, err
}

Check warning on line 59 in doquic.go

View check run for this annotation

Codecov / codecov/patch

doquic.go#L58-L59

Added lines #L58 - L59 were not covered by tests
connPool.Add(udpConn)

// 4. Map the UDP address, which may possibly contain a domain
// name, to an actual UDP address structure to dial with
udpAddr, err := net.ResolveUDPAddr("udp", addr.Address)
if err != nil {
return nil, err
}

Check warning on line 67 in doquic.go

View check run for this annotation

Codecov / codecov/patch

doquic.go#L66-L67

Added lines #L66 - L67 were not covered by tests

// 5. Establish a QUIC connection. Note that the default
// configuration implies a 5s timeout for handshaking and
// a 30s idle connection timeout.
tr := &quic.Transport{
Conn: udpConn,
}
connPool.Add(tr)
quicConfig := &quic.Config{}
quicConn, err := tr.Dial(ctx, udpAddr, tlsConfig, quicConfig)
if err != nil {
return nil, err
}

Check warning on line 80 in doquic.go

View check run for this annotation

Codecov / codecov/patch

doquic.go#L79-L80

Added lines #L79 - L80 were not covered by tests
connPool.Add(closepool.CloserFunc(func() error {
// Closing w/o specific error -- RFC 9250 Sect. 4.3
const doq_no_error = 0x00
return quicConn.CloseWithError(doq_no_error, "")
}))

// 6. Open a stream for sending the DoQ query and wrap it into
// an adapter that makes it usable by DNS-over-stream code
quicStream, err := quicConn.OpenStream()
if err != nil {
return nil, err
}

Check warning on line 92 in doquic.go

View check run for this annotation

Codecov / codecov/patch

doquic.go#L91-L92

Added lines #L91 - L92 were not covered by tests
stream := &quicStreamAdapter{
Stream: quicStream,
localAddr: quicConn.LocalAddr(),
remoteAddr: quicConn.RemoteAddr(),
}
connPool.Add(stream)

// 7. Ensure that we tear down everything which we have set up
// in the case in which the context is canceled
ctx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
defer connPool.Close()
<-ctx.Done()
}()

// 8. defer to queryStream. Note that this method TAKES OWNERSHIP of
// the stream and closes it after we've sent the query, honouring the
// expectations for DoQ queries -- see RFC 9250 Sect. 4.2.
return t.queryStream(ctx, addr, query, stream)
}

// quicStreamAdapter ensures a QUIC stream implements [dnsStream].
type quicStreamAdapter struct {
Stream quic.Stream
localAddr net.Addr
remoteAddr net.Addr
}

// Make sure we actually implement [dnsStream].
var _ dnsStream = &quicStreamAdapter{}

func (qsw *quicStreamAdapter) Read(p []byte) (int, error) {
return qsw.Stream.Read(p)
}

func (qsw *quicStreamAdapter) Write(p []byte) (int, error) {
return qsw.Stream.Write(p)
}

func (qsw *quicStreamAdapter) Close() error {
return qsw.Stream.Close()
}

func (qsw *quicStreamAdapter) SetDeadline(t time.Time) error {
return qsw.Stream.SetDeadline(t)
}

func (qsw *quicStreamAdapter) LocalAddr() net.Addr {
return qsw.localAddr

Check warning on line 142 in doquic.go

View check run for this annotation

Codecov / codecov/patch

doquic.go#L141-L142

Added lines #L141 - L142 were not covered by tests
}

func (qsw *quicStreamAdapter) RemoteAddr() net.Addr {
return qsw.remoteAddr

Check warning on line 146 in doquic.go

View check run for this annotation

Codecov / codecov/patch

doquic.go#L145-L146

Added lines #L145 - L146 were not covered by tests
}
51 changes: 51 additions & 0 deletions doquic_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package dnscore

import (
"context"
"testing"

"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
)

func TestTransport_queryQUIC(t *testing.T) {
// TODO(bassosimone,roopeshsn): currently this is an integration test
// using the network w/ real servers but we should instead have:
//
// 1. an integration test using the network but using a QUIC server running
// locally (a test which should live inside integration_test.go)
//
// 2. unit tests using mocking like we do for, e.g.m dohttps_test.go

tests := []struct {
name string
setupTransport func() *Transport
expectedError error
}{
{
name: "Successful query",
setupTransport: func() *Transport {
return &Transport{}
},
expectedError: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
transport := tt.setupTransport()
addr := NewServerAddr(ProtocolDoQ, "dns.adguard.com:853")
query := new(dns.Msg)
query.SetQuestion("example.com.", dns.TypeA)

_, err := transport.queryQUIC(context.Background(), addr, query)

if tt.expectedError != nil {
assert.Error(t, err)
assert.Equal(t, tt.expectedError.Error(), err.Error())
} else {
assert.NoError(t, err)
}
})
}
}
30 changes: 28 additions & 2 deletions dotcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
//
// Adapted from: https://github.com/ooni/probe-engine/blob/v0.23.0/netx/resolver/dnsovertcp.go
//
// DNS-over-TCP implementation
// DNS-over-TCP implementation. Includes generic code to
// send queries over streams used by DoT and DoQ.
//

package dnscore
Expand All @@ -16,10 +17,19 @@ import (
"io"
"math"
"net"
"time"

"github.com/miekg/dns"
)

// dnsStream is the interface expected by [*Transport.queryStream],
type dnsStream interface {
io.ReadWriteCloser
SetDeadline(t time.Time) error
LocalAddr() net.Addr
RemoteAddr() net.Addr
}

// queryTCP implements [*Transport.Query] for DNS over TCP.
func (t *Transport) queryTCP(ctx context.Context,
addr *ServerAddr, query *dns.Msg) (*dns.Msg, error) {
Expand Down Expand Up @@ -55,7 +65,7 @@ type queryMsg interface {
// This method TAKES OWNERSHIP of the provided connection and is
// responsible for closing it when done.
func (t *Transport) queryStream(ctx context.Context,
addr *ServerAddr, query queryMsg, conn net.Conn) (*dns.Msg, error) {
addr *ServerAddr, query queryMsg, conn dnsStream) (*dns.Msg, error) {

// 1. Use a single connection for request, which is what the standard library
// does as well for TCP and is more robust in terms of residual censorship.
Expand Down Expand Up @@ -96,6 +106,22 @@ func (t *Transport) queryStream(ctx context.Context,
return nil, err
}

// 5b. Ensure we close the stream when using DoQ to signal the
// upstream server that it is okay to send a response.
//
// RFC 9250 is very clear in this respect:
//
// 4.2. Stream Mapping and Usage
// client MUST send the DNS query over the selected stream and MUST
// indicate through the STREAM FIN mechanism that no further data will
// be sent on that stream.
//
// Empirical testing during https://github.com/rbmk-project/dnscore/pull/18
// showed that, in fact, some servers misbehave if we don't do this.
if _, ok := conn.(*quicStreamAdapter); ok {
_ = conn.Close()
}

// 6. Wrap the conn to avoid issuing too many reads
// then read the response header and query
br := bufio.NewReader(conn)
Expand Down
66 changes: 66 additions & 0 deletions example_quic_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package dnscore_test

import (
"context"
"fmt"
"log"
"slices"
"strings"
"time"

"github.com/miekg/dns"
"github.com/rbmk-project/common/runtimex"
"github.com/rbmk-project/dnscore"
)

func ExampleTransport_dnsOverQUIC() {
// create transport, server addr, and query
txp := &dnscore.Transport{}
serverAddr := &dnscore.ServerAddr{
Protocol: dnscore.ProtocolDoQ,
Address: "dns0.eu:853",
}
options := []dnscore.QueryOption{
dnscore.QueryOptionEDNS0(
dnscore.EDNS0SuggestedMaxResponseSizeOtherwise,
dnscore.EDNS0FlagDO|dnscore.EDNS0FlagBlockLengthPadding,
),
}
query, err := dnscore.NewQueryWithServerAddr(serverAddr, "dns.google", dns.TypeA, options...)
if err != nil {
log.Fatal(err)
}

// issue the query and get the response
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := txp.Query(ctx, serverAddr, query)
if err != nil {
log.Fatal(err)
}

// validate the response
if err := dnscore.ValidateResponse(query, resp); err != nil {
log.Fatal(err)
}
runtimex.Assert(len(query.Question) > 0, "expected at least one question")
rrs, err := dnscore.ValidAnswers(query.Question[0], resp)
if err != nil {
log.Fatal(err)
}

// print the results
var addrs []string
for _, rr := range rrs {
switch rr := rr.(type) {
case *dns.A:
addrs = append(addrs, rr.A.String())
}
}
slices.Sort(addrs)
fmt.Printf("%s\n", strings.Join(addrs, "\n"))

// Output:
// 8.8.4.4
// 8.8.8.8
}
9 changes: 8 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
module github.com/rbmk-project/dnscore

go 1.23
go 1.23.0

require (
github.com/miekg/dns v1.1.63
github.com/quic-go/quic-go v0.50.0
github.com/rbmk-project/common v0.17.0
github.com/stretchr/testify v1.10.0
golang.org/x/net v0.35.0
)

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/google/pprof v0.0.0-20250208200701-d0013a598941 // indirect
github.com/onsi/ginkgo/v2 v2.22.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/crypto v0.35.0 // indirect
golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa // indirect
golang.org/x/mod v0.23.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
Expand Down
Loading
Loading