From 1e4f104964ade33bf44666e530612106fa074ecb Mon Sep 17 00:00:00 2001 From: Ain Ghazal <99027643+ainghazal@users.noreply.github.com> Date: Thu, 6 Jun 2024 11:16:00 +0200 Subject: [PATCH] feat: add the openvpn experiment (#1585) # Description First iteration of a new openvpn experiment. This takes a set of endpoints and uses minivpn to attempt a handshake with each of the configured endpoints. A new API endpoint has been added to the backend that is able to distribute valid configuration parameters for an OpenVPN connection, including credentials. # Checklist - [x] I have read the [contribution guidelines](https://github.com/ooni/probe-cli/blob/master/CONTRIBUTING.md) - [x] reference issue for this pull request: https://github.com/ooni/probe/issues/2688 - [x] if you changed anything related to how experiments work and you need to reflect these changes in the ooni/spec repository, please link to the related ooni/spec pull request: https://github.com/ooni/spec/pull/293 - [x] if you changed code inside an experiment, make sure you bump its version number --------- Co-authored-by: Simone Basso --- go.mod | 1 + go.sum | 4 + internal/engine/inputloader.go | 60 +++ internal/engine/inputloader_test.go | 78 +++- internal/engine/session.go | 30 ++ internal/engine/session_internal_test.go | 13 + internal/experiment/openvpn/endpoint.go | 280 ++++++++++++ internal/experiment/openvpn/endpoint_test.go | 405 +++++++++++++++++ internal/experiment/openvpn/openvpn.go | 380 ++++++++++++++++ internal/experiment/openvpn/openvpn_test.go | 447 +++++++++++++++++++ internal/legacy/mockable/mockable.go | 8 + internal/mocks/session.go | 8 + internal/mocks/session_test.go | 16 + internal/model/archival.go | 28 ++ internal/model/experiment.go | 3 + internal/model/ooapi.go | 35 ++ internal/probeservices/openvpn.go | 45 ++ internal/probeservices/openvpn_test.go | 105 +++++ internal/probeservices/tor_test.go | 2 +- internal/registry/factory_test.go | 5 + internal/registry/openvpn.go | 26 ++ internal/testingx/oonibackendwithlogin.go | 28 ++ 22 files changed, 2005 insertions(+), 2 deletions(-) create mode 100644 internal/experiment/openvpn/endpoint.go create mode 100644 internal/experiment/openvpn/endpoint_test.go create mode 100644 internal/experiment/openvpn/openvpn.go create mode 100644 internal/experiment/openvpn/openvpn_test.go create mode 100644 internal/probeservices/openvpn.go create mode 100644 internal/probeservices/openvpn_test.go create mode 100644 internal/registry/openvpn.go diff --git a/go.mod b/go.mod index 7fecb15e37..506266d1ad 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/miekg/dns v1.1.59 github.com/mitchellh/go-wordwrap v1.0.1 github.com/montanaflynn/stats v0.7.1 + github.com/ooni/minivpn v0.0.6 github.com/ooni/netem v0.0.0-20240208095707-608dcbcd82b8 github.com/ooni/oocrypto v0.6.1 github.com/ooni/oohttp v0.7.2 diff --git a/go.sum b/go.sum index 726de7afcd..c54eab98f7 100644 --- a/go.sum +++ b/go.sum @@ -192,6 +192,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= +github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= github.com/google/pprof v0.0.0-20240509144519-723abb6459b7 h1:velgFPYr1X9TDwLIfkV7fWqsFlf7TeP11M/7kPd/dVI= github.com/google/pprof v0.0.0-20240509144519-723abb6459b7/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= @@ -350,6 +352,8 @@ github.com/onsi/ginkgo/v2 v2.17.3/go.mod h1:nP2DPOQoNsQmsVyv5rDA8JkXQoCs6goXIvr/ github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.33.0 h1:snPCflnZrpMsy94p4lXVEkHo12lmPnc3vY5XBbreexE= github.com/onsi/gomega v1.33.0/go.mod h1:+925n5YtiFsLzzafLUHzVMBpvvRAzrydIBiSIxjX3wY= +github.com/ooni/minivpn v0.0.6 h1:pGTsYRtofEupMrJL28f1IXO1LJslSI7dEHxSadNgGik= +github.com/ooni/minivpn v0.0.6/go.mod h1:0KNwmK2Wg9lDbk936XjtxvCq4tPNbK4C3IJvyLwIMrE= github.com/ooni/netem v0.0.0-20240208095707-608dcbcd82b8 h1:kJ2wn19lIP/y9ng85BbFRdWKHK6Er116Bbt5uhqHVD4= github.com/ooni/netem v0.0.0-20240208095707-608dcbcd82b8/go.mod h1:b/wAvTR5n92Vk2b0SBmuMU0xO4ZGVrsXtU7zjTby7vw= github.com/ooni/oocrypto v0.6.1 h1:D0fGokmHoVKGBy39RxPxK77ov0Ob9Z5pdx4vKA6vpWk= diff --git a/internal/engine/inputloader.go b/internal/engine/inputloader.go index 4db0834dc0..d338703203 100644 --- a/internal/engine/inputloader.go +++ b/internal/engine/inputloader.go @@ -9,6 +9,7 @@ import ( "net/url" "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/experiment/openvpn" "github.com/ooni/probe-cli/v3/internal/fsx" "github.com/ooni/probe-cli/v3/internal/model" "github.com/ooni/probe-cli/v3/internal/registry" @@ -28,6 +29,8 @@ var ( // introduce this abstraction because it helps us with testing. type InputLoaderSession interface { CheckIn(ctx context.Context, config *model.OOAPICheckInConfig) (*model.OOAPICheckInResult, error) + FetchOpenVPNConfig(ctx context.Context, + provider, cc string) (*model.OOAPIVPNProviderConfig, error) } // InputLoaderLogger is the logger according to an InputLoader. @@ -299,6 +302,21 @@ func (il *InputLoader) readfile(filepath string, open inputLoaderOpenFn) ([]mode // loadRemote loads inputs from a remote source. func (il *InputLoader) loadRemote(ctx context.Context) ([]model.OOAPIURLInfo, error) { + switch registry.CanonicalizeExperimentName(il.ExperimentName) { + case "openvpn": + // TODO(ainghazal): given the semantics of the current API call, in an ideal world we'd need to pass + // the desired provider here, if known. We only have one provider for now, so I'm acting like YAGNI. Another + // option is perhaps to coalesce all the known providers per proto into a single API call and let client + // pick whatever they want. + // This will likely improve after Richer Input is available. + return il.loadRemoteOpenVPN(ctx) + default: + return il.loadRemoteWebConnectivity(ctx) + } +} + +// loadRemoteWebConnectivity loads webconnectivity inputs from a remote source. +func (il *InputLoader) loadRemoteWebConnectivity(ctx context.Context) ([]model.OOAPIURLInfo, error) { config := il.CheckInConfig if config == nil { // Note: Session.CheckIn documentation says it will fill in @@ -317,6 +335,39 @@ func (il *InputLoader) loadRemote(ctx context.Context) ([]model.OOAPIURLInfo, er return reply.WebConnectivity.URLs, nil } +// loadRemoteOpenVPN loads openvpn inputs from a remote source. +func (il *InputLoader) loadRemoteOpenVPN(ctx context.Context) ([]model.OOAPIURLInfo, error) { + // VPN Inputs do not match exactly the semantics expected from [model.OOAPIURLInfo], + // since OOAPIURLInfo is oriented towards webconnectivity, + // but we force VPN targets in the URL and ignore all the other fields. + // There's still some level of impedance mismatch here, since it's also possible that + // the user of the library wants to use remotes by unknown providers passed via cli options, + // oonirun etc; in that case we'll need to extract the provider annotation from the URLs. + urls := make([]model.OOAPIURLInfo, 0) + + // The openvpn experiment contains an array of the providers that the API knows about. + // We try to get all the remotes from the API for the list of enabled providers. + for _, provider := range openvpn.APIEnabledProviders { + // fetchOpenVPNConfig ultimately uses an internal cache in the session to avoid + // hitting the API too many times. + reply, err := il.fetchOpenVPNConfig(ctx, provider) + if err != nil { + return urls, err + } + for _, input := range reply.Inputs { + urls = append(urls, model.OOAPIURLInfo{URL: input}) + } + } + + if len(urls) <= 0 { + // At some point we might want to return [openvpn.DefaultEndpoints], + // but for now we're assuming that no targets means we've disabled + // the experiment on the backend. + return nil, ErrNoURLsReturned + } + return urls, nil +} + // checkIn executes the check-in and filters the returned URLs to exclude // the URLs that are not part of the requested categories. This is done for // robustness, just in case we or the API do something wrong. @@ -335,6 +386,15 @@ func (il *InputLoader) checkIn( return &reply.Tests, nil } +// fetchOpenVPNConfig fetches vpn information for the configured providers +func (il *InputLoader) fetchOpenVPNConfig(ctx context.Context, provider string) (*model.OOAPIVPNProviderConfig, error) { + reply, err := il.Session.FetchOpenVPNConfig(ctx, provider, "XX") + if err != nil { + return nil, err + } + return reply, nil +} + // preventMistakes makes the code more robust with respect to any possible // integration issue where the backend returns to us URLs that don't // belong to the category codes we requested. diff --git a/internal/engine/inputloader_test.go b/internal/engine/inputloader_test.go index 68e1ab5489..bc8b9686d5 100644 --- a/internal/engine/inputloader_test.go +++ b/internal/engine/inputloader_test.go @@ -9,11 +9,13 @@ import ( "strings" "syscall" "testing" + "time" "github.com/apex/log" "github.com/google/go-cmp/cmp" "github.com/ooni/probe-cli/v3/internal/kvstore" "github.com/ooni/probe-cli/v3/internal/model" + "github.com/ooni/probe-cli/v3/internal/runtimex" ) func TestInputLoaderInputNoneWithStaticInputs(t *testing.T) { @@ -448,6 +450,10 @@ type InputLoaderMockableSession struct { // be nil when Error is not-nil. Output *model.OOAPICheckInResult + // FetchOpenVPNConfigOutput contains the output of FetchOpenVPNConfig. + // It should be nil when Error is non-nil. + FetchOpenVPNConfigOutput *model.OOAPIVPNProviderConfig + // Error is the error to be returned by CheckIn. It // should be nil when Output is not-nil. Error error @@ -462,6 +468,13 @@ func (sess *InputLoaderMockableSession) CheckIn( return sess.Output, sess.Error } +// FetchOpenVPNConfig implements InputLoaderSession.FetchOpenVPNConfig. +func (sess *InputLoaderMockableSession) FetchOpenVPNConfig( + ctx context.Context, provider, cc string) (*model.OOAPIVPNProviderConfig, error) { + runtimex.Assert(!(sess.Error == nil && sess.FetchOpenVPNConfigOutput == nil), "both FetchOpenVPNConfig and Error are nil") + return sess.FetchOpenVPNConfigOutput, sess.Error +} + func TestInputLoaderCheckInFailure(t *testing.T) { il := &InputLoader{ Session: &InputLoaderMockableSession{ @@ -543,6 +556,69 @@ func TestInputLoaderCheckInSuccessWithSomeURLs(t *testing.T) { } } +func TestInputLoaderOpenVPNSuccessWithNoInput(t *testing.T) { + il := &InputLoader{ + ExperimentName: "openvpn", + InputPolicy: model.InputOrQueryBackend, + Session: &InputLoaderMockableSession{ + Error: nil, + FetchOpenVPNConfigOutput: &model.OOAPIVPNProviderConfig{ + Provider: "riseup", + Inputs: []string{ + "openvpn://foo.corp/?address=1.1.1.1:1194&transport=tcp", + }, + DateUpdated: time.Now(), + }, + }, + } + _, err := il.loadRemote(context.Background()) + if err != nil { + t.Fatal("we did not expect an error") + } +} + +func TestInputLoaderOpenVPNSuccessWithNoInputAndAPICall(t *testing.T) { + il := &InputLoader{ + ExperimentName: "openvpn", + InputPolicy: model.InputOrQueryBackend, + Session: &InputLoaderMockableSession{ + Error: nil, + FetchOpenVPNConfigOutput: &model.OOAPIVPNProviderConfig{ + Provider: "riseupvpn", + Inputs: []string{ + "openvpn://foo.corp/?address=1.2.3.4:1194&transport=tcp", + }, + DateUpdated: time.Now(), + }, + }, + } + out, err := il.loadRemote(context.Background()) + if err != nil { + t.Fatal("we did not expect an error") + } + if len(out) != 1 { + t.Fatal("we expected output of len=1") + } +} + +func TestInputLoaderOpenVPNWithAPIFailureAndFallback(t *testing.T) { + expected := errors.New("mocked API error") + il := &InputLoader{ + ExperimentName: "openvpn", + InputPolicy: model.InputOrQueryBackend, + Session: &InputLoaderMockableSession{ + Error: expected, + }, + } + out, err := il.loadRemote(context.Background()) + if err != expected { + t.Fatal("we expected an error") + } + if len(out) != 0 { + t.Fatal("we expected no fallback URLs") + } +} + func TestPreventMistakesWithCategories(t *testing.T) { input := []model.OOAPIURLInfo{{ CategoryCode: "NEWS", @@ -679,7 +755,7 @@ func TestStringListToModelURLInfoWithError(t *testing.T) { expected := errors.New("mocked error") output, err := stringListToModelURLInfo(input, expected) if !errors.Is(err, expected) { - t.Fatal("no the error we expected", err) + t.Fatal("not the error we expected", err) } if output != nil { t.Fatal("unexpected nil output") diff --git a/internal/engine/session.go b/internal/engine/session.go index 3539c1c5e5..1a6bc5443b 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -66,6 +66,7 @@ type Session struct { softwareName string softwareVersion string tempDir string + vpnConfig map[string]model.OOAPIVPNProviderConfig // closeOnce allows us to call Close just once. closeOnce sync.Once @@ -177,6 +178,7 @@ func NewSession(ctx context.Context, config SessionConfig) (*Session, error) { torArgs: config.TorArgs, torBinary: config.TorBinary, tunnelDir: config.TunnelDir, + vpnConfig: make(map[string]model.OOAPIVPNProviderConfig), } proxyURL := config.ProxyURL if proxyURL != nil { @@ -368,9 +370,37 @@ func (s *Session) FetchTorTargets( if err != nil { return nil, err } + + // TODO(bassosimone,DecFox): here we could also lock the mutex + // or we should consider using the same strategy we used for the + // experiments, where we separated mutable state into dedicated types. return clnt.FetchTorTargets(ctx, cc) } +// FetchOpenVPNConfig fetches openvpn config from the API if it's not found in the +// internal cache. We do this to avoid hitting the API for every input. +func (s *Session) FetchOpenVPNConfig( + ctx context.Context, provider, cc string) (*model.OOAPIVPNProviderConfig, error) { + if config, ok := s.vpnConfig[provider]; ok { + return &config, nil + } + clnt, err := s.newOrchestraClient(ctx) + if err != nil { + return nil, err + } + + // we cannot lock earlier because newOrchestraClient locks the mutex. + defer s.mu.Unlock() + s.mu.Lock() + + config, err := clnt.FetchOpenVPNConfig(ctx, provider, cc) + if err != nil { + return nil, err + } + s.vpnConfig[provider] = config + return &config, nil +} + // KeyValueStore returns the configured key-value store. func (s *Session) KeyValueStore() model.KeyValueStore { return s.kvStore diff --git a/internal/engine/session_internal_test.go b/internal/engine/session_internal_test.go index b2971af49b..ea7ec005b1 100644 --- a/internal/engine/session_internal_test.go +++ b/internal/engine/session_internal_test.go @@ -253,6 +253,19 @@ func TestSessionMaybeLookupLocationContextLookupLocationContextFailure(t *testin } } +func TestSessionFetchOpenVPNConfigWithCancelledContext(t *testing.T) { + sess := &Session{} + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cause failure + resp, err := sess.FetchOpenVPNConfig(ctx, "riseup", "XX") + if !errors.Is(err, context.Canceled) { + t.Fatal("not the error we expected", err) + } + if resp != nil { + t.Fatal("expected nil response here") + } +} + func TestSessionFetchTorTargetsWithCancelledContext(t *testing.T) { sess := &Session{} ctx, cancel := context.WithCancel(context.Background()) diff --git a/internal/experiment/openvpn/endpoint.go b/internal/experiment/openvpn/endpoint.go new file mode 100644 index 0000000000..6289d75cda --- /dev/null +++ b/internal/experiment/openvpn/endpoint.go @@ -0,0 +1,280 @@ +package openvpn + +import ( + "encoding/base64" + "errors" + "fmt" + "math/rand" + "net" + "net/url" + "strings" + + vpnconfig "github.com/ooni/minivpn/pkg/config" + vpntracex "github.com/ooni/minivpn/pkg/tracex" + "github.com/ooni/probe-cli/v3/internal/model" +) + +var ( + // ErrBadBase64Blob is the error returned when we cannot decode an option passed as base64. + ErrBadBase64Blob = errors.New("wrong base64 encoding") +) + +// endpoint is a single endpoint to be probed. +// The information contained in here is not sufficient to complete a connection: +// we need to augment it with more info, as cipher selection or obfuscating proxy credentials. +type endpoint struct { + // IPAddr is the IP Address for this endpoint. + IPAddr string + + // Obfuscation is any obfuscation method use to connect to this endpoint. + // Valid values are: obfs4, none. + Obfuscation string + + // Port is the Port for this endpoint. + Port string + + // Protocol is the tunneling protocol (openvpn, openvpn+obfs4). + Protocol string + + // Provider is a unique label identifying the provider maintaining this endpoint. + Provider string + + // Transport is the underlying transport used for this endpoint. Valid transports are `tcp` and `udp`. + Transport string +} + +// newEndpointFromInputString constructs an endpoint after parsing an input string. +// +// The input URI is in the form: +// "openvpn://provider.corp/?address=1.2.3.4:1194&transport=udp +// "openvpn+obfs4://provider.corp/address=1.2.3.4:1194?&cert=deadbeef&iat=0" +func newEndpointFromInputString(uri string) (*endpoint, error) { + parsedURL, err := url.Parse(uri) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrInvalidInput, err) + } + var obfuscation string + switch parsedURL.Scheme { + case "openvpn": + obfuscation = "none" + case "openvpn+obfs4": + obfuscation = "obfs4" + default: + return nil, fmt.Errorf("%w: unknown scheme: %s", ErrInvalidInput, parsedURL.Scheme) + } + + provider := strings.TrimSuffix(parsedURL.Hostname(), ".corp") + if provider == "" { + return nil, fmt.Errorf("%w: expected provider as host: %s", ErrInvalidInput, parsedURL.Host) + } + if !isValidProvider(provider) { + return nil, fmt.Errorf("%w: unknown provider: %s", ErrInvalidInput, provider) + } + + params := parsedURL.Query() + + transport := params.Get("transport") + if transport != "tcp" && transport != "udp" { + return nil, fmt.Errorf("%w: invalid transport: %s", ErrInvalidInput, transport) + } + + address := params.Get("address") + if address == "" { + return nil, fmt.Errorf("%w: please specify an address as part of the input", ErrInvalidInput) + } + ip, port, err := net.SplitHostPort(address) + if err != nil { + return nil, fmt.Errorf("%w: cannot split ip:port", ErrInvalidInput) + } + if parsedIP := net.ParseIP(ip); parsedIP == nil { + return nil, fmt.Errorf("%w: bad ip", ErrInvalidInput) + } + + endpoint := &endpoint{ + IPAddr: ip, + Port: port, + Obfuscation: obfuscation, + Protocol: "openvpn", + Provider: provider, + Transport: transport, + } + return endpoint, nil +} + +// String implements [fmt.Stringer]. This is a compact representation of the endpoint, +// which differs from the input URI scheme. This is the canonical representation, that can be used +// to deterministically slice a list of endpoints, sort them lexicographically, etc. +func (e *endpoint) String() string { + var proto string + if e.Obfuscation == "obfs4" { + proto = e.Protocol + "+obfs4" + } else { + proto = e.Protocol + } + url := &url.URL{ + Scheme: proto, + Host: net.JoinHostPort(e.IPAddr, e.Port), + Path: e.Transport, + } + return url.String() +} + +// AsInputURI is a string representation of this endpoint, as used in the experiment input URI format. +func (e *endpoint) AsInputURI() string { + var proto string + if e.Obfuscation == "obfs4" { + proto = e.Protocol + "+obfs4" + } else { + proto = e.Protocol + } + + provider := e.Provider + if provider == "" { + provider = "unknown" + } + + values := map[string][]string{ + "address": {net.JoinHostPort(e.IPAddr, e.Port)}, + "transport": {e.Transport}, + } + + url := &url.URL{ + Scheme: proto, + Host: provider + ".corp", + RawQuery: url.Values(values).Encode(), + } + return url.String() +} + +// endpointList is a list of endpoints. +type endpointList []*endpoint + +// DefaultEndpoints contains a subset of known endpoints to be used if no input is passed to the experiment and +// the backend query fails for whatever reason. We risk distributing endpoints that can go stale, so we should be careful about +// the stability of the endpoints selected here, but in restrictive environments it's useful to have something +// to probe in absence of an useful OONI API. Valid credentials are still needed, though. +var DefaultEndpoints = endpointList{ + { + Provider: "riseup", + IPAddr: "51.15.187.53", + Port: "1194", + Protocol: "openvpn", + Transport: "tcp", + }, + { + Provider: "riseup", + IPAddr: "51.15.187.53", + Port: "1194", + Protocol: "openvpn", + Transport: "udp", + }, +} + +// Shuffle randomizes the order of items in the endpoint list. +func (e endpointList) Shuffle() endpointList { + rand.Shuffle(len(e), func(i, j int) { + e[i], e[j] = e[j], e[i] + }) + return e +} + +// defaultOptionsByProvider is a map containing base config for +// all the known providers. We extend this base config with credentials coming +// from the OONI API. +var defaultOptionsByProvider = map[string]*vpnconfig.OpenVPNOptions{ + "riseupvpn": { + Auth: "SHA512", + Cipher: "AES-256-GCM", + }, +} + +// APIEnabledProviders is the list of providers that the stable API Endpoint knows about. +// This array will be a subset of the keys in defaultOptionsByProvider, but it might make sense +// to still register info about more providers that the API officially knows about. +var APIEnabledProviders = []string{ + // TODO(ainghazal): fix the backend so that we can remove the spurious "vpn" suffix here. + "riseupvpn", +} + +// isValidProvider returns true if the provider is found as key in the registry of defaultOptionsByProvider. +// TODO(ainghazal): consolidate with list of enabled providers from the API viewpoint. +func isValidProvider(provider string) bool { + _, ok := defaultOptionsByProvider[provider] + return ok +} + +// getOpenVPNConfig gets a properly configured [*vpnconfig.Config] object for the given endpoint. +// To obtain that, we merge the endpoint specific configuration with base options. +// Base options are hardcoded for the moment, for comparability among different providers. +// We can add them to the OONI API and as extra cli options if ever needed. +func getOpenVPNConfig( + tracer *vpntracex.Tracer, + logger model.Logger, + endpoint *endpoint, + creds *vpnconfig.OpenVPNOptions) (*vpnconfig.Config, error) { + // TODO(ainghazal): use merge ability in vpnconfig.OpenVPNOptions merge (pending PR) + provider := endpoint.Provider + if !isValidProvider(provider) { + return nil, fmt.Errorf("%w: unknown provider: %s", ErrInvalidInput, provider) + } + + baseOptions := defaultOptionsByProvider[provider] + + if baseOptions == nil { + return nil, fmt.Errorf("empty baseOptions for provider: %s", provider) + } + if baseOptions.Cipher == "" { + return nil, fmt.Errorf("empty cipher for provider: %s", provider) + } + if baseOptions.Auth == "" { + return nil, fmt.Errorf("empty auth for provider: %s", provider) + } + + cfg := vpnconfig.NewConfig( + vpnconfig.WithLogger(logger), + vpnconfig.WithOpenVPNOptions( + &vpnconfig.OpenVPNOptions{ + // endpoint-specific options. + Remote: endpoint.IPAddr, + Port: endpoint.Port, + Proto: vpnconfig.Proto(endpoint.Transport), + + // options coming from the default known values. + Cipher: baseOptions.Cipher, + Auth: baseOptions.Auth, + + // auth coming from passed credentials. + CA: creds.CA, + Cert: creds.Cert, + Key: creds.Key, + }, + ), + vpnconfig.WithHandshakeTracer(tracer), + ) + + return cfg, nil +} + +// maybeExtractBase64Blob is used to pass credentials as command-line options. +func maybeExtractBase64Blob(val string) (string, error) { + s := strings.TrimPrefix(val, "base64:") + if len(s) == len(val) { + // no prefix, so we'll treat this as a pem-encoded credential. + return s, nil + } + dec, err := base64.URLEncoding.DecodeString(strings.TrimSpace(s)) + if err != nil { + return "", fmt.Errorf("%w: %s", ErrBadBase64Blob, err) + } + return string(dec), nil +} + +func isValidProtocol(s string) bool { + if strings.HasPrefix(s, "openvpn://") { + return true + } + if strings.HasPrefix(s, "openvpn+obfs4://") { + return true + } + return false +} diff --git a/internal/experiment/openvpn/endpoint_test.go b/internal/experiment/openvpn/endpoint_test.go new file mode 100644 index 0000000000..bc33301419 --- /dev/null +++ b/internal/experiment/openvpn/endpoint_test.go @@ -0,0 +1,405 @@ +package openvpn + +import ( + "errors" + "fmt" + "sort" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + vpnconfig "github.com/ooni/minivpn/pkg/config" + vpntracex "github.com/ooni/minivpn/pkg/tracex" +) + +func Test_newEndpointFromInputString(t *testing.T) { + type args struct { + uri string + } + tests := []struct { + name string + args args + want *endpoint + wantErr error + }{ + { + name: "valid endpoint returns good endpoint", + args: args{"openvpn://riseupvpn.corp/?address=1.1.1.1:1194&transport=tcp"}, + want: &endpoint{ + IPAddr: "1.1.1.1", + Obfuscation: "none", + Port: "1194", + Protocol: "openvpn", + Provider: "riseupvpn", + Transport: "tcp", + }, + wantErr: nil, + }, + { + name: "bad url fails", + args: args{"://address=1.1.1.1:1194&transport=tcp"}, + want: nil, + wantErr: ErrInvalidInput, + }, + { + name: "openvpn+obfs4 does not fail", + args: args{"openvpn+obfs4://riseupvpn.corp/?address=1.1.1.1:1194&transport=tcp"}, + want: &endpoint{ + IPAddr: "1.1.1.1", + Obfuscation: "obfs4", + Port: "1194", + Protocol: "openvpn", + Provider: "riseupvpn", + Transport: "tcp", + }, + wantErr: nil, + }, + { + name: "unknown proto fails", + args: args{"unknown://riseupvpn.corp/?address=1.1.1.1:1194&transport=tcp"}, + want: nil, + wantErr: ErrInvalidInput, + }, + { + name: "any tld other than .corp fails", + args: args{"openvpn://riseupvpn.org/?address=1.1.1.1:1194&transport=tcp"}, + want: nil, + wantErr: ErrInvalidInput, + }, + { + name: "empty provider fails", + args: args{"openvpn://.corp/?address=1.1.1.1:1194&transport=tcp"}, + want: nil, + wantErr: ErrInvalidInput, + }, + { + name: "non-registered provider fails", + args: args{"openvpn://nsavpn.corp/?address=1.1.1.1:1194&transport=tcp"}, + want: nil, + wantErr: ErrInvalidInput, + }, + { + name: "endpoint with invalid ipv4 fails", + args: args{"openvpn://riseupvpn.corp/?address=example.com:1194&transport=tcp"}, + want: nil, + wantErr: ErrInvalidInput, + }, + { + name: "endpoint with no port fails", + args: args{"openvpn://riseupvpn.corp/?address=1.1.1.1&transport=tcp"}, + want: nil, + wantErr: ErrInvalidInput, + }, + { + name: "endpoint with empty transport fails", + args: args{"openvpn://riseupvpn.corp/?address=1.1.1.1:1194&transport="}, + want: nil, + wantErr: ErrInvalidInput, + }, + { + name: "endpoint with no transport fails", + args: args{"openvpn://riseupvpn.corp/?address=1.1.1.1:1194"}, + want: nil, + wantErr: ErrInvalidInput, + }, + { + name: "endpoint with unknown transport fails", + args: args{"openvpn://riseupvpn.corp/?address=1.1.1.1:1194&transport=uh"}, + want: nil, + wantErr: ErrInvalidInput, + }, + { + name: "endpoint with no address fails", + args: args{"openvpn://riseupvpn.corp/?transport=tcp"}, + want: nil, + wantErr: ErrInvalidInput, + }, + { + name: "endpoint with empty address fails", + args: args{"openvpn://riseupvpn.corp/?address=&transport=tcp"}, + want: nil, + wantErr: ErrInvalidInput, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := newEndpointFromInputString(tt.args.uri) + if !errors.Is(err, tt.wantErr) { + t.Errorf("newEndpointFromInputString() error = %v, wantErr %v", err, tt.wantErr) + return + } + if diff := cmp.Diff(got, tt.want); diff != "" { + t.Errorf(diff) + } + }) + } +} + +func Test_EndpointToInputURI(t *testing.T) { + type args struct { + endpoint endpoint + } + tests := []struct { + name string + args args + want string + }{ + { + name: "good endpoint with plain openvpn", + args: args{ + endpoint{ + IPAddr: "1.1.1.1", + Obfuscation: "none", + Port: "443", + Protocol: "openvpn", + Provider: "shady", + Transport: "udp", + }, + }, + want: "openvpn://shady.corp?address=1.1.1.1%3A443&transport=udp", + }, + { + name: "good endpoint with openvpn+obfs4", + args: args{ + endpoint{ + IPAddr: "1.1.1.1", + Obfuscation: "obfs4", + Port: "443", + Protocol: "openvpn", + Provider: "shady", + Transport: "udp", + }, + }, + want: "openvpn+obfs4://shady.corp?address=1.1.1.1%3A443&transport=udp", + }, + { + name: "empty provider is marked as unknown", + args: args{ + endpoint{ + IPAddr: "1.1.1.1", + Obfuscation: "obfs4", + Port: "443", + Protocol: "openvpn", + Provider: "", + Transport: "udp", + }, + }, + want: "openvpn+obfs4://unknown.corp?address=1.1.1.1%3A443&transport=udp", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.args.endpoint.AsInputURI(); cmp.Diff(got, tt.want) != "" { + fmt.Println("GOT", got) + t.Error(cmp.Diff(got, tt.want)) + } + }) + } +} + +func Test_endpoint_String(t *testing.T) { + type fields struct { + IPAddr string + Obfuscation string + Port string + Protocol string + Provider string + Transport string + } + tests := []struct { + name string + fields fields + want string + }{ + { + name: "well formed endpoint returns a well formed endpoint string", + fields: fields{ + IPAddr: "1.1.1.1", + Obfuscation: "none", + Port: "1194", + Protocol: "openvpn", + Provider: "unknown", + Transport: "tcp", + }, + want: "openvpn://1.1.1.1:1194/tcp", + }, + { + name: "well formed endpoint, openvpn+obfs4", + fields: fields{ + IPAddr: "1.1.1.1", + Obfuscation: "obfs4", + Port: "1194", + Protocol: "openvpn", + Provider: "unknown", + Transport: "tcp", + }, + want: "openvpn+obfs4://1.1.1.1:1194/tcp", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := &endpoint{ + IPAddr: tt.fields.IPAddr, + Obfuscation: tt.fields.Obfuscation, + Port: tt.fields.Port, + Protocol: tt.fields.Protocol, + Provider: tt.fields.Provider, + Transport: tt.fields.Transport, + } + if got := e.String(); got != tt.want { + t.Errorf("endpoint.String() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_endpointList_Shuffle(t *testing.T) { + shuffled := DefaultEndpoints.Shuffle() + sort.Slice(shuffled, func(i, j int) bool { + return shuffled[i].IPAddr < shuffled[j].IPAddr + }) + if diff := cmp.Diff(shuffled, DefaultEndpoints); diff != "" { + t.Error(diff) + } +} + +func Test_isValidProvider(t *testing.T) { + if valid := isValidProvider("riseupvpn"); !valid { + t.Fatal("riseup is the only valid provider now") + } + if valid := isValidProvider("nsa"); valid { + t.Fatal("nsa will never be a provider") + } +} + +func Test_getVPNConfig(t *testing.T) { + tracer := vpntracex.NewTracer(time.Now()) + e := &endpoint{ + Provider: "riseupvpn", + IPAddr: "1.1.1.1", + Port: "443", + Transport: "udp", + } + creds := &vpnconfig.OpenVPNOptions{ + CA: []byte("ca"), + Cert: []byte("cert"), + Key: []byte("key"), + } + + cfg, err := getOpenVPNConfig(tracer, nil, e, creds) + if err != nil { + t.Fatalf("did not expect error, got: %v", err) + } + if cfg.Tracer() != tracer { + t.Fatal("config tracer is not what passed") + } + if auth := cfg.OpenVPNOptions().Auth; auth != "SHA512" { + t.Errorf("expected auth %s, got %s", "SHA512", auth) + } + if cipher := cfg.OpenVPNOptions().Cipher; cipher != "AES-256-GCM" { + t.Errorf("expected cipher %s, got %s", "AES-256-GCM", cipher) + } + if remote := cfg.OpenVPNOptions().Remote; remote != e.IPAddr { + t.Errorf("expected remote %s, got %s", e.IPAddr, remote) + } + if port := cfg.OpenVPNOptions().Port; port != e.Port { + t.Errorf("expected port %s, got %s", e.Port, port) + } + if transport := cfg.OpenVPNOptions().Proto; string(transport) != e.Transport { + t.Errorf("expected transport %s, got %s", e.Transport, transport) + } + if transport := cfg.OpenVPNOptions().Proto; string(transport) != e.Transport { + t.Errorf("expected transport %s, got %s", e.Transport, transport) + } + if diff := cmp.Diff(cfg.OpenVPNOptions().CA, creds.CA); diff != "" { + t.Error(diff) + } + if diff := cmp.Diff(cfg.OpenVPNOptions().Cert, creds.Cert); diff != "" { + t.Error(diff) + } + if diff := cmp.Diff(cfg.OpenVPNOptions().Key, creds.Key); diff != "" { + t.Error(diff) + } +} + +func Test_getVPNConfig_with_unknown_provider(t *testing.T) { + tracer := vpntracex.NewTracer(time.Now()) + e := &endpoint{ + Provider: "nsa", + IPAddr: "1.1.1.1", + Port: "443", + Transport: "udp", + } + creds := &vpnconfig.OpenVPNOptions{ + CA: []byte("ca"), + Cert: []byte("cert"), + Key: []byte("key"), + } + _, err := getOpenVPNConfig(tracer, nil, e, creds) + if !errors.Is(err, ErrInvalidInput) { + t.Fatalf("expected invalid input error, got: %v", err) + } + +} + +func Test_extractBase64Blob(t *testing.T) { + t.Run("decode good blob", func(t *testing.T) { + blob := "base64:dGhlIGJsdWUgb2N0b3B1cyBpcyB3YXRjaGluZw==" + decoded, err := maybeExtractBase64Blob(blob) + if decoded != "the blue octopus is watching" { + t.Fatal("could not decoded blob correctly") + } + if err != nil { + t.Fatal("should not fail with first blob") + } + }) + t.Run("try decode without prefix", func(t *testing.T) { + blob := "dGhlIGJsdWUgb2N0b3B1cyBpcyB3YXRjaGluZw==" + dec, err := maybeExtractBase64Blob(blob) + if err != nil { + t.Fatal("should fail without prefix") + } + if dec != blob { + t.Fatal("decoded should be the same") + } + }) + t.Run("bad base64 blob should fail", func(t *testing.T) { + blob := "base64:dGhlIGJsdWUgb2N0b3B1cyBpcyB3YXRjaGluZw" + _, err := maybeExtractBase64Blob(blob) + if !errors.Is(err, ErrBadBase64Blob) { + t.Fatal("bad blob should fail without prefix") + } + }) + t.Run("decode empty blob", func(t *testing.T) { + blob := "base64:" + _, err := maybeExtractBase64Blob(blob) + if err != nil { + t.Fatal("empty blob should not fail") + } + }) + t.Run("illegal base64 data should fail", func(t *testing.T) { + blob := "base64:==" + _, err := maybeExtractBase64Blob(blob) + if !errors.Is(err, ErrBadBase64Blob) { + t.Fatal("bad base64 data should fail") + } + }) +} + +func Test_IsValidProtocol(t *testing.T) { + t.Run("openvpn is valid", func(t *testing.T) { + if !isValidProtocol("openvpn://foobar.bar") { + t.Error("openvpn:// should be a valid protocol") + } + }) + t.Run("openvpn+obfs4 is valid", func(t *testing.T) { + if !isValidProtocol("openvpn+obfs4://foobar.bar") { + t.Error("openvpn+obfs4:// should be a valid protocol") + } + }) + t.Run("openvpn+other is not valid", func(t *testing.T) { + if isValidProtocol("openvpn+ss://foobar.bar") { + t.Error("openvpn+ss:// should not be a valid protocol") + } + }) +} diff --git a/internal/experiment/openvpn/openvpn.go b/internal/experiment/openvpn/openvpn.go new file mode 100644 index 0000000000..2d2af0e01b --- /dev/null +++ b/internal/experiment/openvpn/openvpn.go @@ -0,0 +1,380 @@ +// Package openvpn contains a generic openvpn experiment. +package openvpn + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/ooni/probe-cli/v3/internal/measurexlite" + "github.com/ooni/probe-cli/v3/internal/model" + + vpnconfig "github.com/ooni/minivpn/pkg/config" + vpntracex "github.com/ooni/minivpn/pkg/tracex" + "github.com/ooni/minivpn/pkg/tunnel" +) + +const ( + testVersion = "0.1.2" + openVPNProcol = "openvpn" +) + +// Config contains the experiment config. +// +// This contains all the settings that user can set to modify the behaviour +// of this experiment. By tagging these variables with `ooni:"..."`, we allow +// miniooni's -O flag to find them and set them. +// TODO(ainghazal): do pass Auth, Cipher and Compress to OpenVPN config options. +type Config struct { + Auth string `ooni:"OpenVPN authentication to use"` + Cipher string `ooni:"OpenVPN cipher to use"` + Compress string `ooni:"OpenVPN compression to use"` + Provider string `ooni:"VPN provider"` + Obfuscation string `ooni:"Obfuscation to use (obfs4, none)"` + SafeKey string `ooni:"key to connect to the OpenVPN endpoint"` + SafeCert string `ooni:"cert to connect to the OpenVPN endpoint"` + SafeCA string `ooni:"ca to connect to the OpenVPN endpoint"` +} + +// TestKeys contains the experiment's result. +type TestKeys struct { + Success bool `json:"success"` + NetworkEvents []*vpntracex.Event `json:"network_events"` + TCPConnect []*model.ArchivalTCPConnectResult `json:"tcp_connect,omitempty"` + OpenVPNHandshake []*model.ArchivalOpenVPNHandshakeResult `json:"openvpn_handshake"` +} + +// NewTestKeys creates new openvpn TestKeys. +func NewTestKeys() *TestKeys { + return &TestKeys{ + Success: false, + NetworkEvents: []*vpntracex.Event{}, + TCPConnect: []*model.ArchivalTCPConnectResult{}, + OpenVPNHandshake: []*model.ArchivalOpenVPNHandshakeResult{}, + } +} + +// SingleConnection contains the results of a single handshake. +type SingleConnection struct { + TCPConnect *model.ArchivalTCPConnectResult `json:"tcp_connect,omitempty"` + OpenVPNHandshake *model.ArchivalOpenVPNHandshakeResult `json:"openvpn_handshake"` + NetworkEvents []*vpntracex.Event `json:"network_events"` + // TODO(ainghazal): make sure to document in the spec that these network events only cover the handshake. + // TODO(ainghazal): in the future, we will want to store more operations under this struct for a single connection, + // like pingResults or urlgetter calls. +} + +// AddConnectionTestKeys adds the result of a single OpenVPN connection attempt to the +// corresponding array in the [TestKeys] object. +func (tk *TestKeys) AddConnectionTestKeys(result *SingleConnection) { + // Note that TCPConnect is nil when we're using UDP. + if result.TCPConnect != nil { + tk.TCPConnect = append(tk.TCPConnect, result.TCPConnect) + } + tk.OpenVPNHandshake = append(tk.OpenVPNHandshake, result.OpenVPNHandshake) + tk.NetworkEvents = append(tk.NetworkEvents, result.NetworkEvents...) +} + +// AllConnectionsSuccessful returns true if all the registered handshakes have nil failures. +func (tk *TestKeys) AllConnectionsSuccessful() bool { + if len(tk.OpenVPNHandshake) == 0 { + return false + } + for _, c := range tk.OpenVPNHandshake { + if c.Failure != nil { + return false + } + } + return true +} + +// Measurer performs the measurement. +type Measurer struct { + config Config + testName string +} + +// NewExperimentMeasurer creates a new ExperimentMeasurer. +func NewExperimentMeasurer(config Config, testName string) model.ExperimentMeasurer { + return Measurer{config: config, testName: testName} +} + +// ExperimentName implements model.ExperimentMeasurer.ExperimentName. +func (m Measurer) ExperimentName() string { + return m.testName +} + +// ExperimentVersion implements model.ExperimentMeasurer.ExperimentVersion. +func (m Measurer) ExperimentVersion() string { + return testVersion +} + +var ( + // ErrInvalidInput is returned if we failed to parse the input to obtain an endpoint we can measure. + ErrInvalidInput = errors.New("invalid input") +) + +func parseEndpoint(m *model.Measurement) (*endpoint, error) { + if m.Input != "" { + if ok := isValidProtocol(string(m.Input)); !ok { + return nil, ErrInvalidInput + } + return newEndpointFromInputString(string(m.Input)) + } + return nil, fmt.Errorf("%w: %s", ErrInvalidInput, "input is mandatory") +} + +// AuthMethod is the authentication method used by a provider. +type AuthMethod string + +var ( + // AuthCertificate is used for providers that authenticate clients via certificates. + AuthCertificate = AuthMethod("cert") + + // AuthUserPass is used for providers that authenticate clients via username (or token) and password. + AuthUserPass = AuthMethod("userpass") +) + +var providerAuthentication = map[string]AuthMethod{ + "riseup": AuthCertificate, + "tunnelbear": AuthUserPass, + "surfshark": AuthUserPass, +} + +func hasCredentialsInOptions(cfg Config, method AuthMethod) bool { + switch method { + case AuthCertificate: + ok := cfg.SafeCA != "" && cfg.SafeCert != "" && cfg.SafeKey != "" + return ok + default: + return false + } +} + +// MaybeGetCredentialsFromOptions overrides authentication info with what user provided in options. +// Each certificate/key can be encoded in base64 so that a single option can be safely represented as command line options. +// This function returns no error if there are no credentials in the passed options, only if failing to parse them. +func MaybeGetCredentialsFromOptions(cfg Config, opts *vpnconfig.OpenVPNOptions, method AuthMethod) (bool, error) { + if ok := hasCredentialsInOptions(cfg, method); !ok { + return false, nil + } + ca, err := maybeExtractBase64Blob(cfg.SafeCA) + if err != nil { + return false, err + } + opts.CA = []byte(ca) + + key, err := maybeExtractBase64Blob(cfg.SafeKey) + if err != nil { + return false, err + } + opts.Key = []byte(key) + + cert, err := maybeExtractBase64Blob(cfg.SafeCert) + if err != nil { + return false, err + } + opts.Cert = []byte(cert) + return true, nil +} + +func (m *Measurer) getCredentialsFromAPI( + ctx context.Context, + sess model.ExperimentSession, + provider string, + opts *vpnconfig.OpenVPNOptions) error { + // We expect the credentials from the API response to be encoded as the direct PEM serialization. + apiCreds, err := m.FetchProviderCredentials(ctx, sess, provider) + // TODO(ainghazal): validate credentials have the info we expect, certs are not expired etc. + if err != nil { + sess.Logger().Warnf("Error fetching credentials from API: %s", err.Error()) + return err + } + sess.Logger().Infof("Got credentials from provider: %s", provider) + + opts.CA = []byte(apiCreds.Config.CA) + opts.Cert = []byte(apiCreds.Config.Cert) + opts.Key = []byte(apiCreds.Config.Key) + return nil +} + +// GetCredentialsFromOptionsOrAPI attempts to find valid credentials for the given provider, either +// from the passed Options (cli, oonirun), or from a remote call to the OONI API endpoint. +func (m *Measurer) GetCredentialsFromOptionsOrAPI( + ctx context.Context, + sess model.ExperimentSession, + provider string) (*vpnconfig.OpenVPNOptions, error) { + + method, ok := providerAuthentication[strings.TrimSuffix(provider, "vpn")] + if !ok { + return nil, fmt.Errorf("%w: provider auth unknown: %s", ErrInvalidInput, provider) + } + + // Empty options object to fill with credentials. + creds := &vpnconfig.OpenVPNOptions{} + + switch method { + case AuthCertificate: + ok, err := MaybeGetCredentialsFromOptions(m.config, creds, method) + if err != nil { + return nil, err + } + if ok { + return creds, nil + } + // No options passed, so let's get the credentials that inputbuilder should have cached + // for us after hitting the OONI API. + if err := m.getCredentialsFromAPI(ctx, sess, provider, creds); err != nil { + return nil, err + } + return creds, nil + + default: + return nil, fmt.Errorf("%w: method not implemented (%s)", ErrInvalidInput, method) + } + +} + +// mergeOpenVPNConfig attempts to get credentials from Options or API, and then +// constructs a [*vpnconfig.Config] instance after merging the credentials passed by options or API response. +// It also returns an error if the operation fails. +func (m *Measurer) mergeOpenVPNConfig( + ctx context.Context, + sess model.ExperimentSession, + endpoint *endpoint, + tracer *vpntracex.Tracer) (*vpnconfig.Config, error) { + + logger := sess.Logger() + + credentials, err := m.GetCredentialsFromOptionsOrAPI(ctx, sess, endpoint.Provider) + if err != nil { + return nil, err + } + + openvpnConfig, err := getOpenVPNConfig(tracer, logger, endpoint, credentials) + if err != nil { + return nil, err + } + // TODO(ainghazal): sanity check (Remote, Port, Proto etc + missing certs) + return openvpnConfig, nil +} + +// connectAndHandshake dials a connection and attempts an OpenVPN handshake using that dialer. +func (m *Measurer) connectAndHandshake( + ctx context.Context, + zeroTime time.Time, + index int64, + logger model.Logger, + endpoint *endpoint, + openvpnConfig *vpnconfig.Config, + handshakeTracer *vpntracex.Tracer) *SingleConnection { + + // create a trace for the network dialer + trace := measurexlite.NewTrace(index, zeroTime) + dialer := trace.NewDialerWithoutResolver(logger) + + // Create a vpn tun Device that attempts to dial and performs the handshake. + // Any error will be returned as a failure in the SingleConnection result. + tun, err := tunnel.Start(ctx, dialer, openvpnConfig) + if tun != nil { + defer tun.Close() + } + + handshakeEvents := handshakeTracer.Trace() + port, _ := strconv.Atoi(endpoint.Port) + + var ( + tFirst float64 + tLast float64 + bootstrapTime float64 + ) + + if len(handshakeEvents) > 0 { + tFirst = handshakeEvents[0].AtTime + tLast = handshakeEvents[len(handshakeEvents)-1].AtTime + bootstrapTime = tLast - tFirst + } + + return &SingleConnection{ + TCPConnect: trace.FirstTCPConnectOrNil(), + OpenVPNHandshake: &model.ArchivalOpenVPNHandshakeResult{ + BootstrapTime: bootstrapTime, + Endpoint: endpoint.String(), + Failure: measurexlite.NewFailure(err), + IP: endpoint.IPAddr, + Port: port, + Transport: endpoint.Transport, + Provider: endpoint.Provider, + OpenVPNOptions: model.ArchivalOpenVPNOptions{ + Cipher: openvpnConfig.OpenVPNOptions().Cipher, + Auth: openvpnConfig.OpenVPNOptions().Auth, + Compression: string(openvpnConfig.OpenVPNOptions().Compress), + }, + T0: tFirst, + T: tLast, + Tags: []string{}, + TransactionID: index, + }, + NetworkEvents: handshakeEvents, + } +} + +// FetchProviderCredentials will extract credentials from the configuration we gathered for a given provider. +func (m *Measurer) FetchProviderCredentials( + ctx context.Context, + sess model.ExperimentSession, + provider string) (*model.OOAPIVPNProviderConfig, error) { + // TODO(ainghazal): pass real country code, can be useful to orchestrate campaigns specific to areas. + // Since we have contacted the API previously, this call should use the cached info contained in the session. + config, err := sess.FetchOpenVPNConfig(ctx, provider, "XX") + if err != nil { + return nil, err + } + return config, nil +} + +// Run implements model.ExperimentMeasurer.Run. +// A single run expects exactly ONE input (endpoint), but we can modify whether +// to test different transports by settings options. +func (m Measurer) Run(ctx context.Context, args *model.ExperimentArgs) error { + callbacks := args.Callbacks + measurement := args.Measurement + sess := args.Session + + endpoint, err := parseEndpoint(measurement) + if err != nil { + return err + } + + tk := NewTestKeys() + + zeroTime := time.Now() + idx := int64(1) + handshakeTracer := vpntracex.NewTracerWithTransactionID(zeroTime, idx) + + openvpnConfig, err := m.mergeOpenVPNConfig(ctx, sess, endpoint, handshakeTracer) + if err != nil { + return err + } + sess.Logger().Infof("Probing endpoint %s", endpoint.String()) + + connResult := m.connectAndHandshake(ctx, zeroTime, idx, sess.Logger(), endpoint, openvpnConfig, handshakeTracer) + tk.AddConnectionTestKeys(connResult) + tk.Success = tk.AllConnectionsSuccessful() + + callbacks.OnProgress(1.0, "All endpoints probed") + measurement.TestKeys = tk + + // TODO(ainghazal): validate we have valid config for each endpoint. + // TODO(ainghazal): validate hostname is a valid IP (ipv4 or 6) + // TODO(ainghazal): decide what to do if we have expired certs (abort one measurement or abort the whole experiment?) + + // Note: if here we return an error, the parent code will assume + // something fundamental was wrong and we don't have a measurement + // to submit to the OONI collector. Keep this in mind when you + // are writing new experiments! + return nil +} diff --git a/internal/experiment/openvpn/openvpn_test.go b/internal/experiment/openvpn/openvpn_test.go new file mode 100644 index 0000000000..ed0f2b9adb --- /dev/null +++ b/internal/experiment/openvpn/openvpn_test.go @@ -0,0 +1,447 @@ +package openvpn_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + vpnconfig "github.com/ooni/minivpn/pkg/config" + vpntracex "github.com/ooni/minivpn/pkg/tracex" + "github.com/ooni/probe-cli/v3/internal/experiment/openvpn" + "github.com/ooni/probe-cli/v3/internal/mocks" + "github.com/ooni/probe-cli/v3/internal/model" +) + +func makeMockSession() *mocks.Session { + return &mocks.Session{ + MockLogger: func() model.Logger { + return model.DiscardLogger + }, + MockFetchOpenVPNConfig: func(context.Context, string, string) (*model.OOAPIVPNProviderConfig, error) { + return &model.OOAPIVPNProviderConfig{ + Provider: "provider", + Config: &model.OOAPIVPNConfig{ + CA: "ca", + Cert: "cert", + Key: "key", + }, + Inputs: []string{}, + DateUpdated: time.Now(), + }, nil + }, + } +} + +func TestNewExperimentMeasurer(t *testing.T) { + m := openvpn.NewExperimentMeasurer(openvpn.Config{}, "openvpn") + if m.ExperimentName() != "openvpn" { + t.Fatal("invalid ExperimentName") + } + if m.ExperimentVersion() != "0.1.2" { + t.Fatal("invalid ExperimentVersion") + } +} + +func TestNewTestKeys(t *testing.T) { + tk := openvpn.NewTestKeys() + if tk.Success != false { + t.Fatal("default success should be false") + } + if tk.NetworkEvents == nil { + t.Fatal("NetworkEvents not initialized") + } + if tk.TCPConnect == nil { + t.Fatal("TCPConnect not initialized") + } + if tk.OpenVPNHandshake == nil { + t.Fatal("OpenVPNHandshake not initialized") + } +} + +func TestMaybeGetCredentialsFromOptions(t *testing.T) { + t.Run("cert auth returns false if cert, key and ca are not all provided", func(t *testing.T) { + cfg := openvpn.Config{ + SafeCA: "base64:Zm9v", + SafeCert: "base64:Zm9v", + } + ok, err := openvpn.MaybeGetCredentialsFromOptions(cfg, &vpnconfig.OpenVPNOptions{}, openvpn.AuthCertificate) + if err != nil { + t.Fatal("should not raise error") + } + if ok { + t.Fatal("expected false") + } + }) + t.Run("cert auth returns ok if cert, key and ca are all provided", func(t *testing.T) { + cfg := openvpn.Config{ + SafeCA: "base64:Zm9v", + SafeCert: "base64:Zm9v", + SafeKey: "base64:Zm9v", + } + opts := &vpnconfig.OpenVPNOptions{} + ok, err := openvpn.MaybeGetCredentialsFromOptions(cfg, opts, openvpn.AuthCertificate) + if err != nil { + t.Fatalf("expected err=nil, got %v", err) + } + if !ok { + t.Fatal("expected true") + } + if diff := cmp.Diff(opts.CA, []byte("foo")); diff != "" { + t.Fatal(diff) + } + if diff := cmp.Diff(opts.Cert, []byte("foo")); diff != "" { + t.Fatal(diff) + } + if diff := cmp.Diff(opts.Key, []byte("foo")); diff != "" { + t.Fatal(diff) + } + }) + t.Run("cert auth returns false and error if CA base64 is bad blob", func(t *testing.T) { + cfg := openvpn.Config{ + SafeCA: "base64:Zm9vaaa", + SafeCert: "base64:Zm9v", + SafeKey: "base64:Zm9v", + } + opts := &vpnconfig.OpenVPNOptions{} + ok, err := openvpn.MaybeGetCredentialsFromOptions(cfg, opts, openvpn.AuthCertificate) + if ok { + t.Fatal("expected false") + } + if !errors.Is(err, openvpn.ErrBadBase64Blob) { + t.Fatalf("expected err=ErrBase64Blob, got %v", err) + } + }) + t.Run("cert auth returns false and error if key base64 is bad blob", func(t *testing.T) { + cfg := openvpn.Config{ + SafeCA: "base64:Zm9v", + SafeCert: "base64:Zm9v", + SafeKey: "base64:Zm9vaaa", + } + opts := &vpnconfig.OpenVPNOptions{} + ok, err := openvpn.MaybeGetCredentialsFromOptions(cfg, opts, openvpn.AuthCertificate) + if ok { + t.Fatal("expected false") + } + if !errors.Is(err, openvpn.ErrBadBase64Blob) { + t.Fatalf("expected err=ErrBase64Blob, got %v", err) + } + }) + t.Run("cert auth returns false and error if cert base64 is bad blob", func(t *testing.T) { + cfg := openvpn.Config{ + SafeCA: "base64:Zm9v", + SafeCert: "base64:Zm9vaaa", + SafeKey: "base64:Zm9v", + } + opts := &vpnconfig.OpenVPNOptions{} + ok, err := openvpn.MaybeGetCredentialsFromOptions(cfg, opts, openvpn.AuthCertificate) + if ok { + t.Fatal("expected false") + } + if !errors.Is(err, openvpn.ErrBadBase64Blob) { + t.Fatalf("expected err=ErrBase64Blob, got %v", err) + } + }) + t.Run("userpass auth returns error, not yet implemented", func(t *testing.T) { + cfg := openvpn.Config{} + ok, err := openvpn.MaybeGetCredentialsFromOptions(cfg, &vpnconfig.OpenVPNOptions{}, openvpn.AuthUserPass) + if ok { + t.Fatal("expected false") + } + if err != nil { + t.Fatalf("expected err=nil, got %v", err) + } + }) +} + +func TestGetCredentialsFromOptionsOrAPI(t *testing.T) { + t.Run("non-registered provider raises error", func(t *testing.T) { + m := openvpn.NewExperimentMeasurer(openvpn.Config{}, "openvpn").(openvpn.Measurer) + ctx := context.Background() + sess := makeMockSession() + opts, err := m.GetCredentialsFromOptionsOrAPI(ctx, sess, "nsa") + if !errors.Is(err, openvpn.ErrInvalidInput) { + t.Fatalf("expected err=ErrInvalidInput, got %v", err) + } + if opts != nil { + t.Fatal("expected opts=nil") + } + }) + t.Run("providers with userpass auth method raise error, not yet implemented", func(t *testing.T) { + m := openvpn.NewExperimentMeasurer(openvpn.Config{}, "openvpn").(openvpn.Measurer) + ctx := context.Background() + sess := makeMockSession() + opts, err := m.GetCredentialsFromOptionsOrAPI(ctx, sess, "tunnelbear") + if !errors.Is(err, openvpn.ErrInvalidInput) { + t.Fatalf("expected err=ErrInvalidInput, got %v", err) + } + if opts != nil { + t.Fatal("expected opts=nil") + } + }) + t.Run("known cert auth provider and creds in options is ok", func(t *testing.T) { + config := openvpn.Config{ + SafeCA: "base64:Zm9v", + SafeCert: "base64:Zm9v", + SafeKey: "base64:Zm9v", + } + m := openvpn.NewExperimentMeasurer(config, "openvpn").(openvpn.Measurer) + ctx := context.Background() + sess := makeMockSession() + opts, err := m.GetCredentialsFromOptionsOrAPI(ctx, sess, "riseup") + if err != nil { + t.Fatalf("expected err=nil, got %v", err) + } + if opts == nil { + t.Fatal("expected non-nil options") + } + }) + t.Run("known cert auth provider and bad creds in options returns error", func(t *testing.T) { + config := openvpn.Config{ + SafeCA: "base64:Zm9v", + SafeCert: "base64:Zm9v", + SafeKey: "base64:Zm9vaaa", + } + m := openvpn.NewExperimentMeasurer(config, "openvpn").(openvpn.Measurer) + ctx := context.Background() + sess := makeMockSession() + opts, err := m.GetCredentialsFromOptionsOrAPI(ctx, sess, "riseup") + if !errors.Is(err, openvpn.ErrBadBase64Blob) { + t.Fatalf("expected err=ErrBadBase64, got %v", err) + } + if opts != nil { + t.Fatal("expected nil opts") + } + }) + t.Run("known cert auth provider with null options hits the api", func(t *testing.T) { + config := openvpn.Config{} + m := openvpn.NewExperimentMeasurer(config, "openvpn").(openvpn.Measurer) + ctx := context.Background() + sess := makeMockSession() + opts, err := m.GetCredentialsFromOptionsOrAPI(ctx, sess, "riseup") + if err != nil { + t.Fatalf("expected err=nil, got %v", err) + } + if opts == nil { + t.Fatalf("expected not-nil options, got %v", opts) + } + }) + t.Run("known cert auth provider with null options hits the api and raises error if api fails", func(t *testing.T) { + config := openvpn.Config{} + m := openvpn.NewExperimentMeasurer(config, "openvpn").(openvpn.Measurer) + ctx := context.Background() + + someError := errors.New("some error") + sess := makeMockSession() + sess.MockFetchOpenVPNConfig = func(context.Context, string, string) (*model.OOAPIVPNProviderConfig, error) { + return nil, someError + } + + opts, err := m.GetCredentialsFromOptionsOrAPI(ctx, sess, "riseup") + if !errors.Is(err, someError) { + t.Fatalf("expected err=someError, got %v", err) + } + if opts != nil { + t.Fatalf("expected nil options, got %v", opts) + } + }) +} + +func TestAddConnectionTestKeys(t *testing.T) { + t.Run("append tcp connection result to empty keys", func(t *testing.T) { + tk := openvpn.NewTestKeys() + sc := &openvpn.SingleConnection{ + TCPConnect: &model.ArchivalTCPConnectResult{ + IP: "1.1.1.1", + Port: 1194, + Status: model.ArchivalTCPConnectStatus{ + Blocked: new(bool), + Failure: new(string), + Success: false, + }, + T0: 0.1, + T: 0.9, + Tags: []string{}, + TransactionID: 1, + }, + OpenVPNHandshake: &model.ArchivalOpenVPNHandshakeResult{ + BootstrapTime: 1, + Endpoint: "aa", + Failure: nil, + IP: "1.1.1.1", + Port: 1194, + Transport: "tcp", + Provider: "unknown", + OpenVPNOptions: model.ArchivalOpenVPNOptions{}, + T0: 0, + T: 0, + Tags: []string{}, + TransactionID: 1, + }, + NetworkEvents: []*vpntracex.Event{}, + } + tk.AddConnectionTestKeys(sc) + if diff := cmp.Diff(tk.TCPConnect[0], sc.TCPConnect); diff != "" { + t.Fatal(diff) + } + if diff := cmp.Diff(tk.OpenVPNHandshake[0], sc.OpenVPNHandshake); diff != "" { + t.Fatal(diff) + } + if diff := cmp.Diff(tk.NetworkEvents, sc.NetworkEvents); diff != "" { + t.Fatal(diff) + } + }) + + t.Run("append udp connection result to empty keys", func(t *testing.T) { + tk := openvpn.NewTestKeys() + sc := &openvpn.SingleConnection{ + TCPConnect: nil, + OpenVPNHandshake: &model.ArchivalOpenVPNHandshakeResult{ + BootstrapTime: 1, + Endpoint: "aa", + Failure: nil, + IP: "1.1.1.1", + Port: 1194, + Transport: "udp", + Provider: "unknown", + OpenVPNOptions: model.ArchivalOpenVPNOptions{}, + T0: 0, + T: 0, + Tags: []string{}, + TransactionID: 1, + }, + NetworkEvents: []*vpntracex.Event{}, + } + tk.AddConnectionTestKeys(sc) + if len(tk.TCPConnect) != 0 { + t.Fatal("expected empty tcpconnect") + } + if diff := cmp.Diff(tk.OpenVPNHandshake[0], sc.OpenVPNHandshake); diff != "" { + t.Fatal(diff) + } + if diff := cmp.Diff(tk.NetworkEvents, sc.NetworkEvents); diff != "" { + t.Fatal(diff) + } + }) +} + +func TestAllConnectionsSuccessful(t *testing.T) { + t.Run("all success", func(t *testing.T) { + tk := openvpn.NewTestKeys() + tk.OpenVPNHandshake = []*model.ArchivalOpenVPNHandshakeResult{ + {Failure: nil}, + {Failure: nil}, + {Failure: nil}, + } + if tk.AllConnectionsSuccessful() != true { + t.Fatal("expected all connections successful") + } + }) + t.Run("one failure", func(t *testing.T) { + fail := "uh" + tk := openvpn.NewTestKeys() + tk.OpenVPNHandshake = []*model.ArchivalOpenVPNHandshakeResult{ + {Failure: &fail}, + {Failure: nil}, + {Failure: nil}, + } + if tk.AllConnectionsSuccessful() != false { + t.Fatal("expected false") + } + }) + t.Run("all failures", func(t *testing.T) { + fail := "uh" + tk := openvpn.NewTestKeys() + tk.OpenVPNHandshake = []*model.ArchivalOpenVPNHandshakeResult{ + {Failure: &fail}, + {Failure: &fail}, + {Failure: &fail}, + } + if tk.AllConnectionsSuccessful() != false { + t.Fatal("expected false") + } + }) +} + +func TestBadInputFailure(t *testing.T) { + m := openvpn.NewExperimentMeasurer(openvpn.Config{}, "openvpn") + ctx := context.Background() + sess := makeMockSession() + callbacks := model.NewPrinterCallbacks(sess.Logger()) + measurement := new(model.Measurement) + measurement.Input = "openvpn://badprovider/?address=aa" + args := &model.ExperimentArgs{ + Callbacks: callbacks, + Measurement: measurement, + Session: sess, + } + err := m.Run(ctx, args) + if !errors.Is(err, openvpn.ErrInvalidInput) { + t.Fatalf("expected ErrInvalidInput, got %v", err) + } +} + +func TestVPNInput(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + // TODO(ainghazal): do a real test, get credentials etc. +} + +func TestMeasurer_FetchProviderCredentials(t *testing.T) { + t.Run("Measurer.FetchProviderCredentials calls method in session", func(t *testing.T) { + m := openvpn.NewExperimentMeasurer( + openvpn.Config{}, + "openvpn").(openvpn.Measurer) + + sess := makeMockSession() + _, err := m.FetchProviderCredentials( + context.Background(), + sess, "riseup") + if err != nil { + t.Fatal("expected no error") + } + }) + t.Run("Measurer.FetchProviderCredentials raises error if API calls fail", func(t *testing.T) { + someError := errors.New("unexpected") + + m := openvpn.NewExperimentMeasurer( + openvpn.Config{}, + "openvpn").(openvpn.Measurer) + + sess := makeMockSession() + sess.MockFetchOpenVPNConfig = func(context.Context, string, string) (*model.OOAPIVPNProviderConfig, error) { + return nil, someError + } + _, err := m.FetchProviderCredentials( + context.Background(), + sess, "riseup") + if !errors.Is(err, someError) { + t.Fatalf("expected error %v, got %v", someError, err) + } + }) +} + +func TestSuccess(t *testing.T) { + m := openvpn.NewExperimentMeasurer(openvpn.Config{ + Provider: "riseup", + SafeCA: "base64:Zm9v", + SafeKey: "base64:Zm9v", + SafeCert: "base64:Zm9v", + }, "openvpn") + ctx := context.Background() + sess := makeMockSession() + callbacks := model.NewPrinterCallbacks(sess.Logger()) + measurement := new(model.Measurement) + measurement.Input = "openvpn://riseupvpn.corp/?address=127.0.0.1:9989&transport=tcp" + args := &model.ExperimentArgs{ + Callbacks: callbacks, + Measurement: measurement, + Session: sess, + } + err := m.Run(ctx, args) + if err != nil { + t.Fatal(err) + } +} diff --git a/internal/legacy/mockable/mockable.go b/internal/legacy/mockable/mockable.go index 2f39a254c0..cac5718aa6 100644 --- a/internal/legacy/mockable/mockable.go +++ b/internal/legacy/mockable/mockable.go @@ -26,6 +26,7 @@ type Session struct { MockableFetchPsiphonConfigErr error MockableFetchTorTargetsResult map[string]model.OOAPITorTarget MockableFetchTorTargetsErr error + MockableFetchOpenVPNConfigErr error MockableCheckInInfo *model.OOAPICheckInResultNettests MockableCheckInErr error MockableResolverIP string @@ -34,6 +35,7 @@ type Session struct { MockableTempDir string MockableTorArgs []string MockableTorBinary string + MockableOpenVPNConfig *model.OOAPIVPNProviderConfig MockableTunnelDir string MockableUserAgent string } @@ -60,6 +62,12 @@ func (sess *Session) FetchTorTargets( return sess.MockableFetchTorTargetsResult, sess.MockableFetchTorTargetsErr } +// FetchOpenVPNConfig implements ExperimentSession.FetchOpenVPNConfig +func (sess *Session) FetchOpenVPNConfig( + ctx context.Context, provider, cc string) (*model.OOAPIVPNProviderConfig, error) { + return sess.MockableOpenVPNConfig, sess.MockableFetchOpenVPNConfigErr +} + // KeyValueStore returns the configured key-value store. func (sess *Session) KeyValueStore() model.KeyValueStore { return &kvstore.Memory{} diff --git a/internal/mocks/session.go b/internal/mocks/session.go index c0750f7169..b26611268c 100644 --- a/internal/mocks/session.go +++ b/internal/mocks/session.go @@ -18,6 +18,9 @@ type Session struct { MockFetchTorTargets func( ctx context.Context, cc string) (map[string]model.OOAPITorTarget, error) + MockFetchOpenVPNConfig func( + ctx context.Context, provider, cc string) (*model.OOAPIVPNProviderConfig, error) + MockKeyValueStore func() model.KeyValueStore MockLogger func() model.Logger @@ -70,6 +73,11 @@ func (sess *Session) FetchPsiphonConfig(ctx context.Context) ([]byte, error) { return sess.MockFetchPsiphonConfig(ctx) } +func (sess *Session) FetchOpenVPNConfig( + ctx context.Context, provider, cc string) (*model.OOAPIVPNProviderConfig, error) { + return sess.MockFetchOpenVPNConfig(ctx, provider, cc) +} + func (sess *Session) FetchTorTargets( ctx context.Context, cc string) (map[string]model.OOAPITorTarget, error) { return sess.MockFetchTorTargets(ctx, cc) diff --git a/internal/mocks/session_test.go b/internal/mocks/session_test.go index b794941736..e26f67430d 100644 --- a/internal/mocks/session_test.go +++ b/internal/mocks/session_test.go @@ -80,6 +80,22 @@ func TestSession(t *testing.T) { } }) + t.Run("FetchOpenVPNConfig", func(t *testing.T) { + expected := errors.New("mocked err") + s := &Session{ + MockFetchOpenVPNConfig: func(ctx context.Context, provider, cc string) (*model.OOAPIVPNProviderConfig, error) { + return nil, expected + }, + } + cfg, err := s.FetchOpenVPNConfig(context.Background(), "riseup", "XX") + if !errors.Is(err, expected) { + t.Fatal("unexpected err", err) + } + if cfg != nil { + t.Fatal("expected nil cfg") + } + }) + t.Run("KeyValueStore", func(t *testing.T) { expect := &KeyValueStore{} s := &Session{ diff --git a/internal/model/archival.go b/internal/model/archival.go index ecef3c2427..a25ee57105 100644 --- a/internal/model/archival.go +++ b/internal/model/archival.go @@ -392,3 +392,31 @@ type ArchivalNetworkEvent struct { TransactionID int64 `json:"transaction_id,omitempty"` Tags []string `json:"tags,omitempty"` } + +// +// OpenVPN +// + +// ArchivalOpenVPNHandshakeResult contains the result of a OpenVPN handshake. +type ArchivalOpenVPNHandshakeResult struct { + BootstrapTime float64 `json:"bootstrap_time,omitempty"` + Endpoint string `json:"endpoint"` + Failure *string `json:"failure"` + IP string `json:"ip"` + Port int `json:"port"` + Transport string `json:"transport"` + Provider string `json:"provider"` + OpenVPNOptions ArchivalOpenVPNOptions `json:"openvpn_options"` + T0 float64 `json:"t0,omitempty"` + T float64 `json:"t"` + Tags []string `json:"tags"` + TransactionID int64 `json:"transaction_id,omitempty"` +} + +// ArchivalOpenVPNOptions is a subset of [vpnconfig.OpenVPNOptions] that we want to include +// in the archived result. +type ArchivalOpenVPNOptions struct { + Auth string `json:"auth,omitempty"` + Cipher string `json:"cipher,omitempty"` + Compression string `json:"compression,omitempty"` +} diff --git a/internal/model/experiment.go b/internal/model/experiment.go index f070cddf21..e83caa0bce 100644 --- a/internal/model/experiment.go +++ b/internal/model/experiment.go @@ -21,6 +21,9 @@ type ExperimentSession interface { // DefaultHTTPClient returns the default HTTPClient used by the session. DefaultHTTPClient() HTTPClient + // FetchOpenVPNConfig returns vpn config as a serialized JSON or an error. + FetchOpenVPNConfig(ctx context.Context, provider, cc string) (*OOAPIVPNProviderConfig, error) + // FetchPsiphonConfig returns psiphon's config as a serialized JSON or an error. FetchPsiphonConfig(ctx context.Context) ([]byte, error) diff --git a/internal/model/ooapi.go b/internal/model/ooapi.go index 940bc4ce5a..0442e8369a 100644 --- a/internal/model/ooapi.go +++ b/internal/model/ooapi.go @@ -99,6 +99,41 @@ type OOAPICheckReportIDResponse struct { V int64 `json:"v"` } +// OOAPIVPNConfig contains the configuration needed to start an OpenVPN connection, returned as part of +// [OOAPIVPNProviderConfig]. +type OOAPIVPNConfig struct { + // CA is the Certificate Authority for the endpoints by this provider. + CA string `json:"ca"` + + // Cert is a valid certificate, for providers that use x509 certificate authentication. + Cert string `json:"cert,omitempty"` + + // Key is a valid key, for providers that use x509 certificate authentication. + Key string `json:"key,omitempty"` + + // Username is a valid username, for providers that use password authentication. + Username string `json:"username,omitempty"` + + // Password is a valid password, for providers that use password authentication. + Password string `json:"password,omitempty"` +} + +// OOAPIVPNProviderConfig is a minimal valid configuration subset for the openvpn experiment; at the moment it provides +// credentials valid for endpoints in a provider, and a list of inputs to be tested on this provider. +type OOAPIVPNProviderConfig struct { + // Provider is the label for this provider. + Provider string `json:"provider,omitempty"` + + // Config is the provider-specific VPN Config. + Config *OOAPIVPNConfig `json:"config"` + + // Inputs is an array of valid endpoints for this provider. + Inputs []string `json:"endpoints"` + + // DateUpdated is when the credential set was last updated in the server database. + DateUpdated time.Time `json:"date_updated"` +} + // OOAPIService describes a backend service. // // The fields of this struct have the meaning described in v2.0.0 of the OONI diff --git a/internal/probeservices/openvpn.go b/internal/probeservices/openvpn.go new file mode 100644 index 0000000000..326cb70cb7 --- /dev/null +++ b/internal/probeservices/openvpn.go @@ -0,0 +1,45 @@ +package probeservices + +import ( + "context" + "fmt" + "net/url" + "strings" + + "github.com/ooni/probe-cli/v3/internal/httpclientx" + "github.com/ooni/probe-cli/v3/internal/model" + "github.com/ooni/probe-cli/v3/internal/urlx" +) + +// FetchOpenVPNConfig returns valid configuration for the openvpn experiment. +// It accepts the provider label, and the country code for the probe, in case the API wants to +// return different targets to us depending on where we are located. +func (c Client) FetchOpenVPNConfig(ctx context.Context, provider, cc string) (result model.OOAPIVPNProviderConfig, err error) { + // create query string + query := url.Values{} + query.Add("country_code", cc) + + // TODO(ainghazal): remove temporary fix + if !strings.HasSuffix(provider, "vpn") { + provider = provider + "vpn" + } + + URL, err := urlx.ResolveReference(c.BaseURL, + fmt.Sprintf("/api/v2/ooniprobe/vpn-config/%s", provider), + query.Encode()) + if err != nil { + return + } + + // get response + // + // use a model.DiscardLogger to avoid logging config + return httpclientx.GetJSON[model.OOAPIVPNProviderConfig]( + ctx, + httpclientx.NewEndpoint(URL).WithHostOverride(c.Host), + &httpclientx.Config{ + Client: c.HTTPClient, + Logger: model.DiscardLogger, + UserAgent: c.UserAgent, + }) +} diff --git a/internal/probeservices/openvpn_test.go b/internal/probeservices/openvpn_test.go new file mode 100644 index 0000000000..a40b983f25 --- /dev/null +++ b/internal/probeservices/openvpn_test.go @@ -0,0 +1,105 @@ +package probeservices + +import ( + "context" + "fmt" + "net/http" + "net/url" + "testing" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/legacy/mockable" + "github.com/ooni/probe-cli/v3/internal/mocks" + "github.com/ooni/probe-cli/v3/internal/model" + "github.com/ooni/probe-cli/v3/internal/runtimex" + "github.com/ooni/probe-cli/v3/internal/testingx" +) + +func newclientWithStagingEnv() *Client { + client := runtimex.Try1(NewClient( + &mockable.Session{ + MockableHTTPClient: http.DefaultClient, + MockableLogger: log.Log, + }, + model.OOAPIService{ + Address: "https://api.dev.ooni.io/", + Type: "https", + }, + )) + return client +} + +func TestFetchOpenVPNConfig(t *testing.T) { + // First, let's check whether we can get a response from the real OONI backend. + t.Run("is working as intended with the real backend", func(t *testing.T) { + if testing.Short() { + t.Skip("skip test in short mode") + } + + // TODO(ain): switch to newclient() when backend in all environments + // deploys the vpn-config endpoint. + clnt := newclientWithStagingEnv() + + // run the tor flow + config, err := clnt.FetchOpenVPNConfig(context.Background(), "riseup", "ZZ") + + // we do not expect an error here + if err != nil { + t.Fatal(err) + } + + // we expect non-zero length targets + if len(config.Inputs) <= 0 { + fmt.Println(config) + t.Fatal("expected non-zero-length inputs") + } + }) + + t.Run("is working as intended with a local test server", func(t *testing.T) { + // create state for emulating the OONI backend + state := &testingx.OONIBackendWithLoginFlow{} + + // return something that matches thes expected data + state.SetOpenVPNConfig([]byte(`{ +"provider": "demovpn", +"protocol": "openvpn", +"config": { + "ca": "deadbeef", + "cert": "deadbeef", + "key": "deadbeef" + }, + "date_updated": "2024-05-06T15:22:13.152242Z", + "endpoints": [ + "openvpn://demovpn.corp/?address=1.1.1.1:53&transport=udp" + ] +} +`)) + + // expose the state via HTTP + srv := testingx.MustNewHTTPServer(state.NewMux()) + defer srv.Close() + + client := newclient() + + // override the HTTP client so we speak with our local server rather than the true backend + client.HTTPClient = &mocks.HTTPClient{ + MockDo: func(req *http.Request) (*http.Response, error) { + URL := runtimex.Try1(url.Parse(srv.URL)) + req.URL.Scheme = URL.Scheme + req.URL.Host = URL.Host + return http.DefaultClient.Do(req) + }, + MockCloseIdleConnections: func() { + http.DefaultClient.CloseIdleConnections() + }, + } + + // then we can try to fetch the config + _, err := client.FetchOpenVPNConfig(context.Background(), "demo", "ZZ") + + // we do not expect an error here + if err != nil { + t.Fatal(err) + } + }) +} diff --git a/internal/probeservices/tor_test.go b/internal/probeservices/tor_test.go index bb91771b23..79dd2adc2c 100644 --- a/internal/probeservices/tor_test.go +++ b/internal/probeservices/tor_test.go @@ -242,7 +242,7 @@ func TestFetchTorTargets(t *testing.T) { t.Run("when we're not registered", func(t *testing.T) { clnt := newclient() - // With explicitly empty state so it's pretty obvioust there's no state + // With explicitly empty state so it's pretty obvious there's no state state := State{} // force the state to be empty diff --git a/internal/registry/factory_test.go b/internal/registry/factory_test.go index b407f074ea..dd32ddafb3 100644 --- a/internal/registry/factory_test.go +++ b/internal/registry/factory_test.go @@ -455,6 +455,11 @@ func TestNewFactory(t *testing.T) { inputPolicy: model.InputNone, interruptible: true, }, + "openvpn": { + enabledByDefault: true, + inputPolicy: model.InputOrQueryBackend, + interruptible: true, + }, "portfiltering": { enabledByDefault: true, inputPolicy: model.InputNone, diff --git a/internal/registry/openvpn.go b/internal/registry/openvpn.go new file mode 100644 index 0000000000..cf9244786f --- /dev/null +++ b/internal/registry/openvpn.go @@ -0,0 +1,26 @@ +package registry + +// +// Registers the `openvpn' experiment. +// + +import ( + "github.com/ooni/probe-cli/v3/internal/experiment/openvpn" + "github.com/ooni/probe-cli/v3/internal/model" +) + +func init() { + AllExperiments["openvpn"] = func() *Factory { + return &Factory{ + build: func(config interface{}) model.ExperimentMeasurer { + return openvpn.NewExperimentMeasurer( + *config.(*openvpn.Config), "openvpn", + ) + }, + config: &openvpn.Config{}, + enabledByDefault: true, + interruptible: true, + inputPolicy: model.InputOrQueryBackend, + } + } +} diff --git a/internal/testingx/oonibackendwithlogin.go b/internal/testingx/oonibackendwithlogin.go index 9bcf3e0690..1cb3fede6e 100644 --- a/internal/testingx/oonibackendwithlogin.go +++ b/internal/testingx/oonibackendwithlogin.go @@ -38,6 +38,9 @@ type OONIBackendWithLoginFlow struct { // mu provides mutual exclusion. mu sync.Mutex + // openVPNConfig is the serialized openvpn config to send to clients. + openVPNConfig []byte + // psiphonConfig is the serialized psiphon config to send to authenticated clients. psiphonConfig []byte @@ -48,6 +51,15 @@ type OONIBackendWithLoginFlow struct { torTargets []byte } +// SetOpenVPNConfig sets openvpn configuration to use. +// +// This method is safe to call concurrently with incoming HTTP requests. +func (h *OONIBackendWithLoginFlow) SetOpenVPNConfig(config []byte) { + defer h.mu.Unlock() + h.mu.Lock() + h.openVPNConfig = config +} + // SetPsiphonConfig sets psiphon configuration to use. // // This method is safe to call concurrently with incoming HTTP requests. @@ -86,6 +98,7 @@ func (h *OONIBackendWithLoginFlow) NewMux() *http.ServeMux { mux.Handle("/api/v1/login", h.handleLogin()) mux.Handle("/api/v1/test-list/psiphon-config", h.withAuthentication(h.handlePsiphonConfig())) mux.Handle("/api/v1/test-list/tor-targets", h.withAuthentication(h.handleTorTargets())) + mux.Handle("/api/v2/ooniprobe/vpn-config/demovpn", h.handleOpenVPNConfig()) return mux } @@ -211,6 +224,21 @@ func (h *OONIBackendWithLoginFlow) handleLogin() http.Handler { }) } +func (h *OONIBackendWithLoginFlow) handleOpenVPNConfig() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // make sure the method is OK + if r.Method != http.MethodGet { + w.WriteHeader(501) + return + } + + // we must lock because of SetOpenVPNConfig + h.mu.Lock() + w.Write(h.openVPNConfig) + h.mu.Unlock() + }) +} + func (h *OONIBackendWithLoginFlow) handlePsiphonConfig() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // make sure the method is OK