// Package whatsapp contains the WhatsApp network experiment. // // See https://github.com/ooni/spec/blob/master/nettests/ts-018-whatsapp.md. package whatsapp import ( "context" "errors" "fmt" "math/rand" "net/url" "regexp" "time" "github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" "github.com/ooni/probe-cli/v3/internal/model" "github.com/ooni/probe-cli/v3/internal/runtimex" ) const ( // RegistrationServiceURL is the URL used by WhatsApp registration service RegistrationServiceURL = "https://v.whatsapp.net/v2/register" // WebHTTPURL is WhatsApp web's HTTP URL WebHTTPURL = "http://web.whatsapp.com/" // WebHTTPSURL is WhatsApp web's HTTPS URL WebHTTPSURL = "https://web.whatsapp.com/" testName = "whatsapp" testVersion = "0.9.0" ) var endpointPattern = regexp.MustCompile(`^tcpconnect://e[0-9]{1,2}\.whatsapp\.net:[0-9]{3,5}$`) // Config contains the experiment config. type Config struct{} // TestKeys contains the experiment results type TestKeys struct { urlgetter.TestKeys RegistrationServerFailure *string `json:"registration_server_failure"` RegistrationServerStatus string `json:"registration_server_status"` WhatsappEndpointsBlocked []string `json:"whatsapp_endpoints_blocked"` WhatsappEndpointsDNSInconsistent []string `json:"whatsapp_endpoints_dns_inconsistent"` WhatsappEndpointsStatus string `json:"whatsapp_endpoints_status"` WhatsappWebFailure *string `json:"whatsapp_web_failure"` WhatsappWebStatus string `json:"whatsapp_web_status"` WhatsappEndpointsCount map[string]int `json:"-"` WhatsappHTTPFailure *string `json:"-"` WhatsappHTTPSFailure *string `json:"-"` } // NewTestKeys returns a new instance of the test keys. func NewTestKeys() *TestKeys { failure := "unknown_failure" return &TestKeys{ RegistrationServerFailure: &failure, RegistrationServerStatus: "blocked", WhatsappEndpointsBlocked: []string{}, WhatsappEndpointsDNSInconsistent: []string{}, WhatsappEndpointsStatus: "blocked", WhatsappWebFailure: &failure, WhatsappWebStatus: "blocked", WhatsappEndpointsCount: make(map[string]int), WhatsappHTTPFailure: &failure, WhatsappHTTPSFailure: &failure, } } // Update updates the TestKeys using the given MultiOutput result. func (tk *TestKeys) Update(v urlgetter.MultiOutput) { // Update the easy to update entries first tk.NetworkEvents = append(tk.NetworkEvents, v.TestKeys.NetworkEvents...) tk.Queries = append(tk.Queries, v.TestKeys.Queries...) tk.Requests = append(tk.Requests, v.TestKeys.Requests...) tk.TCPConnect = append(tk.TCPConnect, v.TestKeys.TCPConnect...) tk.TLSHandshakes = append(tk.TLSHandshakes, v.TestKeys.TLSHandshakes...) // Set the status of WhatsApp endpoints if endpointPattern.MatchString(v.Input.Target) { if v.TestKeys.Failure != nil { parsed, err := url.Parse(v.Input.Target) runtimex.PanicOnError(err, "url.Parse should not fail here") hostname := parsed.Hostname() tk.WhatsappEndpointsCount[hostname]++ if tk.WhatsappEndpointsCount[hostname] >= 2 { tk.WhatsappEndpointsBlocked = append(tk.WhatsappEndpointsBlocked, hostname) } return } tk.WhatsappEndpointsStatus = "ok" return } // Set the status of the registration service if v.Input.Target == RegistrationServiceURL { tk.RegistrationServerFailure = v.TestKeys.Failure if v.TestKeys.Failure == nil { tk.RegistrationServerStatus = "ok" } return } // Track result of accessing the web interface. switch v.Input.Target { case WebHTTPSURL: tk.WhatsappHTTPSFailure = v.TestKeys.Failure case WebHTTPURL: failure := v.TestKeys.Failure if failure != nil { // nothing to do here } else if v.TestKeys.HTTPResponseStatus != 302 { failure = &model.HTTPUnexpectedStatusCode } else if len(v.TestKeys.HTTPResponseLocations) != 1 { failure = &model.HTTPUnexpectedRedirectURL } else if v.TestKeys.HTTPResponseLocations[0] != WebHTTPSURL { failure = &model.HTTPUnexpectedRedirectURL } tk.WhatsappHTTPFailure = failure } } // ComputeWebStatus sets the web status fields. func (tk *TestKeys) ComputeWebStatus() { if tk.WhatsappHTTPFailure == nil && tk.WhatsappHTTPSFailure == nil { tk.WhatsappWebFailure = nil tk.WhatsappWebStatus = "ok" return } tk.WhatsappWebStatus = "blocked" // must be here because of unit tests if tk.WhatsappHTTPSFailure != nil { tk.WhatsappWebFailure = tk.WhatsappHTTPSFailure return } tk.WhatsappWebFailure = tk.WhatsappHTTPFailure } // Measurer performs the measurement type Measurer struct { // Config contains the experiment settings. If empty we // will be using default settings. Config Config // Getter is an optional getter to be used for testing. Getter urlgetter.MultiGetter } // ExperimentName implements ExperimentMeasurer.ExperimentName func (m Measurer) ExperimentName() string { return testName } // ExperimentVersion implements ExperimentMeasurer.ExperimentVersion func (m Measurer) ExperimentVersion() string { return testVersion } // Run implements ExperimentMeasurer.Run func (m Measurer) Run(ctx context.Context, args *model.ExperimentArgs) error { callbacks := args.Callbacks measurement := args.Measurement sess := args.Session ctx, cancel := context.WithTimeout(ctx, 60*time.Second) defer cancel() urlgetter.RegisterExtensions(measurement) // generate all the inputs var inputs []urlgetter.MultiInput for idx := 1; idx <= 16; idx++ { for _, port := range []string{"443", "5222"} { inputs = append(inputs, urlgetter.MultiInput{ Target: fmt.Sprintf("tcpconnect://e%d.whatsapp.net:%s", idx, port), }) } } inputs = append(inputs, urlgetter.MultiInput{ Config: urlgetter.Config{FailOnHTTPError: true}, Target: RegistrationServiceURL, }) inputs = append(inputs, urlgetter.MultiInput{ // We consider this check successful if we can establish a TLS // connection and we don't see any socket/TLS errors. Hence, we // don't care about the HTTP response code. Target: WebHTTPSURL, }) inputs = append(inputs, urlgetter.MultiInput{ // We consider this check successful if we get a valid redirect // for the HTTPS web interface. No need to follow redirects. Config: urlgetter.Config{NoFollowRedirects: true}, Target: WebHTTPURL, }) rnd := rand.New(rand.NewSource(time.Now().UnixNano())) rnd.Shuffle(len(inputs), func(i, j int) { inputs[i], inputs[j] = inputs[j], inputs[i] }) // measure in parallel multi := urlgetter.Multi{Begin: time.Now(), Getter: m.Getter, Session: sess} testkeys := NewTestKeys() testkeys.Agent = "redirect" measurement.TestKeys = testkeys for entry := range multi.Collect(ctx, inputs, "whatsapp", callbacks) { testkeys.Update(entry) } testkeys.ComputeWebStatus() return nil } // NewExperimentMeasurer creates a new ExperimentMeasurer. func NewExperimentMeasurer(config Config) model.ExperimentMeasurer { return Measurer{Config: config} } // SummaryKeys contains summary keys for this experiment. // // Note that this structure is part of the ABI contract with ooniprobe // therefore we should be careful when changing it. type SummaryKeys struct { RegistrationServerBlocking bool `json:"registration_server_blocking"` WebBlocking bool `json:"whatsapp_web_blocking"` EndpointsBlocking bool `json:"whatsapp_endpoints_blocking"` IsAnomaly bool `json:"-"` } // GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys. func (m Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) { sk := SummaryKeys{IsAnomaly: false} tk, ok := measurement.TestKeys.(*TestKeys) if !ok { return sk, errors.New("invalid test keys type") } blocking := func(value string) bool { return value == "blocked" } sk.RegistrationServerBlocking = blocking(tk.RegistrationServerStatus) sk.WebBlocking = blocking(tk.WhatsappWebStatus) sk.EndpointsBlocking = blocking(tk.WhatsappEndpointsStatus) sk.IsAnomaly = (sk.RegistrationServerBlocking || sk.WebBlocking || sk.EndpointsBlocking) return sk, nil }