ooni-probe-cli/internal/engine/experiment/whatsapp/whatsapp.go
Simone Basso 2d721baa91
cleanup: merge httpheader and httpfailure into model (#758)
These two small packages could easily be merged into the model
package, since they're clearly model-like packages.

Part of https://github.com/ooni/probe/issues/2115
2022-05-25 09:54:50 +02:00

237 lines
8.0 KiB
Go

// 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, sess model.ExperimentSession,
measurement *model.Measurement, callbacks model.ExperimentCallbacks,
) error {
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
}