diff --git a/internal/netxlite/dnsoverudp.go b/internal/netxlite/dnsoverudp.go index 2928826..c492a9f 100644 --- a/internal/netxlite/dnsoverudp.go +++ b/internal/netxlite/dnsoverudp.go @@ -6,16 +6,49 @@ package netxlite 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 sockets are great because they get some ICMP errors to be +// translated into socket errors (among them, host_unreachable); +// +// 2. connected sockets ignore responses from illegitimate IP addresses but +// most if not all DNS resolvers also do that, therefore it does not seem to +// be a realistic censorship vector. At the same time, connected sockets +// provide us for free 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 is capable of collecting additional responses after the first +// response. To see these responses, use the AsyncRoundTrip method. type DNSOverUDPTransport struct { - dialer model.Dialer - decoder model.DNSDecoder - address string + // 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 + + // IOTimeout is the MANDATORY I/O timeout after which any + // conn created to perform round trips times out. + IOTimeout time.Duration } // NewDNSOverUDPTransport creates a DNSOverUDPTransport instance. @@ -25,41 +58,36 @@ type DNSOverUDPTransport struct { // - 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 NewDNSOverUDPTransport(dialer model.Dialer, address string) *DNSOverUDPTransport { return &DNSOverUDPTransport{ - dialer: dialer, - decoder: &DNSDecoderMiekg{}, - address: address, + Decoder: &DNSDecoderMiekg{}, + Dialer: dialer, + Endpoint: address, + IOTimeout: 10 * time.Second, } } -// RoundTrip sends a query and receives a reply. +// RoundTrip sends a query and receives a response. func (t *DNSOverUDPTransport) RoundTrip( ctx context.Context, query model.DNSQuery) (model.DNSResponse, error) { - rawQuery, err := query.Bytes() - if err != nil { - return nil, err - } - conn, err := t.dialer.DialContext(ctx, "udp", t.address) - if err != nil { - return nil, err - } - defer conn.Close() - // Use five seconds timeout like Bionic does. See - // https://labs.ripe.net/Members/baptiste_jonglez_1/persistent-dns-connections-for-reliability-and-performance - const iotimeout = 5 * time.Second - conn.SetDeadline(time.Now().Add(iotimeout)) - if _, err = conn.Write(rawQuery); err != nil { - return nil, err - } - 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) + // QUIRK: the original code had a five seconds timeout, which is + // consistent with the Bionic implementation. Let's enforce such a + // timeout using the context in the outer operation because we + // need to run for more seconds in the background to catch as many + // duplicate replies as possible. + // + // See https://labs.ripe.net/Members/baptiste_jonglez_1/persistent-dns-connections-for-reliability-and-performance + const opTimeout = 5 * time.Second + ctx, cancel := context.WithTimeout(ctx, opTimeout) + defer cancel() + outch := t.AsyncRoundTrip(query, 1) // buffer to avoid background's goroutine leak + return outch.Next(ctx) } // RequiresPadding returns false for UDP according to RFC8467. @@ -74,12 +102,194 @@ func (t *DNSOverUDPTransport) Network() string { // Address returns the upstream server address. func (t *DNSOverUDPTransport) Address() string { - return t.address + return t.Endpoint } // CloseIdleConnections closes idle connections, if any. func (t *DNSOverUDPTransport) CloseIdleConnections() { - // nothing to do + // The underlying dialer MAY have idle connections so let's + // forward the call... + t.Dialer.CloseIdleConnections() } var _ model.DNSTransport = &DNSOverUDPTransport{} + +// DNSOverUDPResponse is a response received by a DNSOverUDPTransport when you +// use its AsyncRoundTrip method as opposed to using RoundTrip. +type DNSOverUDPResponse struct { + // Err is the error that occurred (nil in case of success). + Err error + + // LocalAddr is the local UDP address we're using. + LocalAddr string + + // Operation is the operation that failed. + Operation string + + // Query is the related DNS query. + Query model.DNSQuery + + // RemoteAddr is the remote server address. + RemoteAddr string + + // Response is the response (nil iff error is not nil). + Response model.DNSResponse +} + +// newDNSOverUDPResponse creates a new DNSOverUDPResponse instance. +func (t *DNSOverUDPTransport) newDNSOverUDPResponse(localAddr string, err error, + query model.DNSQuery, resp model.DNSResponse, operation string) *DNSOverUDPResponse { + return &DNSOverUDPResponse{ + Err: err, + LocalAddr: localAddr, + Operation: operation, + Query: query, + RemoteAddr: t.Endpoint, // The common case is to have an IP:port here (domains are discouraged) + Response: resp, + } +} + +// DNSOverUDPChannel is a wrapper around a channel for reading zero +// or more *DNSOverUDPResponse that makes extracting information from +// the underlying channels more user friendly than interacting with +// the channels directly, thanks to useful wrapper methods implementing +// common access patterns. You can still use the channels directly if +// there's no convenience method for your specific access pattern. +type DNSOverUDPChannel struct { + // Response is the channel where we'll post responses. This channel + // WON'T be closed when the background goroutine terminates. + Response <-chan *DNSOverUDPResponse + + // Joined is a channel that IS CLOSED when the background + // goroutine performing this round trip TERMINATES. + Joined <-chan bool +} + +// Next blocks until the next response is received on Response or the +// given context expires, whatever happens first. This function will +// completely ignore the Joined channel and will just timeout in case +// you call Next after the background goroutine had joined. In fact, +// the use case for this function is using it to get a response or +// a timeout when you know the DNS round trip is pending. +func (ch *DNSOverUDPChannel) Next(ctx context.Context) (model.DNSResponse, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case out := <-ch.Response: // Note: AsyncRoundTrip WILL NOT close the channel or emit a nil + return out.Response, out.Err + } +} + +// TryNextResponses attempts to read all the buffered messages inside of the "Response" +// channel that contains successful DNS responses. That is, this function will silently skip +// any possible DNSOverUDPResponse with its Err != nil. The use case for this function is +// to obtain all the subsequent response messages we received while we were performing +// other operations (e.g., contacting the test helper of fetching a webpage). +func (ch *DNSOverUDPChannel) TryNextResponses() (out []model.DNSResponse) { + for { + select { + case r := <-ch.Response: // Note: AsyncRoundTrip WILL NOT close the channel or emit a nil + if r.Err == nil && r.Response != nil { + out = append(out, r.Response) + } + default: + return + } + } +} + +// AsyncRoundTrip performs an async DNS round trip. The "buffer" argument +// controls how many buffer slots the returned DNSOverUDPChannel's Response +// channel should have. A zero or negative value causes this function to +// create a channel having a single-slot buffer. +// +// The real round trip runs in a background goroutine. We will terminate the background +// goroutine when (1) the IOTimeout expires for the connection we're using or (2) we +// cannot write on the "Response" channel. Note that the background goroutine WILL NOT +// close the "Response" channel to signal its completion. Hence, who reads such a +// channel MUST be prepared for read operations to block forever and use a +// select for draining the channel in a deadlock-safe way. Also, we WILL NOT ever +// emit a nil message over the "Response" channel. +// +// The returned DNSOverUDPChannel contains another channel called Joined that is +// closed when the background goroutine terminates, so you can use this channel +// should you need to synchronize with such goroutine's termination. +// +// If you are using the Next or TryNextResponses methods of the DNSOverUDPChannel type, +// you don't need to worry about these low level details though. +func (t *DNSOverUDPTransport) AsyncRoundTrip(query model.DNSQuery, buffer int) *DNSOverUDPChannel { + if buffer < 2 { + buffer = 1 // as documented + } + outch := make(chan *DNSOverUDPResponse, buffer) + joinedch := make(chan bool) + go t.roundTripLoop(query, outch, joinedch) + return &DNSOverUDPChannel{ + Response: outch, + Joined: joinedch, + } +} + +// roundTripLoop performs the round trip and writes results into the "outch" channel. This +// function ASSUMES that "outch" is configured to have AT LEAST one buffer slot. This function +// TAKES OWNERSHIP of "outch" but WILL NOT close it when done. This function instead OWNS +// the "joinedch" channel and WILL CLOSE it when done. +func (t *DNSOverUDPTransport) roundTripLoop( + query model.DNSQuery, outch chan<- *DNSOverUDPResponse, joinedch chan<- bool) { + defer close(joinedch) // as documented + rawQuery, err := query.Bytes() + if err != nil { + outch <- t.newDNSOverUDPResponse( + "", err, query, nil, "serialize_query") // one-sized buffer, can't block + return + } + // While dial operations return immediately for UDP, we MAY be calling the + // dialer's resolver if t.Endpoint contains a domain name. So, let us basically + // enforce the same overall deadline covering DNS lookup and I/O operations. + deadline := time.Now().Add(t.IOTimeout) + ctx, cancel := context.WithDeadline(context.Background(), deadline) + defer cancel() + conn, err := t.Dialer.DialContext(ctx, "udp", t.Endpoint) + if err != nil { + outch <- t.newDNSOverUDPResponse( + "", err, query, nil, ConnectOperation) // one-sized buffer, can't block + return + } + defer conn.Close() // we own the conn + conn.SetDeadline(deadline) + localAddr := conn.LocalAddr().String() + if _, err = conn.Write(rawQuery); err != nil { + outch <- t.newDNSOverUDPResponse( + localAddr, err, query, nil, WriteOperation) // one-sized buffer, can't block + return + } + for { + resp, err := t.recv(query, conn) + select { + case outch <- t.newDNSOverUDPResponse(localAddr, err, query, resp, ReadOperation): + default: + return // no-one is reading the channel -- so long... + } + 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. + 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) +} diff --git a/internal/netxlite/dnsoverudp_test.go b/internal/netxlite/dnsoverudp_test.go index 5720edf..895179a 100644 --- a/internal/netxlite/dnsoverudp_test.go +++ b/internal/netxlite/dnsoverudp_test.go @@ -9,8 +9,11 @@ import ( "time" "github.com/apex/log" + "github.com/google/go-cmp/cmp" + "github.com/miekg/dns" "github.com/ooni/probe-cli/v3/internal/model" "github.com/ooni/probe-cli/v3/internal/model/mocks" + "github.com/ooni/probe-cli/v3/internal/netxlite/filtering" ) func TestDNSOverUDPTransport(t *testing.T) { @@ -70,6 +73,16 @@ func TestDNSOverUDPTransport(t *testing.T) { MockClose: func() error { return nil }, + MockLocalAddr: func() net.Addr { + return &mocks.Addr{ + MockNetwork: func() string { + return "udp" + }, + MockString: func() string { + return "127.0.0.1:1345" + }, + } + }, }, nil }, }, "9.9.9.9:53", @@ -106,6 +119,16 @@ func TestDNSOverUDPTransport(t *testing.T) { MockClose: func() error { return nil }, + MockLocalAddr: func() net.Addr { + return &mocks.Addr{ + MockNetwork: func() string { + return "udp" + }, + MockString: func() string { + return "127.0.0.1:1345" + }, + } + }, }, nil }, }, "9.9.9.9:53", @@ -141,12 +164,22 @@ func TestDNSOverUDPTransport(t *testing.T) { MockClose: func() error { return nil }, + MockLocalAddr: func() net.Addr { + return &mocks.Addr{ + MockNetwork: func() string { + return "udp" + }, + MockString: func() string { + return "127.0.0.1:1345" + }, + } + }, }, nil }, }, "9.9.9.9:53", ) expectedErr := errors.New("mocked error") - txp.decoder = &mocks.DNSDecoder{ + txp.Decoder = &mocks.DNSDecoder{ MockDecodeResponse: func(data []byte, query model.DNSQuery) (model.DNSResponse, error) { return nil, expectedErr }, @@ -165,7 +198,7 @@ func TestDNSOverUDPTransport(t *testing.T) { } }) - t.Run("read success", func(t *testing.T) { + t.Run("decode success", func(t *testing.T) { const expected = 17 input := bytes.NewReader(make([]byte, expected)) txp := NewDNSOverUDPTransport( @@ -182,12 +215,22 @@ func TestDNSOverUDPTransport(t *testing.T) { MockClose: func() error { return nil }, + MockLocalAddr: func() net.Addr { + return &mocks.Addr{ + MockNetwork: func() string { + return "udp" + }, + MockString: func() string { + return "127.0.0.1:1345" + }, + } + }, }, nil }, }, "9.9.9.9:53", ) expectedResp := &mocks.DNSResponse{} - txp.decoder = &mocks.DNSDecoder{ + txp.Decoder = &mocks.DNSDecoder{ MockDecodeResponse: func(data []byte, query model.DNSQuery) (model.DNSResponse, error) { return expectedResp, nil }, @@ -205,6 +248,190 @@ func TestDNSOverUDPTransport(t *testing.T) { t.Fatal("unexpected resp") } }) + + t.Run("using a real server", func(t *testing.T) { + srvr := &filtering.DNSServer{ + OnQuery: func(domain string) filtering.DNSAction { + return filtering.DNSActionCache + }, + Cache: map[string][]string{ + "dns.google.": {"8.8.8.8"}, + }, + } + listener, err := srvr.Start("127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + dialer := NewDialerWithoutResolver(model.DiscardLogger) + txp := NewDNSOverUDPTransport(dialer, listener.LocalAddr().String()) + encoder := &DNSEncoderMiekg{} + query := encoder.Encode("dns.google.", dns.TypeA, false) + resp, err := txp.RoundTrip(context.Background(), query) + if err != nil { + t.Fatal(err) + } + addrs, err := resp.DecodeLookupHost() + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(addrs, []string{"8.8.8.8"}); diff != "" { + t.Fatal(diff) + } + }) + }) + + t.Run("AsyncRoundTrip", func(t *testing.T) { + t.Run("calling Next with cancelled context", func(t *testing.T) { + blocker := make(chan interface{}) + const expected = 17 + input := bytes.NewReader(make([]byte, expected)) + txp := NewDNSOverUDPTransport( + &mocks.Dialer{ + MockDialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + <-blocker // block here until Next returns because of expired context + return &mocks.Conn{ + MockSetDeadline: func(t time.Time) error { + return nil + }, + MockWrite: func(b []byte) (int, error) { + return len(b), nil + }, + MockRead: input.Read, + MockClose: func() error { + return nil + }, + MockLocalAddr: func() net.Addr { + return &mocks.Addr{ + MockNetwork: func() string { + return "udp" + }, + MockString: func() string { + return "127.0.0.1:1345" + }, + } + }, + }, nil + }, + }, "9.9.9.9:53", + ) + expectedResp := &mocks.DNSResponse{} + txp.Decoder = &mocks.DNSDecoder{ + MockDecodeResponse: func(data []byte, query model.DNSQuery) (model.DNSResponse, error) { + return expectedResp, nil + }, + } + query := &mocks.DNSQuery{ + MockBytes: func() ([]byte, error) { + return make([]byte, 128), nil + }, + } + out := txp.AsyncRoundTrip(query, 1) + ctx, cancel := context.WithCancel(context.Background()) + cancel() // immediately cancel + resp, err := out.Next(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatal("unexpected err", err) + } + if resp != nil { + t.Fatal("unexpected resp") + } + close(blocker) // unblock the background goroutine + }) + + t.Run("typical usage to obtain late responses", func(t *testing.T) { + srvr := &filtering.DNSServer{ + OnQuery: func(domain string) filtering.DNSAction { + return filtering.DNSActionLocalHostPlusCache + }, + Cache: map[string][]string{ + "dns.google.": {"8.8.8.8"}, + }, + } + listener, err := srvr.Start("127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + dialer := NewDialerWithoutResolver(model.DiscardLogger) + txp := NewDNSOverUDPTransport(dialer, listener.LocalAddr().String()) + encoder := &DNSEncoderMiekg{} + query := encoder.Encode("dns.google.", dns.TypeA, false) + rch := txp.AsyncRoundTrip(query, 1) + resp, err := rch.Next(context.Background()) + if err != nil { + t.Fatal(err) + } + addrs, err := resp.DecodeLookupHost() + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(addrs, []string{"127.0.0.1"}); diff != "" { + t.Fatal(diff) + } + // One would not normally busy loop but it's fine to do that in the context + // of this test because we know we're going to receive a second reply. In + // a real network experiment here we'll do other activities, e.g., contacting + // the test helper or fetching a webpage. + var additional []model.DNSResponse + for { + additional = rch.TryNextResponses() + if len(additional) > 0 { + if len(additional) != 1 { + t.Fatal("expected exactly one additional response") + } + break + } + } + addrs, err = additional[0].DecodeLookupHost() + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(addrs, []string{"8.8.8.8"}); diff != "" { + t.Fatal(diff) + } + }) + + t.Run("correct behavior when read times out", func(t *testing.T) { + srvr := &filtering.DNSServer{ + OnQuery: func(domain string) filtering.DNSAction { + return filtering.DNSActionTimeout + }, + } + listener, err := srvr.Start("127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + dialer := NewDialerWithoutResolver(model.DiscardLogger) + txp := NewDNSOverUDPTransport(dialer, listener.LocalAddr().String()) + txp.IOTimeout = 30 * time.Millisecond // short timeout to have a fast test + encoder := &DNSEncoderMiekg{} + query := encoder.Encode("dns.google.", dns.TypeA, false) + rch := txp.AsyncRoundTrip(query, 1) + result := <-rch.Response + if result.Err == nil || result.Err.Error() != "generic_timeout_error" { + t.Fatal("unexpected error", result.Err) + } + if result.Operation != ReadOperation { + t.Fatal("unexpected failed operation", result.Operation) + } + }) + }) + + t.Run("CloseIdleConnections", func(t *testing.T) { + var called bool + dialer := &mocks.Dialer{ + MockCloseIdleConnections: func() { + called = true + }, + } + const address = "9.9.9.9:53" + txp := NewDNSOverUDPTransport(dialer, address) + txp.CloseIdleConnections() + if !called { + t.Fatal("not called") + } }) t.Run("other functions okay", func(t *testing.T) { diff --git a/internal/netxlite/filtering/dns.go b/internal/netxlite/filtering/dns.go index 8b63527..3eede6d 100644 --- a/internal/netxlite/filtering/dns.go +++ b/internal/netxlite/filtering/dns.go @@ -1,22 +1,19 @@ package filtering import ( - "errors" "io" "net" "strings" + "time" "github.com/miekg/dns" "github.com/ooni/probe-cli/v3/internal/runtimex" ) -// DNSAction is a DNS filtering action that this proxy should take. +// DNSAction is a DNS filtering action that a DNSServer should take. type DNSAction string const ( - // DNSActionPass passes the traffic to the upstream server. - DNSActionPass = DNSAction("pass") - // DNSActionNXDOMAIN replies with NXDOMAIN. DNSActionNXDOMAIN = DNSAction("nxdomain") @@ -32,15 +29,19 @@ const ( // DNSActionTimeout never replies to the query. DNSActionTimeout = DNSAction("timeout") - // DNSActionCache causes the proxy to check the cache. If there + // DNSActionCache causes the server to check the cache. If there // are entries, they are returned. Otherwise, NXDOMAIN is returned. DNSActionCache = DNSAction("cache") + + // DNSActionLocalHostPlusCache combines the LocalHost and + // Cache actions returning first a localhost response followed + // by a subsequent response obtained using the cache. + DNSActionLocalHostPlusCache = DNSAction("localhost+cache") ) -// DNSProxy is a DNS proxy that routes traffic to an upstream -// resolver and may implement filtering policies. -type DNSProxy struct { - // Cache is the DNS cache. Note that the keys of the map +// DNSServer is a DNS server implementing filtering policies. +type DNSServer struct { + // Cache is the OPTIONAL DNS cache. Note that the keys of the map // must be FQDNs (i.e., including the final `.`). Cache map[string][]string @@ -48,26 +49,27 @@ type DNSProxy struct { // receive a query for the given domain. OnQuery func(domain string) DNSAction - // UpstreamEndpoint is the OPTIONAL upstream transport endpoint. - UpstreamEndpoint string - - // mockableReply allows to mock DNSProxy.reply in tests. - mockableReply func(query *dns.Msg) (*dns.Msg, error) + // onTimeout is the OPTIONAL channel where we emit a true + // value each time there's a timeout. If you set this value + // to a non-nil channel, then you MUST drain the channel + // for each expected timeout. Otherwise, the code will just + // ignore this field and nothing will be emitted. + onTimeout chan bool } -// DNSListener is the interface returned by DNSProxy.Start +// DNSListener is the interface returned by DNSServer.Start. type DNSListener interface { io.Closer LocalAddr() net.Addr } -// Start starts the proxy. -func (p *DNSProxy) Start(address string) (DNSListener, error) { +// Start starts this server. +func (p *DNSServer) Start(address string) (DNSListener, error) { pconn, _, err := p.start(address) return pconn, err } -func (p *DNSProxy) start(address string) (DNSListener, <-chan interface{}, error) { +func (p *DNSServer) start(address string) (DNSListener, <-chan interface{}, error) { pconn, err := net.ListenPacket("udp", address) if err != nil { return nil, nil, err @@ -77,15 +79,15 @@ func (p *DNSProxy) start(address string) (DNSListener, <-chan interface{}, error return pconn, done, nil } -func (p *DNSProxy) mainloop(pconn net.PacketConn, done chan<- interface{}) { +func (p *DNSServer) mainloop(pconn net.PacketConn, done chan<- interface{}) { defer close(done) for p.oneloop(pconn) { // nothing } } -func (p *DNSProxy) oneloop(pconn net.PacketConn) bool { - buffer := make([]byte, 1<<12) +func (p *DNSServer) oneloop(pconn net.PacketConn) bool { + buffer := make([]byte, 1<<17) count, addr, err := pconn.ReadFrom(buffer) if err != nil { return !strings.HasSuffix(err.Error(), "use of closed network connection") @@ -95,73 +97,70 @@ func (p *DNSProxy) oneloop(pconn net.PacketConn) bool { return true } -func (p *DNSProxy) serveAsync(pconn net.PacketConn, addr net.Addr, buffer []byte) { +func (p *DNSServer) emit(pconn net.PacketConn, addr net.Addr, reply ...*dns.Msg) (success int) { + for _, entry := range reply { + replyBytes, err := entry.Pack() + if err != nil { + continue + } + pconn.WriteTo(replyBytes, addr) + success++ // we use this value in tests + } + return +} + +func (p *DNSServer) serveAsync(pconn net.PacketConn, addr net.Addr, buffer []byte) { query := &dns.Msg{} if err := query.Unpack(buffer); err != nil { return } - reply, err := p.reply(query) - if err != nil { - return - } - replyBytes, err := reply.Pack() - if err != nil { - return - } - pconn.WriteTo(replyBytes, addr) -} - -func (p *DNSProxy) reply(query *dns.Msg) (*dns.Msg, error) { - if p.mockableReply != nil { - return p.mockableReply(query) - } - return p.replyDefault(query) -} - -func (p *DNSProxy) replyDefault(query *dns.Msg) (*dns.Msg, error) { - if len(query.Question) != 1 { - return nil, errors.New("unhandled message") + if len(query.Question) < 1 { + return // just discard the query } name := query.Question[0].Name switch p.OnQuery(name) { - case DNSActionPass: - return p.proxy(query) case DNSActionNXDOMAIN: - return p.nxdomain(query), nil + p.emit(pconn, addr, p.nxdomain(query)) case DNSActionLocalHost: - return p.localHost(query), nil + p.emit(pconn, addr, p.localHost(query)) case DNSActionNoAnswer: - return p.empty(query), nil + p.emit(pconn, addr, p.empty(query)) case DNSActionTimeout: - return nil, errors.New("let's ignore this query") + if p.onTimeout != nil { + p.onTimeout <- true + } case DNSActionCache: - return p.cache(name, query), nil + p.emit(pconn, addr, p.cache(name, query)) + case DNSActionLocalHostPlusCache: + p.emit(pconn, addr, p.localHost(query)) + time.Sleep(10 * time.Millisecond) + p.emit(pconn, addr, p.cache(name, query)) default: - return p.refused(query), nil + p.emit(pconn, addr, p.refused(query)) } } -func (p *DNSProxy) refused(query *dns.Msg) *dns.Msg { +func (p *DNSServer) refused(query *dns.Msg) *dns.Msg { m := new(dns.Msg) m.SetRcode(query, dns.RcodeRefused) return m } -func (p *DNSProxy) nxdomain(query *dns.Msg) *dns.Msg { +func (p *DNSServer) nxdomain(query *dns.Msg) *dns.Msg { m := new(dns.Msg) m.SetRcode(query, dns.RcodeNameError) return m } -func (p *DNSProxy) localHost(query *dns.Msg) *dns.Msg { +func (p *DNSServer) localHost(query *dns.Msg) *dns.Msg { return p.compose(query, net.IPv6loopback, net.IPv4(127, 0, 0, 1)) } -func (p *DNSProxy) empty(query *dns.Msg) *dns.Msg { +func (p *DNSServer) empty(query *dns.Msg) *dns.Msg { return p.compose(query) } -func (p *DNSProxy) compose(query *dns.Msg, ips ...net.IP) *dns.Msg { +func (p *DNSServer) compose(query *dns.Msg, ips ...net.IP) *dns.Msg { runtimex.PanicIfTrue(len(query.Question) != 1, "expecting a single question") question := query.Question[0] reply := new(dns.Msg) @@ -195,27 +194,7 @@ func (p *DNSProxy) compose(query *dns.Msg, ips ...net.IP) *dns.Msg { return reply } -var ( - // errDNSExpectedSingleQuestion means we expected to see a single question - errDNSExpectedSingleQuestion = errors.New("filtering: expected single DNS question") - - // errDNSExpectedQueryNotResponse means we expected to see a query. - errDNSExpectedQueryNotResponse = errors.New("filtering: expected query not response") -) - -func (p *DNSProxy) proxy(query *dns.Msg) (*dns.Msg, error) { - if query.Response { - return nil, errDNSExpectedQueryNotResponse - } - if len(query.Question) != 1 { - return nil, errDNSExpectedSingleQuestion - } - clnt := &dns.Client{} - resp, _, err := clnt.Exchange(query, p.upstreamEndpoint()) - return resp, err -} - -func (p *DNSProxy) cache(name string, query *dns.Msg) *dns.Msg { +func (p *DNSServer) cache(name string, query *dns.Msg) *dns.Msg { addrs := p.Cache[name] var ipAddrs []net.IP for _, addr := range addrs { @@ -228,10 +207,3 @@ func (p *DNSProxy) cache(name string, query *dns.Msg) *dns.Msg { } return p.compose(query, ipAddrs...) } - -func (p *DNSProxy) upstreamEndpoint() string { - if p.UpstreamEndpoint != "" { - return p.UpstreamEndpoint - } - return "8.8.8.8:53" -} diff --git a/internal/netxlite/filtering/dns_test.go b/internal/netxlite/filtering/dns_test.go index 88d5ac5..a59ea63 100644 --- a/internal/netxlite/filtering/dns_test.go +++ b/internal/netxlite/filtering/dns_test.go @@ -1,122 +1,98 @@ package filtering import ( - "context" "errors" "net" "strings" "testing" - "time" - "github.com/apex/log" "github.com/miekg/dns" - "github.com/ooni/probe-cli/v3/internal/model" "github.com/ooni/probe-cli/v3/internal/model/mocks" - "github.com/ooni/probe-cli/v3/internal/netxlite" + "github.com/ooni/probe-cli/v3/internal/randx" ) -func TestDNSProxy(t *testing.T) { - if testing.Short() { - t.Skip("skip test in short mode") - } - newProxyWithCache := func(action DNSAction, cache map[string][]string) (DNSListener, <-chan interface{}, error) { - p := &DNSProxy{ +func TestDNSServer(t *testing.T) { + newServerWithCache := func(action DNSAction, cache map[string][]string) ( + *DNSServer, DNSListener, <-chan interface{}, error) { + p := &DNSServer{ Cache: cache, OnQuery: func(domain string) DNSAction { return action }, + onTimeout: make(chan bool), } - return p.start("127.0.0.1:0") + listener, done, err := p.start("127.0.0.1:0") + return p, listener, done, err } - newProxy := func(action DNSAction) (DNSListener, <-chan interface{}, error) { - return newProxyWithCache(action, nil) + newServer := func(action DNSAction) (*DNSServer, DNSListener, <-chan interface{}, error) { + return newServerWithCache(action, nil) } - newresolver := func(listener DNSListener) model.Resolver { - dlr := netxlite.NewDialerWithoutResolver(log.Log) - r := netxlite.NewResolverUDP(log.Log, dlr, listener.LocalAddr().String()) - return r + newQuery := func(qtype uint16) *dns.Msg { + question := dns.Question{ + Name: dns.Fqdn("dns.google"), + Qtype: qtype, + Qclass: dns.ClassINET, + } + query := new(dns.Msg) + query.Id = dns.Id() + query.RecursionDesired = true + query.Question = make([]dns.Question, 1) + query.Question[0] = question + return query } - t.Run("DNSActionPass", func(t *testing.T) { - ctx := context.Background() - listener, done, err := newProxy(DNSActionPass) - if err != nil { - t.Fatal(err) - } - r := newresolver(listener) - addrs, err := r.LookupHost(ctx, "dns.google") - if err != nil { - t.Fatal(err) - } - if addrs == nil { - t.Fatal("unexpected empty addrs") - } - var found bool - for _, addr := range addrs { - found = found || addr == "8.8.8.8" - } - if !found { - t.Fatal("did not find 8.8.8.8") - } - listener.Close() - <-done // wait for background goroutine to exit - }) - t.Run("DNSActionNXDOMAIN", func(t *testing.T) { - ctx := context.Background() - listener, done, err := newProxy(DNSActionNXDOMAIN) + _, listener, done, err := newServer(DNSActionNXDOMAIN) if err != nil { t.Fatal(err) } - r := newresolver(listener) - addrs, err := r.LookupHost(ctx, "dns.google") - if err == nil || err.Error() != netxlite.FailureDNSNXDOMAINError { - t.Fatal("unexpected err", err) + reply, err := dns.Exchange(newQuery(dns.TypeA), listener.LocalAddr().String()) + if err != nil { + t.Fatal(err) } - if addrs != nil { - t.Fatal("expected empty addrs") + if reply.Rcode != dns.RcodeNameError { + t.Fatal("unexpected rcode") } listener.Close() <-done // wait for background goroutine to exit }) t.Run("DNSActionRefused", func(t *testing.T) { - ctx := context.Background() - listener, done, err := newProxy(DNSActionRefused) + _, listener, done, err := newServer(DNSActionRefused) if err != nil { t.Fatal(err) } - r := newresolver(listener) - addrs, err := r.LookupHost(ctx, "dns.google") - if err == nil || err.Error() != netxlite.FailureDNSRefusedError { - t.Fatal("unexpected err", err) + reply, err := dns.Exchange(newQuery(dns.TypeA), listener.LocalAddr().String()) + if err != nil { + t.Fatal(err) } - if addrs != nil { - t.Fatal("expected empty addrs") + if reply.Rcode != dns.RcodeRefused { + t.Fatal("unexpected rcode") } listener.Close() <-done // wait for background goroutine to exit }) t.Run("DNSActionLocalHost", func(t *testing.T) { - ctx := context.Background() - listener, done, err := newProxy(DNSActionLocalHost) + _, listener, done, err := newServer(DNSActionLocalHost) if err != nil { t.Fatal(err) } - r := newresolver(listener) - addrs, err := r.LookupHost(ctx, "dns.google") + reply, err := dns.Exchange(newQuery(dns.TypeA), listener.LocalAddr().String()) if err != nil { t.Fatal(err) } - if addrs == nil { - t.Fatal("expected non-empty addrs") + if reply.Rcode != dns.RcodeSuccess { + t.Fatal("unexpected rcode") } var found bool - for _, addr := range addrs { - found = found || addr == "127.0.0.1" + for _, ans := range reply.Answer { + switch v := ans.(type) { + case *dns.A: + found = found || v.A.String() == "127.0.0.1" + } } if !found { t.Fatal("did not find 127.0.0.1") @@ -126,94 +102,154 @@ func TestDNSProxy(t *testing.T) { }) t.Run("DNSActionEmpty", func(t *testing.T) { - ctx := context.Background() - listener, done, err := newProxy(DNSActionNoAnswer) + _, listener, done, err := newServer(DNSActionNoAnswer) if err != nil { t.Fatal(err) } - r := newresolver(listener) - addrs, err := r.LookupHost(ctx, "dns.google") - if err == nil || err.Error() != netxlite.FailureDNSNoAnswer { - t.Fatal("unexpected err", err) + reply, err := dns.Exchange(newQuery(dns.TypeA), listener.LocalAddr().String()) + if err != nil { + t.Fatal(err) } - if addrs != nil { - t.Fatal("expected empty addrs") + if reply.Rcode != dns.RcodeSuccess { + t.Fatal("unexpected rcode") + } + if len(reply.Answer) != 0 { + t.Fatal("expected no answers") } listener.Close() <-done // wait for background goroutine to exit }) t.Run("DNSActionTimeout", func(t *testing.T) { - // Implementation note: if you see this test running for more - // than one second, then it means we're not checking the context - // immediately. We should be improving there but we need to be - // careful because lots of legacy code uses SerialResolver. - const timeout = time.Second - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - listener, done, err := newProxy(DNSActionTimeout) + srvr, listener, done, err := newServer(DNSActionTimeout) if err != nil { t.Fatal(err) } - r := newresolver(listener) - addrs, err := r.LookupHost(ctx, "dns.google") - if err == nil || err.Error() != netxlite.FailureGenericTimeoutError { + c := &dns.Client{} + conn, err := c.Dial(listener.LocalAddr().String()) + if err != nil { + t.Fatal(err) + } + go func() { + <-srvr.onTimeout + conn.Close() // close as soon as the server times out, so this test is fast + }() + reply, _, err := c.ExchangeWithConn(newQuery(dns.TypeA), conn) + if !errors.Is(err, net.ErrClosed) { t.Fatal("unexpected err", err) } - if addrs != nil { - t.Fatal("expected empty addrs") + if reply != nil { + t.Fatal("expected nil reply here") } listener.Close() <-done // wait for background goroutine to exit }) t.Run("DNSActionCache without entries", func(t *testing.T) { - ctx := context.Background() - listener, done, err := newProxyWithCache(DNSActionCache, nil) + _, listener, done, err := newServerWithCache(DNSActionCache, nil) if err != nil { t.Fatal(err) } - r := newresolver(listener) - addrs, err := r.LookupHost(ctx, "dns.google") - if err == nil || err.Error() != netxlite.FailureDNSNXDOMAINError { - t.Fatal("unexpected err", err) + reply, err := dns.Exchange(newQuery(dns.TypeA), listener.LocalAddr().String()) + if err != nil { + t.Fatal(err) } - if addrs != nil { - t.Fatal("expected empty addrs") + if reply.Rcode != dns.RcodeNameError { + t.Fatal("unexpected rcode") } listener.Close() <-done // wait for background goroutine to exit }) - t.Run("DNSActionCache with entries", func(t *testing.T) { - ctx := context.Background() + t.Run("DNSActionCache with IPv4 entry", func(t *testing.T) { cache := map[string][]string{ - "dns.google.": {"8.8.8.8", "8.8.4.4"}, + "dns.google.": {"8.8.8.8"}, } - listener, done, err := newProxyWithCache(DNSActionCache, cache) + _, listener, done, err := newServerWithCache(DNSActionCache, cache) if err != nil { t.Fatal(err) } - r := newresolver(listener) - addrs, err := r.LookupHost(ctx, "dns.google") + reply, err := dns.Exchange(newQuery(dns.TypeA), listener.LocalAddr().String()) if err != nil { t.Fatal(err) } - if len(addrs) != 2 { - t.Fatal("expected two entries") + if reply.Rcode != dns.RcodeSuccess { + t.Fatal("unexpected rcode") } - if addrs[0] != "8.8.8.8" { - t.Fatal("invalid first entry") + var found bool + for _, ans := range reply.Answer { + switch v := ans.(type) { + case *dns.A: + found = found || v.A.String() == "8.8.8.8" + } } - if addrs[1] != "8.8.4.4" { - t.Fatal("invalid second entry") + if !found { + t.Fatal("did not find 8.8.8.8") + } + listener.Close() + <-done // wait for background goroutine to exit + }) + + t.Run("DNSActionCache with IPv6 entry", func(t *testing.T) { + cache := map[string][]string{ + "dns.google.": {"2001:4860:4860::8888"}, + } + _, listener, done, err := newServerWithCache(DNSActionCache, cache) + if err != nil { + t.Fatal(err) + } + reply, err := dns.Exchange(newQuery(dns.TypeAAAA), listener.LocalAddr().String()) + if err != nil { + t.Fatal(err) + } + if reply.Rcode != dns.RcodeSuccess { + t.Fatal("unexpected rcode") + } + var found bool + for _, ans := range reply.Answer { + switch v := ans.(type) { + case *dns.AAAA: + found = found || v.AAAA.String() == "2001:4860:4860::8888" + } + } + if !found { + t.Fatal("did not find 2001:4860:4860::8888") + } + listener.Close() + <-done // wait for background goroutine to exit + }) + + t.Run("DNSActionLocalHostPlusCache", func(t *testing.T) { + cache := map[string][]string{ + "dns.google.": {"2001:4860:4860::8888"}, + } + _, listener, done, err := newServerWithCache(DNSActionLocalHostPlusCache, cache) + if err != nil { + t.Fatal(err) + } + reply, err := dns.Exchange(newQuery(dns.TypeAAAA), listener.LocalAddr().String()) + if err != nil { + t.Fatal(err) + } + if reply.Rcode != dns.RcodeSuccess { + t.Fatal("unexpected rcode") + } + var found bool + for _, ans := range reply.Answer { + switch v := ans.(type) { + case *dns.AAAA: + found = found || v.AAAA.String() == "::1" + } + } + if !found { + t.Fatal("did not find ::1") } listener.Close() <-done // wait for background goroutine to exit }) t.Run("Start with invalid address", func(t *testing.T) { - p := &DNSProxy{} + p := &DNSServer{} listener, err := p.Start("127.0.0.1") if err == nil || !strings.HasSuffix(err.Error(), "missing port in address") { t.Fatal("unexpected err", err) @@ -226,7 +262,7 @@ func TestDNSProxy(t *testing.T) { t.Run("oneloop", func(t *testing.T) { t.Run("ReadFrom failure after which we should continue", func(t *testing.T) { expected := errors.New("mocked error") - p := &DNSProxy{} + p := &DNSServer{} conn := &mocks.UDPLikeConn{ MockReadFrom: func(p []byte) (n int, addr net.Addr, err error) { return 0, nil, expected @@ -240,7 +276,7 @@ func TestDNSProxy(t *testing.T) { t.Run("ReadFrom the connection is closed", func(t *testing.T) { expected := errors.New("use of closed network connection") - p := &DNSProxy{} + p := &DNSServer{} conn := &mocks.UDPLikeConn{ MockReadFrom: func(p []byte) (n int, addr net.Addr, err error) { return 0, nil, expected @@ -253,7 +289,7 @@ func TestDNSProxy(t *testing.T) { }) t.Run("Unpack fails", func(t *testing.T) { - p := &DNSProxy{} + p := &DNSServer{} conn := &mocks.UDPLikeConn{ MockReadFrom: func(p []byte) (n int, addr net.Addr, err error) { if len(p) < 4 { @@ -269,46 +305,16 @@ func TestDNSProxy(t *testing.T) { } }) - t.Run("reply fails", func(t *testing.T) { - p := &DNSProxy{} + t.Run("no questions", func(t *testing.T) { + query := newQuery(dns.TypeA) + query.Question = nil // remove the question + data, err := query.Pack() + if err != nil { + t.Fatal(err) + } + p := &DNSServer{} conn := &mocks.UDPLikeConn{ MockReadFrom: func(p []byte) (n int, addr net.Addr, err error) { - query := &dns.Msg{} - query.Question = append(query.Question, dns.Question{}) - query.Question = append(query.Question, dns.Question{}) - data, err := query.Pack() - if err != nil { - panic(err) - } - if len(p) < len(data) { - panic("buffer too small") - } - copy(p, data) - return len(data), &net.UDPAddr{}, nil - }, - } - okay := p.oneloop(conn) - if !okay { - t.Fatal("we should be okay after this error") - } - }) - - t.Run("pack fails", func(t *testing.T) { - p := &DNSProxy{ - mockableReply: func(query *dns.Msg) (*dns.Msg, error) { - reply := &dns.Msg{} - reply.MsgHdr.Rcode = -1 // causes pack to fail - return reply, nil - }, - } - conn := &mocks.UDPLikeConn{ - MockReadFrom: func(p []byte) (n int, addr net.Addr, err error) { - query := &dns.Msg{} - query.Question = append(query.Question, dns.Question{}) - data, err := query.Pack() - if err != nil { - panic(err) - } if len(p) < len(data) { panic("buffer too small") } @@ -323,45 +329,13 @@ func TestDNSProxy(t *testing.T) { }) }) - t.Run("proxy", func(t *testing.T) { - t.Run("with response", func(t *testing.T) { - p := &DNSProxy{} - query := &dns.Msg{} - query.Response = true - reply, err := p.proxy(query) - if !errors.Is(err, errDNSExpectedQueryNotResponse) { - t.Fatal("unexpected err", err) - } - if reply != nil { - t.Fatal("expected nil reply") - } - }) - - t.Run("with no questions", func(t *testing.T) { - p := &DNSProxy{} - query := &dns.Msg{} - reply, err := p.proxy(query) - if !errors.Is(err, errDNSExpectedSingleQuestion) { - t.Fatal("unexpected err", err) - } - if reply != nil { - t.Fatal("expected nil reply") - } - }) - - t.Run("round trip fails", func(t *testing.T) { - p := &DNSProxy{ - UpstreamEndpoint: "antani", - } - query := &dns.Msg{} - query.Question = append(query.Question, dns.Question{}) - reply, err := p.proxy(query) - if err == nil || !strings.HasSuffix(err.Error(), "missing port in address") { - t.Fatal("unexpected err", err) - } - if reply != nil { - t.Fatal("expected nil reply here") - } - }) + t.Run("pack fails", func(t *testing.T) { + query := newQuery(dns.TypeA) + query.Question[0].Name = randx.Letters(1024) // should be too large + p := &DNSServer{} + count := p.emit(&mocks.UDPLikeConn{}, &mocks.Addr{}, query) + if count != 0 { + t.Fatal("expected to see zero here") + } }) } diff --git a/internal/netxlite/filtering/doc.go b/internal/netxlite/filtering/doc.go index e5febfa..69bb7ce 100644 --- a/internal/netxlite/filtering/doc.go +++ b/internal/netxlite/filtering/doc.go @@ -1,13 +1,3 @@ -// Package filtering allows to implement self-censorship. -// -// The top-level struct is the TProxy. It implements model's -// UnderlyingNetworkLibrary interface. Therefore, you can use TProxy to -// implement filtering and blocking of TCP, TLS, QUIC, DNS, HTTP. -// -// We also expose proxies that implement filtering policies for -// DNS, TLS, and HTTP. -// -// The typical usage of this package's functionality is to -// load a censoring policy into TProxyConfig and then to create -// and start a TProxy instance using NewTProxy. +// Package filtering allows to implement self-censorship. We expose proxies +// implementing filtering policies for DNS, TLS, and HTTP. package filtering diff --git a/internal/netxlite/filtering/http.go b/internal/netxlite/filtering/http.go index c39fe1b..6097fca 100644 --- a/internal/netxlite/filtering/http.go +++ b/internal/netxlite/filtering/http.go @@ -9,6 +9,9 @@ import ( "github.com/ooni/probe-cli/v3/internal/runtimex" ) +// TODO(bassosimone): remove HTTPActionPass since we want integration tests +// to only run locally to make them much more predictable. + // HTTPAction is an HTTP filtering action that this proxy should take. type HTTPAction string diff --git a/internal/netxlite/filtering/tls.go b/internal/netxlite/filtering/tls.go index 32b092e..298a30b 100644 --- a/internal/netxlite/filtering/tls.go +++ b/internal/netxlite/filtering/tls.go @@ -1,16 +1,17 @@ package filtering import ( - "context" "crypto/tls" "errors" + "io" "net" "strings" "sync" - - "github.com/ooni/probe-cli/v3/internal/netxlite" ) +// TODO(bassosimone): remove TLSActionPass since we want integration tests +// to only run locally to make them much more predictable. + // TLSAction is a TLS filtering action that this proxy should take. type TLSAction string @@ -237,5 +238,11 @@ func (p *TLSProxy) connectingToMyself(conn net.Conn) bool { // forward will forward the traffic. func (p *TLSProxy) forward(wg *sync.WaitGroup, left net.Conn, right net.Conn) { defer wg.Done() - netxlite.CopyContext(context.Background(), left, right) + // We cannot use netxlite.CopyContext here because we want netxlite to + // use filtering inside its test suite, so this package cannot depend on + // netxlite. In general, we don't want to use io.Copy or io.ReadAll + // directly because they may cause the code to block as documented in + // internal/netxlite/iox.go. However, this package is only used for + // testing, so it's completely okay to make an exception here. + io.Copy(left, right) } diff --git a/internal/netxlite/integration_test.go b/internal/netxlite/integration_test.go index 60b2ddf..19a35e1 100644 --- a/internal/netxlite/integration_test.go +++ b/internal/netxlite/integration_test.go @@ -113,7 +113,7 @@ func TestMeasureWithUDPResolver(t *testing.T) { }) t.Run("for nxdomain", func(t *testing.T) { - proxy := &filtering.DNSProxy{ + proxy := &filtering.DNSServer{ OnQuery: func(domain string) filtering.DNSAction { return filtering.DNSActionNXDOMAIN }, @@ -137,7 +137,7 @@ func TestMeasureWithUDPResolver(t *testing.T) { }) t.Run("for refused", func(t *testing.T) { - proxy := &filtering.DNSProxy{ + proxy := &filtering.DNSServer{ OnQuery: func(domain string) filtering.DNSAction { return filtering.DNSActionRefused }, @@ -161,7 +161,7 @@ func TestMeasureWithUDPResolver(t *testing.T) { }) t.Run("for timeout", func(t *testing.T) { - proxy := &filtering.DNSProxy{ + proxy := &filtering.DNSServer{ OnQuery: func(domain string) filtering.DNSAction { return filtering.DNSActionTimeout }, diff --git a/script/nocopyreadall.bash b/script/nocopyreadall.bash index 0797655..cd4a234 100755 --- a/script/nocopyreadall.bash +++ b/script/nocopyreadall.bash @@ -7,6 +7,12 @@ for file in $(find . -type f -name \*.go); do # implement safer wrappers for these functions. continue fi + if [ "$file" = "./internal/netxlite/filtering/tls.go" ]; then + # We're allowed to use ReadAll and Copy in this file to + # avoid depending on netxlite, so we can use filtering + # inside of netxlite's own test suite. + continue + fi if grep -q 'io\.ReadAll' $file; then echo "in $file: do not use io.ReadAll, use netxlite.ReadAllContext" 1>&2 exitcode=1