ooni-probe-cli/internal/netxlite/dnsoverudp.go
Simone Basso cc24f28b9d
feat(netxlite): support extracting the CNAME (#875)
* feat(netxlite): support extracting the CNAME

Closes https://github.com/ooni/probe/issues/2225

* fix(netxlite): attempt to increase coverage and improve tests

1. dnsovergetaddrinfo: specify the behavior of a DNSResponse returned
by this file to make it line with normal responses and write unit tests
to make sure we adhere to expectations;

2. dnsoverudp: make sure we wait to deferred responses also w/o a
custom context and post on a private channel and test that;

3. utls: recognize that we can actually write a test for NetConn and
what needs to change when we'll use go1.19 by default will just be
a cast that at that point can be removed.
2022-08-23 13:04:00 +02:00

191 lines
6.7 KiB
Go

package netxlite
//
// DNS-over-UDP transport
//
import (
"context"
"net"
"time"
"github.com/ooni/probe-cli/v3/internal/model"
)
// DNSOverUDPTransport is a DNS-over-UDP DNSTransport.
//
// To construct this type, either manually fill the fields marked as MANDATORY
// or just use the NewDNSOverUDPTransport factory directly.
//
// RoundTrip creates a new connected UDP socket for each outgoing query. Using a
// new socket is good because some censored environments will block the client UDP
// endpoint for several seconds when you query for blocked domains. We could also
// have used an unconnected UDP socket here, but:
//
// 1. connected UDP sockets are great because they get some ICMP errors to be
// translated into socket errors (among them, host_unreachable);
//
// 2. connected UDP sockets ignore responses from illegitimate IP addresses but
// most if not all DNS resolvers also do that, therefore this does not seem to
// be a realistic censorship vector. At the same time, connected sockets
// provide us for free with the feature that we don't need to bother with checking
// whether the reply comes from the expected server.
//
// Being able to observe some ICMP errors is good because it could possibly
// make this code suitable to implement parasitic traceroute.
//
// This transport by default listens for additional responses after the first
// one and makes them available using the context-configured trace.
type DNSOverUDPTransport struct {
// Decoder is the MANDATORY DNSDecoder to use.
Decoder model.DNSDecoder
// Dialer is the MANDATORY dialer used to create the conn.
Dialer model.Dialer
// Endpoint is the MANDATORY server's endpoint (e.g., 1.1.1.1:53)
Endpoint string
// lateResponses is posted in nonblocking mode each time this
// transport detects there was a late response for a query that had
// already been answered. Use this channel for testing.
lateResponses chan any
}
// NewUnwrappedDNSOverUDPTransport creates a DNSOverUDPTransport instance
// that has not been wrapped yet.
//
// Arguments:
//
// - dialer is any type that implements the Dialer interface;
//
// - address is the endpoint address (e.g., 8.8.8.8:53).
//
// If the address contains a domain name rather than an IP address
// (e.g., dns.google:53), we will end up using the first of the
// IP addresses returned by the underlying DNS lookup performed using
// the dialer. This usage pattern is NOT RECOMMENDED because we'll
// have less control over which IP address is being used.
func NewUnwrappedDNSOverUDPTransport(dialer model.Dialer, address string) *DNSOverUDPTransport {
return &DNSOverUDPTransport{
Decoder: &DNSDecoderMiekg{},
Dialer: dialer,
Endpoint: address,
lateResponses: nil, // not interested by default
}
}
// RoundTrip sends a query and receives a response.
func (t *DNSOverUDPTransport) RoundTrip(
ctx context.Context, query model.DNSQuery) (model.DNSResponse, error) {
// QUIRK: the original DNS-over-UDP code had a five seconds timeout, which is
// consistent with the Bionic implementation. Let's try to preserve such a
// behavior by combining dialing and I/O timeout together.
//
// See https://labs.ripe.net/Members/baptiste_jonglez_1/persistent-dns-connections-for-reliability-and-performance
const opTimeout = 5 * time.Second
deadline := time.Now().Add(opTimeout)
ctx, cancel := context.WithTimeout(ctx, opTimeout)
defer cancel()
rawQuery, err := query.Bytes()
if err != nil {
return nil, err
}
conn, err := t.Dialer.DialContext(ctx, "udp", t.Endpoint)
if err != nil {
return nil, err
}
conn.SetDeadline(deadline) // time to dial (usually ~zero) already factored in
joinedch := make(chan bool)
myaddr := conn.LocalAddr().String()
if _, err := conn.Write(rawQuery); err != nil {
conn.Close() // we still own the conn
return nil, err
}
resp, err := t.recv(query, conn)
if err != nil {
conn.Close() // we still own the conn
return nil, err
}
// start a goroutine to listen for any delayed DNS response and
// TRANSFER the conn's OWNERSHIP to such a goroutine.
go t.ownConnAndSendRecvLoop(ctx, conn, query, myaddr, joinedch)
return resp, nil
}
// RequiresPadding returns false for UDP according to RFC8467.
func (t *DNSOverUDPTransport) RequiresPadding() bool {
return false
}
// Network returns the transport network, i.e., "udp".
func (t *DNSOverUDPTransport) Network() string {
return "udp"
}
// Address returns the upstream server address.
func (t *DNSOverUDPTransport) Address() string {
return t.Endpoint
}
// CloseIdleConnections closes idle connections, if any.
func (t *DNSOverUDPTransport) CloseIdleConnections() {
// The underlying dialer MAY have idle connections so let's
// forward the call...
t.Dialer.CloseIdleConnections()
}
var _ model.DNSTransport = &DNSOverUDPTransport{}
// ownConnAndSendRecvLoop listens for delayed DNS responses after we have returned the
// first response. As the name implies, this function TAKES OWNERSHIP of the [conn].
func (t *DNSOverUDPTransport) ownConnAndSendRecvLoop(ctx context.Context, conn net.Conn,
query model.DNSQuery, myaddr string, eofch chan<- bool) {
defer close(eofch) // synchronize with the caller
defer conn.Close() // we own the conn
trace := ContextTraceOrDefault(ctx)
for {
started := trace.TimeNow()
resp, err := t.recv(query, conn)
finished := trace.TimeNow()
if err != nil {
// We are going to consider all errors as fatal for now until we
// hear of specific errs that it might have sense to ignore.
//
// Note that erroring out here includes the expiration of the conn's
// I/O deadline, which we set above precisely because we want
// the total runtime of this goroutine to be bounded.
//
// Also, we ARE NOT going to report any failure here as a delayed
// DNS response because we only care about duplicate messages, since
// this seems how censorship is implemented in, e.g., China.
return
}
// if there's testing code waiting to be unblocked because we
// received a delayed response, unblock it
select {
case t.lateResponses <- true:
default:
// there's no one waiting and it does not matter
}
addrs, err := resp.DecodeLookupHost()
if err := trace.OnDelayedDNSResponse(started, t, query, resp, addrs, err, finished); err != nil {
// This error typically indicates that the buffer on which we're
// writing is now full, so there's no point in persisting.
return
}
}
}
// recv receives a single response for the given query using the given conn.
func (t *DNSOverUDPTransport) recv(query model.DNSQuery, conn net.Conn) (model.DNSResponse, error) {
const maxmessagesize = 1 << 17
rawResponse := make([]byte, maxmessagesize)
count, err := conn.Read(rawResponse)
if err != nil {
return nil, err
}
rawResponse = rawResponse[:count]
return t.Decoder.DecodeResponse(rawResponse, query)
}