diff --git a/TEST b/TEST new file mode 100755 index 0000000000..87a0adcd5a --- /dev/null +++ b/TEST @@ -0,0 +1,6 @@ +#!/bin/sh +OONI_FORCE_ENABLE_EXPERIMENT=1 ./miniooni openvpn \ + -O SafeCA=base64:LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJZakNDQVFpZ0F3SUJBZ0lCQVRBS0JnZ3Foa2pPUFFRREFqQVhNUlV3RXdZRFZRUURFd3hNUlVGUUlGSnYKYjNRZ1EwRXdIaGNOTWpFeE1UQXlNVGt3TlRNM1doY05Nall4TVRBeU1Ua3hNRE0zV2pBWE1SVXdFd1lEVlFRRApFd3hNUlVGUUlGSnZiM1FnUTBFd1dUQVRCZ2NxaGtqT1BRSUJCZ2dxaGtqT1BRTUJCd05DQUFReE9YQkd1K2dmCnBqSHpWdGVHVFdMNlhuRnh0RW5LTUZwS2FKa0EvVk9IbUVTem9Mc1pSUXh0ODhHc3N4YXFDMDFKMTdpZFFpcXYKemdOcGVkbXR2RnR5bzBVd1F6QU9CZ05WSFE4QkFmOEVCQU1DQXFRd0VnWURWUjBUQVFIL0JBZ3dCZ0VCL3dJQgpBVEFkQmdOVkhRNEVGZ1FVWmRvVWxKckNJVU5GcnBmZkFxK0xRam53RXo0d0NnWUlLb1pJemowRUF3SURTQUF3ClJRSWdmcjN3NHRuUkcrTmRJM0xzR1Bsc1JrdEdLMjB4SFR6c0Izb3JCMHlDNmNJQ0lRQ0IrLzl5OG5tU1N0Zk4KVlVNVXlrMmhOZDcva0M4bkwyMjJUVEQ3VlpVdHNnPT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQ== \ + -O SafeCert=base64:LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNkekNDQWgyZ0F3SUJBZ0lRQy9QRUlmdDBZTGlmRTFIZU8rYjZYakFLQmdncWhrak9QUVFEQWpBek1URXcKTHdZRFZRUUREQ2hNUlVGUUlGSnZiM1FnUTBFZ0tHTnNhV1Z1ZENCalpYSjBhV1pwWTJGMFpYTWdiMjVzZVNFcApNQjRYRFRJME1ETXlOakU0TlRJME1Gb1hEVEkwTURRek1ERTROVEkwTUZvd0ZERVNNQkFHQTFVRUF4TUpWVTVNClNVMUpWRVZFTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUFvWE81azZNOVE4UjMKS0dHU0Q2Y052YktCck9Kc1pOQ1dFMVlVK3dOQ2JERGJPSjlaQmNmSWc4d2wrODNWeGkySUtaL0pNSTJaUXQzegp4UTdRa1NCY0hEK2lZdWlxOVhQL283dDZMTHhzY3FoRU1LWW41UFEwaWo2Qnc5RFUyOXA4c2lXU0lrNUprY3dYCm5rVmVQTUNvb0tMN2NGZ3lyanQrM3liM3Z1TmI3TnZDM3V3akUyR00wa2FLVGp2cEN5MDUyUzI4ZGo4NCtUTksKSHJYa1JzRGNuTmQrZ2xrM2hSZ005VzNCak9uczN5eitnV2pjT3lraXdITElnd05CWjE3aVltNEpUZnV3dnY2RQpJREJQZEVtSUsxYWovWUlRSWNpekxCODFBTXk2U0l0cytVc0V3MkZyVW9zWlRSMUJiZUMyQVNCVEhBOTY0RzBqCmJmalU5V1pScFFJREFRQUJvMmN3WlRBT0JnTlZIUThCQWY4RUJBTUNCNEF3RXdZRFZSMGxCQXd3Q2dZSUt3WUIKQlFVSEF3SXdIUVlEVlIwT0JCWUVGSEJ4a0xvZVpCU29UbkpORmJmUjNoSGlrbi93TUI4R0ExVWRJd1FZTUJhQQpGSDFLWXRqL0swbkVlYmFpc2R5T1BtTUhYS2orTUFvR0NDcUdTTTQ5QkFNQ0EwZ0FNRVVDSUJqNDRiQTRieURECklzT1VQTHVLZEU5Skg4alFkYnZvZTdITUI1dU1IWlZtQWlFQTBrWXM0aUVoRGZOWEx5Y0NxeE5qd0xDYUxMNmwKR055aHZOenNkd2pGRzlVPQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0t \ + -O SafeKey=base64:LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFb3dJQkFBS0NBUUVBb1hPNWs2TTlROFIzS0dHU0Q2Y052YktCck9Kc1pOQ1dFMVlVK3dOQ2JERGJPSjlaCkJjZklnOHdsKzgzVnhpMklLWi9KTUkyWlF0M3p4UTdRa1NCY0hEK2lZdWlxOVhQL283dDZMTHhzY3FoRU1LWW4KNVBRMGlqNkJ3OURVMjlwOHNpV1NJazVKa2N3WG5rVmVQTUNvb0tMN2NGZ3lyanQrM3liM3Z1TmI3TnZDM3V3agpFMkdNMGthS1RqdnBDeTA1MlMyOGRqODQrVE5LSHJYa1JzRGNuTmQrZ2xrM2hSZ005VzNCak9uczN5eitnV2pjCk95a2l3SExJZ3dOQloxN2lZbTRKVGZ1d3Z2NkVJREJQZEVtSUsxYWovWUlRSWNpekxCODFBTXk2U0l0cytVc0UKdzJGclVvc1pUUjFCYmVDMkFTQlRIQTk2NEcwamJmalU5V1pScFFJREFRQUJBb0lCQUdpZEZMVXVROGRDYVRkWQpLWTFNNEdGM1pnRUE0ZDFkTHJFdXlQOXd1RHhrVjlmVG9KcFhQbnp2N2ZqQUFmR2NsU3JyWnpDM3Y0UU5UeVB6Cm1uOCs2WkJBUjFjeGpYem9BZEEwN1ZCSmN6ZkVBaE5IeG5mYktLUWZKblFjUDZDSmJOejk3VGVmWkpvOUZWeXYKZEFXSGpId3h2eHluZVlkZDg3SmlqSXM2eE5pdThZN1ZMSGppU0IvdkUvNnA3YlBYbkZ4L0E3WVl0SFdkallhYwplNGtuRFR4czEwUzVYTEI4V3hvZnNqK1ZGUklkUkFjUURZYnZpTlZ3cXlEVXV3TFFJZ3Nlc1lldUEyVmF3ZU5XClpVTXN2TGZjdXB2QUU2b2ZGM21XcXh2Unljd0ZZRjB4ZUVPNzNrQ0RJbTM5cHdDdVNQOFlYSmk4U3VHUDVQZmwKUTZBQlNBMENnWUVBeDZSQlFHdjM4TE9UOVkzdVV2am9hanNKY25QZWR3ejlITGJjOGJhUk0yMGdJaUdUZlR0Kwp5RnNhT2Raa05GSmtCd3A4amhwaTcvRzBaWURhbUZ1bkhBY2JyUFlhV2FodXhWRlp6OStyOWlrQmtFVzEwOFJ2CkFpNnhXTklNcXlaRkhoZ2d2MGhzeWZFbmoxeSs5TGR4UWVncHlteDhBQzRpSVBtV0cvak9QODhDZ1lFQXp3ZVQKNFBpK2sweUdaSU5DVGdDSmY1blVRT2xnRXF3bGtXbE9hVzNpODdVdnlDaDJZbG5NQjhkSnUwajZBYkZHQWtKMAo0NlFKekIvWkdIZ1lXU0tDdjhLUk9sdkExeElpdXdvaDU3bXcxUUFKNzE2SktRZXk4SXRWUDJEb2JhQXFxTHlpCktkQjJnNjZJZTFaUTAzV2pyaWZZWUE4cUlLeUFRQU5LS00wMFlFc0NnWUJHNjJtbXFmUWxGSlgrQ0JKZWRUK1MKNVRBQThYcFl6a3RvRk9tK0QvM2F4K3cvVTdBaUw2MWxIVC9leGZOSXh1L3p0RnowMmhqRlpoYVFiRXE3RHV2NQpQK2tyOHl6L0pwOWJCd0Fob1RKa09zTHNibWNlT0V5NitMMVZjU0RBOTlKYjAzUm1ueUxPUmhXb2p1amk0L3VlCnp3dHhka0pDaWlEamwrWWtQNmw4N3dLQmdEQWhUdHZLZUdPK01yQkRZN0xHcFRDcERwTllyaUVwTEVLMS9LaSsKQnprcE1rYVNRWk56MU44cUVaWWN6U0ovbUFzR2NDNU1BSXNZREZ5SDd3RXA2TU96OUJkaEpWL2FzNEJRUnJtYwpqZU8yOHBoWG5nT1A2cVhKZnJRUlQzZk0rSjNwM0xsajRXbVR0ZDhXbmd2TEdaWnZaUHBRRERjMFkweCtkMHN0CkNiUXBBb0dCQUkwWjAxU2w3b2wvdTlxNVhrTUhtNUR3UkVIRDhPOXZYZHR6NEQyaUpUK2JjMStPT3ZHTGQzS04KRHBJbVpJYUFUcFh1OEZxdXVDUzN4WDRDNjJSSmgzbC9IRXArWmQ4V2YxL0UxUUFBSHNBSGk0elM3R2RSSnJOdAo3cUZ4UkVsaVJ0Wm10a09TL1B3YzY5eTB0aENJRCtIazhqWGlQRnBEejNMNmNZSWJTaUJqCi0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0tCg== \ +-n diff --git a/internal/engine/inputloader.go b/internal/engine/inputloader.go index 9bc8b0a86e..861be897f4 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" @@ -29,6 +30,8 @@ var ( type InputLoaderSession interface { CheckIn(ctx context.Context, config *model.OOAPICheckInConfig) (*model.OOAPICheckInResultNettests, error) + FetchOpenVPNConfig(ctx context.Context, + provider, cc string) (*model.OOAPIVPNProviderConfig, error) } // InputLoaderLogger is the logger according to an InputLoader. @@ -300,6 +303,16 @@ 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": + 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 @@ -318,6 +331,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. + urls := make([]model.OOAPIURLInfo, 0) + + // The openvpn experiment contains an array of the providers that the API knows about. + for _, provider := range openvpn.APIEnabledProviders { + reply, err := il.vpnConfig(ctx, provider) + if err != nil { + break + } + // here we're just collecting all the inputs. we also cache the configs so that + // each experiment run can access the credentials for a given provider. + for _, input := range reply.Inputs { + urls = append(urls, model.OOAPIURLInfo{URL: input}) + } + } + + if len(urls) == 0 { + // loadRemote returns ErrNoURLsReturned at this point for webconnectivity, + // but for OpenVPN we want to return a sensible default to be + // able to probe some endpoints even in very restrictive environments. + // Do note this means that you have to provide valid credentials + // by some other means. + for _, endpoint := range openvpn.DefaultEndpoints { + urls = append(urls, model.OOAPIURLInfo{URL: endpoint.AsInputURI()}) + } + } + 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. @@ -336,6 +382,15 @@ func (il *InputLoader) checkIn( return reply, nil } +// vpnConfig fetches vpn information for the configured providers +func (il *InputLoader) vpnConfig(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 e6bbf3dcfa..39d225a665 100644 --- a/internal/engine/inputloader_test.go +++ b/internal/engine/inputloader_test.go @@ -444,9 +444,13 @@ func TestInputLoaderReadfileScannerFailure(t *testing.T) { // InputLoaderMockableSession is a mockable session // used by InputLoader tests. type InputLoaderMockableSession struct { - // Output contains the output of CheckIn. It should - // be nil when Error is not-nil. - Output *model.OOAPICheckInResultNettests + // CheckinOutput contains the output of CheckIn. It should + // be nil when Error is non-nil. + CheckinOutput *model.OOAPICheckInResultNettests + + // 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. @@ -456,10 +460,19 @@ type InputLoaderMockableSession struct { // CheckIn implements InputLoaderSession.CheckIn. func (sess *InputLoaderMockableSession) CheckIn( ctx context.Context, config *model.OOAPICheckInConfig) (*model.OOAPICheckInResultNettests, error) { - if sess.Output == nil && sess.Error == nil { + if sess.CheckinOutput == nil && sess.Error == nil { + return nil, errors.New("both Output and Error are nil") + } + return sess.CheckinOutput, sess.Error +} + +// FetchOpenVPNConfig implements InputLoaderSession.FetchOpenVPNConfig. +func (sess *InputLoaderMockableSession) FetchOpenVPNConfig( + ctx context.Context, provider, cc string) (*model.OOAPIVPNProviderConfig, error) { + if sess.FetchOpenVPNConfigOutput == nil && sess.Error == nil { return nil, errors.New("both Output and Error are nil") } - return sess.Output, sess.Error + return sess.FetchOpenVPNConfigOutput, sess.Error } func TestInputLoaderCheckInFailure(t *testing.T) { @@ -480,7 +493,7 @@ func TestInputLoaderCheckInFailure(t *testing.T) { func TestInputLoaderCheckInSuccessWithNilWebConnectivity(t *testing.T) { il := &InputLoader{ Session: &InputLoaderMockableSession{ - Output: &model.OOAPICheckInResultNettests{}, + CheckinOutput: &model.OOAPICheckInResultNettests{}, }, } out, err := il.loadRemote(context.Background()) @@ -495,7 +508,7 @@ func TestInputLoaderCheckInSuccessWithNilWebConnectivity(t *testing.T) { func TestInputLoaderCheckInSuccessWithNoURLs(t *testing.T) { il := &InputLoader{ Session: &InputLoaderMockableSession{ - Output: &model.OOAPICheckInResultNettests{ + CheckinOutput: &model.OOAPICheckInResultNettests{ WebConnectivity: &model.OOAPICheckInInfoWebConnectivity{}, }, }, @@ -521,7 +534,7 @@ func TestInputLoaderCheckInSuccessWithSomeURLs(t *testing.T) { }} il := &InputLoader{ Session: &InputLoaderMockableSession{ - Output: &model.OOAPICheckInResultNettests{ + CheckinOutput: &model.OOAPICheckInResultNettests{ WebConnectivity: &model.OOAPICheckInInfoWebConnectivity{ URLs: expect, }, diff --git a/internal/engine/session.go b/internal/engine/session.go index 27e897d48c..bf3afac97e 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "io/ioutil" "net/url" "os" "sync" @@ -67,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 @@ -155,7 +155,7 @@ func NewSession(ctx context.Context, config SessionConfig) (*Session, error) { // use the temporary directory on the current system. This should // work on Desktop. We tested that it did also work on iOS, but // we have also seen on 2020-06-10 that it does not work on Android. - tempDir, err := ioutil.TempDir(config.TempDir, "ooniengine") + tempDir, err := os.MkdirTemp(config.TempDir, "ooniengine") if err != nil { return nil, err } @@ -178,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 { @@ -374,6 +375,25 @@ func (s *Session) FetchTorTargets( 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 + } + 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 7eea180ef2..d017ef6db4 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..d8aacde16a --- /dev/null +++ b/internal/experiment/openvpn/endpoint.go @@ -0,0 +1,253 @@ +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 = 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 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 + } + return fmt.Sprintf("%s://%s:%s/%s", proto, e.IPAddr, e.Port, e.Transport) +} + +// 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" + } + + return fmt.Sprintf( + "%s://%s.corp/?address=%s:%s&transport=%s", + proto, provider, e.IPAddr, e.Port, e.Transport) +} + +// 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 returns a shuffled copy of the endpointList. +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{ + "riseup": { + 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{ + "riseup", +} + +// 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] + + 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 +} + +// extractBase64Blob is used to pass credentials as command-line options. +func extractBase64Blob(val string) (string, error) { + s := strings.TrimPrefix(val, "base64:") + if len(s) == len(val) { + return "", fmt.Errorf("%w: %s", ErrBadBase64Blob, "missing prefix") + } + 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..502fd379c2 --- /dev/null +++ b/internal/experiment/openvpn/endpoint_test.go @@ -0,0 +1,400 @@ +package openvpn + +import ( + "errors" + "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://riseup.corp/?address=1.1.1.1:1194&transport=tcp"}, + want: &endpoint{ + IPAddr: "1.1.1.1", + Obfuscation: "none", + Port: "1194", + Protocol: "openvpn", + Provider: "riseup", + 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://riseup.corp/?address=1.1.1.1:1194&transport=tcp"}, + want: &endpoint{ + IPAddr: "1.1.1.1", + Obfuscation: "obfs4", + Port: "1194", + Protocol: "openvpn", + Provider: "riseup", + Transport: "tcp", + }, + wantErr: nil, + }, + { + name: "unknown proto fails", + args: args{"unknown://riseup.corp/?address=1.1.1.1:1194&transport=tcp"}, + want: nil, + wantErr: ErrInvalidInput, + }, + { + name: "any tld other than .corp fails", + args: args{"openvpn://riseup.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://riseup.corp/?address=example.com:1194&transport=tcp"}, + want: nil, + wantErr: ErrInvalidInput, + }, + { + name: "endpoint with no port fails", + args: args{"openvpn://riseup.corp/?address=1.1.1.1&transport=tcp"}, + want: nil, + wantErr: ErrInvalidInput, + }, + { + name: "endpoint with empty transport fails", + args: args{"openvpn://riseup.corp/?address=1.1.1.1:1194&transport="}, + want: nil, + wantErr: ErrInvalidInput, + }, + { + name: "endpoint with no transport fails", + args: args{"openvpn://riseup.corp/?address=1.1.1.1:1194"}, + want: nil, + wantErr: ErrInvalidInput, + }, + { + name: "endpoint with unknown transport fails", + args: args{"openvpn://riseup.corp/?address=1.1.1.1:1194&transport=uh"}, + want: nil, + wantErr: ErrInvalidInput, + }, + { + name: "endpoint with no address fails", + args: args{"openvpn://riseup.corp/?transport=tcp"}, + want: nil, + wantErr: ErrInvalidInput, + }, + { + name: "endpoint with empty address fails", + args: args{"openvpn://riseup.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:443&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:443&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:443&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) != "" { + 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("riseup"); !valid { + t.Fatal("riseup is the only valid provider now") + } + if valid := isValidProvider("nsa"); valid { + t.Fatal("nsa will nevel be a provider") + } +} + +func Test_getVPNConfig(t *testing.T) { + tracer := vpntracex.NewTracer(time.Now()) + e := &endpoint{ + Provider: "riseup", + 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 := extractBase64Blob(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==" + _, err := extractBase64Blob(blob) + if !errors.Is(err, ErrBadBase64Blob) { + t.Fatal("should fail without prefix") + } + }) + t.Run("bad base64 blob should fail", func(t *testing.T) { + blob := "base64:dGhlIGJsdWUgb2N0b3B1cyBpcyB3YXRjaGluZw" + _, err := extractBase64Blob(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 := extractBase64Blob(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 := extractBase64Blob(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..5d500e7383 --- /dev/null +++ b/internal/experiment/openvpn/openvpn.go @@ -0,0 +1,383 @@ +// Package openvpn contains a generic openvpn experiment. +package openvpn + +import ( + "context" + "errors" + "fmt" + "strconv" + "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.1" + openVPNProcol = "openvpn" +) + +var ( + ErrBadAuth = errors.New("bad provider authentication") +) + +// 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. +type Config struct { + Provider string `ooni:"VPN provider"` + 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) { + 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 Status.Success equal to true. +func (tk *TestKeys) AllConnectionsSuccessful() bool { + for _, c := range tk.OpenVPNHandshake { + if !c.Status.Success { + 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 = 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)) + } + // The current InputPolicy should ensure we have a hardcoded input, + // so this error should only be raised if by mistake we change the InputPolicy. + return nil, fmt.Errorf("%w: %s", ErrInvalidInput, "input is mandatory") +} + +// AuthMethod is the authentication method used by a provider. +type AuthMethod string + +var ( + AuthCertificate = AuthMethod("cert") + 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 := extractBase64Blob(cfg.SafeCA) + if err != nil { + return false, err + } + opts.CA = []byte(ca) + + key, err := extractBase64Blob(cfg.SafeKey) + if err != nil { + return false, err + } + opts.Key = []byte(key) + + cert, err := extractBase64Blob(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[provider] + 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: 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, error) { + + // create a trace for the network dialer + trace := measurexlite.NewTrace(index, zeroTime) + + dialer := trace.NewDialerWithoutResolver(logger) + + var failure string + // create a vpn tun Device that attempts to dial and performs the handshake + tun, err := tunnel.Start(ctx, dialer, openvpnConfig) + if err != nil { + failure = err.Error() + } + 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(), + 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), + }, + Status: model.ArchivalOpenVPNConnectStatus{ + Failure: &failure, + Success: err == nil, + }, + T0: tFirst, + T: tLast, + Tags: []string{}, + TransactionID: index, + }, + NetworkEvents: handshakeEvents, + }, nil +} + +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. + config, err := sess.FetchOpenVPNConfig(ctx, provider, "XX") + if err != nil { + return &model.OOAPIVPNProviderConfig{}, 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, err := m.connectAndHandshake(ctx, zeroTime, idx, sess.Logger(), endpoint, openvpnConfig, handshakeTracer) + if err != nil { + sess.Logger().Warn("Fatal error while attempting to connect to endpoint, aborting!") + return err + } + if connResult != nil { + 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..0952d285fb --- /dev/null +++ b/internal/experiment/openvpn/openvpn_test.go @@ -0,0 +1,427 @@ +package openvpn_test + +import ( + "context" + "errors" + "fmt" + "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: &struct { + CA string "json:\"ca\"" + Cert string "json:\"cert,omitempty\"" + Key string "json:\"key,omitempty\"" + Username string "json:\"username,omitempty\"" + Password string "json:\"password,omitempty\"" + }{ + 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.1" { + 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 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", + IP: "1.1.1.1", + Port: 1194, + Transport: "tcp", + Provider: "unknown", + OpenVPNOptions: model.ArchivalOpenVPNOptions{}, + Status: model.ArchivalOpenVPNConnectStatus{}, + 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) + } + }) +} + +func TestAllConnectionsSuccessful(t *testing.T) { + t.Run("all success", func(t *testing.T) { + tk := openvpn.NewTestKeys() + tk.OpenVPNHandshake = []*model.ArchivalOpenVPNHandshakeResult{ + {Status: model.ArchivalOpenVPNConnectStatus{Success: true}}, + {Status: model.ArchivalOpenVPNConnectStatus{Success: true}}, + {Status: model.ArchivalOpenVPNConnectStatus{Success: true}}, + } + if tk.AllConnectionsSuccessful() != true { + t.Fatal("expected all connections successful") + } + }) + t.Run("one failure", func(t *testing.T) { + tk := openvpn.NewTestKeys() + tk.OpenVPNHandshake = []*model.ArchivalOpenVPNHandshakeResult{ + {Status: model.ArchivalOpenVPNConnectStatus{Success: false}}, + {Status: model.ArchivalOpenVPNConnectStatus{Success: true}}, + {Status: model.ArchivalOpenVPNConnectStatus{Success: true}}, + } + if tk.AllConnectionsSuccessful() != false { + t.Fatal("expected false") + } + }) + t.Run("all failures", func(t *testing.T) { + tk := openvpn.NewTestKeys() + tk.OpenVPNHandshake = []*model.ArchivalOpenVPNHandshakeResult{ + {Status: model.ArchivalOpenVPNConnectStatus{Success: false}}, + {Status: model.ArchivalOpenVPNConnectStatus{Success: false}}, + {Status: model.ArchivalOpenVPNConnectStatus{Success: false}}, + } + 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 -- 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://riseup.corp/?address=127.0.0.1:9989&transport=tcp" + args := &model.ExperimentArgs{ + Callbacks: callbacks, + Measurement: measurement, + Session: sess, + } + if sess.Logger() == nil { + t.Fatal("logger should not be nil") + } + fmt.Println(ctx, args, m) + + 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 d5270f8fd0..3677107ead 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/model/archival.go b/internal/model/archival.go index ecef3c2427..86c2b52aec 100644 --- a/internal/model/archival.go +++ b/internal/model/archival.go @@ -392,3 +392,38 @@ 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"` + IP string `json:"ip"` + Port int `json:"port"` + Transport string `json:"transport"` + Provider string `json:"provider"` + OpenVPNOptions ArchivalOpenVPNOptions `json:"openvpn_options"` + Status ArchivalOpenVPNConnectStatus `json:"status"` + 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"` +} + +// ArchivalOpenVPNConnectStatus is the status of ArchivalOpenVPNConnectResult. +type ArchivalOpenVPNConnectStatus struct { + Blocked *bool `json:"blocked,omitempty"` + Failure *string `json:"failure"` + Success bool `json:"success"` +} diff --git a/internal/model/experiment.go b/internal/model/experiment.go index 29385fa2ea..789c8302b6 100644 --- a/internal/model/experiment.go +++ b/internal/model/experiment.go @@ -17,6 +17,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..da89835aff 100644 --- a/internal/model/ooapi.go +++ b/internal/model/ooapi.go @@ -99,6 +99,37 @@ type OOAPICheckReportIDResponse struct { V int64 `json:"v"` } +// 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 *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"` + } `json:"config"` + + // Inputs is an array of valid endpoints for this provider. + Inputs []string + + // 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..802fbdfa50 --- /dev/null +++ b/internal/probeservices/openvpn.go @@ -0,0 +1,26 @@ +package probeservices + +import ( + "context" + "fmt" + "net/url" + + "github.com/ooni/probe-cli/v3/internal/model" +) + +// 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) { + client := c.APIClientTemplate.Build() + query := url.Values{} + query.Add("country_code", cc) + + err = client.GetJSONWithQuery( + ctx, + fmt.Sprintf("/api/v2/ooniprobe/vpn-config/%s/", provider), + query, + &result, + ) + return +} diff --git a/internal/registry/openvpn.go b/internal/registry/openvpn.go new file mode 100644 index 0000000000..7dba22e807 --- /dev/null +++ b/internal/registry/openvpn.go @@ -0,0 +1,24 @@ +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"] = &Factory{ + build: func(config interface{}) model.ExperimentMeasurer { + return openvpn.NewExperimentMeasurer( + *config.(*openvpn.Config), "openvpn", + ) + }, + config: &openvpn.Config{}, + enabledByDefault: true, + interruptible: true, + inputPolicy: model.InputOrQueryBackend, + } +}