diff --git a/internal/mocks/measuringnetwork.go b/internal/mocks/measuringnetwork.go new file mode 100644 index 0000000000..1153abe8b7 --- /dev/null +++ b/internal/mocks/measuringnetwork.go @@ -0,0 +1,67 @@ +package mocks + +import ( + "github.com/ooni/probe-cli/v3/internal/model" + utls "gitlab.com/yawning/utls.git" +) + +// MeasuringNetwork allows mocking [model.MeasuringNetwork]. +type MeasuringNetwork struct { + MockNewDialerWithResolver func(dl model.DebugLogger, r model.Resolver, w ...model.DialerWrapper) model.Dialer + + MockNewParallelDNSOverHTTPSResolver func(logger model.DebugLogger, URL string) model.Resolver + + MockNewParallelUDPResolver func(logger model.DebugLogger, dialer model.Dialer, address string) model.Resolver + + MockNewQUICDialerWithResolver func(listener model.UDPListener, logger model.DebugLogger, resolver model.Resolver, w ...model.QUICDialerWrapper) model.QUICDialer + + MockNewStdlibResolver func(logger model.DebugLogger) model.Resolver + + MockNewTLSHandshakerStdlib func(logger model.DebugLogger) model.TLSHandshaker + + MockNewTLSHandshakerUTLS func(logger model.DebugLogger, id *utls.ClientHelloID) model.TLSHandshaker + + MockNewUDPListener func() model.UDPListener +} + +var _ model.MeasuringNetwork = &MeasuringNetwork{} + +// NewDialerWithResolver implements model.MeasuringNetwork. +func (mn *MeasuringNetwork) NewDialerWithResolver(dl model.DebugLogger, r model.Resolver, w ...model.DialerWrapper) model.Dialer { + return mn.MockNewDialerWithResolver(dl, r, w...) +} + +// NewParallelDNSOverHTTPSResolver implements model.MeasuringNetwork. +func (mn *MeasuringNetwork) NewParallelDNSOverHTTPSResolver(logger model.DebugLogger, URL string) model.Resolver { + return mn.MockNewParallelDNSOverHTTPSResolver(logger, URL) +} + +// NewParallelUDPResolver implements model.MeasuringNetwork. +func (mn *MeasuringNetwork) NewParallelUDPResolver(logger model.DebugLogger, dialer model.Dialer, address string) model.Resolver { + return mn.MockNewParallelUDPResolver(logger, dialer, address) +} + +// NewQUICDialerWithResolver implements model.MeasuringNetwork. +func (mn *MeasuringNetwork) NewQUICDialerWithResolver(listener model.UDPListener, logger model.DebugLogger, resolver model.Resolver, w ...model.QUICDialerWrapper) model.QUICDialer { + return mn.MockNewQUICDialerWithResolver(listener, logger, resolver, w...) +} + +// NewStdlibResolver implements model.MeasuringNetwork. +func (mn *MeasuringNetwork) NewStdlibResolver(logger model.DebugLogger) model.Resolver { + return mn.MockNewStdlibResolver(logger) +} + +// NewTLSHandshakerStdlib implements model.MeasuringNetwork. +func (mn *MeasuringNetwork) NewTLSHandshakerStdlib(logger model.DebugLogger) model.TLSHandshaker { + return mn.MockNewTLSHandshakerStdlib(logger) +} + +// NewTLSHandshakerUTLS implements model.MeasuringNetwork. +func (mn *MeasuringNetwork) NewTLSHandshakerUTLS(logger model.DebugLogger, id *utls.ClientHelloID) model.TLSHandshaker { + return mn.MockNewTLSHandshakerUTLS(logger, id) +} + +// NewUDPListener implements model.MeasuringNetwork. +func (mn *MeasuringNetwork) NewUDPListener() model.UDPListener { + return mn.MockNewUDPListener() +} diff --git a/internal/mocks/measuringnetwork_test.go b/internal/mocks/measuringnetwork_test.go new file mode 100644 index 0000000000..cdb3d077bb --- /dev/null +++ b/internal/mocks/measuringnetwork_test.go @@ -0,0 +1,114 @@ +package mocks + +import ( + "testing" + + "github.com/ooni/probe-cli/v3/internal/model" + utls "gitlab.com/yawning/utls.git" +) + +func TestMeasuringN(t *testing.T) { + t.Run("MockNewDialerWithResolver", func(t *testing.T) { + expected := &Dialer{} + mn := &MeasuringNetwork{ + MockNewDialerWithResolver: func(dl model.DebugLogger, r model.Resolver, w ...model.DialerWrapper) model.Dialer { + return expected + }, + } + got := mn.NewDialerWithResolver(nil, nil) + if expected != got { + t.Fatal("unexpected result") + } + }) + + t.Run("MockNewParallelDNSOverHTTPSResolver", func(t *testing.T) { + expected := &Resolver{} + mn := &MeasuringNetwork{ + MockNewParallelDNSOverHTTPSResolver: func(logger model.DebugLogger, URL string) model.Resolver { + return expected + }, + } + got := mn.NewParallelDNSOverHTTPSResolver(nil, "") + if expected != got { + t.Fatal("unexpected result") + } + }) + + t.Run("MockNewParallelUDPResolver", func(t *testing.T) { + expected := &Resolver{} + mn := &MeasuringNetwork{ + MockNewParallelUDPResolver: func(logger model.DebugLogger, dialer model.Dialer, address string) model.Resolver { + return expected + }, + } + got := mn.NewParallelUDPResolver(nil, nil, "") + if expected != got { + t.Fatal("unexpected result") + } + }) + + t.Run("MockNewQUICDialerWithResolver", func(t *testing.T) { + expected := &QUICDialer{} + mn := &MeasuringNetwork{ + MockNewQUICDialerWithResolver: func(listener model.UDPListener, logger model.DebugLogger, resolver model.Resolver, w ...model.QUICDialerWrapper) model.QUICDialer { + return expected + }, + } + got := mn.NewQUICDialerWithResolver(nil, nil, nil) + if expected != got { + t.Fatal("unexpected result") + } + }) + + t.Run("MockNewStdlibResolver", func(t *testing.T) { + expected := &Resolver{} + mn := &MeasuringNetwork{ + MockNewStdlibResolver: func(logger model.DebugLogger) model.Resolver { + return expected + }, + } + got := mn.NewStdlibResolver(nil) + if expected != got { + t.Fatal("unexpected result") + } + }) + + t.Run("MockNewTLSHandshakerStdlib", func(t *testing.T) { + expected := &TLSHandshaker{} + mn := &MeasuringNetwork{ + MockNewTLSHandshakerStdlib: func(logger model.DebugLogger) model.TLSHandshaker { + return expected + }, + } + got := mn.NewTLSHandshakerStdlib(nil) + if expected != got { + t.Fatal("unexpected result") + } + }) + + t.Run("MockNewTLSHandshakerUTLS", func(t *testing.T) { + expected := &TLSHandshaker{} + mn := &MeasuringNetwork{ + MockNewTLSHandshakerUTLS: func(logger model.DebugLogger, id *utls.ClientHelloID) model.TLSHandshaker { + return expected + }, + } + got := mn.NewTLSHandshakerUTLS(nil, nil) + if expected != got { + t.Fatal("unexpected result") + } + }) + + t.Run("MockNewUDPListener", func(t *testing.T) { + expected := &UDPListener{} + mn := &MeasuringNetwork{ + MockNewUDPListener: func() model.UDPListener { + return expected + }, + } + got := mn.NewUDPListener() + if expected != got { + t.Fatal("unexpected result") + } + }) +} diff --git a/internal/mocks/quic.go b/internal/mocks/quic.go index 102bb2f1dd..d42d9aeed8 100644 --- a/internal/mocks/quic.go +++ b/internal/mocks/quic.go @@ -11,16 +11,6 @@ import ( "github.com/quic-go/quic-go" ) -// UDPListener is a mockable netxlite.UDPListener. -type UDPListener struct { - MockListen func(addr *net.UDPAddr) (model.UDPLikeConn, error) -} - -// Listen calls MockListen. -func (ql *UDPListener) Listen(addr *net.UDPAddr) (model.UDPLikeConn, error) { - return ql.MockListen(addr) -} - // QUICDialer is a mockable netxlite.QUICDialer. type QUICDialer struct { // MockDialContext allows mocking DialContext. diff --git a/internal/mocks/quic_test.go b/internal/mocks/quic_test.go index bb5505f437..93fc00f937 100644 --- a/internal/mocks/quic_test.go +++ b/internal/mocks/quic_test.go @@ -11,28 +11,9 @@ import ( "time" "github.com/google/go-cmp/cmp" - "github.com/ooni/probe-cli/v3/internal/model" "github.com/quic-go/quic-go" ) -func TestUDPListenerListen(t *testing.T) { - t.Run("Listen", func(t *testing.T) { - expected := errors.New("mocked error") - ql := &UDPListener{ - MockListen: func(addr *net.UDPAddr) (model.UDPLikeConn, error) { - return nil, expected - }, - } - pconn, err := ql.Listen(&net.UDPAddr{}) - if !errors.Is(err, expected) { - t.Fatal("not the error we expected", expected) - } - if pconn != nil { - t.Fatal("expected nil conn here") - } - }) -} - func TestQUICDialer(t *testing.T) { t.Run("DialContext", func(t *testing.T) { expected := errors.New("mocked error") diff --git a/internal/mocks/udplistener.go b/internal/mocks/udplistener.go new file mode 100644 index 0000000000..58a0f3a1d4 --- /dev/null +++ b/internal/mocks/udplistener.go @@ -0,0 +1,17 @@ +package mocks + +import ( + "net" + + "github.com/ooni/probe-cli/v3/internal/model" +) + +// UDPListener is a mockable netxlite.UDPListener. +type UDPListener struct { + MockListen func(addr *net.UDPAddr) (model.UDPLikeConn, error) +} + +// Listen calls MockListen. +func (ql *UDPListener) Listen(addr *net.UDPAddr) (model.UDPLikeConn, error) { + return ql.MockListen(addr) +} diff --git a/internal/mocks/udplistener_test.go b/internal/mocks/udplistener_test.go new file mode 100644 index 0000000000..ef7fab0e0e --- /dev/null +++ b/internal/mocks/udplistener_test.go @@ -0,0 +1,27 @@ +package mocks + +import ( + "errors" + "net" + "testing" + + "github.com/ooni/probe-cli/v3/internal/model" +) + +func TestUDPListener(t *testing.T) { + t.Run("Listen", func(t *testing.T) { + expected := errors.New("mocked error") + ql := &UDPListener{ + MockListen: func(addr *net.UDPAddr) (model.UDPLikeConn, error) { + return nil, expected + }, + } + pconn, err := ql.Listen(&net.UDPAddr{}) + if !errors.Is(err, expected) { + t.Fatal("not the error we expected", expected) + } + if pconn != nil { + t.Fatal("expected nil conn here") + } + }) +} diff --git a/internal/model/netx.go b/internal/model/netx.go index b55cbd5001..264559a25f 100644 --- a/internal/model/netx.go +++ b/internal/model/netx.go @@ -13,7 +13,9 @@ import ( "syscall" "time" + oohttp "github.com/ooni/oohttp" "github.com/quic-go/quic-go" + utls "gitlab.com/yawning/utls.git" ) // DNSResponse is a parsed DNS response ready for further processing. @@ -175,10 +177,63 @@ type HTTPSSvc struct { IPv6 []string } -// UDPListener listens for connections over UDP, e.g. QUIC. -type UDPListener interface { - // Listen creates a new listening UDPLikeConn. - Listen(addr *net.UDPAddr) (UDPLikeConn, error) +// MeasuringNetwork defines the constructors required for implementing OONI experiments. All +// these constructors MUST guarantee proper error wrapping to map Go errors to OONI errors +// as documented by the [netxlite] package. The [*netxlite.Netx] type is currently the default +// implementation of this interface. This interface SHOULD always be implemented in terms of +// an [UnderlyingNetwork] that allows to switch between the host network and [netemx]. +type MeasuringNetwork interface { + // NewDialerWithResolver creates a [Dialer] with error wrapping. + // + // This dialer will try to connect to each of the resolved IP address + // sequentially. In case of failure, such a resolver will return the first + // error that occurred. This implementation strategy is a QUIRK that is + // documented at TODO(https://github.com/ooni/probe/issues/1779). + // + // The [DialerWrapper] arguments wrap the returned dialer in such a way + // that we can implement the legacy [netx] package. New code MUST NOT + // use this functionality, which we'd like to remove ASAP. + NewDialerWithResolver(dl DebugLogger, r Resolver, w ...DialerWrapper) Dialer + + // NewParallelDNSOverHTTPSResolver creates a new DNS-over-HTTPS resolver with error wrapping. + NewParallelDNSOverHTTPSResolver(logger DebugLogger, URL string) Resolver + + // NewParallelUDPResolver creates a new Resolver using DNS-over-UDP + // that performs parallel A/AAAA lookups during LookupHost. + // + // The address argument is the UDP endpoint address (e.g., 1.1.1.1:53, [::1]:53). + NewParallelUDPResolver(logger DebugLogger, dialer Dialer, address string) Resolver + + // NewQUICDialerWithResolver creates a QUICDialer with error wrapping. + // + // Unlike the dialer returned by NewDialerWithResolver, this dialer MAY attempt + // happy eyeballs, perform parallel dial attempts, and return an error + // that aggregates all the errors that occurred. + // + // The [QUICDialerWrapper] arguments wrap the returned dialer in such a way + // that we can implement the legacy [netx] package. New code MUST NOT + // use this functionality, which we'd like to remove ASAP. + NewQUICDialerWithResolver( + listener UDPListener, logger DebugLogger, resolver Resolver, w ...QUICDialerWrapper) QUICDialer + + // NewStdlibResolver creates a new Resolver with error wrapping using + // getaddrinfo or &net.Resolver{} depending on `-tags netgo`. + NewStdlibResolver(logger DebugLogger) Resolver + + // NewTLSHandshakerStdlib creates a new TLSHandshaker with error wrapping + // that is using the go standard library to manage TLS. + NewTLSHandshakerStdlib(logger DebugLogger) TLSHandshaker + + // NewTLSHandshakerUTLS creates a new TLS handshaker using + // gitlab.com/yawning/utls for TLS that implements error wrapping. + // + // The id is the address of something like utls.HelloFirefox_55. + // + // Passing a nil `id` will make this function panic. + NewTLSHandshakerUTLS(logger DebugLogger, id *utls.ClientHelloID) TLSHandshaker + + // NewUDPListener creates a new UDPListener with error wrapping. + NewUDPListener() UDPListener } // QUICDialerWrapper is a type that takes in input a QUICDialer @@ -251,6 +306,16 @@ type Resolver interface { LookupNS(ctx context.Context, domain string) ([]*net.NS, error) } +// TLSConn is the type of connection that oohttp expects from +// any library that implements TLS functionality. By using this +// kind of TLSConn we're able to use both the standard library +// and gitlab.com/yawning/utls.git to perform TLS operations. Note +// that the stdlib's tls.Conn implements this interface. +type TLSConn = oohttp.TLSConn + +// Ensures that a [*tls.Conn] implements the [TLSConn] interface. +var _ TLSConn = &tls.Conn{} + // TLSDialer is a Dialer dialing TLS connections. type TLSDialer interface { // CloseIdleConnections closes idle connections, if any. @@ -476,8 +541,14 @@ type UDPLikeConn interface { SyscallConn() (syscall.RawConn, error) } +// UDPListener listens for connections over UDP, e.g. QUIC. +type UDPListener interface { + // Listen creates a new listening UDPLikeConn. + Listen(addr *net.UDPAddr) (UDPLikeConn, error) +} + // UnderlyingNetwork implements the underlying network APIs on -// top of which we implement network extensions. +// top of which we implement network extensions such as [MeasuringNetwork]. type UnderlyingNetwork interface { // DefaultCertPool returns the underlying cert pool used by the // network extensions library. You MUST NOT use this function to diff --git a/internal/netxlite/dialer.go b/internal/netxlite/dialer.go index 1840e3cba3..e30e344514 100644 --- a/internal/netxlite/dialer.go +++ b/internal/netxlite/dialer.go @@ -22,8 +22,7 @@ func NewDialerWithStdlibResolver(dl model.DebugLogger) model.Dialer { return NewDialerWithResolver(dl, reso) } -// NewDialerWithResolver is equivalent to calling WrapDialer with a dialer using the -// the [*Netx] UnderlyingNetwork for dialing new connections. +// NewDialerWithResolver implements [model.MeasuringNetwork]. func (netx *Netx) NewDialerWithResolver(dl model.DebugLogger, r model.Resolver, w ...model.DialerWrapper) model.Dialer { return WrapDialer(dl, r, &dialerSystem{provider: netx.maybeCustomUnderlyingNetwork()}, w...) } @@ -109,7 +108,7 @@ func NewDialerWithResolver(dl model.DebugLogger, r model.Resolver, w ...model.Di // When the resolver is &NullResolver{} any attempt to perform DNS resolutions // in the dialer at index N+2 will fail with ErrNoResolver. // -// Otherwise, the dialer at index N+2 will try each resolver IP address +// Otherwise, the dialer at index N+2 will try each resolved IP address // sequentially. In case of failure, such a resolver will return the first // error that occurred. This implementation strategy is a QUIRK that is // documented at TODO(https://github.com/ooni/probe/issues/1779). diff --git a/internal/netxlite/netx.go b/internal/netxlite/netx.go index 67e30e830b..fd059d57db 100644 --- a/internal/netxlite/netx.go +++ b/internal/netxlite/netx.go @@ -7,9 +7,6 @@ package netxlite import "github.com/ooni/probe-cli/v3/internal/model" -// TODO(bassosimone,kelmenhorst): we should gradually refactor the top-level netxlite -// functions to operate on a [Netx] struct using a nil-initialized Underlying field. - // Netx allows constructing netxlite data types using a specific [model.UnderlyingNetwork]. type Netx struct { // Underlying is the OPTIONAL [model.UnderlyingNetwork] to use. Leaving this field @@ -17,6 +14,8 @@ type Netx struct { Underlying model.UnderlyingNetwork } +var _ model.MeasuringNetwork = &Netx{} + // maybeCustomUnderlyingNetwork wraps the [model.UnderlyingNetwork] using a [*MaybeCustomUnderlyingNetwork]. func (netx *Netx) maybeCustomUnderlyingNetwork() *MaybeCustomUnderlyingNetwork { return &MaybeCustomUnderlyingNetwork{netx.Underlying} diff --git a/internal/netxlite/quic.go b/internal/netxlite/quic.go index 674da6b10f..df5ac80e9c 100644 --- a/internal/netxlite/quic.go +++ b/internal/netxlite/quic.go @@ -16,23 +16,7 @@ import ( "github.com/quic-go/quic-go" ) -// NewQUICDialerWithResolver is the WrapDialer equivalent for QUIC where -// we return a composed QUICDialer modified by optional wrappers. -// -// The returned dialer guarantees: -// -// 1. logging; -// -// 2. error wrapping; -// -// 3. that we are going to use Mozilla CA if the [tls.Config] -// RootCAs field is zero initialized. -// -// Please, note that this fuunction will just ignore any nil wrapper. -// -// Unlike the dialer returned by NewDialerWithResolver, this dialer MAY attempt -// happy eyeballs, perform parallel dial attempts, and return an error -// that aggregates all the errors that occurred. +// NewQUICDialerWithResolver implements [model.MeasuringNetwork]. func (netx *Netx) NewQUICDialerWithResolver(listener model.UDPListener, logger model.DebugLogger, resolver model.Resolver, wrappers ...model.QUICDialerWrapper) (outDialer model.QUICDialer) { baseDialer := &quicDialerQUICGo{ diff --git a/internal/netxlite/resolvercore.go b/internal/netxlite/resolvercore.go index 020f5cc8b0..8ebfaf3f04 100644 --- a/internal/netxlite/resolvercore.go +++ b/internal/netxlite/resolvercore.go @@ -23,8 +23,7 @@ import ( // but you are using the "stdlib" resolver instead. var ErrNoDNSTransport = errors.New("operation requires a DNS transport") -// NewStdlibResolver creates a new Resolver by combining WrapResolver -// with an internal "stdlib" resolver type. +// NewStdlibResolver implements [model.MeasuringNetwork]. func (netx *Netx) NewStdlibResolver(logger model.DebugLogger) model.Resolver { return WrapResolver(logger, netx.newUnwrappedStdlibResolver()) } @@ -36,9 +35,7 @@ func NewStdlibResolver(logger model.DebugLogger) model.Resolver { return netx.NewStdlibResolver(logger) } -// NewParallelDNSOverHTTPSResolver creates a new DNS over HTTPS resolver -// that uses the standard library for all operations. This function constructs -// all the building blocks and calls WrapResolver on the returned resolver. +// NewParallelDNSOverHTTPSResolver implements [model.MeasuringNetwork]. func (netx *Netx) NewParallelDNSOverHTTPSResolver(logger model.DebugLogger, URL string) model.Resolver { client := &http.Client{Transport: netx.NewHTTPTransportStdlib(logger)} txp := wrapDNSTransport(NewUnwrappedDNSOverHTTPSTransport(client, URL)) @@ -84,16 +81,7 @@ func NewSerialUDPResolver(logger model.DebugLogger, dialer model.Dialer, address )) } -// NewParallelUDPResolver creates a new Resolver using DNS-over-UDP -// that performs parallel A/AAAA lookups during LookupHost. -// -// Arguments: -// -// - logger is the logger to use -// -// - dialer is the dialer to create and connect UDP conns -// -// - address is the server address (e.g., 1.1.1.1:53) +// NewParallelUDPResolver implements [model.MeasuringNetwork]. func (netx *Netx) NewParallelUDPResolver(logger model.DebugLogger, dialer model.Dialer, address string) model.Resolver { return WrapResolver(logger, NewUnwrappedParallelResolver( wrapDNSTransport(NewUnwrappedDNSOverUDPTransport(dialer, address)), diff --git a/internal/netxlite/tls.go b/internal/netxlite/tls.go index 0871ddf045..15bf0a10be 100644 --- a/internal/netxlite/tls.go +++ b/internal/netxlite/tls.go @@ -14,7 +14,6 @@ import ( "time" ootls "github.com/ooni/oocrypto/tls" - oohttp "github.com/ooni/oohttp" "github.com/ooni/probe-cli/v3/internal/model" "github.com/ooni/probe-cli/v3/internal/runtimex" ) @@ -142,27 +141,11 @@ func ConfigureTLSVersion(config *tls.Config, version string) error { return nil } -// TLSConn is the type of connection that oohttp expects from -// any library that implements TLS functionality. By using this -// kind of TLSConn we're able to use both the standard library -// and gitlab.com/yawning/utls.git to perform TLS operations. Note -// that the stdlib's tls.Conn implements this interface. -type TLSConn = oohttp.TLSConn +// The TLSConn alias was originally defined here in [netxlite] and we +// want to keep it available to other packages for now. +type TLSConn = model.TLSConn -// Ensures that a tls.Conn implements the TLSConn interface. -var _ TLSConn = &tls.Conn{} - -// NewTLSHandshakerStdlib creates a new TLS handshaker using the -// go standard library to manage TLS. -// -// The handshaker guarantees: -// -// 1. logging; -// -// 2. error wrapping; -// -// 3. that we are going to use Mozilla CA if the [tls.Config] -// RootCAs field is zero initialized. +// NewTLSHandshakerStdlib implements [model.MeasuringNetwork]. func (netx *Netx) NewTLSHandshakerStdlib(logger model.DebugLogger) model.TLSHandshaker { return newTLSHandshakerLogger( &tlsHandshakerConfigurable{provider: netx.maybeCustomUnderlyingNetwork()}, diff --git a/internal/netxlite/utls.go b/internal/netxlite/utls.go index 68ab17ad6a..b339e1cf1e 100644 --- a/internal/netxlite/utls.go +++ b/internal/netxlite/utls.go @@ -16,21 +16,7 @@ import ( utls "gitlab.com/yawning/utls.git" ) -// NewTLSHandshakerUTLS creates a new TLS handshaker using -// gitlab.com/yawning/utls for TLS. -// -// The id is the address of something like utls.HelloFirefox_55. -// -// The handshaker guarantees: -// -// 1. logging; -// -// 2. error wrapping; -// -// 3. that we are going to use Mozilla CA if the [tls.Config] -// RootCAs field is zero initialized. -// -// Passing a nil `id` will make this function panic. +// NewTLSHandshakerUTLS implements [model.MeasuringNetwork]. func (netx *Netx) NewTLSHandshakerUTLS(logger model.DebugLogger, id *utls.ClientHelloID) model.TLSHandshaker { return newTLSHandshakerLogger(&tlsHandshakerConfigurable{ NewConn: newUTLSConnFactory(id),