());
+ auto taskp = mk_task_start(settings.c_str());
+ if (taskp == nullptr) {
+ std::clog << "fatal: cannot start task" << std::endl;
+ exit(1);
+ }
+ while (!mk_task_is_done(taskp)) {
+ auto evp = mk_task_wait_for_next_event(taskp);
+ if (evp == nullptr) {
+ std::clog << "warning: cannot wait for next event" << std::endl;
+ break;
+ }
+ auto evstr = mk_event_serialization(evp);
+ if (evstr != nullptr) {
+ std::cout << evstr << std::endl;
+ } else {
+ std::clog << "warning: cannot get event serialization" << std::endl;
+ }
+ mk_event_destroy(evp);
+ }
+ mk_task_destroy(taskp);
+}
diff --git a/internal/engine/libooniffi/testdata/webconnectivity.json b/internal/engine/libooniffi/testdata/webconnectivity.json
new file mode 100644
index 0000000..791e58e
--- /dev/null
+++ b/internal/engine/libooniffi/testdata/webconnectivity.json
@@ -0,0 +1,17 @@
+{
+ "assets_dir": ".",
+ "inputs": [
+ "https://www.example.com",
+ "https://www.example.org"
+ ],
+ "name": "WebConnectivity",
+ "log_level": "INFO",
+ "options": {
+ "no_collector": true,
+ "software_name": "ooniffi",
+ "software_version": "0.1.0-dev"
+ },
+ "state_dir": ".",
+ "temp_dir": ".",
+ "version": 1
+}
diff --git a/internal/engine/model/README.md b/internal/engine/model/README.md
new file mode 100644
index 0000000..a3cf1c1
--- /dev/null
+++ b/internal/engine/model/README.md
@@ -0,0 +1,3 @@
+# Package github.com/ooni/probe-engine/model
+
+Shared data structures and interfaces.
diff --git a/internal/engine/model/checkinconfig.go b/internal/engine/model/checkinconfig.go
new file mode 100644
index 0000000..1bdd646
--- /dev/null
+++ b/internal/engine/model/checkinconfig.go
@@ -0,0 +1,19 @@
+package model
+
+// CheckInConfigWebConnectivity is the configuration for the WebConnectivity test
+type CheckInConfigWebConnectivity struct {
+ CategoryCodes []string `json:"category_codes"` // CategoryCodes is an array of category codes
+}
+
+// CheckInConfig contains configuration for calling the checkin API.
+type CheckInConfig struct {
+ Charging bool `json:"charging"` // Charging indicate if the phone is actually charging
+ OnWiFi bool `json:"on_wifi"` // OnWiFi indicate if the phone is actually connected to a WiFi network
+ Platform string `json:"platform"` // Platform of the probe
+ ProbeASN string `json:"probe_asn"` // ProbeASN is the probe country code
+ ProbeCC string `json:"probe_cc"` // ProbeCC is the probe country code
+ RunType string `json:"run_type"` // RunType
+ SoftwareName string `json:"software_name"` // SoftwareName of the probe
+ SoftwareVersion string `json:"software_version"` // SoftwareVersion of the probe
+ WebConnectivity CheckInConfigWebConnectivity `json:"web_connectivity"` // WebConnectivity class contain an array of categories
+}
diff --git a/internal/engine/model/checkininfo.go b/internal/engine/model/checkininfo.go
new file mode 100644
index 0000000..211e45e
--- /dev/null
+++ b/internal/engine/model/checkininfo.go
@@ -0,0 +1,12 @@
+package model
+
+// CheckInInfoWebConnectivity contains the array of URLs returned by the checkin API
+type CheckInInfoWebConnectivity struct {
+ ReportID string `json:"report_id"`
+ URLs []URLInfo `json:"urls"`
+}
+
+// CheckInInfo contains the return test objects from the checkin API
+type CheckInInfo struct {
+ WebConnectivity *CheckInInfoWebConnectivity `json:"web_connectivity"`
+}
diff --git a/internal/engine/model/experiment.go b/internal/engine/model/experiment.go
new file mode 100644
index 0000000..18e8add
--- /dev/null
+++ b/internal/engine/model/experiment.go
@@ -0,0 +1,75 @@
+package model
+
+import (
+ "context"
+ "net/http"
+)
+
+// ExperimentOrchestraClient is the experiment's view of
+// a client for querying the OONI orchestra API.
+type ExperimentOrchestraClient interface {
+ FetchPsiphonConfig(ctx context.Context) ([]byte, error)
+ FetchTorTargets(ctx context.Context, cc string) (map[string]TorTarget, error)
+ FetchURLList(ctx context.Context, config URLListConfig) ([]URLInfo, error)
+}
+
+// ExperimentSession is the experiment's view of a session.
+type ExperimentSession interface {
+ ASNDatabasePath() string
+ GetTestHelpersByName(name string) ([]Service, bool)
+ DefaultHTTPClient() *http.Client
+ Logger() Logger
+ NewOrchestraClient(ctx context.Context) (ExperimentOrchestraClient, error)
+ ProbeCC() string
+ ResolverIP() string
+ TempDir() string
+ TorArgs() []string
+ TorBinary() string
+ UserAgent() string
+}
+
+// ExperimentCallbacks contains experiment event-handling callbacks
+type ExperimentCallbacks interface {
+ // OnProgress provides information about an experiment progress.
+ OnProgress(percentage float64, message string)
+}
+
+// PrinterCallbacks is the default event handler
+type PrinterCallbacks struct {
+ Logger
+}
+
+// NewPrinterCallbacks returns a new default callback handler
+func NewPrinterCallbacks(logger Logger) PrinterCallbacks {
+ return PrinterCallbacks{Logger: logger}
+}
+
+// OnProgress provides information about an experiment progress.
+func (d PrinterCallbacks) OnProgress(percentage float64, message string) {
+ d.Logger.Infof("[%5.1f%%] %s", percentage*100, message)
+}
+
+// ExperimentMeasurer is the interface that allows to run a
+// measurement for a specific experiment.
+type ExperimentMeasurer interface {
+ // ExperimentName returns the experiment name.
+ ExperimentName() string
+
+ // ExperimentVersion returns the experiment version.
+ ExperimentVersion() string
+
+ // Run runs the experiment with the specified context, session,
+ // measurement, and experiment calbacks. This method should only
+ // return an error in case the experiment could not run (e.g.,
+ // a required input is missing). Otherwise, the code should just
+ // set the relevant OONI error inside of the measurmeent and
+ // return nil. This is important because the caller may not submit
+ // the measurement if this method returns an error.
+ Run(
+ ctx context.Context, sess ExperimentSession,
+ measurement *Measurement, callbacks ExperimentCallbacks,
+ ) error
+
+ // GetSummaryKeys returns summary keys expected by ooni/probe-cli.
+ GetSummaryKeys(*Measurement) (interface{}, error)
+}
diff --git a/internal/engine/model/experiment_test.go b/internal/engine/model/experiment_test.go
new file mode 100644
index 0000000..e1f1e96
--- /dev/null
+++ b/internal/engine/model/experiment_test.go
@@ -0,0 +1,13 @@
+package model_test
+
+import (
+ "testing"
+
+ "github.com/apex/log"
+ "github.com/ooni/probe-cli/v3/internal/engine/model"
+)
+
+func TestPrinterCallbacksCallbacks(t *testing.T) {
+ printer := model.NewPrinterCallbacks(log.Log)
+ printer.OnProgress(0.4, "progress")
+}
diff --git a/internal/engine/model/keyvaluestore.go b/internal/engine/model/keyvaluestore.go
new file mode 100644
index 0000000..f10df93
--- /dev/null
+++ b/internal/engine/model/keyvaluestore.go
@@ -0,0 +1,7 @@
+package model
+
+// KeyValueStore is a key-value store used by the session.
+type KeyValueStore interface {
+ Get(key string) (value []byte, err error)
+ Set(key string, value []byte) (err error)
+}
diff --git a/internal/engine/model/logger.go b/internal/engine/model/logger.go
new file mode 100644
index 0000000..54cbeae
--- /dev/null
+++ b/internal/engine/model/logger.go
@@ -0,0 +1,40 @@
+package model
+
+// Logger defines the common interface that a logger should have. It is
+// out of the box compatible with `log.Log` in `apex/log`.
+type Logger interface {
+ // Debug emits a debug message.
+ Debug(msg string)
+
+ // Debugf formats and emits a debug message.
+ Debugf(format string, v ...interface{})
+
+ // Info emits an informational message.
+ Info(msg string)
+
+ // Infof format and emits an informational message.
+ Infof(format string, v ...interface{})
+
+ // Warn emits a warning message.
+ Warn(msg string)
+
+ // Warnf formats and emits a warning message.
+ Warnf(format string, v ...interface{})
+}
+
+// DiscardLogger is a logger that discards its input
+var DiscardLogger Logger = logDiscarder{}
+
+type logDiscarder struct{}
+
+func (logDiscarder) Debug(msg string) {}
+
+func (logDiscarder) Debugf(format string, v ...interface{}) {}
+
+func (logDiscarder) Info(msg string) {}
+
+func (logDiscarder) Infof(format string, v ...interface{}) {}
+
+func (logDiscarder) Warn(msg string) {}
+
+func (logDiscarder) Warnf(format string, v ...interface{}) {}
diff --git a/internal/engine/model/measurement.go b/internal/engine/model/measurement.go
new file mode 100644
index 0000000..6ccad4a
--- /dev/null
+++ b/internal/engine/model/measurement.go
@@ -0,0 +1,126 @@
+package model
+
+import (
+ "encoding/json"
+ "time"
+)
+
+// MeasurementTarget is the target of a OONI measurement.
+type MeasurementTarget string
+
+// MarshalJSON serializes the MeasurementTarget.
+func (t MeasurementTarget) MarshalJSON() ([]byte, error) {
+ if t == "" {
+ return json.Marshal(nil)
+ }
+ return json.Marshal(string(t))
+}
+
+// Measurement is a OONI measurement.
+//
+// This structure is compatible with the definition of the base data format in
+// https://github.com/ooni/spec/blob/master/data-formats/df-000-base.md.
+type Measurement struct {
+ // Annotations contains results annotations
+ Annotations map[string]string `json:"annotations,omitempty"`
+
+ // DataFormatVersion is the version of the data format
+ DataFormatVersion string `json:"data_format_version"`
+
+ // Extensions contains information about the extensions included
+ // into the test_keys of this measurement.
+ Extensions map[string]int64 `json:"extensions,omitempty"`
+
+ // ID is the locally generated measurement ID
+ ID string `json:"id,omitempty"`
+
+ // Input is the measurement input
+ Input MeasurementTarget `json:"input"`
+
+ // InputHashes contains input hashes
+ InputHashes []string `json:"input_hashes,omitempty"`
+
+ // MeasurementStartTime is the time when the measurement started
+ MeasurementStartTime string `json:"measurement_start_time"`
+
+ // MeasurementStartTimeSaved is the moment in time when we
+ // started the measurement. This is not included into the JSON
+ // and is only used within probe-engine as a "zero" time.
+ MeasurementStartTimeSaved time.Time `json:"-"`
+
+ // Options contains command line options
+ Options []string `json:"options,omitempty"`
+
+ // ProbeASN contains the probe autonomous system number
+ ProbeASN string `json:"probe_asn"`
+
+ // ProbeCC contains the probe country code
+ ProbeCC string `json:"probe_cc"`
+
+ // ProbeCity contains the probe city
+ ProbeCity string `json:"probe_city,omitempty"`
+
+ // ProbeIP contains the probe IP
+ ProbeIP string `json:"probe_ip,omitempty"`
+
+ // ProbeNetworkName contains the probe network name
+ ProbeNetworkName string `json:"probe_network_name"`
+
+ // ReportID contains the report ID
+ ReportID string `json:"report_id"`
+
+ // ResolverASN is the ASN of the resolver
+ ResolverASN string `json:"resolver_asn"`
+
+ // ResolverIP is the resolver IP
+ ResolverIP string `json:"resolver_ip"`
+
+ // ResolverNetworkName is the network name of the resolver.
+ ResolverNetworkName string `json:"resolver_network_name"`
+
+ // SoftwareName contains the software name
+ SoftwareName string `json:"software_name"`
+
+ // SoftwareVersion contains the software version
+ SoftwareVersion string `json:"software_version"`
+
+ // TestHelpers contains the test helpers. It seems this structure is more
+ // complex than we would like. In particular, using a map from string to
+ // string does not fit into the web_connectivity use case. Hence, for now
+ // we're going to represent this using interface{}. In going forward we
+ // may probably want to have more uniform test helpers.
+ TestHelpers map[string]interface{} `json:"test_helpers,omitempty"`
+
+ // TestKeys contains the real test result. This field is opaque because
+ // each experiment will insert here a different structure.
+ TestKeys interface{} `json:"test_keys"`
+
+ // TestName contains the test name
+ TestName string `json:"test_name"`
+
+ // MeasurementRuntime contains the measurement runtime. The JSON name
+ // is test_runtime because this is the name expected by the OONI backend
+ // even though that name is clearly a misleading one.
+ MeasurementRuntime float64 `json:"test_runtime"`
+
+ // TestStartTime contains the test start time
+ TestStartTime string `json:"test_start_time"`
+
+ // TestVersion contains the test version
+ TestVersion string `json:"test_version"`
+}
+
+// AddAnnotations adds the annotations from input to m.Annotations.
+func (m *Measurement) AddAnnotations(input map[string]string) {
+ for key, value := range input {
+ m.AddAnnotation(key, value)
+ }
+}
+
+// AddAnnotation adds a single annotations to m.Annotations.
+func (m *Measurement) AddAnnotation(key, value string) {
+ if m.Annotations == nil {
+ m.Annotations = make(map[string]string)
+ }
+ m.Annotations[key] = value
+}
diff --git a/internal/engine/model/model.go b/internal/engine/model/model.go
new file mode 100644
index 0000000..af7a40d
--- /dev/null
+++ b/internal/engine/model/model.go
@@ -0,0 +1,7 @@
+// Package model defines shared data structures and interfaces.
+package model
+
+const (
+ // DefaultProbeIP is the default probe IP.
+ DefaultProbeIP = "127.0.0.1"
+)
diff --git a/internal/engine/model/model_test.go b/internal/engine/model/model_test.go
new file mode 100644
index 0000000..18915be
--- /dev/null
+++ b/internal/engine/model/model_test.go
@@ -0,0 +1,223 @@
+package model_test
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "testing"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/model"
+)
+
+func TestMeasurementTargetMarshalJSON(t *testing.T) {
+ var mt model.MeasurementTarget
+ data, err := json.Marshal(mt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(data) != "null" {
+ t.Fatal("unexpected serialization")
+ }
+ mt = "xx"
+ data, err = json.Marshal(mt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(data) != `"xx"` {
+ t.Fatal("unexpected serialization")
+ }
+}
+
+type fakeTestKeys struct {
+ ClientResolver string `json:"client_resolver"`
+ Body string `json:"body"`
+}
+
+func TestAddAnnotations(t *testing.T) {
+ m := &model.Measurement{}
+ m.AddAnnotations(map[string]string{
+ "foo": "bar",
+ "f": "b",
+ })
+ m.AddAnnotations(map[string]string{
+ "foobar": "bar",
+ "f": "b",
+ })
+ if len(m.Annotations) != 3 {
+ t.Fatal("unexpected number of annotations")
+ }
+ if m.Annotations["foo"] != "bar" {
+ t.Fatal("unexpected annotation")
+ }
+ if m.Annotations["f"] != "b" {
+ t.Fatal("unexpected annotation")
+ }
+ if m.Annotations["foobar"] != "bar" {
+ t.Fatal("unexpected annotation")
+ }
+}
+
+type makeMeasurementConfig struct {
+ ProbeIP string
+ ProbeASN string
+ ProbeNetworkName string
+ ProbeCC string
+ ResolverIP string
+ ResolverNetworkName string
+ ResolverASN string
+}
+
+func makeMeasurement(config makeMeasurementConfig) model.Measurement {
+ return model.Measurement{
+ DataFormatVersion: "0.3.0",
+ ID: "bdd20d7a-bba5-40dd-a111-9863d7908572",
+ MeasurementStartTime: "2018-11-01 15:33:20",
+ ProbeIP: config.ProbeIP,
+ ProbeASN: config.ProbeASN,
+ ProbeNetworkName: config.ProbeNetworkName,
+ ProbeCC: config.ProbeCC,
+ ReportID: "",
+ ResolverIP: config.ResolverIP,
+ ResolverNetworkName: config.ResolverNetworkName,
+ ResolverASN: config.ResolverASN,
+ SoftwareName: "probe-engine",
+ SoftwareVersion: "0.1.0",
+ TestKeys: &fakeTestKeys{
+ ClientResolver: "91.80.37.104",
+ Body: fmt.Sprintf(`
+ Your IP is %s
+ Hey you, I see your IP and it's %s!
+ `, config.ProbeIP, config.ProbeIP),
+ },
+ TestName: "dummy",
+ MeasurementRuntime: 5.0565230846405,
+ TestStartTime: "2018-11-01 15:33:17",
+ TestVersion: "0.1.0",
+ }
+}
+
+func TestScrubWeAreScrubbing(t *testing.T) {
+ config := makeMeasurementConfig{
+ ProbeIP: "130.192.91.211",
+ ProbeASN: "AS137",
+ ProbeCC: "IT",
+ ProbeNetworkName: "Vodafone Italia S.p.A.",
+ ResolverIP: "8.8.8.8",
+ ResolverNetworkName: "Google LLC",
+ ResolverASN: "AS12345",
+ }
+ m := makeMeasurement(config)
+ if err := m.Scrub(config.ProbeIP); err != nil {
+ t.Fatal(err)
+ }
+ if m.ProbeASN != config.ProbeASN {
+ t.Fatal("ProbeASN has been scrubbed")
+ }
+ if m.ProbeCC != config.ProbeCC {
+ t.Fatal("ProbeCC has been scrubbed")
+ }
+ if m.ProbeIP == config.ProbeIP {
+ t.Fatal("ProbeIP HAS NOT been scrubbed")
+ }
+ if m.ProbeNetworkName != config.ProbeNetworkName {
+ t.Fatal("ProbeNetworkName has been scrubbed")
+ }
+ if m.ResolverIP != config.ResolverIP {
+ t.Fatal("ResolverIP has been scrubbed")
+ }
+ if m.ResolverNetworkName != config.ResolverNetworkName {
+ t.Fatal("ResolverNetworkName has been scrubbed")
+ }
+ if m.ResolverASN != config.ResolverASN {
+ t.Fatal("ResolverASN has been scrubbed")
+ }
+ data, err := json.Marshal(m)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if bytes.Count(data, []byte(config.ProbeIP)) != 0 {
+ t.Fatal("ProbeIP not fully redacted")
+ }
+}
+
+func TestScrubNoScrubbingRequired(t *testing.T) {
+ config := makeMeasurementConfig{
+ ProbeIP: "130.192.91.211",
+ ProbeASN: "AS137",
+ ProbeCC: "IT",
+ ProbeNetworkName: "Vodafone Italia S.p.A.",
+ ResolverIP: "8.8.8.8",
+ ResolverNetworkName: "Google LLC",
+ ResolverASN: "AS12345",
+ }
+ m := makeMeasurement(config)
+ m.TestKeys.(*fakeTestKeys).Body = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
+ if err := m.Scrub(config.ProbeIP); err != nil {
+ t.Fatal(err)
+ }
+ if m.ProbeASN != config.ProbeASN {
+ t.Fatal("ProbeASN has been scrubbed")
+ }
+ if m.ProbeCC != config.ProbeCC {
+ t.Fatal("ProbeCC has been scrubbed")
+ }
+ if m.ProbeIP == config.ProbeIP {
+ t.Fatal("ProbeIP HAS NOT been scrubbed")
+ }
+ if m.ProbeNetworkName != config.ProbeNetworkName {
+ t.Fatal("ProbeNetworkName has been scrubbed")
+ }
+ if m.ResolverIP != config.ResolverIP {
+ t.Fatal("ResolverIP has been scrubbed")
+ }
+ if m.ResolverNetworkName != config.ResolverNetworkName {
+ t.Fatal("ResolverNetworkName has been scrubbed")
+ }
+ if m.ResolverASN != config.ResolverASN {
+ t.Fatal("ResolverASN has been scrubbed")
+ }
+ data, err := json.Marshal(m)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if bytes.Count(data, []byte(model.Scrubbed)) > 0 {
+ t.Fatal("We should not see any scrubbing")
+ }
+}
+
+func TestScrubInvalidIP(t *testing.T) {
+ m := &model.Measurement{
+ ProbeASN: "AS1234",
+ ProbeCC: "IT",
+ }
+ err := m.Scrub("") // invalid IP
+ if !errors.Is(err, model.ErrInvalidProbeIP) {
+ t.Fatal("not the error we expected")
+ }
+}
+
+func TestScrubMarshalError(t *testing.T) {
+ expected := errors.New("mocked error")
+ m := &model.Measurement{
+ ProbeASN: "AS1234",
+ ProbeCC: "IT",
+ }
+ err := m.MaybeRewriteTestKeys(
+ "8.8.8.8", func(v interface{}) ([]byte, error) {
+ return nil, expected
+ })
+ if !errors.Is(err, expected) {
+ t.Fatal("not the error we expected")
+ }
+}
+
+func TestDiscardLoggerWorksAsIntended(t *testing.T) {
+ logger := model.DiscardLogger
+ logger.Debug("foo")
+ logger.Debugf("%s", "foo")
+ logger.Info("foo")
+ logger.Infof("%s", "foo")
+ logger.Warn("foo")
+ logger.Warnf("%s", "foo")
+}
diff --git a/internal/engine/model/scrub.go b/internal/engine/model/scrub.go
new file mode 100644
index 0000000..da754d2
--- /dev/null
+++ b/internal/engine/model/scrub.go
@@ -0,0 +1,49 @@
+package model
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "net"
+)
+
+// ErrInvalidProbeIP indicates that we're dealing with a string that
+// is not the valid serialization of an IP address.
+var ErrInvalidProbeIP = errors.New("model: invalid probe IP")
+
+// Scrub scrubs the probeIP out of the measurement.
+func (m *Measurement) Scrub(probeIP string) (err error) {
+ // We now behave like we can share everything except the
+ // probe IP, which we instead cannot ever share
+ m.ProbeIP = DefaultProbeIP
+ return m.MaybeRewriteTestKeys(probeIP, json.Marshal)
+}
+
+// Scrubbed is the string that replaces IP addresses.
+const Scrubbed = `[scrubbed]`
+
+// MaybeRewriteTestKeys is the function called by Scrub that
+// ensures that m's serialization doesn't include the IP
+func (m *Measurement) MaybeRewriteTestKeys(
+ currentIP string, marshal func(interface{}) ([]byte, error)) error {
+ if net.ParseIP(currentIP) == nil {
+ return ErrInvalidProbeIP
+ }
+ data, err := marshal(m.TestKeys)
+ if err != nil {
+ return err
+ }
+ // The check using Count is to save an unnecessary copy performed by
+ // ReplaceAll when there are no matches into the body. This is what
+ // we would like the common case to be, meaning that the code has done
+ // its job correctly and has not leaked the IP.
+ bpip := []byte(currentIP)
+ if bytes.Count(data, bpip) <= 0 {
+ return nil
+ }
+ data = bytes.ReplaceAll(data, bpip, []byte(Scrubbed))
+ // We add an annotation such that hopefully later we can measure the
+ // number of cases where we failed to sanitize properly.
+ m.AddAnnotation("_probe_engine_sanitize_test_keys", "true")
+ return json.Unmarshal(data, &m.TestKeys)
+}
diff --git a/internal/engine/model/service.go b/internal/engine/model/service.go
new file mode 100644
index 0000000..19fb186
--- /dev/null
+++ b/internal/engine/model/service.go
@@ -0,0 +1,17 @@
+package model
+
+// Service describes a backend service.
+//
+// The fields of this struct have the meaning described in v2.0.0 of the OONI
+// bouncer specification defined by
+// https://github.com/ooni/spec/blob/master/backends/bk-004-bouncer.md.
+type Service struct {
+ // Address is the address of the server.
+ Address string `json:"address"`
+
+ // Type is the type of the service.
+ Type string `json:"type"`
+
+ // Front is the front to use with "cloudfront" type entries.
+ Front string `json:"front,omitempty"`
+}
diff --git a/internal/engine/model/tortarget.go b/internal/engine/model/tortarget.go
new file mode 100644
index 0000000..d64460e
--- /dev/null
+++ b/internal/engine/model/tortarget.go
@@ -0,0 +1,21 @@
+package model
+
+// TorTarget is a target for the tor experiment.
+type TorTarget struct {
+ // Address is the address of the target.
+ Address string `json:"address"`
+
+ // Name is the name of the target.
+ Name string `json:"name"`
+
+ // Params contains optional params for, e.g., pluggable transports.
+ Params map[string][]string `json:"params"`
+
+ // Protocol is the protocol to use with the target.
+ Protocol string `json:"protocol"`
+
+ // Source is the source from which we fetched this specific
+ // target. Whenever the source is non-empty, we will treat
+ // this specific target as a private target.
+ Source string `json:"source"`
+}
diff --git a/internal/engine/model/urlinfo.go b/internal/engine/model/urlinfo.go
new file mode 100644
index 0000000..79ec721
--- /dev/null
+++ b/internal/engine/model/urlinfo.go
@@ -0,0 +1,8 @@
+package model
+
+// URLInfo contains info on a test lists URL
+type URLInfo struct {
+ CategoryCode string `json:"category_code"`
+ CountryCode string `json:"country_code"`
+ URL string `json:"url"`
+}
diff --git a/internal/engine/model/urllistconfig.go b/internal/engine/model/urllistconfig.go
new file mode 100644
index 0000000..f85c018
--- /dev/null
+++ b/internal/engine/model/urllistconfig.go
@@ -0,0 +1,8 @@
+package model
+
+// URLListConfig contains configuration for fetching the URL list.
+type URLListConfig struct {
+ Categories []string // Categories to query for (empty means all)
+ CountryCode string // CountryCode is the optional country code
+ Limit int64 // Max number of URLs (<= 0 means no limit)
+}
diff --git a/internal/engine/netx/README.md b/internal/engine/netx/README.md
new file mode 100644
index 0000000..3272b92
--- /dev/null
+++ b/internal/engine/netx/README.md
@@ -0,0 +1,23 @@
+# Package github.com/ooni/probe-engine/netx
+
+OONI extensions to the `net` and `net/http` packages. This code is
+used by `ooni/probe-engine` as a low level library to collect
+network, DNS, and HTTP events occurring during OONI measurements.
+
+This library contains replacements for commonly used standard library
+interfaces that facilitate seamless network measurements. By using
+such replacements, as opposed to standard library interfaces, we can:
+
+* save the timing of HTTP events (e.g. received response headers)
+* save the timing and result of every Connect, Read, Write, Close operation
+* save the timing and result of the TLS handshake (including certificates)
+
+By default, this library uses the system resolver. In addition, it
+is possible to configure alternative DNS transports and remote
+servers. We support DNS over UDP, DNS over TCP, DNS over TLS (DoT),
+and DNS over HTTPS (DoH). When using an alternative transport, we
+are also able to intercept and save DNS messages, as well as any
+other interaction with the remote server (e.g., the result of the
+TLS handshake for DoT and DoH).
+
+This package is a fork of [github.com/ooni/netx](https://github.com/ooni/netx).
diff --git a/internal/engine/netx/archival/archival.go b/internal/engine/netx/archival/archival.go
new file mode 100644
index 0000000..ec1fdac
--- /dev/null
+++ b/internal/engine/netx/archival/archival.go
@@ -0,0 +1,580 @@
+// Package archival contains data formats used for archival.
+//
+// See https://github.com/ooni/spec.
+package archival
+
+import (
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "net"
+ "net/http"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/geolocate"
+ "github.com/ooni/probe-cli/v3/internal/engine/model"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/trace"
+)
+
+// ExtSpec describes a data format extension
+type ExtSpec struct {
+ Name string // extension name
+ V int64 // extension version
+}
+
+// AddTo adds the current ExtSpec to the specified measurement
+func (spec ExtSpec) AddTo(m *model.Measurement) {
+ if m.Extensions == nil {
+ m.Extensions = make(map[string]int64)
+ }
+ m.Extensions[spec.Name] = spec.V
+}
+
+var (
+ // ExtDNS is the version of df-002-dnst.md
+ ExtDNS = ExtSpec{Name: "dnst", V: 0}
+
+ // ExtNetevents is the version of df-008-netevents.md
+ ExtNetevents = ExtSpec{Name: "netevents", V: 0}
+
+ // ExtHTTP is the version of df-001-httpt.md
+ ExtHTTP = ExtSpec{Name: "httpt", V: 0}
+
+ // ExtTCPConnect is the version of df-005-tcpconnect.md
+ ExtTCPConnect = ExtSpec{Name: "tcpconnect", V: 0}
+
+ // ExtTLSHandshake is the version of df-006-tlshandshake.md
+ ExtTLSHandshake = ExtSpec{Name: "tlshandshake", V: 0}
+
+ // ExtTunnel is the version of df-009-tunnel.md
+ ExtTunnel = ExtSpec{Name: "tunnel", V: 0}
+)
+
+// TCPConnectStatus contains the TCP connect status.
+//
+// The Blocked field breaks the separation between measurement and analysis
+// we have been enforcing for quite some time now. It is a legacy from the
+// Web Connectivity experiment and it should be here because of that.
+type TCPConnectStatus struct {
+ Blocked *bool `json:"blocked,omitempty"` // Web Connectivity only
+ Failure *string `json:"failure"`
+ Success bool `json:"success"`
+}
+
+// TCPConnectEntry contains one of the entries that are part
+// of the "tcp_connect" key of a OONI report.
+type TCPConnectEntry struct {
+ ConnID int64 `json:"conn_id,omitempty"`
+ DialID int64 `json:"dial_id,omitempty"`
+ IP string `json:"ip"`
+ Port int `json:"port"`
+ Status TCPConnectStatus `json:"status"`
+ T float64 `json:"t"`
+ TransactionID int64 `json:"transaction_id,omitempty"`
+}
+
+// NewTCPConnectList creates a new TCPConnectList
+func NewTCPConnectList(begin time.Time, events []trace.Event) []TCPConnectEntry {
+ var out []TCPConnectEntry
+ for _, event := range events {
+ if event.Name != errorx.ConnectOperation {
+ continue
+ }
+ if event.Proto != "tcp" {
+ continue
+ }
+ // We assume Go is passing us legit data structures
+ ip, sport, _ := net.SplitHostPort(event.Address)
+ iport, _ := strconv.Atoi(sport)
+ out = append(out, TCPConnectEntry{
+ IP: ip,
+ Port: iport,
+ Status: TCPConnectStatus{
+ Failure: NewFailure(event.Err),
+ Success: event.Err == nil,
+ },
+ T: event.Time.Sub(begin).Seconds(),
+ })
+ }
+ return out
+}
+
+// NewFailure creates a failure nullable string from the given error
+func NewFailure(err error) *string {
+ if err == nil {
+ return nil
+ }
+ // The following code guarantees that the error is always wrapped even
+ // when we could not actually hit our code that does the wrapping. A case
+ // in which this happen is with context deadline for HTTP.
+ err = errorx.SafeErrWrapperBuilder{
+ Error: err,
+ Operation: errorx.TopLevelOperation,
+ }.MaybeBuild()
+ errWrapper := err.(*errorx.ErrWrapper)
+ s := errWrapper.Failure
+ if s == "" {
+ s = "unknown_failure: errWrapper.Failure is empty"
+ }
+ return &s
+}
+
+// NewFailedOperation creates a failed operation string from the given error.
+func NewFailedOperation(err error) *string {
+ if err == nil {
+ return nil
+ }
+ var (
+ errWrapper *errorx.ErrWrapper
+ s = errorx.UnknownOperation
+ )
+ if errors.As(err, &errWrapper) && errWrapper.Operation != "" {
+ s = errWrapper.Operation
+ }
+ return &s
+}
+
+// HTTPTor contains Tor information
+type HTTPTor struct {
+ ExitIP *string `json:"exit_ip"`
+ ExitName *string `json:"exit_name"`
+ IsTor bool `json:"is_tor"`
+}
+
+// MaybeBinaryValue is a possibly binary string. We use this helper class
+// to define a custom JSON encoder that allows us to choose the proper
+// representation depending on whether the Value field is valid UTF-8 or not.
+type MaybeBinaryValue struct {
+ Value string
+}
+
+// MarshalJSON marshals a string-like to JSON following the OONI spec that
+// says that UTF-8 content is represened as string and non-UTF-8 content is
+// instead represented using `{"format":"base64","data":"..."}`.
+func (hb MaybeBinaryValue) MarshalJSON() ([]byte, error) {
+ if utf8.ValidString(hb.Value) {
+ return json.Marshal(hb.Value)
+ }
+ er := make(map[string]string)
+ er["format"] = "base64"
+ er["data"] = base64.StdEncoding.EncodeToString([]byte(hb.Value))
+ return json.Marshal(er)
+}
+
+// UnmarshalJSON is the opposite of MarshalJSON.
+func (hb *MaybeBinaryValue) UnmarshalJSON(d []byte) error {
+ if err := json.Unmarshal(d, &hb.Value); err == nil {
+ return nil
+ }
+ er := make(map[string]string)
+ if err := json.Unmarshal(d, &er); err != nil {
+ return err
+ }
+ if v, ok := er["format"]; !ok || v != "base64" {
+ return errors.New("missing or invalid format field")
+ }
+ if _, ok := er["data"]; !ok {
+ return errors.New("missing data field")
+ }
+ b64, err := base64.StdEncoding.DecodeString(er["data"])
+ if err != nil {
+ return err
+ }
+ hb.Value = string(b64)
+ return nil
+}
+
+// HTTPBody is an HTTP body. As an implementation note, this type must be
+// an alias for the MaybeBinaryValue type, otherwise the specific serialisation
+// mechanism implemented by MaybeBinaryValue is not working.
+type HTTPBody = MaybeBinaryValue
+
+// HTTPHeader is a single HTTP header.
+type HTTPHeader struct {
+ Key string
+ Value MaybeBinaryValue
+}
+
+// MarshalJSON marshals a single HTTP header to a tuple where the first
+// element is a string and the second element is maybe-binary data.
+func (hh HTTPHeader) MarshalJSON() ([]byte, error) {
+ if utf8.ValidString(hh.Value.Value) {
+ return json.Marshal([]string{hh.Key, hh.Value.Value})
+ }
+ value := make(map[string]string)
+ value["format"] = "base64"
+ value["data"] = base64.StdEncoding.EncodeToString([]byte(hh.Value.Value))
+ return json.Marshal([]interface{}{hh.Key, value})
+}
+
+// UnmarshalJSON is the opposite of MarshalJSON.
+func (hh *HTTPHeader) UnmarshalJSON(d []byte) error {
+ var pair []interface{}
+ if err := json.Unmarshal(d, &pair); err != nil {
+ return err
+ }
+ if len(pair) != 2 {
+ return errors.New("unexpected pair length")
+ }
+ key, ok := pair[0].(string)
+ if !ok {
+ return errors.New("the key is not a string")
+ }
+ value, ok := pair[1].(string)
+ if !ok {
+ mapvalue, ok := pair[1].(map[string]interface{})
+ if !ok {
+ return errors.New("the value is neither a string nor a map[string]interface{}")
+ }
+ if _, ok := mapvalue["format"]; !ok {
+ return errors.New("missing format")
+ }
+ if v, ok := mapvalue["format"].(string); !ok || v != "base64" {
+ return errors.New("invalid format")
+ }
+ if _, ok := mapvalue["data"]; !ok {
+ return errors.New("missing data field")
+ }
+ v, ok := mapvalue["data"].(string)
+ if !ok {
+ return errors.New("the data field is not a string")
+ }
+ b64, err := base64.StdEncoding.DecodeString(v)
+ if err != nil {
+ return err
+ }
+ value = string(b64)
+ }
+ hh.Key, hh.Value = key, MaybeBinaryValue{Value: value}
+ return nil
+}
+
+// HTTPRequest contains an HTTP request.
+//
+// Headers are a map in Web Connectivity data format but
+// we have added support for a list since January 2020.
+type HTTPRequest struct {
+ Body HTTPBody `json:"body"`
+ BodyIsTruncated bool `json:"body_is_truncated"`
+ HeadersList []HTTPHeader `json:"headers_list"`
+ Headers map[string]MaybeBinaryValue `json:"headers"`
+ Method string `json:"method"`
+ Tor HTTPTor `json:"tor"`
+ Transport string `json:"x_transport"`
+ URL string `json:"url"`
+}
+
+// HTTPResponse contains an HTTP response.
+//
+// Headers are a map in Web Connectivity data format but
+// we have added support for a list since January 2020.
+type HTTPResponse struct {
+ Body HTTPBody `json:"body"`
+ BodyIsTruncated bool `json:"body_is_truncated"`
+ Code int64 `json:"code"`
+ HeadersList []HTTPHeader `json:"headers_list"`
+ Headers map[string]MaybeBinaryValue `json:"headers"`
+
+ // The following fields are not serialised but are useful to simplify
+ // analysing the measurements in telegram, whatsapp, etc.
+ Locations []string `json:"-"`
+}
+
+// RequestEntry is one of the entries that are part of
+// the "requests" key of a OONI report.
+type RequestEntry struct {
+ Failure *string `json:"failure"`
+ Request HTTPRequest `json:"request"`
+ Response HTTPResponse `json:"response"`
+ T float64 `json:"t"`
+ TransactionID int64 `json:"transaction_id,omitempty"`
+}
+
+func addheaders(
+ source http.Header,
+ destList *[]HTTPHeader,
+ destMap *map[string]MaybeBinaryValue,
+) {
+ for key, values := range source {
+ for index, value := range values {
+ value := MaybeBinaryValue{Value: value}
+ // With the map representation we can only represent a single
+ // value for every key. Hence the list representation.
+ if index == 0 {
+ (*destMap)[key] = value
+ }
+ *destList = append(*destList, HTTPHeader{
+ Key: key,
+ Value: value,
+ })
+ }
+ }
+ sort.Slice(*destList, func(i, j int) bool {
+ return (*destList)[i].Key < (*destList)[j].Key
+ })
+}
+
+// NewRequestList returns the list for "requests"
+func NewRequestList(begin time.Time, events []trace.Event) []RequestEntry {
+ // OONI wants the last request to appear first
+ var out []RequestEntry
+ tmp := newRequestList(begin, events)
+ for i := len(tmp) - 1; i >= 0; i-- {
+ out = append(out, tmp[i])
+ }
+ return out
+}
+
+func newRequestList(begin time.Time, events []trace.Event) []RequestEntry {
+ var (
+ out []RequestEntry
+ entry RequestEntry
+ )
+ for _, ev := range events {
+ switch ev.Name {
+ case "http_transaction_start":
+ entry = RequestEntry{}
+ entry.T = ev.Time.Sub(begin).Seconds()
+ case "http_request_body_snapshot":
+ entry.Request.Body.Value = string(ev.Data)
+ entry.Request.BodyIsTruncated = ev.DataIsTruncated
+ case "http_request_metadata":
+ entry.Request.Headers = make(map[string]MaybeBinaryValue)
+ addheaders(
+ ev.HTTPHeaders, &entry.Request.HeadersList, &entry.Request.Headers)
+ entry.Request.Method = ev.HTTPMethod
+ entry.Request.URL = ev.HTTPURL
+ entry.Request.Transport = ev.Transport
+ case "http_response_metadata":
+ entry.Response.Headers = make(map[string]MaybeBinaryValue)
+ addheaders(
+ ev.HTTPHeaders, &entry.Response.HeadersList, &entry.Response.Headers)
+ entry.Response.Code = int64(ev.HTTPStatusCode)
+ entry.Response.Locations = ev.HTTPHeaders.Values("Location")
+ case "http_response_body_snapshot":
+ entry.Response.Body.Value = string(ev.Data)
+ entry.Response.BodyIsTruncated = ev.DataIsTruncated
+ case "http_transaction_done":
+ entry.Failure = NewFailure(ev.Err)
+ out = append(out, entry)
+ }
+ }
+ return out
+}
+
+// DNSAnswerEntry is the answer to a DNS query
+type DNSAnswerEntry struct {
+ ASN int64 `json:"asn,omitempty"`
+ ASOrgName string `json:"as_org_name,omitempty"`
+ AnswerType string `json:"answer_type"`
+ Hostname string `json:"hostname,omitempty"`
+ IPv4 string `json:"ipv4,omitempty"`
+ IPv6 string `json:"ipv6,omitempty"`
+ TTL *uint32 `json:"ttl"`
+}
+
+// DNSQueryEntry is a DNS query with possibly an answer
+type DNSQueryEntry struct {
+ Answers []DNSAnswerEntry `json:"answers"`
+ DialID int64 `json:"dial_id,omitempty"`
+ Engine string `json:"engine"`
+ Failure *string `json:"failure"`
+ Hostname string `json:"hostname"`
+ QueryType string `json:"query_type"`
+ ResolverHostname *string `json:"resolver_hostname"`
+ ResolverPort *string `json:"resolver_port"`
+ ResolverAddress string `json:"resolver_address"`
+ T float64 `json:"t"`
+ TransactionID int64 `json:"transaction_id,omitempty"`
+}
+
+type dnsQueryType string
+
+// NewDNSQueriesList returns a list of DNS queries.
+func NewDNSQueriesList(begin time.Time, events []trace.Event, dbpath string) []DNSQueryEntry {
+ // TODO(bassosimone): add support for CNAME lookups.
+ var out []DNSQueryEntry
+ for _, ev := range events {
+ if ev.Name != "resolve_done" {
+ continue
+ }
+ for _, qtype := range []dnsQueryType{"A", "AAAA"} {
+ entry := qtype.makequeryentry(begin, ev)
+ for _, addr := range ev.Addresses {
+ if qtype.ipoftype(addr) {
+ entry.Answers = append(
+ entry.Answers, qtype.makeanswerentry(addr, dbpath))
+ }
+ }
+ if len(entry.Answers) <= 0 && ev.Err == nil {
+ // This allows us to skip cases where the server does not have
+ // an IPv6 address but has an IPv4 address. Instead, when we
+ // receive an error, we want to track its existence. The main
+ // issue here is that we are cheating, because we are creating
+ // entries representing queries, but we don't know what the
+ // resolver actually did, especially the system resolver. So,
+ // this output is just our best guess.
+ continue
+ }
+ out = append(out, entry)
+ }
+ }
+ return out
+}
+
+func (qtype dnsQueryType) ipoftype(addr string) bool {
+ switch qtype {
+ case "A":
+ return strings.Contains(addr, ":") == false
+ case "AAAA":
+ return strings.Contains(addr, ":") == true
+ }
+ return false
+}
+
+func (qtype dnsQueryType) makeanswerentry(addr string, dbpath string) DNSAnswerEntry {
+ answer := DNSAnswerEntry{AnswerType: string(qtype)}
+ asn, org, _ := geolocate.LookupASN(dbpath, addr)
+ answer.ASN = int64(asn)
+ answer.ASOrgName = org
+ switch qtype {
+ case "A":
+ answer.IPv4 = addr
+ case "AAAA":
+ answer.IPv6 = addr
+ }
+ return answer
+}
+
+func (qtype dnsQueryType) makequeryentry(begin time.Time, ev trace.Event) DNSQueryEntry {
+ return DNSQueryEntry{
+ Engine: ev.Proto,
+ Failure: NewFailure(ev.Err),
+ Hostname: ev.Hostname,
+ QueryType: string(qtype),
+ ResolverAddress: ev.Address,
+ T: ev.Time.Sub(begin).Seconds(),
+ }
+}
+
+// NetworkEvent is a network event.
+type NetworkEvent struct {
+ Address string `json:"address,omitempty"`
+ ConnID int64 `json:"conn_id,omitempty"`
+ DialID int64 `json:"dial_id,omitempty"`
+ Failure *string `json:"failure"`
+ NumBytes int64 `json:"num_bytes,omitempty"`
+ Operation string `json:"operation"`
+ Proto string `json:"proto,omitempty"`
+ T float64 `json:"t"`
+ TransactionID int64 `json:"transaction_id,omitempty"`
+}
+
+// NewNetworkEventsList returns a list of DNS queries.
+func NewNetworkEventsList(begin time.Time, events []trace.Event) []NetworkEvent {
+ var out []NetworkEvent
+ for _, ev := range events {
+ if ev.Name == errorx.ConnectOperation {
+ out = append(out, NetworkEvent{
+ Address: ev.Address,
+ Failure: NewFailure(ev.Err),
+ Operation: ev.Name,
+ Proto: ev.Proto,
+ T: ev.Time.Sub(begin).Seconds(),
+ })
+ continue
+ }
+ if ev.Name == errorx.ReadOperation {
+ out = append(out, NetworkEvent{
+ Failure: NewFailure(ev.Err),
+ Operation: ev.Name,
+ NumBytes: int64(ev.NumBytes),
+ T: ev.Time.Sub(begin).Seconds(),
+ })
+ continue
+ }
+ if ev.Name == errorx.WriteOperation {
+ out = append(out, NetworkEvent{
+ Failure: NewFailure(ev.Err),
+ Operation: ev.Name,
+ NumBytes: int64(ev.NumBytes),
+ T: ev.Time.Sub(begin).Seconds(),
+ })
+ continue
+ }
+ if ev.Name == errorx.ReadFromOperation {
+ out = append(out, NetworkEvent{
+ Address: ev.Address,
+ Failure: NewFailure(ev.Err),
+ Operation: ev.Name,
+ NumBytes: int64(ev.NumBytes),
+ T: ev.Time.Sub(begin).Seconds(),
+ })
+ continue
+ }
+ if ev.Name == errorx.WriteToOperation {
+ out = append(out, NetworkEvent{
+ Address: ev.Address,
+ Failure: NewFailure(ev.Err),
+ Operation: ev.Name,
+ NumBytes: int64(ev.NumBytes),
+ T: ev.Time.Sub(begin).Seconds(),
+ })
+ continue
+ }
+ out = append(out, NetworkEvent{
+ Failure: NewFailure(ev.Err),
+ Operation: ev.Name,
+ T: ev.Time.Sub(begin).Seconds(),
+ })
+ }
+ return out
+}
+
+// TLSHandshake contains TLS handshake data
+type TLSHandshake struct {
+ CipherSuite string `json:"cipher_suite"`
+ ConnID int64 `json:"conn_id,omitempty"`
+ Failure *string `json:"failure"`
+ NegotiatedProtocol string `json:"negotiated_protocol"`
+ NoTLSVerify bool `json:"no_tls_verify"`
+ PeerCertificates []MaybeBinaryValue `json:"peer_certificates"`
+ ServerName string `json:"server_name"`
+ T float64 `json:"t"`
+ TLSVersion string `json:"tls_version"`
+ TransactionID int64 `json:"transaction_id,omitempty"`
+}
+
+// NewTLSHandshakesList creates a new TLSHandshakesList
+func NewTLSHandshakesList(begin time.Time, events []trace.Event) []TLSHandshake {
+ var out []TLSHandshake
+ for _, ev := range events {
+ if !strings.Contains(ev.Name, "_handshake_done") {
+ continue
+ }
+ out = append(out, TLSHandshake{
+ CipherSuite: ev.TLSCipherSuite,
+ Failure: NewFailure(ev.Err),
+ NegotiatedProtocol: ev.TLSNegotiatedProto,
+ NoTLSVerify: ev.NoTLSVerify,
+ PeerCertificates: makePeerCerts(ev.TLSPeerCerts),
+ ServerName: ev.TLSServerName,
+ T: ev.Time.Sub(begin).Seconds(),
+ TLSVersion: ev.TLSVersion,
+ })
+ }
+ return out
+}
+
+func makePeerCerts(in []*x509.Certificate) (out []MaybeBinaryValue) {
+ for _, e := range in {
+ out = append(out, MaybeBinaryValue{Value: string(e.Raw)})
+ }
+ return
+}
diff --git a/internal/engine/netx/archival/archival_test.go b/internal/engine/netx/archival/archival_test.go
new file mode 100644
index 0000000..667ecff
--- /dev/null
+++ b/internal/engine/netx/archival/archival_test.go
@@ -0,0 +1,1092 @@
+package archival_test
+
+import (
+ "context"
+ "crypto/x509"
+ "errors"
+ "io"
+ "net/http"
+ "reflect"
+ "testing"
+ "time"
+
+ "github.com/apex/log"
+ "github.com/google/go-cmp/cmp"
+ "github.com/gorilla/websocket"
+ "github.com/ooni/probe-cli/v3/internal/engine/model"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/archival"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/trace"
+ "github.com/ooni/probe-cli/v3/internal/engine/resources"
+)
+
+func TestNewTCPConnectList(t *testing.T) {
+ begin := time.Now()
+ type args struct {
+ begin time.Time
+ events []trace.Event
+ }
+ tests := []struct {
+ name string
+ args args
+ want []archival.TCPConnectEntry
+ }{{
+ name: "empty run",
+ args: args{
+ begin: begin,
+ events: nil,
+ },
+ want: nil,
+ }, {
+ name: "realistic run",
+ args: args{
+ begin: begin,
+ events: []trace.Event{{
+ Addresses: []string{"8.8.8.8", "8.8.4.4"},
+ Hostname: "dns.google.com",
+ Name: "resolve_done",
+ Time: begin.Add(100 * time.Millisecond),
+ }, {
+ Address: "8.8.8.8:853",
+ Duration: 30 * time.Millisecond,
+ Name: errorx.ConnectOperation,
+ Proto: "tcp",
+ Time: begin.Add(130 * time.Millisecond),
+ }, {
+ Address: "8.8.8.8:853",
+ Duration: 55 * time.Millisecond,
+ Name: errorx.ConnectOperation,
+ Proto: "udp",
+ Time: begin.Add(130 * time.Millisecond),
+ }, {
+ Address: "8.8.4.4:53",
+ Duration: 50 * time.Millisecond,
+ Err: io.EOF,
+ Name: errorx.ConnectOperation,
+ Proto: "tcp",
+ Time: begin.Add(180 * time.Millisecond),
+ }},
+ },
+ want: []archival.TCPConnectEntry{{
+ IP: "8.8.8.8",
+ Port: 853,
+ Status: archival.TCPConnectStatus{
+ Success: true,
+ },
+ T: 0.13,
+ }, {
+ IP: "8.8.4.4",
+ Port: 53,
+ Status: archival.TCPConnectStatus{
+ Failure: archival.NewFailure(io.EOF),
+ Success: false,
+ },
+ T: 0.18,
+ }},
+ }}
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := archival.NewTCPConnectList(tt.args.begin, tt.args.events); !reflect.DeepEqual(got, tt.want) {
+ t.Error(cmp.Diff(got, tt.want))
+ }
+ })
+ }
+}
+
+func TestNewRequestList(t *testing.T) {
+ begin := time.Now()
+ type args struct {
+ begin time.Time
+ events []trace.Event
+ }
+ tests := []struct {
+ name string
+ args args
+ want []archival.RequestEntry
+ }{{
+ name: "empty run",
+ args: args{
+ begin: begin,
+ events: nil,
+ },
+ want: nil,
+ }, {
+ name: "realistic run",
+ args: args{
+ begin: begin,
+ events: []trace.Event{{
+ Name: "http_transaction_start",
+ Time: begin.Add(10 * time.Millisecond),
+ }, {
+ Name: "http_request_body_snapshot",
+ Data: []byte("deadbeef"),
+ DataIsTruncated: false,
+ }, {
+ Name: "http_request_metadata",
+ HTTPHeaders: http.Header{
+ "User-Agent": []string{"miniooni/0.1.0-dev"},
+ },
+ HTTPMethod: "POST",
+ HTTPURL: "https://www.example.com/submit",
+ }, {
+ Name: "http_response_metadata",
+ HTTPHeaders: http.Header{
+ "Server": []string{"orchestra/0.1.0-dev"},
+ },
+ HTTPStatusCode: 200,
+ }, {
+ Name: "http_response_body_snapshot",
+ Data: []byte("{}"),
+ DataIsTruncated: false,
+ }, {
+ Name: "http_transaction_done",
+ }, {
+ Name: "http_transaction_start",
+ Time: begin.Add(20 * time.Millisecond),
+ }, {
+ Name: "http_request_metadata",
+ HTTPHeaders: http.Header{
+ "User-Agent": []string{"miniooni/0.1.0-dev"},
+ },
+ HTTPMethod: "GET",
+ HTTPURL: "https://www.example.com/result",
+ }, {
+ Name: "http_transaction_done",
+ Err: io.EOF,
+ }},
+ },
+ want: []archival.RequestEntry{{
+ Failure: archival.NewFailure(io.EOF),
+ Request: archival.HTTPRequest{
+ HeadersList: []archival.HTTPHeader{{
+ Key: "User-Agent",
+ Value: archival.MaybeBinaryValue{
+ Value: "miniooni/0.1.0-dev",
+ },
+ }},
+ Headers: map[string]archival.MaybeBinaryValue{
+ "User-Agent": {Value: "miniooni/0.1.0-dev"},
+ },
+ Method: "GET",
+ URL: "https://www.example.com/result",
+ },
+ T: 0.02,
+ }, {
+ Request: archival.HTTPRequest{
+ Body: archival.MaybeBinaryValue{
+ Value: "deadbeef",
+ },
+ HeadersList: []archival.HTTPHeader{{
+ Key: "User-Agent",
+ Value: archival.MaybeBinaryValue{
+ Value: "miniooni/0.1.0-dev",
+ },
+ }},
+ Headers: map[string]archival.MaybeBinaryValue{
+ "User-Agent": {Value: "miniooni/0.1.0-dev"},
+ },
+ Method: "POST",
+ URL: "https://www.example.com/submit",
+ },
+ Response: archival.HTTPResponse{
+ Body: archival.MaybeBinaryValue{
+ Value: "{}",
+ },
+ Code: 200,
+ HeadersList: []archival.HTTPHeader{{
+ Key: "Server",
+ Value: archival.MaybeBinaryValue{
+ Value: "orchestra/0.1.0-dev",
+ },
+ }},
+ Headers: map[string]archival.MaybeBinaryValue{
+ "Server": {Value: "orchestra/0.1.0-dev"},
+ },
+ Locations: nil,
+ },
+ T: 0.01,
+ }},
+ }, {
+ // for an example of why we need to sort headers, see
+ // https://github.com/ooni/probe-cli/v3/internal/engine/pull/751/checks?check_run_id=853562310
+ name: "run with redirect and headers to sort",
+ args: args{
+ begin: begin,
+ events: []trace.Event{{
+ Name: "http_transaction_start",
+ Time: begin.Add(10 * time.Millisecond),
+ }, {
+ Name: "http_request_metadata",
+ HTTPHeaders: http.Header{
+ "User-Agent": []string{"miniooni/0.1.0-dev"},
+ },
+ HTTPMethod: "GET",
+ HTTPURL: "https://www.example.com/",
+ }, {
+ Name: "http_response_metadata",
+ HTTPHeaders: http.Header{
+ "Server": []string{"orchestra/0.1.0-dev"},
+ "Location": []string{"https://x.example.com", "https://y.example.com"},
+ },
+ HTTPStatusCode: 302,
+ }, {
+ Name: "http_transaction_done",
+ }},
+ },
+ want: []archival.RequestEntry{{
+ Request: archival.HTTPRequest{
+ HeadersList: []archival.HTTPHeader{{
+ Key: "User-Agent",
+ Value: archival.MaybeBinaryValue{
+ Value: "miniooni/0.1.0-dev",
+ },
+ }},
+ Headers: map[string]archival.MaybeBinaryValue{
+ "User-Agent": {Value: "miniooni/0.1.0-dev"},
+ },
+ Method: "GET",
+ URL: "https://www.example.com/",
+ },
+ Response: archival.HTTPResponse{
+ Code: 302,
+ HeadersList: []archival.HTTPHeader{{
+ Key: "Location",
+ Value: archival.MaybeBinaryValue{
+ Value: "https://x.example.com",
+ },
+ }, {
+ Key: "Location",
+ Value: archival.MaybeBinaryValue{
+ Value: "https://y.example.com",
+ },
+ }, {
+ Key: "Server",
+ Value: archival.MaybeBinaryValue{
+ Value: "orchestra/0.1.0-dev",
+ },
+ }},
+ Headers: map[string]archival.MaybeBinaryValue{
+ "Server": {Value: "orchestra/0.1.0-dev"},
+ "Location": {Value: "https://x.example.com"},
+ },
+ Locations: []string{
+ "https://x.example.com", "https://y.example.com",
+ },
+ },
+ T: 0.01,
+ }},
+ }}
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := archival.NewRequestList(tt.args.begin, tt.args.events); !reflect.DeepEqual(got, tt.want) {
+ t.Error(cmp.Diff(got, tt.want))
+ }
+ })
+ }
+}
+
+func TestNewDNSQueriesList(t *testing.T) {
+ err := (&resources.Client{
+ HTTPClient: http.DefaultClient,
+ Logger: log.Log,
+ UserAgent: "miniooni/0.1.0-dev",
+ WorkDir: "../../testdata",
+ }).Ensure(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ begin := time.Now()
+ type args struct {
+ begin time.Time
+ events []trace.Event
+ dbpath string
+ }
+ tests := []struct {
+ name string
+ args args
+ want []archival.DNSQueryEntry
+ }{{
+ name: "empty run",
+ args: args{
+ begin: begin,
+ events: nil,
+ },
+ want: nil,
+ }, {
+ name: "realistic run",
+ args: args{
+ begin: begin,
+ events: []trace.Event{{
+ Address: "1.1.1.1:853",
+ Addresses: []string{"8.8.8.8", "8.8.4.4"},
+ Hostname: "dns.google.com",
+ Name: "resolve_done",
+ Proto: "dot",
+ Time: begin.Add(100 * time.Millisecond),
+ }, {
+ Address: "8.8.8.8:853",
+ Duration: 30 * time.Millisecond,
+ Name: errorx.ConnectOperation,
+ Proto: "tcp",
+ Time: begin.Add(130 * time.Millisecond),
+ }, {
+ Address: "8.8.4.4:53",
+ Duration: 50 * time.Millisecond,
+ Err: io.EOF,
+ Name: errorx.ConnectOperation,
+ Proto: "tcp",
+ Time: begin.Add(180 * time.Millisecond),
+ }},
+ },
+ want: []archival.DNSQueryEntry{{
+ Answers: []archival.DNSAnswerEntry{{
+ AnswerType: "A",
+ IPv4: "8.8.8.8",
+ }, {
+ AnswerType: "A",
+ IPv4: "8.8.4.4",
+ }},
+ Engine: "dot",
+ Hostname: "dns.google.com",
+ QueryType: "A",
+ ResolverAddress: "1.1.1.1:853",
+ T: 0.1,
+ }},
+ }, {
+ name: "run with IPv6 results",
+ args: args{
+ begin: begin,
+ events: []trace.Event{{
+ Addresses: []string{"2001:4860:4860::8888"},
+ Hostname: "dns.google.com",
+ Name: "resolve_done",
+ Time: begin.Add(200 * time.Millisecond),
+ }},
+ },
+ want: []archival.DNSQueryEntry{{
+ Answers: []archival.DNSAnswerEntry{{
+ AnswerType: "AAAA",
+ IPv6: "2001:4860:4860::8888",
+ }},
+ Hostname: "dns.google.com",
+ QueryType: "AAAA",
+ T: 0.2,
+ }},
+ }, {
+ name: "run with ASN DB",
+ args: args{
+ begin: begin,
+ events: []trace.Event{{
+ Addresses: []string{"2001:4860:4860::8888"},
+ Hostname: "dns.google.com",
+ Name: "resolve_done",
+ Time: begin.Add(200 * time.Millisecond),
+ }},
+ dbpath: "../../testdata/asn.mmdb",
+ },
+ want: []archival.DNSQueryEntry{{
+ Answers: []archival.DNSAnswerEntry{{
+ ASN: 15169,
+ ASOrgName: "Google LLC",
+ AnswerType: "AAAA",
+ IPv6: "2001:4860:4860::8888",
+ }},
+ Hostname: "dns.google.com",
+ QueryType: "AAAA",
+ T: 0.2,
+ }},
+ }, {
+ name: "run with errors",
+ args: args{
+ begin: begin,
+ events: []trace.Event{{
+ Err: &errorx.ErrWrapper{Failure: errorx.FailureDNSNXDOMAINError},
+ Hostname: "dns.google.com",
+ Name: "resolve_done",
+ Time: begin.Add(200 * time.Millisecond),
+ }},
+ dbpath: "../../testdata/asn.mmdb",
+ },
+ want: []archival.DNSQueryEntry{{
+ Answers: nil,
+ Failure: archival.NewFailure(
+ &errorx.ErrWrapper{Failure: errorx.FailureDNSNXDOMAINError}),
+ Hostname: "dns.google.com",
+ QueryType: "A",
+ T: 0.2,
+ }, {
+ Answers: nil,
+ Failure: archival.NewFailure(
+ &errorx.ErrWrapper{Failure: errorx.FailureDNSNXDOMAINError}),
+ Hostname: "dns.google.com",
+ QueryType: "AAAA",
+ T: 0.2,
+ }},
+ }}
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := archival.NewDNSQueriesList(
+ tt.args.begin, tt.args.events, tt.args.dbpath); !reflect.DeepEqual(got, tt.want) {
+ t.Error(cmp.Diff(got, tt.want))
+ }
+ })
+ }
+}
+
+func TestNewNetworkEventsList(t *testing.T) {
+ begin := time.Now()
+ type args struct {
+ begin time.Time
+ events []trace.Event
+ }
+ tests := []struct {
+ name string
+ args args
+ want []archival.NetworkEvent
+ }{{
+ name: "empty run",
+ args: args{
+ begin: begin,
+ events: nil,
+ },
+ want: nil,
+ }, {
+ name: "realistic run",
+ args: args{
+ begin: begin,
+ events: []trace.Event{{
+ Name: errorx.ConnectOperation,
+ Address: "8.8.8.8:853",
+ Err: io.EOF,
+ Proto: "tcp",
+ Time: begin.Add(7 * time.Millisecond),
+ }, {
+ Name: errorx.ReadOperation,
+ Err: context.Canceled,
+ NumBytes: 7117,
+ Time: begin.Add(11 * time.Millisecond),
+ }, {
+ Address: "8.8.8.8:853",
+ Name: errorx.ReadFromOperation,
+ Err: context.Canceled,
+ NumBytes: 7117,
+ Time: begin.Add(11 * time.Millisecond),
+ }, {
+ Name: errorx.WriteOperation,
+ Err: websocket.ErrBadHandshake,
+ NumBytes: 4114,
+ Time: begin.Add(14 * time.Millisecond),
+ }, {
+ Address: "8.8.8.8:853",
+ Name: errorx.WriteToOperation,
+ Err: websocket.ErrBadHandshake,
+ NumBytes: 4114,
+ Time: begin.Add(14 * time.Millisecond),
+ }, {
+ Name: errorx.CloseOperation,
+ Err: websocket.ErrReadLimit,
+ Time: begin.Add(17 * time.Millisecond),
+ }},
+ },
+ want: []archival.NetworkEvent{{
+ Address: "8.8.8.8:853",
+ Failure: archival.NewFailure(io.EOF),
+ Operation: errorx.ConnectOperation,
+ Proto: "tcp",
+ T: 0.007,
+ }, {
+ Failure: archival.NewFailure(context.Canceled),
+ NumBytes: 7117,
+ Operation: errorx.ReadOperation,
+ T: 0.011,
+ }, {
+ Address: "8.8.8.8:853",
+ Failure: archival.NewFailure(context.Canceled),
+ NumBytes: 7117,
+ Operation: errorx.ReadFromOperation,
+ T: 0.011,
+ }, {
+ Failure: archival.NewFailure(websocket.ErrBadHandshake),
+ NumBytes: 4114,
+ Operation: errorx.WriteOperation,
+ T: 0.014,
+ }, {
+ Address: "8.8.8.8:853",
+ Failure: archival.NewFailure(websocket.ErrBadHandshake),
+ NumBytes: 4114,
+ Operation: errorx.WriteToOperation,
+ T: 0.014,
+ }, {
+ Failure: archival.NewFailure(websocket.ErrReadLimit),
+ Operation: errorx.CloseOperation,
+ T: 0.017,
+ }},
+ }}
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := archival.NewNetworkEventsList(tt.args.begin, tt.args.events); !reflect.DeepEqual(got, tt.want) {
+ t.Error(cmp.Diff(got, tt.want))
+ }
+ })
+ }
+}
+
+func TestNewTLSHandshakesList(t *testing.T) {
+ begin := time.Now()
+ type args struct {
+ begin time.Time
+ events []trace.Event
+ }
+ tests := []struct {
+ name string
+ args args
+ want []archival.TLSHandshake
+ }{{
+ name: "empty run",
+ args: args{
+ begin: begin,
+ events: nil,
+ },
+ want: nil,
+ }, {
+ name: "realistic run",
+ args: args{
+ begin: begin,
+ events: []trace.Event{{
+ Name: errorx.CloseOperation,
+ Err: websocket.ErrReadLimit,
+ Time: begin.Add(17 * time.Millisecond),
+ }, {
+ Name: "tls_handshake_done",
+ Err: io.EOF,
+ NoTLSVerify: false,
+ TLSCipherSuite: "SUITE",
+ TLSNegotiatedProto: "h2",
+ TLSPeerCerts: []*x509.Certificate{{
+ Raw: []byte("deadbeef"),
+ }, {
+ Raw: []byte("abad1dea"),
+ }},
+ TLSServerName: "x.org",
+ TLSVersion: "TLSv1.3",
+ Time: begin.Add(55 * time.Millisecond),
+ }},
+ },
+ want: []archival.TLSHandshake{{
+ CipherSuite: "SUITE",
+ Failure: archival.NewFailure(io.EOF),
+ NegotiatedProtocol: "h2",
+ NoTLSVerify: false,
+ PeerCertificates: []archival.MaybeBinaryValue{{
+ Value: "deadbeef",
+ }, {
+ Value: "abad1dea",
+ }},
+ ServerName: "x.org",
+ T: 0.055,
+ TLSVersion: "TLSv1.3",
+ }},
+ }}
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := archival.NewTLSHandshakesList(tt.args.begin, tt.args.events); !reflect.DeepEqual(got, tt.want) {
+ t.Error(cmp.Diff(got, tt.want))
+ }
+ })
+ }
+}
+
+func TestExtSpec_AddTo(t *testing.T) {
+ m := new(model.Measurement)
+ archival.ExtDNS.AddTo(m)
+ expected := map[string]int64{"dnst": 0}
+ if d := cmp.Diff(m.Extensions, expected); d != "" {
+ t.Fatal(d)
+ }
+}
+
+var binaryInput = []uint8{
+ 0x57, 0xe5, 0x79, 0xfb, 0xa6, 0xbb, 0x0d, 0xbc, 0xce, 0xbd, 0xa7, 0xa0,
+ 0xba, 0xa4, 0x78, 0x78, 0x12, 0x59, 0xee, 0x68, 0x39, 0xa4, 0x07, 0x98,
+ 0xc5, 0x3e, 0xbc, 0x55, 0xcb, 0xfe, 0x34, 0x3c, 0x7e, 0x1b, 0x5a, 0xb3,
+ 0x22, 0x9d, 0xc1, 0x2d, 0x6e, 0xca, 0x5b, 0xf1, 0x10, 0x25, 0x47, 0x1e,
+ 0x44, 0xe2, 0x2d, 0x60, 0x08, 0xea, 0xb0, 0x0a, 0xcc, 0x05, 0x48, 0xa0,
+ 0xf5, 0x78, 0x38, 0xf0, 0xdb, 0x3f, 0x9d, 0x9f, 0x25, 0x6f, 0x89, 0x00,
+ 0x96, 0x93, 0xaf, 0x43, 0xac, 0x4d, 0xc9, 0xac, 0x13, 0xdb, 0x22, 0xbe,
+ 0x7a, 0x7d, 0xd9, 0x24, 0xa2, 0x52, 0x69, 0xd8, 0x89, 0xc1, 0xd1, 0x57,
+ 0xaa, 0x04, 0x2b, 0xa2, 0xd8, 0xb1, 0x19, 0xf6, 0xd5, 0x11, 0x39, 0xbb,
+ 0x80, 0xcf, 0x86, 0xf9, 0x5f, 0x9d, 0x8c, 0xab, 0xf5, 0xc5, 0x74, 0x24,
+ 0x3a, 0xa2, 0xd4, 0x40, 0x4e, 0xd7, 0x10, 0x1f,
+}
+
+var encodedBinaryInput = []byte(`{"data":"V+V5+6a7DbzOvaeguqR4eBJZ7mg5pAeYxT68Vcv+NDx+G1qzIp3BLW7KW/EQJUceROItYAjqsArMBUig9Xg48Ns/nZ8lb4kAlpOvQ6xNyawT2yK+en3ZJKJSadiJwdFXqgQrotixGfbVETm7gM+G+V+djKv1xXQkOqLUQE7XEB8=","format":"base64"}`)
+
+func TestMaybeBinaryValue_MarshalJSON(t *testing.T) {
+ type fields struct {
+ Value string
+ }
+ tests := []struct {
+ name string
+ fields fields
+ want []byte
+ wantErr bool
+ }{{
+ name: "with string input",
+ fields: fields{
+ Value: "antani",
+ },
+ want: []byte(`"antani"`),
+ wantErr: false,
+ }, {
+ name: "with binary input",
+ fields: fields{
+ Value: string(binaryInput),
+ },
+ want: encodedBinaryInput,
+ wantErr: false,
+ }}
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ hb := archival.MaybeBinaryValue{
+ Value: tt.fields.Value,
+ }
+ got, err := hb.MarshalJSON()
+ if (err != nil) != tt.wantErr {
+ t.Errorf("MaybeBinaryValue.MarshalJSON() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Error(cmp.Diff(got, tt.want))
+ }
+ })
+ }
+}
+
+func TestMaybeBinaryValue_UnmarshalJSON(t *testing.T) {
+ type fields struct {
+ WantValue string
+ }
+ type args struct {
+ d []byte
+ }
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ wantErr bool
+ }{{
+ name: "with string input",
+ fields: fields{
+ WantValue: "xo",
+ },
+ args: args{d: []byte(`"xo"`)},
+ wantErr: false,
+ }, {
+ name: "with nil input",
+ fields: fields{
+ WantValue: "",
+ },
+ args: args{d: nil},
+ wantErr: true,
+ }, {
+ name: "with missing/invalid format",
+ fields: fields{
+ WantValue: "",
+ },
+ args: args{d: []byte(`{"format": "foo"}`)},
+ wantErr: true,
+ }, {
+ name: "with missing data",
+ fields: fields{
+ WantValue: "",
+ },
+ args: args{d: []byte(`{"format": "base64"}`)},
+ wantErr: true,
+ }, {
+ name: "with invalid base64 data",
+ fields: fields{
+ WantValue: "",
+ },
+ args: args{d: []byte(`{"format": "base64", "data": "x"}`)},
+ wantErr: true,
+ }, {
+ name: "with valid base64 data",
+ fields: fields{
+ WantValue: string(binaryInput),
+ },
+ args: args{d: encodedBinaryInput},
+ wantErr: false,
+ }}
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ hb := &archival.MaybeBinaryValue{}
+ if err := hb.UnmarshalJSON(tt.args.d); (err != nil) != tt.wantErr {
+ t.Errorf("MaybeBinaryValue.UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ if d := cmp.Diff(tt.fields.WantValue, hb.Value); d != "" {
+ t.Error(d)
+ }
+ })
+ }
+}
+
+func TestHTTPHeader_MarshalJSON(t *testing.T) {
+ type fields struct {
+ Key string
+ Value archival.MaybeBinaryValue
+ }
+ tests := []struct {
+ name string
+ fields fields
+ want []byte
+ wantErr bool
+ }{{
+ name: "with string value",
+ fields: fields{
+ Key: "Content-Type",
+ Value: archival.MaybeBinaryValue{
+ Value: "text/plain",
+ },
+ },
+ want: []byte(`["Content-Type","text/plain"]`),
+ wantErr: false,
+ }, {
+ name: "with binary value",
+ fields: fields{
+ Key: "Content-Type",
+ Value: archival.MaybeBinaryValue{
+ Value: string(binaryInput),
+ },
+ },
+ want: []byte(`["Content-Type",` + string(encodedBinaryInput) + `]`),
+ wantErr: false,
+ }}
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ hh := archival.HTTPHeader{
+ Key: tt.fields.Key,
+ Value: tt.fields.Value,
+ }
+ got, err := hh.MarshalJSON()
+ if (err != nil) != tt.wantErr {
+ t.Errorf("HTTPHeader.MarshalJSON() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Error(cmp.Diff(got, tt.want))
+ }
+ })
+ }
+}
+
+func TestHTTPHeader_UnmarshalJSON(t *testing.T) {
+ type fields struct {
+ WantKey string
+ WantValue archival.MaybeBinaryValue
+ }
+ type args struct {
+ d []byte
+ }
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ wantErr bool
+ }{{
+ name: "with invalid input",
+ fields: fields{
+ WantKey: "",
+ WantValue: archival.MaybeBinaryValue{
+ Value: "",
+ },
+ },
+ args: args{
+ d: []byte(`{}`),
+ },
+ wantErr: true,
+ }, {
+ name: "with unexpected number of items",
+ fields: fields{
+ WantKey: "",
+ WantValue: archival.MaybeBinaryValue{
+ Value: "",
+ },
+ },
+ args: args{
+ d: []byte(`[]`),
+ },
+ wantErr: true,
+ }, {
+ name: "with first item not being a string",
+ fields: fields{
+ WantKey: "",
+ WantValue: archival.MaybeBinaryValue{
+ Value: "",
+ },
+ },
+ args: args{
+ d: []byte(`[0,0]`),
+ },
+ wantErr: true,
+ }, {
+ name: "with both items being a string",
+ fields: fields{
+ WantKey: "x",
+ WantValue: archival.MaybeBinaryValue{
+ Value: "y",
+ },
+ },
+ args: args{
+ d: []byte(`["x","y"]`),
+ },
+ wantErr: false,
+ }, {
+ name: "with second item not being a map[string]interface{}",
+ fields: fields{
+ WantKey: "",
+ WantValue: archival.MaybeBinaryValue{
+ Value: "",
+ },
+ },
+ args: args{
+ d: []byte(`["x",[]]`),
+ },
+ wantErr: true,
+ }, {
+ name: "with missing format key in second item",
+ fields: fields{
+ WantKey: "",
+ WantValue: archival.MaybeBinaryValue{
+ Value: "",
+ },
+ },
+ args: args{
+ d: []byte(`["x",{}]`),
+ },
+ wantErr: true,
+ }, {
+ name: "with format value not being base64",
+ fields: fields{
+ WantKey: "",
+ WantValue: archival.MaybeBinaryValue{
+ Value: "",
+ },
+ },
+ args: args{
+ d: []byte(`["x",{"format":1}]`),
+ },
+ wantErr: true,
+ }, {
+ name: "with missing data field",
+ fields: fields{
+ WantKey: "",
+ WantValue: archival.MaybeBinaryValue{
+ Value: "",
+ },
+ },
+ args: args{
+ d: []byte(`["x",{"format":"base64"}]`),
+ },
+ wantErr: true,
+ }, {
+ name: "with data not being a string",
+ fields: fields{
+ WantKey: "",
+ WantValue: archival.MaybeBinaryValue{
+ Value: "",
+ },
+ },
+ args: args{
+ d: []byte(`["x",{"format":"base64","data":1}]`),
+ },
+ wantErr: true,
+ }, {
+ name: "with data not being base64",
+ fields: fields{
+ WantKey: "",
+ WantValue: archival.MaybeBinaryValue{
+ Value: "",
+ },
+ },
+ args: args{
+ d: []byte(`["x",{"format":"base64","data":"xx"}]`),
+ },
+ wantErr: true,
+ }, {
+ name: "with correctly encoded base64 data",
+ fields: fields{
+ WantKey: "x",
+ WantValue: archival.MaybeBinaryValue{
+ Value: string(binaryInput),
+ },
+ },
+ args: args{
+ d: []byte(`["x",` + string(encodedBinaryInput) + `]`),
+ },
+ wantErr: false,
+ }}
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ hh := &archival.HTTPHeader{}
+ if err := hh.UnmarshalJSON(tt.args.d); (err != nil) != tt.wantErr {
+ t.Errorf("HTTPHeader.UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ expect := &archival.HTTPHeader{
+ Key: tt.fields.WantKey,
+ Value: tt.fields.WantValue,
+ }
+ if d := cmp.Diff(hh, expect); d != "" {
+ t.Error(d)
+ }
+ })
+ }
+}
+
+func TestNewFailure(t *testing.T) {
+ type args struct {
+ err error
+ }
+ tests := []struct {
+ name string
+ args args
+ want *string
+ }{{
+ name: "when error is nil",
+ args: args{
+ err: nil,
+ },
+ want: nil,
+ }, {
+ name: "when error is wrapped and failure meaningful",
+ args: args{
+ err: &errorx.ErrWrapper{
+ Failure: errorx.FailureConnectionRefused,
+ },
+ },
+ want: func() *string {
+ s := errorx.FailureConnectionRefused
+ return &s
+ }(),
+ }, {
+ name: "when error is wrapped and failure is not meaningful",
+ args: args{
+ err: &errorx.ErrWrapper{},
+ },
+ want: func() *string {
+ s := "unknown_failure: errWrapper.Failure is empty"
+ return &s
+ }(),
+ }, {
+ name: "when error is not wrapped but wrappable",
+ args: args{err: io.EOF},
+ want: func() *string {
+ s := "eof_error"
+ return &s
+ }(),
+ }, {
+ name: "when the error is not wrapped and not wrappable",
+ args: args{
+ err: errors.New("use of closed socket 127.0.0.1:8080->10.0.0.1:22"),
+ },
+ want: func() *string {
+ s := "unknown_failure: use of closed socket [scrubbed]->[scrubbed]"
+ return &s
+ }(),
+ }}
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := archival.NewFailure(tt.args.err)
+ if tt.want == nil && got == nil {
+ return
+ }
+ if tt.want == nil && got != nil {
+ t.Errorf("NewFailure: want %+v, got %s", tt.want, *got)
+ return
+ }
+ if tt.want != nil && got == nil {
+ t.Errorf("NewFailure: want %s, got %+v", *tt.want, got)
+ return
+ }
+ if *tt.want != *got {
+ t.Errorf("NewFailure: want %s, got %s", *tt.want, *got)
+ return
+ }
+ })
+ }
+}
+
+func TestDNSQueryTypeInvalidIPOfType(t *testing.T) {
+ qtype := archival.DNSQueryType("ANTANI")
+ if qtype.IPOfType("8.8.8.8") != false {
+ t.Fatal("unexpected return value")
+ }
+}
+
+func TestNewFailedOperation(t *testing.T) {
+ type args struct {
+ err error
+ }
+ tests := []struct {
+ name string
+ args args
+ want *string
+ }{{
+ name: "With no error",
+ args: args{
+ err: nil, // explicit
+ },
+ want: nil, // explicit
+ }, {
+ name: "With wrapped error and non-empty operation",
+ args: args{
+ err: &errorx.ErrWrapper{
+ Failure: errorx.FailureConnectionRefused,
+ Operation: errorx.ConnectOperation,
+ },
+ },
+ want: (func() *string {
+ s := errorx.ConnectOperation
+ return &s
+ })(),
+ }, {
+ name: "With wrapped error and empty operation",
+ args: args{
+ err: &errorx.ErrWrapper{
+ Failure: errorx.FailureConnectionRefused,
+ },
+ },
+ want: (func() *string {
+ s := errorx.UnknownOperation
+ return &s
+ })(),
+ }, {
+ name: "With non wrapped error",
+ args: args{
+ err: io.EOF,
+ },
+ want: (func() *string {
+ s := errorx.UnknownOperation
+ return &s
+ })(),
+ }}
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := archival.NewFailedOperation(tt.args.err)
+ if got == nil && tt.want == nil {
+ return
+ }
+ if got == nil && tt.want != nil {
+ t.Errorf("NewFailedOperation() = %v, want %v", got, tt.want)
+ return
+ }
+ if got != nil && tt.want == nil {
+ t.Errorf("NewFailedOperation() = %v, want %v", got, tt.want)
+ return
+ }
+ if got != nil && tt.want != nil && *got != *tt.want {
+ t.Errorf("NewFailedOperation() = %v, want %v", got, tt.want)
+ return
+ }
+ })
+ }
+}
diff --git a/internal/engine/netx/archival/archival_test_internal.go b/internal/engine/netx/archival/archival_test_internal.go
new file mode 100644
index 0000000..976ba3f
--- /dev/null
+++ b/internal/engine/netx/archival/archival_test_internal.go
@@ -0,0 +1,8 @@
+package archival
+
+// DNSQueryType allows to access dnsQueryType from unit tests
+type DNSQueryType = dnsQueryType
+
+func (qtype dnsQueryType) IPOfType(addr string) bool {
+ return qtype.ipoftype(addr)
+}
diff --git a/internal/engine/netx/bytecounter/bytecounter.go b/internal/engine/netx/bytecounter/bytecounter.go
new file mode 100644
index 0000000..426b874
--- /dev/null
+++ b/internal/engine/netx/bytecounter/bytecounter.go
@@ -0,0 +1,54 @@
+package bytecounter
+
+import "github.com/ooni/probe-cli/v3/internal/engine/atomicx"
+
+// Counter counts bytes sent and received.
+type Counter struct {
+ Received *atomicx.Int64
+ Sent *atomicx.Int64
+}
+
+// New creates a new Counter.
+func New() *Counter {
+ return &Counter{Received: atomicx.NewInt64(), Sent: atomicx.NewInt64()}
+}
+
+// CountBytesSent adds count to the bytes sent counter.
+func (c *Counter) CountBytesSent(count int) {
+ c.Sent.Add(int64(count))
+}
+
+// CountKibiBytesSent adds 1024*count to the bytes sent counter.
+func (c *Counter) CountKibiBytesSent(count float64) {
+ c.Sent.Add(int64(1024 * count))
+}
+
+// BytesSent returns the bytes sent so far.
+func (c *Counter) BytesSent() int64 {
+ return c.Sent.Load()
+}
+
+// KibiBytesSent returns the KiB sent so far.
+func (c *Counter) KibiBytesSent() float64 {
+ return float64(c.BytesSent()) / 1024
+}
+
+// CountBytesReceived adds count to the bytes received counter.
+func (c *Counter) CountBytesReceived(count int) {
+ c.Received.Add(int64(count))
+}
+
+// CountKibiBytesReceived adds 1024*count to the bytes received counter.
+func (c *Counter) CountKibiBytesReceived(count float64) {
+ c.Received.Add(int64(1024 * count))
+}
+
+// BytesReceived returns the bytes received so far.
+func (c *Counter) BytesReceived() int64 {
+ return c.Received.Load()
+}
+
+// KibiBytesReceived returns the KiB received so far.
+func (c *Counter) KibiBytesReceived() float64 {
+ return float64(c.BytesReceived()) / 1024
+}
diff --git a/internal/engine/netx/bytecounter/bytecounter_test.go b/internal/engine/netx/bytecounter/bytecounter_test.go
new file mode 100644
index 0000000..908651a
--- /dev/null
+++ b/internal/engine/netx/bytecounter/bytecounter_test.go
@@ -0,0 +1,27 @@
+package bytecounter_test
+
+import (
+ "testing"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/bytecounter"
+)
+
+func TestGood(t *testing.T) {
+ counter := bytecounter.New()
+ counter.CountBytesReceived(16384)
+ counter.CountKibiBytesReceived(10)
+ counter.CountBytesSent(2048)
+ counter.CountKibiBytesSent(10)
+ if counter.BytesSent() != 12288 {
+ t.Fatal("invalid bytes sent")
+ }
+ if counter.BytesReceived() != 26624 {
+ t.Fatal("invalid bytes received")
+ }
+ if v := counter.KibiBytesSent(); v < 11.9 || v > 12.1 {
+ t.Fatal("invalid kibibytes sent")
+ }
+ if v := counter.KibiBytesReceived(); v < 25.9 || v > 26.1 {
+ t.Fatal("invalid kibibytes received")
+ }
+}
diff --git a/internal/engine/netx/dialer/bytecounter.go b/internal/engine/netx/dialer/bytecounter.go
new file mode 100644
index 0000000..c818ba8
--- /dev/null
+++ b/internal/engine/netx/dialer/bytecounter.go
@@ -0,0 +1,93 @@
+package dialer
+
+import (
+ "context"
+ "net"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/bytecounter"
+)
+
+// ByteCounterDialer is a byte-counting-aware dialer. To perform byte counting, you
+// should make sure that you insert this dialer in the dialing chain.
+//
+// Bug
+//
+// This implementation cannot properly account for the bytes that are sent by
+// persistent connections, because they strick to the counters set when the
+// connection was established. This typically means we miss the bytes sent and
+// received when submitting a measurement. Such bytes are specifically not
+// see by the experiment specific byte counter.
+//
+// For this reason, this implementation may be heavily changed/removed.
+type ByteCounterDialer struct {
+ Dialer
+}
+
+// DialContext implements Dialer.DialContext
+func (d ByteCounterDialer) DialContext(
+ ctx context.Context, network, address string) (net.Conn, error) {
+ conn, err := d.Dialer.DialContext(ctx, network, address)
+ if err != nil {
+ return nil, err
+ }
+ exp := ContextExperimentByteCounter(ctx)
+ sess := ContextSessionByteCounter(ctx)
+ if exp == nil && sess == nil {
+ return conn, nil // no point in wrapping
+ }
+ return byteCounterConnWrapper{Conn: conn, exp: exp, sess: sess}, nil
+}
+
+type byteCounterSessionKey struct{}
+
+// ContextSessionByteCounter retrieves the session byte counter from the context
+func ContextSessionByteCounter(ctx context.Context) *bytecounter.Counter {
+ counter, _ := ctx.Value(byteCounterSessionKey{}).(*bytecounter.Counter)
+ return counter
+}
+
+// WithSessionByteCounter assigns the session byte counter to the context
+func WithSessionByteCounter(ctx context.Context, counter *bytecounter.Counter) context.Context {
+ return context.WithValue(ctx, byteCounterSessionKey{}, counter)
+}
+
+type byteCounterExperimentKey struct{}
+
+// ContextExperimentByteCounter retrieves the experiment byte counter from the context
+func ContextExperimentByteCounter(ctx context.Context) *bytecounter.Counter {
+ counter, _ := ctx.Value(byteCounterExperimentKey{}).(*bytecounter.Counter)
+ return counter
+}
+
+// WithExperimentByteCounter assigns the experiment byte counter to the context
+func WithExperimentByteCounter(ctx context.Context, counter *bytecounter.Counter) context.Context {
+ return context.WithValue(ctx, byteCounterExperimentKey{}, counter)
+}
+
+type byteCounterConnWrapper struct {
+ net.Conn
+ exp *bytecounter.Counter
+ sess *bytecounter.Counter
+}
+
+func (c byteCounterConnWrapper) Read(p []byte) (int, error) {
+ count, err := c.Conn.Read(p)
+ if c.exp != nil {
+ c.exp.CountBytesReceived(count)
+ }
+ if c.sess != nil {
+ c.sess.CountBytesReceived(count)
+ }
+ return count, err
+}
+
+func (c byteCounterConnWrapper) Write(p []byte) (int, error) {
+ count, err := c.Conn.Write(p)
+ if c.exp != nil {
+ c.exp.CountBytesSent(count)
+ }
+ if c.sess != nil {
+ c.sess.CountBytesSent(count)
+ }
+ return count, err
+}
diff --git a/internal/engine/netx/dialer/bytecounter_test.go b/internal/engine/netx/dialer/bytecounter_test.go
new file mode 100644
index 0000000..3376385
--- /dev/null
+++ b/internal/engine/netx/dialer/bytecounter_test.go
@@ -0,0 +1,81 @@
+package dialer_test
+
+import (
+ "context"
+ "errors"
+ "io"
+ "io/ioutil"
+ "net"
+ "net/http"
+ "testing"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/bytecounter"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer"
+)
+
+func dorequest(ctx context.Context, url string) error {
+ txp := http.DefaultTransport.(*http.Transport).Clone()
+ defer txp.CloseIdleConnections()
+ dialer := dialer.ByteCounterDialer{Dialer: new(net.Dialer)}
+ txp.DialContext = dialer.DialContext
+ client := &http.Client{Transport: txp}
+ req, err := http.NewRequestWithContext(ctx, "GET", "http://www.google.com", nil)
+ if err != nil {
+ return err
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ if _, err := io.Copy(ioutil.Discard, resp.Body); err != nil {
+ return err
+ }
+ return resp.Body.Close()
+}
+
+func TestByteCounterNormalUsage(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ sess := bytecounter.New()
+ ctx := context.Background()
+ ctx = dialer.WithSessionByteCounter(ctx, sess)
+ if err := dorequest(ctx, "http://www.google.com"); err != nil {
+ t.Fatal(err)
+ }
+ exp := bytecounter.New()
+ ctx = dialer.WithExperimentByteCounter(ctx, exp)
+ if err := dorequest(ctx, "http://facebook.com"); err != nil {
+ t.Fatal(err)
+ }
+ if sess.Received.Load() <= exp.Received.Load() {
+ t.Fatal("session should have received more than experiment")
+ }
+ if sess.Sent.Load() <= exp.Sent.Load() {
+ t.Fatal("session should have sent more than experiment")
+ }
+}
+
+func TestByteCounterNoHandlers(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ ctx := context.Background()
+ if err := dorequest(ctx, "http://www.google.com"); err != nil {
+ t.Fatal(err)
+ }
+ if err := dorequest(ctx, "http://facebook.com"); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestByteCounterConnectFailure(t *testing.T) {
+ dialer := dialer.ByteCounterDialer{Dialer: dialer.EOFDialer{}}
+ conn, err := dialer.DialContext(context.Background(), "tcp", "www.google.com:80")
+ if !errors.Is(err, io.EOF) {
+ t.Fatal("not the error we expected")
+ }
+ if conn != nil {
+ t.Fatal("expected nil conn here")
+ }
+}
diff --git a/internal/engine/netx/dialer/dialer.go b/internal/engine/netx/dialer/dialer.go
new file mode 100644
index 0000000..16a2ecc
--- /dev/null
+++ b/internal/engine/netx/dialer/dialer.go
@@ -0,0 +1,29 @@
+package dialer
+
+import (
+ "context"
+ "net"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/connid"
+)
+
+// Dialer is the interface we expect from a dialer
+type Dialer interface {
+ DialContext(ctx context.Context, network, address string) (net.Conn, error)
+}
+
+// Resolver is the interface we expect from a resolver
+type Resolver interface {
+ LookupHost(ctx context.Context, hostname string) (addrs []string, err error)
+}
+
+func safeLocalAddress(conn net.Conn) (s string) {
+ if conn != nil && conn.LocalAddr() != nil {
+ s = conn.LocalAddr().String()
+ }
+ return
+}
+
+func safeConnID(network string, conn net.Conn) int64 {
+ return connid.Compute(network, safeLocalAddress(conn))
+}
diff --git a/internal/engine/netx/dialer/dns.go b/internal/engine/netx/dialer/dns.go
new file mode 100644
index 0000000..e6d30ce
--- /dev/null
+++ b/internal/engine/netx/dialer/dns.go
@@ -0,0 +1,73 @@
+package dialer
+
+import (
+ "context"
+ "errors"
+ "net"
+ "strings"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/dialid"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx"
+)
+
+// DNSDialer is a dialer that uses the configured Resolver to resolver a
+// domain name to IP addresses, and the configured Dialer to connect.
+type DNSDialer struct {
+ Dialer
+ Resolver Resolver
+}
+
+// DialContext implements Dialer.DialContext.
+func (d DNSDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
+ onlyhost, onlyport, err := net.SplitHostPort(address)
+ if err != nil {
+ return nil, err
+ }
+ ctx = dialid.WithDialID(ctx) // important to create before lookupHost
+ var addrs []string
+ addrs, err = d.LookupHost(ctx, onlyhost)
+ if err != nil {
+ return nil, err
+ }
+ var errorslist []error
+ for _, addr := range addrs {
+ target := net.JoinHostPort(addr, onlyport)
+ conn, err := d.Dialer.DialContext(ctx, network, target)
+ if err == nil {
+ return conn, nil
+ }
+ errorslist = append(errorslist, err)
+ }
+ return nil, ReduceErrors(errorslist)
+}
+
+// ReduceErrors finds a known error in a list of errors since it's probably most relevant
+func ReduceErrors(errorslist []error) error {
+ if len(errorslist) == 0 {
+ return nil
+ }
+ // If we have a known error, let's consider this the real error
+ // since it's probably most relevant. Otherwise let's return the
+ // first considering that (1) local resolvers likely will give
+ // us IPv4 first and (2) also our resolver does that. So, in case
+ // the user has no IPv6 connectivity, an IPv6 error is going to
+ // appear later in the list of errors.
+ for _, err := range errorslist {
+ var wrapper *errorx.ErrWrapper
+ if errors.As(err, &wrapper) && !strings.HasPrefix(
+ err.Error(), "unknown_failure",
+ ) {
+ return err
+ }
+ }
+ // TODO(bassosimone): handle this case in a better way
+ return errorslist[0]
+}
+
+// LookupHost implements Resolver.LookupHost
+func (d DNSDialer) LookupHost(ctx context.Context, hostname string) ([]string, error) {
+ if net.ParseIP(hostname) != nil {
+ return []string{hostname}, nil
+ }
+ return d.Resolver.LookupHost(ctx, hostname)
+}
diff --git a/internal/engine/netx/dialer/dns_test.go b/internal/engine/netx/dialer/dns_test.go
new file mode 100644
index 0000000..ba8e406
--- /dev/null
+++ b/internal/engine/netx/dialer/dns_test.go
@@ -0,0 +1,171 @@
+package dialer_test
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net"
+ "testing"
+ "time"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/handlers"
+ "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/modelx"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx"
+)
+
+func TestDNSDialerNoPort(t *testing.T) {
+ dialer := dialer.DNSDialer{Dialer: new(net.Dialer), Resolver: new(net.Resolver)}
+ conn, err := dialer.DialContext(context.Background(), "tcp", "antani.ooni.nu")
+ if err == nil {
+ t.Fatal("expected an error here")
+ }
+ if conn != nil {
+ t.Fatal("expected a nil conn here")
+ }
+}
+
+func TestDNSDialerLookupHostAddress(t *testing.T) {
+ dialer := dialer.DNSDialer{Dialer: new(net.Dialer), Resolver: MockableResolver{
+ Err: errors.New("mocked error"),
+ }}
+ addrs, err := dialer.LookupHost(context.Background(), "1.1.1.1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(addrs) != 1 || addrs[0] != "1.1.1.1" {
+ t.Fatal("not the result we expected")
+ }
+}
+
+func TestDNSDialerLookupHostFailure(t *testing.T) {
+ expected := errors.New("mocked error")
+ dialer := dialer.DNSDialer{Dialer: new(net.Dialer), Resolver: MockableResolver{
+ Err: expected,
+ }}
+ conn, err := dialer.DialContext(context.Background(), "tcp", "dns.google.com:853")
+ if !errors.Is(err, expected) {
+ t.Fatal("not the error we expected")
+ }
+ if conn != nil {
+ t.Fatal("expected nil conn")
+ }
+}
+
+type MockableResolver struct {
+ Addresses []string
+ Err error
+}
+
+func (r MockableResolver) LookupHost(ctx context.Context, host string) ([]string, error) {
+ return r.Addresses, r.Err
+}
+
+func TestDNSDialerDialForSingleIPFails(t *testing.T) {
+ dialer := dialer.DNSDialer{Dialer: dialer.EOFDialer{}, Resolver: new(net.Resolver)}
+ conn, err := dialer.DialContext(context.Background(), "tcp", "1.1.1.1:853")
+ if !errors.Is(err, io.EOF) {
+ t.Fatal("not the error we expected")
+ }
+ if conn != nil {
+ t.Fatal("expected nil conn")
+ }
+}
+
+func TestDNSDialerDialForManyIPFails(t *testing.T) {
+ dialer := dialer.DNSDialer{Dialer: dialer.EOFDialer{}, Resolver: MockableResolver{
+ Addresses: []string{"1.1.1.1", "8.8.8.8"},
+ }}
+ conn, err := dialer.DialContext(context.Background(), "tcp", "dot.dns:853")
+ if !errors.Is(err, io.EOF) {
+ t.Fatal("not the error we expected")
+ }
+ if conn != nil {
+ t.Fatal("expected nil conn")
+ }
+}
+
+func TestDNSDialerDialForManyIPSuccess(t *testing.T) {
+ dialer := dialer.DNSDialer{Dialer: dialer.EOFConnDialer{}, Resolver: MockableResolver{
+ Addresses: []string{"1.1.1.1", "8.8.8.8"},
+ }}
+ conn, err := dialer.DialContext(context.Background(), "tcp", "dot.dns:853")
+ if err != nil {
+ t.Fatal("expected nil error here")
+ }
+ if conn == nil {
+ t.Fatal("expected non-nil conn")
+ }
+ conn.Close()
+}
+
+func TestDNSDialerDialSetsDialID(t *testing.T) {
+ saver := &handlers.SavingHandler{}
+ ctx := modelx.WithMeasurementRoot(context.Background(), &modelx.MeasurementRoot{
+ Beginning: time.Now(),
+ Handler: saver,
+ })
+ dialer := dialer.DNSDialer{Dialer: dialer.EmitterDialer{
+ Dialer: dialer.EOFConnDialer{},
+ }, Resolver: MockableResolver{
+ Addresses: []string{"1.1.1.1", "8.8.8.8"},
+ }}
+ conn, err := dialer.DialContext(ctx, "tcp", "dot.dns:853")
+ if err != nil {
+ t.Fatal("expected nil error here")
+ }
+ if conn == nil {
+ t.Fatal("expected non-nil conn")
+ }
+ conn.Close()
+ events := saver.Read()
+ if len(events) != 2 {
+ t.Fatal("unexpected number of events")
+ }
+ for _, ev := range events {
+ if ev.Connect != nil && ev.Connect.DialID == 0 {
+ t.Fatal("unexpected DialID")
+ }
+ }
+}
+
+func TestReduceErrors(t *testing.T) {
+ t.Run("no errors", func(t *testing.T) {
+ result := dialer.ReduceErrors(nil)
+ if result != nil {
+ t.Fatal("wrong result")
+ }
+ })
+
+ t.Run("single error", func(t *testing.T) {
+ err := errors.New("mocked error")
+ result := dialer.ReduceErrors([]error{err})
+ if result != err {
+ t.Fatal("wrong result")
+ }
+ })
+
+ t.Run("multiple errors", func(t *testing.T) {
+ err1 := errors.New("mocked error #1")
+ err2 := errors.New("mocked error #2")
+ result := dialer.ReduceErrors([]error{err1, err2})
+ if result.Error() != "mocked error #1" {
+ t.Fatal("wrong result")
+ }
+ })
+
+ t.Run("multiple errors with meaningful ones", func(t *testing.T) {
+ err1 := errors.New("mocked error #1")
+ err2 := &errorx.ErrWrapper{
+ Failure: "unknown_failure: antani",
+ }
+ err3 := &errorx.ErrWrapper{
+ Failure: errorx.FailureConnectionRefused,
+ }
+ err4 := errors.New("mocked error #3")
+ result := dialer.ReduceErrors([]error{err1, err2, err3, err4})
+ if result.Error() != errorx.FailureConnectionRefused {
+ t.Fatal("wrong result")
+ }
+ })
+}
diff --git a/internal/engine/netx/dialer/emitter.go b/internal/engine/netx/dialer/emitter.go
new file mode 100644
index 0000000..c9dad70
--- /dev/null
+++ b/internal/engine/netx/dialer/emitter.go
@@ -0,0 +1,103 @@
+package dialer
+
+import (
+ "context"
+ "net"
+ "time"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/dialid"
+ "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/modelx"
+ "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/transactionid"
+)
+
+// EmitterDialer is a Dialer that emits events
+type EmitterDialer struct {
+ Dialer
+}
+
+// DialContext implements Dialer.DialContext
+func (d EmitterDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
+ start := time.Now()
+ conn, err := d.Dialer.DialContext(ctx, network, address)
+ stop := time.Now()
+ root := modelx.ContextMeasurementRootOrDefault(ctx)
+ root.Handler.OnMeasurement(modelx.Measurement{
+ Connect: &modelx.ConnectEvent{
+ ConnID: safeConnID(network, conn),
+ DialID: dialid.ContextDialID(ctx),
+ DurationSinceBeginning: stop.Sub(root.Beginning),
+ Error: err,
+ Network: network,
+ RemoteAddress: address,
+ SyscallDuration: stop.Sub(start),
+ TransactionID: transactionid.ContextTransactionID(ctx),
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+ return EmitterConn{
+ Conn: conn,
+ Beginning: root.Beginning,
+ Handler: root.Handler,
+ ID: safeConnID(network, conn),
+ }, nil
+}
+
+// EmitterConn is a net.Conn used to emit events
+type EmitterConn struct {
+ net.Conn
+ Beginning time.Time
+ Handler modelx.Handler
+ ID int64
+}
+
+// Read implements net.Conn.Read
+func (c EmitterConn) Read(b []byte) (n int, err error) {
+ start := time.Now()
+ n, err = c.Conn.Read(b)
+ stop := time.Now()
+ c.Handler.OnMeasurement(modelx.Measurement{
+ Read: &modelx.ReadEvent{
+ ConnID: c.ID,
+ DurationSinceBeginning: stop.Sub(c.Beginning),
+ Error: err,
+ NumBytes: int64(n),
+ SyscallDuration: stop.Sub(start),
+ },
+ })
+ return
+}
+
+// Write implements net.Conn.Write
+func (c EmitterConn) Write(b []byte) (n int, err error) {
+ start := time.Now()
+ n, err = c.Conn.Write(b)
+ stop := time.Now()
+ c.Handler.OnMeasurement(modelx.Measurement{
+ Write: &modelx.WriteEvent{
+ ConnID: c.ID,
+ DurationSinceBeginning: stop.Sub(c.Beginning),
+ Error: err,
+ NumBytes: int64(n),
+ SyscallDuration: stop.Sub(start),
+ },
+ })
+ return
+}
+
+// Close implements net.Conn.Close
+func (c EmitterConn) Close() (err error) {
+ start := time.Now()
+ err = c.Conn.Close()
+ stop := time.Now()
+ c.Handler.OnMeasurement(modelx.Measurement{
+ Close: &modelx.CloseEvent{
+ ConnID: c.ID,
+ DurationSinceBeginning: stop.Sub(c.Beginning),
+ Error: err,
+ SyscallDuration: stop.Sub(start),
+ },
+ })
+ return
+}
diff --git a/internal/engine/netx/dialer/emitter_test.go b/internal/engine/netx/dialer/emitter_test.go
new file mode 100644
index 0000000..adf5ad5
--- /dev/null
+++ b/internal/engine/netx/dialer/emitter_test.go
@@ -0,0 +1,166 @@
+package dialer_test
+
+import (
+ "context"
+ "errors"
+ "io"
+ "testing"
+ "time"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/dialid"
+ "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/handlers"
+ "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/modelx"
+ "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/transactionid"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer"
+)
+
+func TestEmitterFailure(t *testing.T) {
+ ctx := dialid.WithDialID(context.Background())
+ saver := &handlers.SavingHandler{}
+ ctx = modelx.WithMeasurementRoot(ctx, &modelx.MeasurementRoot{
+ Beginning: time.Now(),
+ Handler: saver,
+ })
+ ctx = transactionid.WithTransactionID(ctx)
+ d := dialer.EmitterDialer{Dialer: dialer.EOFDialer{}}
+ conn, err := d.DialContext(ctx, "tcp", "www.google.com:443")
+ if !errors.Is(err, io.EOF) {
+ t.Fatal("not the error we expected")
+ }
+ if conn != nil {
+ t.Fatal("expected a nil conn here")
+ }
+ events := saver.Read()
+ if len(events) != 1 {
+ t.Fatal("unexpected number of events saved")
+ }
+ if events[0].Connect == nil {
+ t.Fatal("expected non nil Connect")
+ }
+ conninfo := events[0].Connect
+ if conninfo.ConnID != 0 {
+ t.Fatal("unexpected ConnID value")
+ }
+ emitterCheckConnectEventCommon(t, conninfo, io.EOF)
+}
+
+func emitterCheckConnectEventCommon(
+ t *testing.T, conninfo *modelx.ConnectEvent, err error) {
+ if conninfo.DialID == 0 {
+ t.Fatal("unexpected DialID value")
+ }
+ if conninfo.DurationSinceBeginning == 0 {
+ t.Fatal("unexpected DurationSinceBeginning value")
+ }
+ if !errors.Is(conninfo.Error, err) {
+ t.Fatal("unexpected Error value")
+ }
+ if conninfo.Network != "tcp" {
+ t.Fatal("unexpected Network value")
+ }
+ if conninfo.RemoteAddress != "www.google.com:443" {
+ t.Fatal("unexpected Network value")
+ }
+ if conninfo.SyscallDuration == 0 {
+ t.Fatal("unexpected SyscallDuration value")
+ }
+ if conninfo.TransactionID == 0 {
+ t.Fatal("unexpected TransactionID value")
+ }
+}
+
+func TestEmitterSuccess(t *testing.T) {
+ ctx := dialid.WithDialID(context.Background())
+ saver := &handlers.SavingHandler{}
+ ctx = modelx.WithMeasurementRoot(ctx, &modelx.MeasurementRoot{
+ Beginning: time.Now(),
+ Handler: saver,
+ })
+ ctx = transactionid.WithTransactionID(ctx)
+ d := dialer.EmitterDialer{Dialer: dialer.EOFConnDialer{}}
+ conn, err := d.DialContext(ctx, "tcp", "www.google.com:443")
+ if err != nil {
+ t.Fatal("we expected no error")
+ }
+ if conn == nil {
+ t.Fatal("expected a non-nil conn here")
+ }
+ conn.Read(nil)
+ conn.Write(nil)
+ conn.Close()
+ events := saver.Read()
+ if len(events) != 4 {
+ t.Fatal("unexpected number of events saved")
+ }
+ if events[0].Connect == nil {
+ t.Fatal("expected non nil Connect")
+ }
+ conninfo := events[0].Connect
+ if conninfo.ConnID == 0 {
+ t.Fatal("unexpected ConnID value")
+ }
+ emitterCheckConnectEventCommon(t, conninfo, nil)
+ if events[1].Read == nil {
+ t.Fatal("expected non nil Read")
+ }
+ emitterCheckReadEvent(t, events[1].Read)
+ if events[2].Write == nil {
+ t.Fatal("expected non nil Write")
+ }
+ emitterCheckWriteEvent(t, events[2].Write)
+ if events[3].Close == nil {
+ t.Fatal("expected non nil Close")
+ }
+ emitterCheckCloseEvent(t, events[3].Close)
+}
+
+func emitterCheckReadEvent(t *testing.T, ev *modelx.ReadEvent) {
+ if ev.ConnID == 0 {
+ t.Fatal("unexpected ConnID")
+ }
+ if ev.DurationSinceBeginning == 0 {
+ t.Fatal("unexpected DurationSinceBeginning")
+ }
+ if !errors.Is(ev.Error, io.EOF) {
+ t.Fatal("unexpected Error")
+ }
+ if ev.NumBytes != 0 {
+ t.Fatal("unexpected NumBytes")
+ }
+ if ev.SyscallDuration == 0 {
+ t.Fatal("unexpected SyscallDuration")
+ }
+}
+
+func emitterCheckWriteEvent(t *testing.T, ev *modelx.WriteEvent) {
+ if ev.ConnID == 0 {
+ t.Fatal("unexpected ConnID")
+ }
+ if ev.DurationSinceBeginning == 0 {
+ t.Fatal("unexpected DurationSinceBeginning")
+ }
+ if !errors.Is(ev.Error, io.EOF) {
+ t.Fatal("unexpected Error")
+ }
+ if ev.NumBytes != 0 {
+ t.Fatal("unexpected NumBytes")
+ }
+ if ev.SyscallDuration == 0 {
+ t.Fatal("unexpected SyscallDuration")
+ }
+}
+
+func emitterCheckCloseEvent(t *testing.T, ev *modelx.CloseEvent) {
+ if ev.ConnID == 0 {
+ t.Fatal("unexpected ConnID")
+ }
+ if ev.DurationSinceBeginning == 0 {
+ t.Fatal("unexpected DurationSinceBeginning")
+ }
+ if !errors.Is(ev.Error, io.EOF) {
+ t.Fatal("unexpected Error")
+ }
+ if ev.SyscallDuration == 0 {
+ t.Fatal("unexpected SyscallDuration")
+ }
+}
diff --git a/internal/engine/netx/dialer/eof_test.go b/internal/engine/netx/dialer/eof_test.go
new file mode 100644
index 0000000..9016658
--- /dev/null
+++ b/internal/engine/netx/dialer/eof_test.go
@@ -0,0 +1,80 @@
+package dialer
+
+import (
+ "context"
+ "crypto/tls"
+ "io"
+ "net"
+ "time"
+)
+
+type EOFDialer struct{}
+
+func (EOFDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
+ time.Sleep(10 * time.Microsecond)
+ return nil, io.EOF
+}
+
+type EOFConnDialer struct{}
+
+func (EOFConnDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
+ return EOFConn{}, nil
+}
+
+type EOFConn struct {
+ net.Conn
+}
+
+func (EOFConn) Read(p []byte) (int, error) {
+ time.Sleep(10 * time.Microsecond)
+ return 0, io.EOF
+}
+
+func (EOFConn) Write(p []byte) (int, error) {
+ time.Sleep(10 * time.Microsecond)
+ return 0, io.EOF
+}
+
+func (EOFConn) Close() error {
+ time.Sleep(10 * time.Microsecond)
+ return io.EOF
+}
+
+func (EOFConn) LocalAddr() net.Addr {
+ return EOFAddr{}
+}
+
+func (EOFConn) RemoteAddr() net.Addr {
+ return EOFAddr{}
+}
+
+func (EOFConn) SetDeadline(t time.Time) error {
+ return nil
+}
+
+func (EOFConn) SetReadDeadline(t time.Time) error {
+ return nil
+}
+
+func (EOFConn) SetWriteDeadline(t time.Time) error {
+ return nil
+}
+
+type EOFAddr struct{}
+
+func (EOFAddr) Network() string {
+ return "tcp"
+}
+
+func (EOFAddr) String() string {
+ return "127.0.0.1:1234"
+}
+
+type EOFTLSHandshaker struct{}
+
+func (EOFTLSHandshaker) Handshake(
+ ctx context.Context, conn net.Conn, config *tls.Config,
+) (net.Conn, tls.ConnectionState, error) {
+ time.Sleep(10 * time.Microsecond)
+ return nil, tls.ConnectionState{}, io.EOF
+}
diff --git a/internal/engine/netx/dialer/errorwrapper.go b/internal/engine/netx/dialer/errorwrapper.go
new file mode 100644
index 0000000..96a462e
--- /dev/null
+++ b/internal/engine/netx/dialer/errorwrapper.go
@@ -0,0 +1,75 @@
+package dialer
+
+import (
+ "context"
+ "net"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/dialid"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx"
+)
+
+// ErrorWrapperDialer is a dialer that performs err wrapping
+type ErrorWrapperDialer struct {
+ Dialer
+}
+
+// DialContext implements Dialer.DialContext
+func (d ErrorWrapperDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
+ dialID := dialid.ContextDialID(ctx)
+ conn, err := d.Dialer.DialContext(ctx, network, address)
+ err = errorx.SafeErrWrapperBuilder{
+ // ConnID does not make any sense if we've failed and the error
+ // does not make any sense (and is nil) if we succeded.
+ DialID: dialID,
+ Error: err,
+ Operation: errorx.ConnectOperation,
+ }.MaybeBuild()
+ if err != nil {
+ return nil, err
+ }
+ return &ErrorWrapperConn{
+ Conn: conn, ConnID: safeConnID(network, conn), DialID: dialID}, nil
+}
+
+// ErrorWrapperConn is a net.Conn that performs error wrapping.
+type ErrorWrapperConn struct {
+ net.Conn
+ ConnID int64
+ DialID int64
+}
+
+// Read implements net.Conn.Read
+func (c ErrorWrapperConn) Read(b []byte) (n int, err error) {
+ n, err = c.Conn.Read(b)
+ err = errorx.SafeErrWrapperBuilder{
+ ConnID: c.ConnID,
+ DialID: c.DialID,
+ Error: err,
+ Operation: errorx.ReadOperation,
+ }.MaybeBuild()
+ return
+}
+
+// Write implements net.Conn.Write
+func (c ErrorWrapperConn) Write(b []byte) (n int, err error) {
+ n, err = c.Conn.Write(b)
+ err = errorx.SafeErrWrapperBuilder{
+ ConnID: c.ConnID,
+ DialID: c.DialID,
+ Error: err,
+ Operation: errorx.WriteOperation,
+ }.MaybeBuild()
+ return
+}
+
+// Close implements net.Conn.Close
+func (c ErrorWrapperConn) Close() (err error) {
+ err = c.Conn.Close()
+ err = errorx.SafeErrWrapperBuilder{
+ ConnID: c.ConnID,
+ DialID: c.DialID,
+ Error: err,
+ Operation: errorx.CloseOperation,
+ }.MaybeBuild()
+ return
+}
diff --git a/internal/engine/netx/dialer/errorwrapper_test.go b/internal/engine/netx/dialer/errorwrapper_test.go
new file mode 100644
index 0000000..260519e
--- /dev/null
+++ b/internal/engine/netx/dialer/errorwrapper_test.go
@@ -0,0 +1,66 @@
+package dialer_test
+
+import (
+ "context"
+ "errors"
+ "io"
+ "testing"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/dialid"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx"
+)
+
+func TestErrorWrapperFailure(t *testing.T) {
+ ctx := dialid.WithDialID(context.Background())
+ d := dialer.ErrorWrapperDialer{Dialer: dialer.EOFDialer{}}
+ conn, err := d.DialContext(ctx, "tcp", "www.google.com:443")
+ if conn != nil {
+ t.Fatal("expected a nil conn here")
+ }
+ errorWrapperCheckErr(t, err, errorx.ConnectOperation)
+}
+
+func errorWrapperCheckErr(t *testing.T, err error, op string) {
+ if !errors.Is(err, io.EOF) {
+ t.Fatal("expected another error here")
+ }
+ var errWrapper *errorx.ErrWrapper
+ if !errors.As(err, &errWrapper) {
+ t.Fatal("cannot cast to ErrWrapper")
+ }
+ if errWrapper.DialID == 0 {
+ t.Fatal("unexpected DialID")
+ }
+ if errWrapper.Operation != op {
+ t.Fatal("unexpected Operation")
+ }
+ if errWrapper.Failure != errorx.FailureEOFError {
+ t.Fatal("unexpected failure")
+ }
+}
+
+func TestErrorWrapperSuccess(t *testing.T) {
+ ctx := dialid.WithDialID(context.Background())
+ d := dialer.ErrorWrapperDialer{Dialer: dialer.EOFConnDialer{}}
+ conn, err := d.DialContext(ctx, "tcp", "www.google.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if conn == nil {
+ t.Fatal("expected non-nil conn here")
+ }
+ count, err := conn.Read(nil)
+ errorWrapperCheckIOResult(t, count, err, errorx.ReadOperation)
+ count, err = conn.Write(nil)
+ errorWrapperCheckIOResult(t, count, err, errorx.WriteOperation)
+ err = conn.Close()
+ errorWrapperCheckErr(t, err, errorx.CloseOperation)
+}
+
+func errorWrapperCheckIOResult(t *testing.T, count int, err error, op string) {
+ if count != 0 {
+ t.Fatal("expected nil count here")
+ }
+ errorWrapperCheckErr(t, err, op)
+}
diff --git a/internal/engine/netx/dialer/fake_test.go b/internal/engine/netx/dialer/fake_test.go
new file mode 100644
index 0000000..9b66568
--- /dev/null
+++ b/internal/engine/netx/dialer/fake_test.go
@@ -0,0 +1,71 @@
+package dialer
+
+import (
+ "context"
+ "io"
+ "net"
+ "time"
+)
+
+type FakeDialer struct {
+ Conn net.Conn
+ Err error
+}
+
+func (d FakeDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
+ time.Sleep(10 * time.Microsecond)
+ return d.Conn, d.Err
+}
+
+type FakeConn struct {
+ ReadError error
+ ReadData []byte
+ SetDeadlineError error
+ SetReadDeadlineError error
+ SetWriteDeadlineError error
+ WriteError error
+}
+
+func (c *FakeConn) Read(b []byte) (int, error) {
+ if len(c.ReadData) > 0 {
+ n := copy(b, c.ReadData)
+ c.ReadData = c.ReadData[n:]
+ return n, nil
+ }
+ if c.ReadError != nil {
+ return 0, c.ReadError
+ }
+ return 0, io.EOF
+}
+
+func (c *FakeConn) Write(b []byte) (n int, err error) {
+ if c.WriteError != nil {
+ return 0, c.WriteError
+ }
+ n = len(b)
+ return
+}
+
+func (*FakeConn) Close() (err error) {
+ return
+}
+
+func (*FakeConn) LocalAddr() net.Addr {
+ return &net.TCPAddr{}
+}
+
+func (*FakeConn) RemoteAddr() net.Addr {
+ return &net.TCPAddr{}
+}
+
+func (c *FakeConn) SetDeadline(t time.Time) (err error) {
+ return c.SetDeadlineError
+}
+
+func (c *FakeConn) SetReadDeadline(t time.Time) (err error) {
+ return c.SetReadDeadlineError
+}
+
+func (c *FakeConn) SetWriteDeadline(t time.Time) (err error) {
+ return c.SetWriteDeadlineError
+}
diff --git a/internal/engine/netx/dialer/integration_test.go b/internal/engine/netx/dialer/integration_test.go
new file mode 100644
index 0000000..2072a01
--- /dev/null
+++ b/internal/engine/netx/dialer/integration_test.go
@@ -0,0 +1,57 @@
+package dialer_test
+
+import (
+ "context"
+ "net"
+ "net/http"
+ "testing"
+
+ "github.com/apex/log"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer"
+)
+
+func TestTLSDialerSuccess(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ log.SetLevel(log.DebugLevel)
+ dialer := dialer.TLSDialer{Dialer: new(net.Dialer),
+ TLSHandshaker: dialer.LoggingTLSHandshaker{
+ TLSHandshaker: dialer.SystemTLSHandshaker{},
+ Logger: log.Log,
+ },
+ }
+ txp := &http.Transport{DialTLS: func(network, address string) (net.Conn, error) {
+ // AlpineLinux edge is still using Go 1.13. We cannot switch to
+ // using DialTLSContext here as we'd like to until either Alpine
+ // switches to Go 1.14 or we drop the MK dependency.
+ return dialer.DialTLSContext(context.Background(), network, address)
+ }}
+ client := &http.Client{Transport: txp}
+ resp, err := client.Get("https://www.google.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+}
+
+func TestDNSDialerSuccess(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ log.SetLevel(log.DebugLevel)
+ dialer := dialer.DNSDialer{
+ Dialer: dialer.LoggingDialer{
+ Dialer: new(net.Dialer),
+ Logger: log.Log,
+ },
+ Resolver: new(net.Resolver),
+ }
+ txp := &http.Transport{DialContext: dialer.DialContext}
+ client := &http.Client{Transport: txp}
+ resp, err := client.Get("http://www.google.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+}
diff --git a/internal/engine/netx/dialer/logging.go b/internal/engine/netx/dialer/logging.go
new file mode 100644
index 0000000..c998122
--- /dev/null
+++ b/internal/engine/netx/dialer/logging.go
@@ -0,0 +1,56 @@
+package dialer
+
+import (
+ "context"
+ "crypto/tls"
+ "net"
+ "time"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/internal/tlsx"
+)
+
+// Logger is the logger assumed by this package
+type Logger interface {
+ Debugf(format string, v ...interface{})
+ Debug(message string)
+}
+
+// LoggingDialer is a Dialer with logging
+type LoggingDialer struct {
+ Dialer
+ Logger Logger
+}
+
+// DialContext implements Dialer.DialContext
+func (d LoggingDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
+ d.Logger.Debugf("dial %s/%s...", address, network)
+ start := time.Now()
+ conn, err := d.Dialer.DialContext(ctx, network, address)
+ stop := time.Now()
+ d.Logger.Debugf("dial %s/%s... %+v in %s", address, network, err, stop.Sub(start))
+ return conn, err
+}
+
+// LoggingTLSHandshaker is a TLSHandshaker with logging
+type LoggingTLSHandshaker struct {
+ TLSHandshaker
+ Logger Logger
+}
+
+// Handshake implements Handshaker.Handshake
+func (h LoggingTLSHandshaker) Handshake(
+ ctx context.Context, conn net.Conn, config *tls.Config,
+) (net.Conn, tls.ConnectionState, error) {
+ h.Logger.Debugf("tls {sni=%s next=%+v}...", config.ServerName, config.NextProtos)
+ start := time.Now()
+ tlsconn, state, err := h.TLSHandshaker.Handshake(ctx, conn, config)
+ stop := time.Now()
+ h.Logger.Debugf(
+ "tls {sni=%s next=%+v}... %+v in %s {next=%s cipher=%s v=%s}", config.ServerName,
+ config.NextProtos, err, stop.Sub(start), state.NegotiatedProtocol,
+ tlsx.CipherSuiteString(state.CipherSuite), tlsx.VersionString(state.Version))
+ return tlsconn, state, err
+}
+
+var _ Dialer = LoggingDialer{}
+var _ TLSHandshaker = LoggingTLSHandshaker{}
diff --git a/internal/engine/netx/dialer/logging_test.go b/internal/engine/netx/dialer/logging_test.go
new file mode 100644
index 0000000..9f58836
--- /dev/null
+++ b/internal/engine/netx/dialer/logging_test.go
@@ -0,0 +1,42 @@
+package dialer_test
+
+import (
+ "context"
+ "crypto/tls"
+ "errors"
+ "io"
+ "testing"
+
+ "github.com/apex/log"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer"
+)
+
+func TestLoggingDialerFailure(t *testing.T) {
+ d := dialer.LoggingDialer{
+ Dialer: dialer.EOFDialer{},
+ Logger: log.Log,
+ }
+ conn, err := d.DialContext(context.Background(), "tcp", "www.google.com:443")
+ if !errors.Is(err, io.EOF) {
+ t.Fatal("not the error we expected")
+ }
+ if conn != nil {
+ t.Fatal("expected nil conn here")
+ }
+}
+
+func TestLoggingTLSHandshakerFailure(t *testing.T) {
+ h := dialer.LoggingTLSHandshaker{
+ TLSHandshaker: dialer.EOFTLSHandshaker{},
+ Logger: log.Log,
+ }
+ tlsconn, _, err := h.Handshake(context.Background(), dialer.EOFConn{}, &tls.Config{
+ ServerName: "www.google.com",
+ })
+ if !errors.Is(err, io.EOF) {
+ t.Fatal("not the error we expected")
+ }
+ if tlsconn != nil {
+ t.Fatal("expected nil tlsconn here")
+ }
+}
diff --git a/internal/engine/netx/dialer/proxy.go b/internal/engine/netx/dialer/proxy.go
new file mode 100644
index 0000000..fde442e
--- /dev/null
+++ b/internal/engine/netx/dialer/proxy.go
@@ -0,0 +1,94 @@
+package dialer
+
+import (
+ "context"
+ "errors"
+ "net"
+ "net/url"
+
+ "golang.org/x/net/proxy"
+)
+
+// ProxyDialer is a dialer that uses a proxy. If the ProxyURL is not configured, this
+// dialer is a passthrough for the next Dialer in chain. Otherwise, it will internally
+// create a SOCKS5 dialer that will connect to the proxy using the underlying Dialer.
+//
+// As a special case, you can force a proxy to be used only extemporarily. To this end,
+// you can use the WithProxyURL function, to store the proxy URL in the context. This
+// will take precedence over any otherwise configured proxy. The use case for this
+// functionality is when you need a tunnel to contact OONI probe services.
+type ProxyDialer struct {
+ Dialer
+ ProxyURL *url.URL
+}
+
+type proxyKey struct{}
+
+// ContextProxyURL retrieves the proxy URL from the context. This is mainly used
+// to force a tunnel when we fail contacting OONI probe services otherwise.
+func ContextProxyURL(ctx context.Context) *url.URL {
+ url, _ := ctx.Value(proxyKey{}).(*url.URL)
+ return url
+}
+
+// WithProxyURL assigns the proxy URL to the context
+func WithProxyURL(ctx context.Context, url *url.URL) context.Context {
+ return context.WithValue(ctx, proxyKey{}, url)
+}
+
+// DialContext implements Dialer.DialContext
+func (d ProxyDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
+ url := ContextProxyURL(ctx) // context URL takes precendence
+ if url == nil {
+ url = d.ProxyURL
+ }
+ if url == nil {
+ return d.Dialer.DialContext(ctx, network, address)
+ }
+ if url.Scheme != "socks5" {
+ return nil, errors.New("Scheme is not socks5")
+ }
+ // the code at proxy/socks5.go never fails; see https://git.io/JfJ4g
+ child, _ := proxy.SOCKS5(
+ network, url.Host, nil, proxyDialerWrapper{Dialer: d.Dialer})
+ return d.dial(ctx, child, network, address)
+}
+
+func (d ProxyDialer) dial(
+ ctx context.Context, child proxy.Dialer, network, address string) (net.Conn, error) {
+ connch := make(chan net.Conn)
+ errch := make(chan error, 1)
+ go func() {
+ conn, err := child.Dial(network, address)
+ if err != nil {
+ errch <- err
+ return
+ }
+ select {
+ case connch <- conn:
+ default:
+ conn.Close()
+ }
+ }()
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case err := <-errch:
+ return nil, err
+ case conn := <-connch:
+ return conn, nil
+ }
+}
+
+// proxyDialerWrapper is required because SOCKS5 expects a Dialer.Dial type but internally
+// it checks whether DialContext is available and prefers that. So, we need to use this
+// structure to cast our inner Dialer the way in which SOCKS5 likes it.
+//
+// See https://git.io/JfJ4g.
+type proxyDialerWrapper struct {
+ Dialer
+}
+
+func (d proxyDialerWrapper) Dial(network, address string) (net.Conn, error) {
+ return d.DialContext(context.Background(), network, address)
+}
diff --git a/internal/engine/netx/dialer/proxy_internal_test.go b/internal/engine/netx/dialer/proxy_internal_test.go
new file mode 100644
index 0000000..063eef2
--- /dev/null
+++ b/internal/engine/netx/dialer/proxy_internal_test.go
@@ -0,0 +1,15 @@
+package dialer
+
+import (
+ "context"
+ "net"
+
+ "golang.org/x/net/proxy"
+)
+
+type ProxyDialerWrapper = proxyDialerWrapper
+
+func (d ProxyDialer) DialContextWithDialer(
+ ctx context.Context, child proxy.Dialer, network, address string) (net.Conn, error) {
+ return d.dial(ctx, child, network, address)
+}
diff --git a/internal/engine/netx/dialer/proxy_test.go b/internal/engine/netx/dialer/proxy_test.go
new file mode 100644
index 0000000..ebaf4ad
--- /dev/null
+++ b/internal/engine/netx/dialer/proxy_test.go
@@ -0,0 +1,152 @@
+package dialer_test
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net/url"
+ "testing"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer"
+)
+
+func TestProxyDialerDialContextNoProxyURL(t *testing.T) {
+ expected := errors.New("mocked error")
+ d := dialer.ProxyDialer{
+ Dialer: dialer.FakeDialer{Err: expected},
+ }
+ conn, err := d.DialContext(context.Background(), "tcp", "www.google.com:443")
+ if !errors.Is(err, expected) {
+ t.Fatal(err)
+ }
+ if conn != nil {
+ t.Fatal("conn is not nil")
+ }
+}
+
+func TestProxyDialerContextTakesPrecedence(t *testing.T) {
+ expected := errors.New("mocked error")
+ d := dialer.ProxyDialer{
+ Dialer: dialer.FakeDialer{Err: expected},
+ ProxyURL: &url.URL{Scheme: "antani"},
+ }
+ ctx := context.Background()
+ ctx = dialer.WithProxyURL(ctx, &url.URL{Scheme: "socks5", Host: "[::1]:443"})
+ conn, err := d.DialContext(ctx, "tcp", "www.google.com:443")
+ if !errors.Is(err, expected) {
+ t.Fatal("not the error we expected")
+ }
+ if conn != nil {
+ t.Fatal("conn is not nil")
+ }
+}
+
+func TestProxyDialerDialContextInvalidScheme(t *testing.T) {
+ d := dialer.ProxyDialer{
+ Dialer: dialer.FakeDialer{},
+ ProxyURL: &url.URL{Scheme: "antani"},
+ }
+ conn, err := d.DialContext(context.Background(), "tcp", "www.google.com:443")
+ if err.Error() != "Scheme is not socks5" {
+ t.Fatal("not the error we expected")
+ }
+ if conn != nil {
+ t.Fatal("conn is not nil")
+ }
+}
+
+func TestProxyDialerDialContextWithEOF(t *testing.T) {
+ d := dialer.ProxyDialer{
+ Dialer: dialer.FakeDialer{
+ Err: io.EOF,
+ },
+ ProxyURL: &url.URL{Scheme: "socks5"},
+ }
+ conn, err := d.DialContext(context.Background(), "tcp", "www.google.com:443")
+ if !errors.Is(err, io.EOF) {
+ t.Fatal("not the error we expected")
+ }
+ if conn != nil {
+ t.Fatal("conn is not nil")
+ }
+}
+
+func TestProxyDialerDialContextWithContextCanceled(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel() // immediately fail
+ d := dialer.ProxyDialer{
+ Dialer: dialer.FakeDialer{
+ Err: io.EOF,
+ },
+ ProxyURL: &url.URL{Scheme: "socks5"},
+ }
+ conn, err := d.DialContext(ctx, "tcp", "www.google.com:443")
+ if !errors.Is(err, context.Canceled) {
+ t.Fatal("not the error we expected")
+ }
+ if conn != nil {
+ t.Fatal("conn is not nil")
+ }
+}
+
+func TestProxyDialerDialContextWithDialerSuccess(t *testing.T) {
+ d := dialer.ProxyDialer{
+ Dialer: dialer.FakeDialer{
+ Conn: &dialer.FakeConn{
+ ReadError: io.EOF,
+ WriteError: io.EOF,
+ },
+ },
+ ProxyURL: &url.URL{Scheme: "socks5"},
+ }
+ conn, err := d.DialContextWithDialer(
+ context.Background(), dialer.ProxyDialerWrapper{
+ Dialer: d.Dialer,
+ }, "tcp", "www.google.com:443")
+ if err != nil {
+ t.Fatal(err)
+ }
+ conn.Close()
+}
+
+func TestProxyDialerDialContextWithDialerCanceledContext(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ // Stop immediately. The FakeDialer sleeps for some microseconds so
+ // it is much more likely we immediately exit with done context. The
+ // arm where we receive the conn is much less likely.
+ cancel()
+ d := dialer.ProxyDialer{
+ Dialer: dialer.FakeDialer{
+ Conn: &dialer.FakeConn{
+ ReadError: io.EOF,
+ WriteError: io.EOF,
+ },
+ },
+ ProxyURL: &url.URL{Scheme: "socks5"},
+ }
+ conn, err := d.DialContextWithDialer(
+ ctx, dialer.ProxyDialerWrapper{
+ Dialer: d.Dialer,
+ }, "tcp", "www.google.com:443")
+ if !errors.Is(err, context.Canceled) {
+ t.Fatal("not the error we expected")
+ }
+ if conn != nil {
+ t.Fatal("expected nil conn here")
+ }
+}
+
+func TestProxyDialerWrapper(t *testing.T) {
+ d := dialer.ProxyDialerWrapper{
+ Dialer: dialer.FakeDialer{
+ Err: io.EOF,
+ },
+ }
+ conn, err := d.Dial("tcp", "www.google.com:443")
+ if !errors.Is(err, io.EOF) {
+ t.Fatal("not the error we expected")
+ }
+ if conn != nil {
+ t.Fatal("conn is not nil")
+ }
+}
diff --git a/internal/engine/netx/dialer/saver.go b/internal/engine/netx/dialer/saver.go
new file mode 100644
index 0000000..db18076
--- /dev/null
+++ b/internal/engine/netx/dialer/saver.go
@@ -0,0 +1,125 @@
+package dialer
+
+import (
+ "context"
+ "crypto/tls"
+ "net"
+ "time"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/internal/tlsx"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/trace"
+)
+
+// SaverDialer saves events occurring during the dial
+type SaverDialer struct {
+ Dialer
+ Saver *trace.Saver
+}
+
+// DialContext implements Dialer.DialContext
+func (d SaverDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
+ start := time.Now()
+ conn, err := d.Dialer.DialContext(ctx, network, address)
+ stop := time.Now()
+ d.Saver.Write(trace.Event{
+ Address: address,
+ Duration: stop.Sub(start),
+ Err: err,
+ Name: errorx.ConnectOperation,
+ Proto: network,
+ Time: stop,
+ })
+ return conn, err
+}
+
+// SaverTLSHandshaker saves events occurring during the handshake
+type SaverTLSHandshaker struct {
+ TLSHandshaker
+ Saver *trace.Saver
+}
+
+// Handshake implements TLSHandshaker.Handshake
+func (h SaverTLSHandshaker) Handshake(
+ ctx context.Context, conn net.Conn, config *tls.Config,
+) (net.Conn, tls.ConnectionState, error) {
+ start := time.Now()
+ h.Saver.Write(trace.Event{
+ Name: "tls_handshake_start",
+ NoTLSVerify: config.InsecureSkipVerify,
+ TLSNextProtos: config.NextProtos,
+ TLSServerName: config.ServerName,
+ Time: start,
+ })
+ tlsconn, state, err := h.TLSHandshaker.Handshake(ctx, conn, config)
+ stop := time.Now()
+ h.Saver.Write(trace.Event{
+ Duration: stop.Sub(start),
+ Err: err,
+ Name: "tls_handshake_done",
+ NoTLSVerify: config.InsecureSkipVerify,
+ TLSCipherSuite: tlsx.CipherSuiteString(state.CipherSuite),
+ TLSNegotiatedProto: state.NegotiatedProtocol,
+ TLSNextProtos: config.NextProtos,
+ TLSPeerCerts: trace.PeerCerts(state, err),
+ TLSServerName: config.ServerName,
+ TLSVersion: tlsx.VersionString(state.Version),
+ Time: stop,
+ })
+ return tlsconn, state, err
+}
+
+// SaverConnDialer wraps the returned connection such that we
+// collect all the read/write events that occur.
+type SaverConnDialer struct {
+ Dialer
+ Saver *trace.Saver
+}
+
+// DialContext implements Dialer.DialContext
+func (d SaverConnDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
+ conn, err := d.Dialer.DialContext(ctx, network, address)
+ if err != nil {
+ return nil, err
+ }
+ return saverConn{saver: d.Saver, Conn: conn}, nil
+}
+
+type saverConn struct {
+ net.Conn
+ saver *trace.Saver
+}
+
+func (c saverConn) Read(p []byte) (int, error) {
+ start := time.Now()
+ count, err := c.Conn.Read(p)
+ stop := time.Now()
+ c.saver.Write(trace.Event{
+ Data: p[:count],
+ Duration: stop.Sub(start),
+ Err: err,
+ NumBytes: count,
+ Name: errorx.ReadOperation,
+ Time: stop,
+ })
+ return count, err
+}
+
+func (c saverConn) Write(p []byte) (int, error) {
+ start := time.Now()
+ count, err := c.Conn.Write(p)
+ stop := time.Now()
+ c.saver.Write(trace.Event{
+ Data: p[:count],
+ Duration: stop.Sub(start),
+ Err: err,
+ NumBytes: count,
+ Name: errorx.WriteOperation,
+ Time: stop,
+ })
+ return count, err
+}
+
+var _ Dialer = SaverDialer{}
+var _ TLSHandshaker = SaverTLSHandshaker{}
+var _ net.Conn = saverConn{}
diff --git a/internal/engine/netx/dialer/saver_test.go b/internal/engine/netx/dialer/saver_test.go
new file mode 100644
index 0000000..8d30323
--- /dev/null
+++ b/internal/engine/netx/dialer/saver_test.go
@@ -0,0 +1,371 @@
+package dialer_test
+
+import (
+ "context"
+ "crypto/tls"
+ "errors"
+ "net"
+ "reflect"
+ "testing"
+ "time"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/trace"
+)
+
+func TestSaverDialerFailure(t *testing.T) {
+ expected := errors.New("mocked error")
+ saver := &trace.Saver{}
+ dlr := dialer.SaverDialer{
+ Dialer: dialer.FakeDialer{
+ Err: expected,
+ },
+ Saver: saver,
+ }
+ conn, err := dlr.DialContext(context.Background(), "tcp", "www.google.com:443")
+ if !errors.Is(err, expected) {
+ t.Fatal("expected another error here")
+ }
+ if conn != nil {
+ t.Fatal("expected nil conn here")
+ }
+ ev := saver.Read()
+ if len(ev) != 1 {
+ t.Fatal("expected a single event here")
+ }
+ if ev[0].Address != "www.google.com:443" {
+ t.Fatal("unexpected Address")
+ }
+ if ev[0].Duration <= 0 {
+ t.Fatal("unexpected Duration")
+ }
+ if !errors.Is(ev[0].Err, expected) {
+ t.Fatal("unexpected Err")
+ }
+ if ev[0].Name != errorx.ConnectOperation {
+ t.Fatal("unexpected Name")
+ }
+ if ev[0].Proto != "tcp" {
+ t.Fatal("unexpected Proto")
+ }
+ if !ev[0].Time.Before(time.Now()) {
+ t.Fatal("unexpected Time")
+ }
+}
+
+func TestSaverConnDialerFailure(t *testing.T) {
+ expected := errors.New("mocked error")
+ saver := &trace.Saver{}
+ dlr := dialer.SaverConnDialer{
+ Dialer: dialer.FakeDialer{
+ Err: expected,
+ },
+ Saver: saver,
+ }
+ conn, err := dlr.DialContext(context.Background(), "tcp", "www.google.com:443")
+ if !errors.Is(err, expected) {
+ t.Fatal("not the error we expected")
+ }
+ if conn != nil {
+ t.Fatal("expected nil conn here")
+ }
+}
+
+func TestSaverTLSHandshakerSuccessWithReadWrite(t *testing.T) {
+ // This is the most common use case for collecting reads, writes
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ nextprotos := []string{"h2"}
+ saver := &trace.Saver{}
+ tlsdlr := dialer.TLSDialer{
+ Config: &tls.Config{NextProtos: nextprotos},
+ Dialer: dialer.SaverConnDialer{
+ Dialer: new(net.Dialer),
+ Saver: saver,
+ },
+ TLSHandshaker: dialer.SaverTLSHandshaker{
+ TLSHandshaker: dialer.SystemTLSHandshaker{},
+ Saver: saver,
+ },
+ }
+ // Implementation note: we don't close the connection here because it is
+ // very handy to have the last event being the end of the handshake
+ _, err := tlsdlr.DialTLSContext(context.Background(), "tcp", "www.google.com:443")
+ if err != nil {
+ t.Fatal(err)
+ }
+ ev := saver.Read()
+ if len(ev) < 4 {
+ // it's a bit tricky to be sure about the right number of
+ // events because network conditions may influence that
+ t.Fatal("unexpected number of events")
+ }
+ if ev[0].Name != "tls_handshake_start" {
+ t.Fatal("unexpected Name")
+ }
+ if ev[0].TLSServerName != "www.google.com" {
+ t.Fatal("unexpected TLSServerName")
+ }
+ if !reflect.DeepEqual(ev[0].TLSNextProtos, nextprotos) {
+ t.Fatal("unexpected TLSNextProtos")
+ }
+ if ev[0].Time.After(time.Now()) {
+ t.Fatal("unexpected Time")
+ }
+ last := len(ev) - 1
+ for idx := 1; idx < last; idx++ {
+ if ev[idx].Data == nil {
+ t.Fatal("unexpected Data")
+ }
+ if ev[idx].Duration <= 0 {
+ t.Fatal("unexpected Duration")
+ }
+ if ev[idx].Err != nil {
+ t.Fatal("unexpected Err")
+ }
+ if ev[idx].NumBytes <= 0 {
+ t.Fatal("unexpected NumBytes")
+ }
+ switch ev[idx].Name {
+ case errorx.ReadOperation, errorx.WriteOperation:
+ default:
+ t.Fatal("unexpected Name")
+ }
+ if ev[idx].Time.Before(ev[idx-1].Time) {
+ t.Fatal("unexpected Time")
+ }
+ }
+ if ev[last].Duration <= 0 {
+ t.Fatal("unexpected Duration")
+ }
+ if ev[last].Err != nil {
+ t.Fatal("unexpected Err")
+ }
+ if ev[last].Name != "tls_handshake_done" {
+ t.Fatal("unexpected Name")
+ }
+ if ev[last].TLSCipherSuite == "" {
+ t.Fatal("unexpected TLSCipherSuite")
+ }
+ if ev[last].TLSNegotiatedProto != "h2" {
+ t.Fatal("unexpected TLSNegotiatedProto")
+ }
+ if !reflect.DeepEqual(ev[last].TLSNextProtos, nextprotos) {
+ t.Fatal("unexpected TLSNextProtos")
+ }
+ if ev[last].TLSPeerCerts == nil {
+ t.Fatal("unexpected TLSPeerCerts")
+ }
+ if ev[last].TLSServerName != "www.google.com" {
+ t.Fatal("unexpected TLSServerName")
+ }
+ if ev[last].TLSVersion == "" {
+ t.Fatal("unexpected TLSVersion")
+ }
+ if ev[last].Time.Before(ev[last-1].Time) {
+ t.Fatal("unexpected Time")
+ }
+}
+
+func TestSaverTLSHandshakerSuccess(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ nextprotos := []string{"h2"}
+ saver := &trace.Saver{}
+ tlsdlr := dialer.TLSDialer{
+ Config: &tls.Config{NextProtos: nextprotos},
+ Dialer: new(net.Dialer),
+ TLSHandshaker: dialer.SaverTLSHandshaker{
+ TLSHandshaker: dialer.SystemTLSHandshaker{},
+ Saver: saver,
+ },
+ }
+ conn, err := tlsdlr.DialTLSContext(context.Background(), "tcp", "www.google.com:443")
+ if err != nil {
+ t.Fatal(err)
+ }
+ conn.Close()
+ ev := saver.Read()
+ if len(ev) != 2 {
+ t.Fatal("unexpected number of events")
+ }
+ if ev[0].Name != "tls_handshake_start" {
+ t.Fatal("unexpected Name")
+ }
+ if ev[0].TLSServerName != "www.google.com" {
+ t.Fatal("unexpected TLSServerName")
+ }
+ if !reflect.DeepEqual(ev[0].TLSNextProtos, nextprotos) {
+ t.Fatal("unexpected TLSNextProtos")
+ }
+ if ev[0].Time.After(time.Now()) {
+ t.Fatal("unexpected Time")
+ }
+ if ev[1].Duration <= 0 {
+ t.Fatal("unexpected Duration")
+ }
+ if ev[1].Err != nil {
+ t.Fatal("unexpected Err")
+ }
+ if ev[1].Name != "tls_handshake_done" {
+ t.Fatal("unexpected Name")
+ }
+ if ev[1].TLSCipherSuite == "" {
+ t.Fatal("unexpected TLSCipherSuite")
+ }
+ if ev[1].TLSNegotiatedProto != "h2" {
+ t.Fatal("unexpected TLSNegotiatedProto")
+ }
+ if !reflect.DeepEqual(ev[1].TLSNextProtos, nextprotos) {
+ t.Fatal("unexpected TLSNextProtos")
+ }
+ if ev[1].TLSPeerCerts == nil {
+ t.Fatal("unexpected TLSPeerCerts")
+ }
+ if ev[1].TLSServerName != "www.google.com" {
+ t.Fatal("unexpected TLSServerName")
+ }
+ if ev[1].TLSVersion == "" {
+ t.Fatal("unexpected TLSVersion")
+ }
+ if ev[1].Time.Before(ev[0].Time) {
+ t.Fatal("unexpected Time")
+ }
+}
+
+func TestSaverTLSHandshakerHostnameError(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ saver := &trace.Saver{}
+ tlsdlr := dialer.TLSDialer{
+ Dialer: new(net.Dialer),
+ TLSHandshaker: dialer.SaverTLSHandshaker{
+ TLSHandshaker: dialer.SystemTLSHandshaker{},
+ Saver: saver,
+ },
+ }
+ conn, err := tlsdlr.DialTLSContext(
+ context.Background(), "tcp", "wrong.host.badssl.com:443")
+ if err == nil {
+ t.Fatal("expected an error here")
+ }
+ if conn != nil {
+ t.Fatal("expected nil conn here")
+ }
+ for _, ev := range saver.Read() {
+ if ev.Name != "tls_handshake_done" {
+ continue
+ }
+ if ev.NoTLSVerify == true {
+ t.Fatal("expected NoTLSVerify to be false")
+ }
+ if len(ev.TLSPeerCerts) < 1 {
+ t.Fatal("expected at least a certificate here")
+ }
+ }
+}
+
+func TestSaverTLSHandshakerInvalidCertError(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ saver := &trace.Saver{}
+ tlsdlr := dialer.TLSDialer{
+ Dialer: new(net.Dialer),
+ TLSHandshaker: dialer.SaverTLSHandshaker{
+ TLSHandshaker: dialer.SystemTLSHandshaker{},
+ Saver: saver,
+ },
+ }
+ conn, err := tlsdlr.DialTLSContext(
+ context.Background(), "tcp", "expired.badssl.com:443")
+ if err == nil {
+ t.Fatal("expected an error here")
+ }
+ if conn != nil {
+ t.Fatal("expected nil conn here")
+ }
+ for _, ev := range saver.Read() {
+ if ev.Name != "tls_handshake_done" {
+ continue
+ }
+ if ev.NoTLSVerify == true {
+ t.Fatal("expected NoTLSVerify to be false")
+ }
+ if len(ev.TLSPeerCerts) < 1 {
+ t.Fatal("expected at least a certificate here")
+ }
+ }
+}
+
+func TestSaverTLSHandshakerAuthorityError(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ saver := &trace.Saver{}
+ tlsdlr := dialer.TLSDialer{
+ Dialer: new(net.Dialer),
+ TLSHandshaker: dialer.SaverTLSHandshaker{
+ TLSHandshaker: dialer.SystemTLSHandshaker{},
+ Saver: saver,
+ },
+ }
+ conn, err := tlsdlr.DialTLSContext(
+ context.Background(), "tcp", "self-signed.badssl.com:443")
+ if err == nil {
+ t.Fatal("expected an error here")
+ }
+ if conn != nil {
+ t.Fatal("expected nil conn here")
+ }
+ for _, ev := range saver.Read() {
+ if ev.Name != "tls_handshake_done" {
+ continue
+ }
+ if ev.NoTLSVerify == true {
+ t.Fatal("expected NoTLSVerify to be false")
+ }
+ if len(ev.TLSPeerCerts) < 1 {
+ t.Fatal("expected at least a certificate here")
+ }
+ }
+}
+
+func TestSaverTLSHandshakerNoTLSVerify(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ saver := &trace.Saver{}
+ tlsdlr := dialer.TLSDialer{
+ Config: &tls.Config{InsecureSkipVerify: true},
+ Dialer: new(net.Dialer),
+ TLSHandshaker: dialer.SaverTLSHandshaker{
+ TLSHandshaker: dialer.SystemTLSHandshaker{},
+ Saver: saver,
+ },
+ }
+ conn, err := tlsdlr.DialTLSContext(
+ context.Background(), "tcp", "self-signed.badssl.com:443")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if conn == nil {
+ t.Fatal("expected non-nil conn here")
+ }
+ conn.Close()
+ for _, ev := range saver.Read() {
+ if ev.Name != "tls_handshake_done" {
+ continue
+ }
+ if ev.NoTLSVerify != true {
+ t.Fatal("expected NoTLSVerify to be true")
+ }
+ if len(ev.TLSPeerCerts) < 1 {
+ t.Fatal("expected at least a certificate here")
+ }
+ }
+}
diff --git a/internal/engine/netx/dialer/shaping_disabled.go b/internal/engine/netx/dialer/shaping_disabled.go
new file mode 100644
index 0000000..0d07bf6
--- /dev/null
+++ b/internal/engine/netx/dialer/shaping_disabled.go
@@ -0,0 +1,21 @@
+// +build !shaping
+
+package dialer
+
+import (
+ "context"
+ "net"
+)
+
+// ShapingDialer ensures we don't use too much bandwidth
+// when using integration tests at GitHub. To select
+// the implementation with shaping use `-tags shaping`.
+type ShapingDialer struct {
+ Dialer
+}
+
+// DialContext implements Dialer.DialContext
+func (d ShapingDialer) DialContext(
+ ctx context.Context, network, address string) (net.Conn, error) {
+ return d.Dialer.DialContext(ctx, network, address)
+}
diff --git a/internal/engine/netx/dialer/shaping_enabled.go b/internal/engine/netx/dialer/shaping_enabled.go
new file mode 100644
index 0000000..00b27ca
--- /dev/null
+++ b/internal/engine/netx/dialer/shaping_enabled.go
@@ -0,0 +1,40 @@
+// +build shaping
+
+package dialer
+
+import (
+ "context"
+ "net"
+ "time"
+)
+
+// ShapingDialer ensures we don't use too much bandwidth
+// when using integration tests at GitHub. To select
+// the implementation with shaping use `-tags shaping`.
+type ShapingDialer struct {
+ Dialer
+}
+
+// DialContext implements Dialer.DialContext
+func (d ShapingDialer) DialContext(
+ ctx context.Context, network, address string) (net.Conn, error) {
+ conn, err := d.Dialer.DialContext(ctx, network, address)
+ if err != nil {
+ return nil, err
+ }
+ return &shapingConn{Conn: conn}, nil
+}
+
+type shapingConn struct {
+ net.Conn
+}
+
+func (c shapingConn) Read(p []byte) (int, error) {
+ time.Sleep(100 * time.Millisecond)
+ return c.Conn.Read(p)
+}
+
+func (c shapingConn) Write(p []byte) (int, error) {
+ time.Sleep(100 * time.Millisecond)
+ return c.Conn.Write(p)
+}
diff --git a/internal/engine/netx/dialer/shaping_test.go b/internal/engine/netx/dialer/shaping_test.go
new file mode 100644
index 0000000..e910c04
--- /dev/null
+++ b/internal/engine/netx/dialer/shaping_test.go
@@ -0,0 +1,27 @@
+package dialer_test
+
+import (
+ "net"
+ "net/http"
+ "testing"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/netx"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer"
+)
+
+func TestGood(t *testing.T) {
+ txp := netx.NewHTTPTransport(netx.Config{
+ Dialer: dialer.ShapingDialer{
+ Dialer: new(net.Dialer),
+ },
+ })
+ client := &http.Client{Transport: txp}
+ resp, err := client.Get("https://www.google.com/")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resp == nil {
+ t.Fatal("expected nil response here")
+ }
+ resp.Body.Close()
+}
diff --git a/internal/engine/netx/dialer/timeout.go b/internal/engine/netx/dialer/timeout.go
new file mode 100644
index 0000000..36b24a2
--- /dev/null
+++ b/internal/engine/netx/dialer/timeout.go
@@ -0,0 +1,24 @@
+package dialer
+
+import (
+ "context"
+ "net"
+ "time"
+)
+
+// TimeoutDialer is a Dialer that enforces a timeout
+type TimeoutDialer struct {
+ Dialer
+ ConnectTimeout time.Duration // default: 30 seconds
+}
+
+// DialContext implements Dialer.DialContext
+func (d TimeoutDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
+ timeout := 30 * time.Second
+ if d.ConnectTimeout != 0 {
+ timeout = d.ConnectTimeout
+ }
+ ctx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+ return d.Dialer.DialContext(ctx, network, address)
+}
diff --git a/internal/engine/netx/dialer/timeout_test.go b/internal/engine/netx/dialer/timeout_test.go
new file mode 100644
index 0000000..62bb6bb
--- /dev/null
+++ b/internal/engine/netx/dialer/timeout_test.go
@@ -0,0 +1,34 @@
+package dialer_test
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net"
+ "testing"
+ "time"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer"
+)
+
+type SlowDialer struct{}
+
+func (SlowDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case <-time.After(30 * time.Second):
+ return nil, io.EOF
+ }
+}
+
+func TestTimeoutDialer(t *testing.T) {
+ d := dialer.TimeoutDialer{Dialer: SlowDialer{}, ConnectTimeout: time.Second}
+ conn, err := d.DialContext(context.Background(), "tcp", "www.google.com:443")
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatal("not the error we expected")
+ }
+ if conn != nil {
+ t.Fatal("expected nil conn here")
+ }
+}
diff --git a/internal/engine/netx/dialer/tls.go b/internal/engine/netx/dialer/tls.go
new file mode 100644
index 0000000..cf958d8
--- /dev/null
+++ b/internal/engine/netx/dialer/tls.go
@@ -0,0 +1,141 @@
+package dialer
+
+import (
+ "context"
+ "crypto/tls"
+ "net"
+ "time"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/connid"
+ "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/modelx"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx"
+)
+
+// TLSHandshaker is the generic TLS handshaker
+type TLSHandshaker interface {
+ Handshake(ctx context.Context, conn net.Conn, config *tls.Config) (
+ net.Conn, tls.ConnectionState, error)
+}
+
+// SystemTLSHandshaker is the system TLS handshaker.
+type SystemTLSHandshaker struct{}
+
+// Handshake implements Handshaker.Handshake
+func (h SystemTLSHandshaker) Handshake(
+ ctx context.Context, conn net.Conn, config *tls.Config,
+) (net.Conn, tls.ConnectionState, error) {
+ tlsconn := tls.Client(conn, config)
+ if err := tlsconn.Handshake(); err != nil {
+ return nil, tls.ConnectionState{}, err
+ }
+ return tlsconn, tlsconn.ConnectionState(), nil
+}
+
+// TimeoutTLSHandshaker is a TLSHandshaker with timeout
+type TimeoutTLSHandshaker struct {
+ TLSHandshaker
+ HandshakeTimeout time.Duration // default: 10 second
+}
+
+// Handshake implements Handshaker.Handshake
+func (h TimeoutTLSHandshaker) Handshake(
+ ctx context.Context, conn net.Conn, config *tls.Config,
+) (net.Conn, tls.ConnectionState, error) {
+ timeout := 10 * time.Second
+ if h.HandshakeTimeout != 0 {
+ timeout = h.HandshakeTimeout
+ }
+ if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil {
+ return nil, tls.ConnectionState{}, err
+ }
+ tlsconn, connstate, err := h.TLSHandshaker.Handshake(ctx, conn, config)
+ conn.SetDeadline(time.Time{})
+ return tlsconn, connstate, err
+}
+
+// ErrorWrapperTLSHandshaker wraps the returned error to be an OONI error
+type ErrorWrapperTLSHandshaker struct {
+ TLSHandshaker
+}
+
+// Handshake implements Handshaker.Handshake
+func (h ErrorWrapperTLSHandshaker) Handshake(
+ ctx context.Context, conn net.Conn, config *tls.Config,
+) (net.Conn, tls.ConnectionState, error) {
+ connID := connid.Compute(conn.RemoteAddr().Network(), conn.RemoteAddr().String())
+ tlsconn, state, err := h.TLSHandshaker.Handshake(ctx, conn, config)
+ err = errorx.SafeErrWrapperBuilder{
+ ConnID: connID,
+ Error: err,
+ Operation: errorx.TLSHandshakeOperation,
+ }.MaybeBuild()
+ return tlsconn, state, err
+}
+
+// EmitterTLSHandshaker emits events using the MeasurementRoot
+type EmitterTLSHandshaker struct {
+ TLSHandshaker
+}
+
+// Handshake implements Handshaker.Handshake
+func (h EmitterTLSHandshaker) Handshake(
+ ctx context.Context, conn net.Conn, config *tls.Config,
+) (net.Conn, tls.ConnectionState, error) {
+ connID := connid.Compute(conn.RemoteAddr().Network(), conn.RemoteAddr().String())
+ root := modelx.ContextMeasurementRootOrDefault(ctx)
+ root.Handler.OnMeasurement(modelx.Measurement{
+ TLSHandshakeStart: &modelx.TLSHandshakeStartEvent{
+ ConnID: connID,
+ DurationSinceBeginning: time.Now().Sub(root.Beginning),
+ SNI: config.ServerName,
+ },
+ })
+ tlsconn, state, err := h.TLSHandshaker.Handshake(ctx, conn, config)
+ root.Handler.OnMeasurement(modelx.Measurement{
+ TLSHandshakeDone: &modelx.TLSHandshakeDoneEvent{
+ ConnID: connID,
+ ConnectionState: modelx.NewTLSConnectionState(state),
+ Error: err,
+ DurationSinceBeginning: time.Now().Sub(root.Beginning),
+ },
+ })
+ return tlsconn, state, err
+}
+
+// TLSDialer is the TLS dialer
+type TLSDialer struct {
+ Config *tls.Config
+ Dialer Dialer
+ TLSHandshaker TLSHandshaker
+}
+
+// DialTLSContext is like tls.DialTLS but with the signature of net.Dialer.DialContext
+func (d TLSDialer) DialTLSContext(ctx context.Context, network, address string) (net.Conn, error) {
+ // Implementation note: when DialTLS is not set, the code in
+ // net/http will perform the handshake. Otherwise, if DialTLS
+ // is set, we will end up here. This code is still used when
+ // performing non-HTTP TLS-enabled dial operations.
+ host, _, err := net.SplitHostPort(address)
+ if err != nil {
+ return nil, err
+ }
+ conn, err := d.Dialer.DialContext(ctx, network, address)
+ if err != nil {
+ return nil, err
+ }
+ config := d.Config
+ if config == nil {
+ config = new(tls.Config)
+ } else {
+ config = config.Clone()
+ }
+ if config.ServerName == "" {
+ config.ServerName = host
+ }
+ tlsconn, _, err := d.TLSHandshaker.Handshake(ctx, conn, config)
+ if err != nil {
+ conn.Close()
+ return nil, err
+ }
+ return tlsconn, nil
+}
diff --git a/internal/engine/netx/dialer/tls_test.go b/internal/engine/netx/dialer/tls_test.go
new file mode 100644
index 0000000..d7b16ac
--- /dev/null
+++ b/internal/engine/netx/dialer/tls_test.go
@@ -0,0 +1,277 @@
+package dialer_test
+
+import (
+ "context"
+ "crypto/tls"
+ "errors"
+ "io"
+ "net"
+ "testing"
+ "time"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/handlers"
+ "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/modelx"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx"
+)
+
+func TestSystemTLSHandshakerEOFError(t *testing.T) {
+ h := dialer.SystemTLSHandshaker{}
+ conn, _, err := h.Handshake(context.Background(), dialer.EOFConn{}, &tls.Config{
+ ServerName: "x.org",
+ })
+ if err != io.EOF {
+ t.Fatal("not the error that we expected")
+ }
+ if conn != nil {
+ t.Fatal("expected nil con here")
+ }
+}
+
+func TestTimeoutTLSHandshakerSetDeadlineError(t *testing.T) {
+ h := dialer.TimeoutTLSHandshaker{
+ TLSHandshaker: dialer.SystemTLSHandshaker{},
+ HandshakeTimeout: 200 * time.Millisecond,
+ }
+ expected := errors.New("mocked error")
+ conn, _, err := h.Handshake(
+ context.Background(), &dialer.FakeConn{SetDeadlineError: expected},
+ new(tls.Config))
+ if !errors.Is(err, expected) {
+ t.Fatal("not the error that we expected")
+ }
+ if conn != nil {
+ t.Fatal("expected nil con here")
+ }
+}
+
+func TestTimeoutTLSHandshakerEOFError(t *testing.T) {
+ h := dialer.TimeoutTLSHandshaker{
+ TLSHandshaker: dialer.SystemTLSHandshaker{},
+ HandshakeTimeout: 200 * time.Millisecond,
+ }
+ conn, _, err := h.Handshake(
+ context.Background(), dialer.EOFConn{}, &tls.Config{ServerName: "x.org"})
+ if !errors.Is(err, io.EOF) {
+ t.Fatal("not the error that we expected")
+ }
+ if conn != nil {
+ t.Fatal("expected nil con here")
+ }
+}
+
+func TestTimeoutTLSHandshakerCallsSetDeadline(t *testing.T) {
+ h := dialer.TimeoutTLSHandshaker{
+ TLSHandshaker: dialer.SystemTLSHandshaker{},
+ HandshakeTimeout: 200 * time.Millisecond,
+ }
+ underlying := &SetDeadlineConn{}
+ conn, _, err := h.Handshake(
+ context.Background(), underlying, &tls.Config{ServerName: "x.org"})
+ if !errors.Is(err, io.EOF) {
+ t.Fatal("not the error that we expected")
+ }
+ if conn != nil {
+ t.Fatal("expected nil con here")
+ }
+ if len(underlying.deadlines) != 2 {
+ t.Fatal("SetDeadline not called twice")
+ }
+ if underlying.deadlines[0].Before(time.Now()) {
+ t.Fatal("the first SetDeadline call was incorrect")
+ }
+ if !underlying.deadlines[1].IsZero() {
+ t.Fatal("the second SetDeadline call was incorrect")
+ }
+}
+
+type SetDeadlineConn struct {
+ dialer.EOFConn
+ deadlines []time.Time
+}
+
+func (c *SetDeadlineConn) SetDeadline(t time.Time) error {
+ c.deadlines = append(c.deadlines, t)
+ return nil
+}
+
+func TestErrorWrapperTLSHandshakerFailure(t *testing.T) {
+ h := dialer.ErrorWrapperTLSHandshaker{TLSHandshaker: dialer.EOFTLSHandshaker{}}
+ conn, _, err := h.Handshake(
+ context.Background(), dialer.EOFConn{}, new(tls.Config))
+ if !errors.Is(err, io.EOF) {
+ t.Fatal("not the error that we expected")
+ }
+ if conn != nil {
+ t.Fatal("expected nil con here")
+ }
+ var errWrapper *errorx.ErrWrapper
+ if !errors.As(err, &errWrapper) {
+ t.Fatal("cannot cast to ErrWrapper")
+ }
+ if errWrapper.ConnID == 0 {
+ t.Fatal("unexpected ConnID")
+ }
+ if errWrapper.Failure != errorx.FailureEOFError {
+ t.Fatal("unexpected Failure")
+ }
+ if errWrapper.Operation != errorx.TLSHandshakeOperation {
+ t.Fatal("unexpected Operation")
+ }
+}
+
+func TestEmitterTLSHandshakerFailure(t *testing.T) {
+ saver := &handlers.SavingHandler{}
+ ctx := modelx.WithMeasurementRoot(context.Background(), &modelx.MeasurementRoot{
+ Beginning: time.Now(),
+ Handler: saver,
+ })
+ h := dialer.EmitterTLSHandshaker{TLSHandshaker: dialer.EOFTLSHandshaker{}}
+ conn, _, err := h.Handshake(ctx, dialer.EOFConn{}, &tls.Config{
+ ServerName: "www.kernel.org",
+ })
+ if !errors.Is(err, io.EOF) {
+ t.Fatal("not the error that we expected")
+ }
+ if conn != nil {
+ t.Fatal("expected nil con here")
+ }
+ events := saver.Read()
+ if len(events) != 2 {
+ t.Fatal("Wrong number of events")
+ }
+ if events[0].TLSHandshakeStart == nil {
+ t.Fatal("missing TLSHandshakeStart event")
+ }
+ if events[0].TLSHandshakeStart.ConnID == 0 {
+ t.Fatal("expected nonzero ConnID")
+ }
+ if events[0].TLSHandshakeStart.DurationSinceBeginning == 0 {
+ t.Fatal("expected nonzero DurationSinceBeginning")
+ }
+ if events[0].TLSHandshakeStart.SNI != "www.kernel.org" {
+ t.Fatal("expected nonzero SNI")
+ }
+ if events[1].TLSHandshakeDone == nil {
+ t.Fatal("missing TLSHandshakeDone event")
+ }
+ if events[1].TLSHandshakeDone.ConnID == 0 {
+ t.Fatal("expected nonzero ConnID")
+ }
+ if events[1].TLSHandshakeDone.DurationSinceBeginning == 0 {
+ t.Fatal("expected nonzero DurationSinceBeginning")
+ }
+}
+
+func TestTLSDialerFailureSplitHostPort(t *testing.T) {
+ dialer := dialer.TLSDialer{}
+ conn, err := dialer.DialTLSContext(
+ context.Background(), "tcp", "www.google.com") // missing port
+ if err == nil {
+ t.Fatal("expected an error here")
+ }
+ if conn != nil {
+ t.Fatal("connection is not nil")
+ }
+}
+
+func TestTLSDialerFailureDialing(t *testing.T) {
+ dialer := dialer.TLSDialer{Dialer: dialer.EOFDialer{}}
+ conn, err := dialer.DialTLSContext(
+ context.Background(), "tcp", "www.google.com:443")
+ if !errors.Is(err, io.EOF) {
+ t.Fatal("expected an error here")
+ }
+ if conn != nil {
+ t.Fatal("connection is not nil")
+ }
+}
+
+func TestTLSDialerFailureHandshaking(t *testing.T) {
+ rec := &RecorderTLSHandshaker{TLSHandshaker: dialer.SystemTLSHandshaker{}}
+ dialer := dialer.TLSDialer{
+ Dialer: dialer.EOFConnDialer{},
+ TLSHandshaker: rec,
+ }
+ conn, err := dialer.DialTLSContext(
+ context.Background(), "tcp", "www.google.com:443")
+ if !errors.Is(err, io.EOF) {
+ t.Fatal("expected an error here")
+ }
+ if conn != nil {
+ t.Fatal("connection is not nil")
+ }
+ if rec.SNI != "www.google.com" {
+ t.Fatal("unexpected SNI value")
+ }
+}
+
+func TestTLSDialerFailureHandshakingOverrideSNI(t *testing.T) {
+ rec := &RecorderTLSHandshaker{TLSHandshaker: dialer.SystemTLSHandshaker{}}
+ dialer := dialer.TLSDialer{
+ Config: &tls.Config{
+ ServerName: "x.org",
+ },
+ Dialer: dialer.EOFConnDialer{},
+ TLSHandshaker: rec,
+ }
+ conn, err := dialer.DialTLSContext(
+ context.Background(), "tcp", "www.google.com:443")
+ if !errors.Is(err, io.EOF) {
+ t.Fatal("expected an error here")
+ }
+ if conn != nil {
+ t.Fatal("connection is not nil")
+ }
+ if rec.SNI != "x.org" {
+ t.Fatal("unexpected SNI value")
+ }
+}
+
+type RecorderTLSHandshaker struct {
+ dialer.TLSHandshaker
+ SNI string
+}
+
+func (h *RecorderTLSHandshaker) Handshake(
+ ctx context.Context, conn net.Conn, config *tls.Config,
+) (net.Conn, tls.ConnectionState, error) {
+ h.SNI = config.ServerName
+ return h.TLSHandshaker.Handshake(ctx, conn, config)
+}
+
+func TestDialTLSContextGood(t *testing.T) {
+ dialer := dialer.TLSDialer{
+ Config: &tls.Config{ServerName: "google.com"},
+ Dialer: new(net.Dialer),
+ TLSHandshaker: dialer.SystemTLSHandshaker{},
+ }
+ conn, err := dialer.DialTLSContext(context.Background(), "tcp", "google.com:443")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if conn == nil {
+ t.Fatal("connection is nil")
+ }
+ conn.Close()
+}
+
+func TestDialTLSContextTimeout(t *testing.T) {
+ dialer := dialer.TLSDialer{
+ Config: &tls.Config{ServerName: "google.com"},
+ Dialer: new(net.Dialer),
+ TLSHandshaker: dialer.ErrorWrapperTLSHandshaker{
+ TLSHandshaker: dialer.TimeoutTLSHandshaker{
+ TLSHandshaker: dialer.SystemTLSHandshaker{},
+ HandshakeTimeout: 10 * time.Microsecond,
+ },
+ },
+ }
+ conn, err := dialer.DialTLSContext(context.Background(), "tcp", "google.com:443")
+ if err.Error() != errorx.FailureGenericTimeoutError {
+ t.Fatal("not the error that we expected")
+ }
+ if conn != nil {
+ t.Fatal("connection is not nil")
+ }
+}
diff --git a/internal/engine/netx/errorx/errorx.go b/internal/engine/netx/errorx/errorx.go
new file mode 100644
index 0000000..fc72510
--- /dev/null
+++ b/internal/engine/netx/errorx/errorx.go
@@ -0,0 +1,322 @@
+// Package errorx contains error extensions
+package errorx
+
+import (
+ "context"
+ "crypto/x509"
+ "errors"
+ "fmt"
+ "regexp"
+ "strings"
+)
+
+const (
+ // FailureConnectionRefused means ECONNREFUSED.
+ FailureConnectionRefused = "connection_refused"
+
+ // FailureConnectionReset means ECONNRESET.
+ FailureConnectionReset = "connection_reset"
+
+ // FailureDNSBogonError means we detected bogon in DNS reply.
+ FailureDNSBogonError = "dns_bogon_error"
+
+ // FailureDNSNXDOMAINError means we got NXDOMAIN in DNS reply.
+ FailureDNSNXDOMAINError = "dns_nxdomain_error"
+
+ // FailureEOFError means we got unexpected EOF on connection.
+ FailureEOFError = "eof_error"
+
+ // FailureGenericTimeoutError means we got some timer has expired.
+ FailureGenericTimeoutError = "generic_timeout_error"
+
+ // FailureInterrupted means that the user interrupted us.
+ FailureInterrupted = "interrupted"
+
+ // FailureNoCompatibleQUICVersion means that the server does not support the proposed QUIC version
+ FailureNoCompatibleQUICVersion = "quic_incompatible_version"
+
+ // FailureSSLInvalidHostname means we got certificate is not valid for SNI.
+ FailureSSLInvalidHostname = "ssl_invalid_hostname"
+
+ // FailureSSLUnknownAuthority means we cannot find CA validating certificate.
+ FailureSSLUnknownAuthority = "ssl_unknown_authority"
+
+ // FailureSSLInvalidCertificate means certificate experired or other
+ // sort of errors causing it to be invalid.
+ FailureSSLInvalidCertificate = "ssl_invalid_certificate"
+
+ // FailureJSONParseError indicates that we couldn't parse a JSON
+ FailureJSONParseError = "json_parse_error"
+)
+
+const (
+ // ResolveOperation is the operation where we resolve a domain name
+ ResolveOperation = "resolve"
+
+ // ConnectOperation is the operation where we do a TCP connect
+ ConnectOperation = "connect"
+
+ // TLSHandshakeOperation is the TLS handshake
+ TLSHandshakeOperation = "tls_handshake"
+
+ // QUICHandshakeOperation is the handshake to setup a QUIC connection
+ QUICHandshakeOperation = "quic_handshake"
+
+ // HTTPRoundTripOperation is the HTTP round trip
+ HTTPRoundTripOperation = "http_round_trip"
+
+ // CloseOperation is when we close a socket
+ CloseOperation = "close"
+
+ // ReadOperation is when we read from a socket
+ ReadOperation = "read"
+
+ // WriteOperation is when we write to a socket
+ WriteOperation = "write"
+
+ // ReadFromOperation is when we read from an UDP socket
+ ReadFromOperation = "read_from"
+
+ // WriteToOperation is when we write to an UDP socket
+ WriteToOperation = "write_to"
+
+ // UnknownOperation is when we cannot determine the operation
+ UnknownOperation = "unknown"
+
+ // TopLevelOperation is used when the failure happens at top level. This
+ // happens for example with urlgetter with a cancelled context.
+ TopLevelOperation = "top_level"
+)
+
+// ErrDNSBogon indicates that we found a bogon address. This is the
+// correct value with which to initialize MeasurementRoot.ErrDNSBogon
+// to tell this library to return an error when a bogon is found.
+var ErrDNSBogon = errors.New("dns: detected bogon address")
+
+// ErrWrapper is our error wrapper for Go errors. The key objective of
+// this structure is to properly set Failure, which is also returned by
+// the Error() method, so be one of the OONI defined strings.
+type ErrWrapper struct {
+ // ConnID is the connection ID, or zero if not known.
+ ConnID int64
+
+ // DialID is the dial ID, or zero if not known.
+ DialID int64
+
+ // Failure is the OONI failure string. The failure strings are
+ // loosely backward compatible with Measurement Kit.
+ //
+ // This is either one of the FailureXXX strings or any other
+ // string like `unknown_failure ...`. The latter represents an
+ // error that we have not yet mapped to a failure.
+ Failure string
+
+ // Operation is the operation that failed. If possible, it
+ // SHOULD be a _major_ operation. Major operations are:
+ //
+ // - ResolveOperation: resolving a domain name failed
+ // - ConnectOperation: connecting to an IP failed
+ // - TLSHandshakeOperation: TLS handshaking failed
+ // - HTTPRoundTripOperation: other errors during round trip
+ //
+ // Because a network connection doesn't necessarily know
+ // what is the current major operation we also have the
+ // following _minor_ operations:
+ //
+ // - CloseOperation: CLOSE failed
+ // - ReadOperation: READ failed
+ // - WriteOperation: WRITE failed
+ //
+ // If an ErrWrapper referring to a major operation is wrapping
+ // another ErrWrapper and such ErrWrapper already refers to
+ // a major operation, then the new ErrWrapper should use the
+ // child ErrWrapper major operation. Otherwise, it should use
+ // its own major operation. This way, the topmost wrapper is
+ // supposed to refer to the major operation that failed.
+ Operation string
+
+ // TransactionID is the transaction ID, or zero if not known.
+ TransactionID int64
+
+ // WrappedErr is the error that we're wrapping.
+ WrappedErr error
+}
+
+// Error returns a description of the error that occurred.
+func (e *ErrWrapper) Error() string {
+ return e.Failure
+}
+
+// Unwrap allows to access the underlying error
+func (e *ErrWrapper) Unwrap() error {
+ return e.WrappedErr
+}
+
+// SafeErrWrapperBuilder contains a builder for ErrWrapper that
+// is safe, i.e., behaves correctly when the error is nil.
+type SafeErrWrapperBuilder struct {
+ // ConnID is the connection ID, if any
+ ConnID int64
+
+ // DialID is the dial ID, if any
+ DialID int64
+
+ // Error is the error, if any
+ Error error
+
+ // Operation is the operation that failed
+ Operation string
+
+ // TransactionID is the transaction ID, if any
+ TransactionID int64
+}
+
+// MaybeBuild builds a new ErrWrapper, if b.Error is not nil, and returns
+// a nil error value, instead, if b.Error is nil.
+func (b SafeErrWrapperBuilder) MaybeBuild() (err error) {
+ if b.Error != nil {
+ err = &ErrWrapper{
+ ConnID: b.ConnID,
+ DialID: b.DialID,
+ Failure: toFailureString(b.Error),
+ Operation: toOperationString(b.Error, b.Operation),
+ TransactionID: b.TransactionID,
+ WrappedErr: b.Error,
+ }
+ }
+ return
+}
+
+func toFailureString(err error) string {
+ // The list returned here matches the values used by MK unless
+ // explicitly noted otherwise with a comment.
+
+ var errwrapper *ErrWrapper
+ if errors.As(err, &errwrapper) {
+ return errwrapper.Error() // we've already wrapped it
+ }
+
+ if errors.Is(err, ErrDNSBogon) {
+ return FailureDNSBogonError // not in MK
+ }
+ if errors.Is(err, context.Canceled) {
+ return FailureInterrupted
+ }
+ var x509HostnameError x509.HostnameError
+ if errors.As(err, &x509HostnameError) {
+ // Test case: https://wrong.host.badssl.com/
+ return FailureSSLInvalidHostname
+ }
+ var x509UnknownAuthorityError x509.UnknownAuthorityError
+ if errors.As(err, &x509UnknownAuthorityError) {
+ // Test case: https://self-signed.badssl.com/. This error has
+ // never been among the ones returned by MK.
+ return FailureSSLUnknownAuthority
+ }
+ var x509CertificateInvalidError x509.CertificateInvalidError
+ if errors.As(err, &x509CertificateInvalidError) {
+ // Test case: https://expired.badssl.com/
+ return FailureSSLInvalidCertificate
+ }
+
+ s := err.Error()
+ if strings.HasSuffix(s, "operation was canceled") {
+ return FailureInterrupted
+ }
+ if strings.HasSuffix(s, "EOF") {
+ return FailureEOFError
+ }
+ if strings.HasSuffix(s, "connection refused") {
+ return FailureConnectionRefused
+ }
+ if strings.HasSuffix(s, "connection reset by peer") {
+ return FailureConnectionReset
+ }
+ if strings.HasSuffix(s, "context deadline exceeded") {
+ return FailureGenericTimeoutError
+ }
+ if strings.HasSuffix(s, "transaction is timed out") {
+ return FailureGenericTimeoutError
+ }
+ if strings.HasSuffix(s, "i/o timeout") {
+ return FailureGenericTimeoutError
+ }
+ if strings.HasSuffix(s, "TLS handshake timeout") {
+ return FailureGenericTimeoutError
+ }
+ if strings.HasSuffix(s, "no such host") {
+ // This is dns_lookup_error in MK but such error is used as a
+ // generic "hey, the lookup failed" error. Instead, this error
+ // that we return here is significantly more specific.
+ return FailureDNSNXDOMAINError
+ }
+
+ // TODO(kelmenhorst): see whether it is possible to match errors
+ // from qtls rather than strings for TLS errors below.
+ //
+ // TODO(kelmenhorst): make sure we have tests for all errors. Also,
+ // how to ensure we are robust to changes in other libs?
+ //
+ // special QUIC errors
+ matched, err := regexp.MatchString(`.*x509: certificate is valid for.*not.*`, s)
+ if matched {
+ return FailureSSLInvalidHostname
+ }
+ if strings.HasSuffix(s, "x509: certificate signed by unknown authority") {
+ return FailureSSLUnknownAuthority
+ }
+ certInvalidErrors := []string{"x509: certificate is not authorized to sign other certificates", "x509: certificate has expired or is not yet valid:", "x509: a root or intermediate certificate is not authorized to sign for this name:", "x509: a root or intermediate certificate is not authorized for an extended key usage:", "x509: too many intermediates for path length constraint", "x509: certificate specifies an incompatible key usage", "x509: issuer name does not match subject from issuing certificate", "x509: issuer has name constraints but leaf doesn't have a SAN extension", "x509: issuer has name constraints but leaf contains unknown or unconstrained name:"}
+ for _, errstr := range certInvalidErrors {
+ if strings.Contains(s, errstr) {
+ return FailureSSLInvalidCertificate
+ }
+ }
+ if strings.HasPrefix(s, "No compatible QUIC version found") {
+ return FailureNoCompatibleQUICVersion
+ }
+ if strings.HasSuffix(s, "Handshake did not complete in time") {
+ return FailureGenericTimeoutError
+ }
+ if strings.HasSuffix(s, "connection_refused") {
+ return FailureConnectionRefused
+ }
+ if strings.Contains(s, "stateless_reset") {
+ return FailureConnectionReset
+ }
+ if strings.Contains(s, "deadline exceeded") {
+ return FailureGenericTimeoutError
+ }
+ formatted := fmt.Sprintf("unknown_failure: %s", s)
+ return Scrub(formatted) // scrub IP addresses in the error
+}
+
+func toOperationString(err error, operation string) string {
+ var errwrapper *ErrWrapper
+ if errors.As(err, &errwrapper) {
+ // Basically, as explained in ErrWrapper docs, let's
+ // keep the child major operation, if any.
+ if errwrapper.Operation == ConnectOperation {
+ return errwrapper.Operation
+ }
+ if errwrapper.Operation == HTTPRoundTripOperation {
+ return errwrapper.Operation
+ }
+ if errwrapper.Operation == ResolveOperation {
+ return errwrapper.Operation
+ }
+ if errwrapper.Operation == TLSHandshakeOperation {
+ return errwrapper.Operation
+ }
+ if errwrapper.Operation == QUICHandshakeOperation {
+ return errwrapper.Operation
+ }
+ if errwrapper.Operation == "quic_handshake_start" {
+ return QUICHandshakeOperation
+ }
+ if errwrapper.Operation == "quic_handshake_done" {
+ return QUICHandshakeOperation
+ }
+ // FALLTHROUGH
+ }
+ return operation
+}
diff --git a/internal/engine/netx/errorx/errorx_test.go b/internal/engine/netx/errorx/errorx_test.go
new file mode 100644
index 0000000..9b69d2a
--- /dev/null
+++ b/internal/engine/netx/errorx/errorx_test.go
@@ -0,0 +1,247 @@
+package errorx
+
+import (
+ "context"
+ "crypto/tls"
+ "crypto/x509"
+ "errors"
+ "io"
+ "net"
+ "syscall"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/lucas-clemente/quic-go"
+ "github.com/pion/stun"
+)
+
+func TestMaybeBuildFactory(t *testing.T) {
+ err := SafeErrWrapperBuilder{
+ ConnID: 1,
+ DialID: 10,
+ Error: errors.New("mocked error"),
+ TransactionID: 100,
+ }.MaybeBuild()
+ var target *ErrWrapper
+ if errors.As(err, &target) == false {
+ t.Fatal("not the expected error type")
+ }
+ if target.ConnID != 1 {
+ t.Fatal("wrong ConnID")
+ }
+ if target.DialID != 10 {
+ t.Fatal("wrong DialID")
+ }
+ if target.Failure != "unknown_failure: mocked error" {
+ t.Fatal("the failure string is wrong")
+ }
+ if target.TransactionID != 100 {
+ t.Fatal("the transactionID is wrong")
+ }
+ if target.WrappedErr.Error() != "mocked error" {
+ t.Fatal("the wrapped error is wrong")
+ }
+}
+
+func TestToFailureString(t *testing.T) {
+ t.Run("for already wrapped error", func(t *testing.T) {
+ err := SafeErrWrapperBuilder{Error: io.EOF}.MaybeBuild()
+ if toFailureString(err) != FailureEOFError {
+ t.Fatal("unexpected result")
+ }
+ })
+ t.Run("for ErrDNSBogon", func(t *testing.T) {
+ if toFailureString(ErrDNSBogon) != FailureDNSBogonError {
+ t.Fatal("unexpected result")
+ }
+ })
+ t.Run("for context.Canceled", func(t *testing.T) {
+ if toFailureString(context.Canceled) != FailureInterrupted {
+ t.Fatal("unexpected result")
+ }
+ })
+ t.Run("for x509.HostnameError", func(t *testing.T) {
+ var err x509.HostnameError
+ if toFailureString(err) != FailureSSLInvalidHostname {
+ t.Fatal("unexpected result")
+ }
+ })
+ t.Run("for x509.UnknownAuthorityError", func(t *testing.T) {
+ var err x509.UnknownAuthorityError
+ if toFailureString(err) != FailureSSLUnknownAuthority {
+ t.Fatal("unexpected result")
+ }
+ })
+ t.Run("for x509.CertificateInvalidError", func(t *testing.T) {
+ var err x509.CertificateInvalidError
+ if toFailureString(err) != FailureSSLInvalidCertificate {
+ t.Fatal("unexpected result")
+ }
+ })
+ t.Run("for operation was canceled error", func(t *testing.T) {
+ if toFailureString(errors.New("operation was canceled")) != FailureInterrupted {
+ t.Fatal("unexpected result")
+ }
+ })
+ t.Run("for EOF", func(t *testing.T) {
+ if toFailureString(io.EOF) != FailureEOFError {
+ t.Fatal("unexpected results")
+ }
+ })
+ t.Run("for connection_refused", func(t *testing.T) {
+ if toFailureString(syscall.ECONNREFUSED) != FailureConnectionRefused {
+ t.Fatal("unexpected results")
+ }
+ })
+ t.Run("for connection_reset", func(t *testing.T) {
+ if toFailureString(syscall.ECONNRESET) != FailureConnectionReset {
+ t.Fatal("unexpected results")
+ }
+ })
+ t.Run("for context deadline exceeded", func(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 1)
+ defer cancel()
+ <-ctx.Done()
+ if toFailureString(ctx.Err()) != FailureGenericTimeoutError {
+ t.Fatal("unexpected results")
+ }
+ })
+ t.Run("for stun's transaction is timed out", func(t *testing.T) {
+ if toFailureString(stun.ErrTransactionTimeOut) != FailureGenericTimeoutError {
+ t.Fatal("unexpected results")
+ }
+ })
+ t.Run("for i/o error", func(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 1)
+ defer cancel() // fail immediately
+ conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", "www.google.com:80")
+ if err == nil {
+ t.Fatal("expected an error here")
+ }
+ if conn != nil {
+ t.Fatal("expected nil connection here")
+ }
+ if toFailureString(err) != FailureGenericTimeoutError {
+ t.Fatal("unexpected results")
+ }
+ })
+ t.Run("for TLS handshake timeout error", func(t *testing.T) {
+ err := errors.New("net/http: TLS handshake timeout")
+ if toFailureString(err) != FailureGenericTimeoutError {
+ t.Fatal("unexpected results")
+ }
+ })
+ t.Run("for no such host", func(t *testing.T) {
+ if toFailureString(&net.DNSError{
+ Err: "no such host",
+ }) != FailureDNSNXDOMAINError {
+ t.Fatal("unexpected results")
+ }
+ })
+ t.Run("for errors including IPv4 address", func(t *testing.T) {
+ input := errors.New("read tcp 10.0.2.15:56948->93.184.216.34:443: use of closed network connection")
+ expected := "unknown_failure: read tcp [scrubbed]->[scrubbed]: use of closed network connection"
+ out := toFailureString(input)
+ if out != expected {
+ t.Fatal(cmp.Diff(expected, out))
+ }
+ })
+ t.Run("for errors including IPv6 address", func(t *testing.T) {
+ input := errors.New("read tcp [::1]:56948->[::1]:443: use of closed network connection")
+ expected := "unknown_failure: read tcp [scrubbed]->[scrubbed]: use of closed network connection"
+ out := toFailureString(input)
+ if out != expected {
+ t.Fatal(cmp.Diff(expected, out))
+ }
+ })
+ // QUIC failures
+ t.Run("for connection_refused", func(t *testing.T) {
+ if toFailureString(errors.New("connection_refused")) != FailureConnectionRefused {
+ t.Fatal("unexpected results")
+ }
+ })
+ t.Run("for connection_reset", func(t *testing.T) {
+ if toFailureString(errors.New("stateless_reset")) != FailureConnectionReset {
+ t.Fatal("unexpected results")
+ }
+ })
+ t.Run("for incompatible quic version", func(t *testing.T) {
+ if toFailureString(errors.New("No compatible QUIC version found")) != FailureNoCompatibleQUICVersion {
+ t.Fatal("unexpected results")
+ }
+ })
+ t.Run("for i/o error", func(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 1)
+ defer cancel() // fail immediately
+ udpAddr := &net.UDPAddr{IP: net.ParseIP("216.58.212.164"), Port: 80, Zone: ""}
+ udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
+ sess, err := quic.DialEarlyContext(ctx, udpConn, udpAddr, "google.com:80", &tls.Config{}, &quic.Config{})
+ if err == nil {
+ t.Fatal("expected an error here")
+ }
+ if sess != nil {
+ t.Fatal("expected nil session here")
+ }
+ if toFailureString(err) != FailureGenericTimeoutError {
+ t.Fatal("unexpected results")
+ }
+ })
+ t.Run("for QUIC handshake timeout error", func(t *testing.T) {
+ err := errors.New("Handshake did not complete in time")
+ if toFailureString(err) != FailureGenericTimeoutError {
+ t.Fatal("unexpected results")
+ }
+ })
+}
+
+func TestToOperationString(t *testing.T) {
+ t.Run("for connect", func(t *testing.T) {
+ // You're doing HTTP and connect fails. You want to know
+ // that connect failed not that HTTP failed.
+ err := &ErrWrapper{Operation: ConnectOperation}
+ if toOperationString(err, HTTPRoundTripOperation) != ConnectOperation {
+ t.Fatal("unexpected result")
+ }
+ })
+ t.Run("for http_round_trip", func(t *testing.T) {
+ // You're doing DoH and something fails inside HTTP. You want
+ // to know about the internal HTTP error, not resolve.
+ err := &ErrWrapper{Operation: HTTPRoundTripOperation}
+ if toOperationString(err, ResolveOperation) != HTTPRoundTripOperation {
+ t.Fatal("unexpected result")
+ }
+ })
+ t.Run("for resolve", func(t *testing.T) {
+ // You're doing HTTP and the DNS fails. You want to
+ // know that resolve failed.
+ err := &ErrWrapper{Operation: ResolveOperation}
+ if toOperationString(err, HTTPRoundTripOperation) != ResolveOperation {
+ t.Fatal("unexpected result")
+ }
+ })
+ t.Run("for tls_handshake", func(t *testing.T) {
+ // You're doing HTTP and the TLS handshake fails. You want
+ // to know about a TLS handshake error.
+ err := &ErrWrapper{Operation: TLSHandshakeOperation}
+ if toOperationString(err, HTTPRoundTripOperation) != TLSHandshakeOperation {
+ t.Fatal("unexpected result")
+ }
+ })
+ t.Run("for minor operation", func(t *testing.T) {
+ // You just noticed that TLS handshake failed and you
+ // have a child error telling you that read failed. Here
+ // you want to know about a TLS handshake error.
+ err := &ErrWrapper{Operation: ReadOperation}
+ if toOperationString(err, TLSHandshakeOperation) != TLSHandshakeOperation {
+ t.Fatal("unexpected result")
+ }
+ })
+ t.Run("for quic_handshake", func(t *testing.T) {
+ // You're doing HTTP and the TLS handshake fails. You want
+ // to know about a TLS handshake error.
+ err := &ErrWrapper{Operation: QUICHandshakeOperation}
+ if toOperationString(err, HTTPRoundTripOperation) != QUICHandshakeOperation {
+ t.Fatal("unexpected result")
+ }
+ })
+}
diff --git a/internal/engine/netx/errorx/sanitizer.go b/internal/engine/netx/errorx/sanitizer.go
new file mode 100644
index 0000000..d181f31
--- /dev/null
+++ b/internal/engine/netx/errorx/sanitizer.go
@@ -0,0 +1,70 @@
+package errorx
+
+import "regexp"
+
+// The code in this file is adapted from github.com/keroserene/snowflake's
+// common/safelog/safelog.go implementation .
+//
+// ================================================================================
+// Copyright (c) 2016, Serene Han, Arlo Breault
+// Copyright (c) 2019-2020, The Tor Project, Inc
+//
+// Redistribution and use in source and binary forms, with or without modification,
+// are permitted provided that the following conditions are met:
+//
+// * Redistributions of source code must retain the above copyright notice, this
+// list of conditions and the following disclaimer.
+//
+// * Redistributions in binary form must reproduce the above copyright notice,
+// this list of conditions and the following disclaimer in the documentation and/or
+// other materials provided with the distribution.
+//
+// * Neither the names of the copyright owners nor the names of its
+// contributors may be used to endorse or promote products derived from this
+// software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+// ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+// ================================================================================
+
+const ipv4Address = `\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}`
+const ipv6Address = `([0-9a-fA-F]{0,4}:){5,7}([0-9a-fA-F]{0,4})?`
+const ipv6Compressed = `([0-9a-fA-F]{0,4}:){0,5}([0-9a-fA-F]{0,4})?(::)([0-9a-fA-F]{0,4}:){0,5}([0-9a-fA-F]{0,4})?`
+const ipv6Full = `(` + ipv6Address + `(` + ipv4Address + `))` +
+ `|(` + ipv6Compressed + `(` + ipv4Address + `))` +
+ `|(` + ipv6Address + `)` + `|(` + ipv6Compressed + `)`
+const optionalPort = `(:\d{1,5})?`
+const addressPattern = `((` + ipv4Address + `)|(\[(` + ipv6Full + `)\])|(` + ipv6Full + `))` + optionalPort
+const fullAddrPattern = `(^|\s|[^\w:])` + addressPattern + `(\s|(:\s)|[^\w:]|$)`
+
+var scrubberPatterns = []*regexp.Regexp{
+ regexp.MustCompile(fullAddrPattern),
+}
+
+var addressRegexp = regexp.MustCompile(addressPattern)
+
+func scrub(b []byte) []byte {
+ scrubbedBytes := b
+ for _, pattern := range scrubberPatterns {
+ // this is a workaround since go does not yet support look ahead or look
+ // behind for regular expressions.
+ scrubbedBytes = pattern.ReplaceAllFunc(scrubbedBytes, func(b []byte) []byte {
+ return addressRegexp.ReplaceAll(b, []byte("[scrubbed]"))
+ })
+ }
+ return scrubbedBytes
+}
+
+// Scrub sanitizes a string containing an error such that
+// any occurrence of IP endpoints is scrubbed
+func Scrub(s string) string {
+ return string(scrub([]byte(s)))
+}
diff --git a/internal/engine/netx/errorx/sanitizer_test.go b/internal/engine/netx/errorx/sanitizer_test.go
new file mode 100644
index 0000000..0b7692f
--- /dev/null
+++ b/internal/engine/netx/errorx/sanitizer_test.go
@@ -0,0 +1,129 @@
+package errorx
+
+import (
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+)
+
+// The code in this file is adapted from github.com/keroserene/snowflake's
+// common/safelog/safelog.go implementation .
+//
+// ================================================================================
+// Copyright (c) 2016, Serene Han, Arlo Breault
+// Copyright (c) 2019-2020, The Tor Project, Inc
+//
+// Redistribution and use in source and binary forms, with or without modification,
+// are permitted provided that the following conditions are met:
+//
+// * Redistributions of source code must retain the above copyright notice, this
+// list of conditions and the following disclaimer.
+//
+// * Redistributions in binary form must reproduce the above copyright notice,
+// this list of conditions and the following disclaimer in the documentation and/or
+// other materials provided with the distribution.
+//
+// * Neither the names of the copyright owners nor the names of its
+// contributors may be used to endorse or promote products derived from this
+// software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+// ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+// ================================================================================
+
+//Test the log scrubber on known problematic log messages
+func TestLogScrubberMessages(t *testing.T) {
+ for _, test := range []struct {
+ input, expected string
+ }{
+ {
+ "http: TLS handshake error from 129.97.208.23:38310: ",
+ "http: TLS handshake error from [scrubbed]: ",
+ },
+ {
+ "http2: panic serving [2620:101:f000:780:9097:75b1:519f:dbb8]:58344: interface conversion: *http2.responseWriter is not http.Hijacker: missing method Hijack",
+ "http2: panic serving [scrubbed]: interface conversion: *http2.responseWriter is not http.Hijacker: missing method Hijack",
+ },
+ {
+ //Make sure it doesn't scrub fingerprint
+ "a=fingerprint:sha-256 33:B6:FA:F6:94:CA:74:61:45:4A:D2:1F:2C:2F:75:8A:D9:EB:23:34:B2:30:E9:1B:2A:A6:A9:E0:44:72:CC:74",
+ "a=fingerprint:sha-256 33:B6:FA:F6:94:CA:74:61:45:4A:D2:1F:2C:2F:75:8A:D9:EB:23:34:B2:30:E9:1B:2A:A6:A9:E0:44:72:CC:74",
+ },
+ {
+ //try with enclosing parens
+ "(1:2:3:4:c:d:e:f) {1:2:3:4:c:d:e:f}",
+ "([scrubbed]) {[scrubbed]}",
+ },
+ {
+ //Make sure it doesn't scrub timestamps
+ "2019/05/08 15:37:31 starting",
+ "2019/05/08 15:37:31 starting",
+ },
+ } {
+ if Scrub(test.input) != test.expected {
+ t.Error(cmp.Diff(test.input, test.expected))
+ }
+ }
+}
+
+func TestLogScrubberGoodFormats(t *testing.T) {
+ for _, addr := range []string{
+ // IPv4
+ "1.2.3.4",
+ "255.255.255.255",
+ // IPv4 with port
+ "1.2.3.4:55",
+ "255.255.255.255:65535",
+ // IPv6
+ "1:2:3:4:c:d:e:f",
+ "1111:2222:3333:4444:CCCC:DDDD:EEEE:FFFF",
+ // IPv6 with brackets
+ "[1:2:3:4:c:d:e:f]",
+ "[1111:2222:3333:4444:CCCC:DDDD:EEEE:FFFF]",
+ // IPv6 with brackets and port
+ "[1:2:3:4:c:d:e:f]:55",
+ "[1111:2222:3333:4444:CCCC:DDDD:EEEE:FFFF]:65535",
+ // compressed IPv6
+ "::f",
+ "::d:e:f",
+ "1:2:3::",
+ "1:2:3::d:e:f",
+ "1:2:3:d:e:f::",
+ "::1:2:3:d:e:f",
+ "1111:2222:3333::DDDD:EEEE:FFFF",
+ // compressed IPv6 with brackets
+ "[::d:e:f]",
+ "[1:2:3::]",
+ "[1:2:3::d:e:f]",
+ "[1111:2222:3333::DDDD:EEEE:FFFF]",
+ "[1:2:3:4:5:6::8]",
+ "[1::7:8]",
+ // compressed IPv6 with brackets and port
+ "[1::]:58344",
+ "[::d:e:f]:55",
+ "[1:2:3::]:55",
+ "[1:2:3::d:e:f]:55",
+ "[1111:2222:3333::DDDD:EEEE:FFFF]:65535",
+ // IPv4-compatible and IPv4-mapped
+ "::255.255.255.255",
+ "::ffff:255.255.255.255",
+ "[::255.255.255.255]",
+ "[::ffff:255.255.255.255]",
+ "[::255.255.255.255]:65535",
+ "[::ffff:255.255.255.255]:65535",
+ "[::ffff:0:255.255.255.255]",
+ "[2001:db8:3:4::192.0.2.33]",
+ } {
+ if Scrub(addr) != "[scrubbed]" {
+ t.Error(cmp.Diff(addr, "[scrubbed]"))
+ }
+ }
+}
diff --git a/internal/engine/netx/fake_test.go b/internal/engine/netx/fake_test.go
new file mode 100644
index 0000000..2c3b464
--- /dev/null
+++ b/internal/engine/netx/fake_test.go
@@ -0,0 +1,56 @@
+package netx
+
+import (
+ "context"
+ "io/ioutil"
+ "net"
+ "net/http"
+ "time"
+)
+
+type FakeDialer struct {
+ Conn net.Conn
+ Err error
+}
+
+func (d FakeDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
+ time.Sleep(10 * time.Microsecond)
+ return d.Conn, d.Err
+}
+
+type FakeTransport struct {
+ Err error
+ Func func(*http.Request) (*http.Response, error)
+ Resp *http.Response
+}
+
+func (txp FakeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ time.Sleep(10 * time.Microsecond)
+ if txp.Func != nil {
+ return txp.Func(req)
+ }
+ if req.Body != nil {
+ ioutil.ReadAll(req.Body)
+ req.Body.Close()
+ }
+ if txp.Err != nil {
+ return nil, txp.Err
+ }
+ txp.Resp.Request = req // non thread safe but it doesn't matter
+ return txp.Resp, nil
+}
+
+func (txp FakeTransport) CloseIdleConnections() {}
+
+type FakeBody struct {
+ Err error
+}
+
+func (fb FakeBody) Read(p []byte) (int, error) {
+ time.Sleep(10 * time.Microsecond)
+ return 0, fb.Err
+}
+
+func (fb FakeBody) Close() error {
+ return nil
+}
diff --git a/internal/engine/netx/gocertifi/certifi.go b/internal/engine/netx/gocertifi/certifi.go
new file mode 100644
index 0000000..7de7804
--- /dev/null
+++ b/internal/engine/netx/gocertifi/certifi.go
@@ -0,0 +1,3250 @@
+// Code generated by go generate; DO NOT EDIT.
+// 2021-01-29 09:54:51.941105652 +0100 CET m=+1.231498959
+// https://curl.haxx.se/ca/cacert.pem
+
+package gocertifi
+
+//go:generate go run generate.go "https://curl.haxx.se/ca/cacert.pem"
+
+import "crypto/x509"
+
+const pemcerts string = `
+##
+## Bundle of CA Root Certificates
+##
+## Certificate data from Mozilla as of: Tue Jan 19 04:12:04 2021 GMT
+##
+## This is a bundle of X.509 certificates of public Certificate Authorities
+## (CA). These were automatically extracted from Mozilla's root certificates
+## file (certdata.txt). This file can be found in the mozilla source tree:
+## https://hg.mozilla.org/releases/mozilla-release/raw-file/default/security/nss/lib/ckfw/builtins/certdata.txt
+##
+## It contains the certificates in PEM format and therefore
+## can be directly used with curl / libcurl / php_curl, or with
+## an Apache+mod_ssl webserver for SSL client authentication.
+## Just configure this file as the SSLCACertificateFile.
+##
+## Conversion done with mk-ca-bundle.pl version 1.28.
+## SHA256: 3bdc63d1de27058fec943a999a2a8a01fcc6806a611b19221a7727d3d9bbbdfd
+##
+
+
+GlobalSign Root CA
+==================
+-----BEGIN CERTIFICATE-----
+MIIDdTCCAl2gAwIBAgILBAAAAAABFUtaw5QwDQYJKoZIhvcNAQEFBQAwVzELMAkGA1UEBhMCQkUx
+GTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExEDAOBgNVBAsTB1Jvb3QgQ0ExGzAZBgNVBAMTEkds
+b2JhbFNpZ24gUm9vdCBDQTAeFw05ODA5MDExMjAwMDBaFw0yODAxMjgxMjAwMDBaMFcxCzAJBgNV
+BAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMRAwDgYDVQQLEwdSb290IENBMRswGQYD
+VQQDExJHbG9iYWxTaWduIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDa
+DuaZjc6j40+Kfvvxi4Mla+pIH/EqsLmVEQS98GPR4mdmzxzdzxtIK+6NiY6arymAZavpxy0Sy6sc
+THAHoT0KMM0VjU/43dSMUBUc71DuxC73/OlS8pF94G3VNTCOXkNz8kHp1Wrjsok6Vjk4bwY8iGlb
+Kk3Fp1S4bInMm/k8yuX9ifUSPJJ4ltbcdG6TRGHRjcdGsnUOhugZitVtbNV4FpWi6cgKOOvyJBNP
+c1STE4U6G7weNLWLBYy5d4ux2x8gkasJU26Qzns3dLlwR5EiUWMWea6xrkEmCMgZK9FGqkjWZCrX
+gzT/LCrBbBlDSgeF59N89iFo7+ryUp9/k5DPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
+HRMBAf8EBTADAQH/MB0GA1UdDgQWBBRge2YaRQ2XyolQL30EzTSo//z9SzANBgkqhkiG9w0BAQUF
+AAOCAQEA1nPnfE920I2/7LqivjTFKDK1fPxsnCwrvQmeU79rXqoRSLblCKOzyj1hTdNGCbM+w6Dj
+Y1Ub8rrvrTnhQ7k4o+YviiY776BQVvnGCv04zcQLcFGUl5gE38NflNUVyRRBnMRddWQVDf9VMOyG
+j/8N7yy5Y0b2qvzfvGn9LhJIZJrglfCm7ymPAbEVtQwdpf5pLGkkeB6zpxxxYu7KyJesF12KwvhH
+hm4qxFYxldBniYUr+WymXUadDKqC5JlR3XC321Y9YeRq4VzW9v493kHMB65jUr9TU/Qr6cf9tveC
+X4XSQRjbgbMEHMUfpIBvFSDJ3gyICh3WZlXi/EjJKSZp4A==
+-----END CERTIFICATE-----
+
+GlobalSign Root CA - R2
+=======================
+-----BEGIN CERTIFICATE-----
+MIIDujCCAqKgAwIBAgILBAAAAAABD4Ym5g0wDQYJKoZIhvcNAQEFBQAwTDEgMB4GA1UECxMXR2xv
+YmFsU2lnbiBSb290IENBIC0gUjIxEzARBgNVBAoTCkdsb2JhbFNpZ24xEzARBgNVBAMTCkdsb2Jh
+bFNpZ24wHhcNMDYxMjE1MDgwMDAwWhcNMjExMjE1MDgwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxT
+aWduIFJvb3QgQ0EgLSBSMjETMBEGA1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2ln
+bjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKbPJA6+Lm8omUVCxKs+IVSbC9N/hHD6
+ErPLv4dfxn+G07IwXNb9rfF73OX4YJYJkhD10FPe+3t+c4isUoh7SqbKSaZeqKeMWhG8eoLrvozp
+s6yWJQeXSpkqBy+0Hne/ig+1AnwblrjFuTosvNYSuetZfeLQBoZfXklqtTleiDTsvHgMCJiEbKjN
+S7SgfQx5TfC4LcshytVsW33hoCmEofnTlEnLJGKRILzdC9XZzPnqJworc5HGnRusyMvo4KD0L5CL
+TfuwNhv2GXqF4G3yYROIXJ/gkwpRl4pazq+r1feqCapgvdzZX99yqWATXgAByUr6P6TqBwMhAo6C
+ygPCm48CAwEAAaOBnDCBmTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E
+FgQUm+IHV2ccHsBqBt5ZtJot39wZhi4wNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL2NybC5nbG9i
+YWxzaWduLm5ldC9yb290LXIyLmNybDAfBgNVHSMEGDAWgBSb4gdXZxwewGoG3lm0mi3f3BmGLjAN
+BgkqhkiG9w0BAQUFAAOCAQEAmYFThxxol4aR7OBKuEQLq4GsJ0/WwbgcQ3izDJr86iw8bmEbTUsp
+9Z8FHSbBuOmDAGJFtqkIk7mpM0sYmsL4h4hO291xNBrBVNpGP+DTKqttVCL1OmLNIG+6KYnX3ZHu
+01yiPqFbQfXf5WRDLenVOavSot+3i9DAgBkcRcAtjOj4LaR0VknFBbVPFd5uRHg5h6h+u/N5GJG7
+9G+dwfCMNYxdAfvDbbnvRG15RjF+Cv6pgsH/76tuIMRQyV+dTZsXjAzlAcmgQWpzU/qlULRuJQ/7
+TBj0/VLZjmmx6BEP3ojY+x1J96relc8geMJgEtslQIxq/H5COEBkEveegeGTLg==
+-----END CERTIFICATE-----
+
+Entrust.net Premium 2048 Secure Server CA
+=========================================
+-----BEGIN CERTIFICATE-----
+MIIEKjCCAxKgAwIBAgIEOGPe+DANBgkqhkiG9w0BAQUFADCBtDEUMBIGA1UEChMLRW50cnVzdC5u
+ZXQxQDA+BgNVBAsUN3d3dy5lbnRydXN0Lm5ldC9DUFNfMjA0OCBpbmNvcnAuIGJ5IHJlZi4gKGxp
+bWl0cyBsaWFiLikxJTAjBgNVBAsTHChjKSAxOTk5IEVudHJ1c3QubmV0IExpbWl0ZWQxMzAxBgNV
+BAMTKkVudHJ1c3QubmV0IENlcnRpZmljYXRpb24gQXV0aG9yaXR5ICgyMDQ4KTAeFw05OTEyMjQx
+NzUwNTFaFw0yOTA3MjQxNDE1MTJaMIG0MRQwEgYDVQQKEwtFbnRydXN0Lm5ldDFAMD4GA1UECxQ3
+d3d3LmVudHJ1c3QubmV0L0NQU18yMDQ4IGluY29ycC4gYnkgcmVmLiAobGltaXRzIGxpYWIuKTEl
+MCMGA1UECxMcKGMpIDE5OTkgRW50cnVzdC5uZXQgTGltaXRlZDEzMDEGA1UEAxMqRW50cnVzdC5u
+ZXQgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgKDIwNDgpMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
+MIIBCgKCAQEArU1LqRKGsuqjIAcVFmQqK0vRvwtKTY7tgHalZ7d4QMBzQshowNtTK91euHaYNZOL
+Gp18EzoOH1u3Hs/lJBQesYGpjX24zGtLA/ECDNyrpUAkAH90lKGdCCmziAv1h3edVc3kw37XamSr
+hRSGlVuXMlBvPci6Zgzj/L24ScF2iUkZ/cCovYmjZy/Gn7xxGWC4LeksyZB2ZnuU4q941mVTXTzW
+nLLPKQP5L6RQstRIzgUyVYr9smRMDuSYB3Xbf9+5CFVghTAp+XtIpGmG4zU/HoZdenoVve8AjhUi
+VBcAkCaTvA5JaJG/+EfTnZVCwQ5N328mz8MYIWJmQ3DW1cAH4QIDAQABo0IwQDAOBgNVHQ8BAf8E
+BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUVeSB0RGAvtiJuQijMfmhJAkWuXAwDQYJ
+KoZIhvcNAQEFBQADggEBADubj1abMOdTmXx6eadNl9cZlZD7Bh/KM3xGY4+WZiT6QBshJ8rmcnPy
+T/4xmf3IDExoU8aAghOY+rat2l098c5u9hURlIIM7j+VrxGrD9cv3h8Dj1csHsm7mhpElesYT6Yf
+zX1XEC+bBAlahLVu2B064dae0Wx5XnkcFMXj0EyTO2U87d89vqbllRrDtRnDvV5bu/8j72gZyxKT
+J1wDLW8w0B62GqzeWvfRqqgnpv55gcR5mTNXuhKwqeBCbJPKVt7+bYQLCIt+jerXmCHG8+c8eS9e
+nNFMFY3h7CI3zJpDC5fcgJCNs2ebb0gIFVbPv/ErfF6adulZkMV8gzURZVE=
+-----END CERTIFICATE-----
+
+Baltimore CyberTrust Root
+=========================
+-----BEGIN CERTIFICATE-----
+MIIDdzCCAl+gAwIBAgIEAgAAuTANBgkqhkiG9w0BAQUFADBaMQswCQYDVQQGEwJJRTESMBAGA1UE
+ChMJQmFsdGltb3JlMRMwEQYDVQQLEwpDeWJlclRydXN0MSIwIAYDVQQDExlCYWx0aW1vcmUgQ3li
+ZXJUcnVzdCBSb290MB4XDTAwMDUxMjE4NDYwMFoXDTI1MDUxMjIzNTkwMFowWjELMAkGA1UEBhMC
+SUUxEjAQBgNVBAoTCUJhbHRpbW9yZTETMBEGA1UECxMKQ3liZXJUcnVzdDEiMCAGA1UEAxMZQmFs
+dGltb3JlIEN5YmVyVHJ1c3QgUm9vdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKME
+uyKrmD1X6CZymrV51Cni4eiVgLGw41uOKymaZN+hXe2wCQVt2yguzmKiYv60iNoS6zjrIZ3AQSsB
+UnuId9Mcj8e6uYi1agnnc+gRQKfRzMpijS3ljwumUNKoUMMo6vWrJYeKmpYcqWe4PwzV9/lSEy/C
+G9VwcPCPwBLKBsua4dnKM3p31vjsufFoREJIE9LAwqSuXmD+tqYF/LTdB1kC1FkYmGP1pWPgkAx9
+XbIGevOF6uvUA65ehD5f/xXtabz5OTZydc93Uk3zyZAsuT3lySNTPx8kmCFcB5kpvcY67Oduhjpr
+l3RjM71oGDHweI12v/yejl0qhqdNkNwnGjkCAwEAAaNFMEMwHQYDVR0OBBYEFOWdWTCCR1jMrPoI
+VDaGezq1BE3wMBIGA1UdEwEB/wQIMAYBAf8CAQMwDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEB
+BQUAA4IBAQCFDF2O5G9RaEIFoN27TyclhAO992T9Ldcw46QQF+vaKSm2eT929hkTI7gQCvlYpNRh
+cL0EYWoSihfVCr3FvDB81ukMJY2GQE/szKN+OMY3EU/t3WgxjkzSswF07r51XgdIGn9w/xZchMB5
+hbgF/X++ZRGjD8ACtPhSNzkE1akxehi/oCr0Epn3o0WC4zxe9Z2etciefC7IpJ5OCBRLbf1wbWsa
+Y71k5h+3zvDyny67G7fyUIhzksLi4xaNmjICq44Y3ekQEe5+NauQrz4wlHrQMz2nZQ/1/I6eYs9H
+RCwBXbsdtTLSR9I4LtD+gdwyah617jzV/OeBHRnDJELqYzmp
+-----END CERTIFICATE-----
+
+Entrust Root Certification Authority
+====================================
+-----BEGIN CERTIFICATE-----
+MIIEkTCCA3mgAwIBAgIERWtQVDANBgkqhkiG9w0BAQUFADCBsDELMAkGA1UEBhMCVVMxFjAUBgNV
+BAoTDUVudHJ1c3QsIEluYy4xOTA3BgNVBAsTMHd3dy5lbnRydXN0Lm5ldC9DUFMgaXMgaW5jb3Jw
+b3JhdGVkIGJ5IHJlZmVyZW5jZTEfMB0GA1UECxMWKGMpIDIwMDYgRW50cnVzdCwgSW5jLjEtMCsG
+A1UEAxMkRW50cnVzdCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTA2MTEyNzIwMjM0
+MloXDTI2MTEyNzIwNTM0MlowgbAxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1FbnRydXN0LCBJbmMu
+MTkwNwYDVQQLEzB3d3cuZW50cnVzdC5uZXQvQ1BTIGlzIGluY29ycG9yYXRlZCBieSByZWZlcmVu
+Y2UxHzAdBgNVBAsTFihjKSAyMDA2IEVudHJ1c3QsIEluYy4xLTArBgNVBAMTJEVudHJ1c3QgUm9v
+dCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
+ALaVtkNC+sZtKm9I35RMOVcF7sN5EUFoNu3s/poBj6E4KPz3EEZmLk0eGrEaTsbRwJWIsMn/MYsz
+A9u3g3s+IIRe7bJWKKf44LlAcTfFy0cOlypowCKVYhXbR9n10Cv/gkvJrT7eTNuQgFA/CYqEAOww
+Cj0Yzfv9KlmaI5UXLEWeH25DeW0MXJj+SKfFI0dcXv1u5x609mhF0YaDW6KKjbHjKYD+JXGIrb68
+j6xSlkuqUY3kEzEZ6E5Nn9uss2rVvDlUccp6en+Q3X0dgNmBu1kmwhH+5pPi94DkZfs0Nw4pgHBN
+rziGLp5/V6+eF67rHMsoIV+2HNjnogQi+dPa2MsCAwEAAaOBsDCBrTAOBgNVHQ8BAf8EBAMCAQYw
+DwYDVR0TAQH/BAUwAwEB/zArBgNVHRAEJDAigA8yMDA2MTEyNzIwMjM0MlqBDzIwMjYxMTI3MjA1
+MzQyWjAfBgNVHSMEGDAWgBRokORnpKZTgMeGZqTx90tD+4S9bTAdBgNVHQ4EFgQUaJDkZ6SmU4DH
+hmak8fdLQ/uEvW0wHQYJKoZIhvZ9B0EABBAwDhsIVjcuMTo0LjADAgSQMA0GCSqGSIb3DQEBBQUA
+A4IBAQCT1DCw1wMgKtD5Y+iRDAUgqV8ZyntyTtSx29CW+1RaGSwMCPeyvIWonX9tO1KzKtvn1ISM
+Y/YPyyYBkVBs9F8U4pN0wBOeMDpQ47RgxRzwIkSNcUesyBrJ6ZuaAGAT/3B+XxFNSRuzFVJ7yVTa
+v52Vr2ua2J7p8eRDjeIRRDq/r72DQnNSi6q7pynP9WQcCk3RvKqsnyrQ/39/2n3qse0wJcGE2jTS
+W3iDVuycNsMm4hH2Z0kdkquM++v/eu6FSqdQgPCnXEqULl8FmTxSQeDNtGPPAUO6nIPcj2A781q0
+tHuu2guQOHXvgR1m0vdXcDazv/wor3ElhVsT/h5/WrQ8
+-----END CERTIFICATE-----
+
+Comodo AAA Services root
+========================
+-----BEGIN CERTIFICATE-----
+MIIEMjCCAxqgAwIBAgIBATANBgkqhkiG9w0BAQUFADB7MQswCQYDVQQGEwJHQjEbMBkGA1UECAwS
+R3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0Eg
+TGltaXRlZDEhMB8GA1UEAwwYQUFBIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTA0MDEwMTAwMDAw
+MFoXDTI4MTIzMTIzNTk1OVowezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hl
+c3RlcjEQMA4GA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV
+BAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC
+ggEBAL5AnfRu4ep2hxxNRUSOvkbIgwadwSr+GB+O5AL686tdUIoWMQuaBtDFcCLNSS1UY8y2bmhG
+C1Pqy0wkwLxyTurxFa70VJoSCsN6sjNg4tqJVfMiWPPe3M/vg4aijJRPn2jymJBGhCfHdr/jzDUs
+i14HZGWCwEiwqJH5YZ92IFCokcdmtet4YgNW8IoaE+oxox6gmf049vYnMlhvB/VruPsUK6+3qszW
+Y19zjNoFmag4qMsXeDZRrOme9Hg6jc8P2ULimAyrL58OAd7vn5lJ8S3frHRNG5i1R8XlKdH5kBjH
+Ypy+g8cmez6KJcfA3Z3mNWgQIJ2P2N7Sw4ScDV7oL8kCAwEAAaOBwDCBvTAdBgNVHQ4EFgQUoBEK
+Iz6W8Qfs4q8p74Klf9AwpLQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wewYDVR0f
+BHQwcjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20vQUFBQ2VydGlmaWNhdGVTZXJ2aWNl
+cy5jcmwwNqA0oDKGMGh0dHA6Ly9jcmwuY29tb2RvLm5ldC9BQUFDZXJ0aWZpY2F0ZVNlcnZpY2Vz
+LmNybDANBgkqhkiG9w0BAQUFAAOCAQEACFb8AvCb6P+k+tZ7xkSAzk/ExfYAWMymtrwUSWgEdujm
+7l3sAg9g1o1QGE8mTgHj5rCl7r+8dFRBv/38ErjHT1r0iWAFf2C3BUrz9vHCv8S5dIa2LX1rzNLz
+Rt0vxuBqw8M0Ayx9lt1awg6nCpnBBYurDC/zXDrPbDdVCYfeU0BsWO/8tqtlbgT2G9w84FoVxp7Z
+8VlIMCFlA2zs6SFz7JsDoeA3raAVGI/6ugLOpyypEBMs1OUIJqsil2D4kF501KKaU73yqWjgom7C
+12yxow+ev+to51byrvLjKzg6CYG1a4XXvi3tPxq3smPi9WIsgtRqAEFQ8TmDn5XpNpaYbg==
+-----END CERTIFICATE-----
+
+QuoVadis Root CA
+================
+-----BEGIN CERTIFICATE-----
+MIIF0DCCBLigAwIBAgIEOrZQizANBgkqhkiG9w0BAQUFADB/MQswCQYDVQQGEwJCTTEZMBcGA1UE
+ChMQUXVvVmFkaXMgTGltaXRlZDElMCMGA1UECxMcUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0
+eTEuMCwGA1UEAxMlUXVvVmFkaXMgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw0wMTAz
+MTkxODMzMzNaFw0yMTAzMTcxODMzMzNaMH8xCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRp
+cyBMaW1pdGVkMSUwIwYDVQQLExxSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MS4wLAYDVQQD
+EyVRdW9WYWRpcyBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIIBIjANBgkqhkiG9w0BAQEF
+AAOCAQ8AMIIBCgKCAQEAv2G1lVO6V/z68mcLOhrfEYBklbTRvM16z/Ypli4kVEAkOPcahdxYTMuk
+J0KX0J+DisPkBgNbAKVRHnAEdOLB1Dqr1607BxgFjv2DrOpm2RgbaIr1VxqYuvXtdj182d6UajtL
+F8HVj71lODqV0D1VNk7feVcxKh7YWWVJWCCYfqtffp/p1k3sg3Spx2zY7ilKhSoGFPlU5tPaZQeL
+YzcS19Dsw3sgQUSj7cugF+FxZc4dZjH3dgEZyH0DWLaVSR2mEiboxgx24ONmy+pdpibu5cxfvWen
+AScOospUxbF6lR1xHkopigPcakXBpBlebzbNw6Kwt/5cOOJSvPhEQ+aQuwIDAQABo4ICUjCCAk4w
+PQYIKwYBBQUHAQEEMTAvMC0GCCsGAQUFBzABhiFodHRwczovL29jc3AucXVvdmFkaXNvZmZzaG9y
+ZS5jb20wDwYDVR0TAQH/BAUwAwEB/zCCARoGA1UdIASCAREwggENMIIBCQYJKwYBBAG+WAABMIH7
+MIHUBggrBgEFBQcCAjCBxxqBxFJlbGlhbmNlIG9uIHRoZSBRdW9WYWRpcyBSb290IENlcnRpZmlj
+YXRlIGJ5IGFueSBwYXJ0eSBhc3N1bWVzIGFjY2VwdGFuY2Ugb2YgdGhlIHRoZW4gYXBwbGljYWJs
+ZSBzdGFuZGFyZCB0ZXJtcyBhbmQgY29uZGl0aW9ucyBvZiB1c2UsIGNlcnRpZmljYXRpb24gcHJh
+Y3RpY2VzLCBhbmQgdGhlIFF1b1ZhZGlzIENlcnRpZmljYXRlIFBvbGljeS4wIgYIKwYBBQUHAgEW
+Fmh0dHA6Ly93d3cucXVvdmFkaXMuYm0wHQYDVR0OBBYEFItLbe3TKbkGGew5Oanwl4Rqy+/fMIGu
+BgNVHSMEgaYwgaOAFItLbe3TKbkGGew5Oanwl4Rqy+/foYGEpIGBMH8xCzAJBgNVBAYTAkJNMRkw
+FwYDVQQKExBRdW9WYWRpcyBMaW1pdGVkMSUwIwYDVQQLExxSb290IENlcnRpZmljYXRpb24gQXV0
+aG9yaXR5MS4wLAYDVQQDEyVRdW9WYWRpcyBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5ggQ6
+tlCLMA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQUFAAOCAQEAitQUtf70mpKnGdSkfnIYj9lo
+fFIk3WdvOXrEql494liwTXCYhGHoG+NpGA7O+0dQoE7/8CQfvbLO9Sf87C9TqnN7Az10buYWnuul
+LsS/VidQK2K6vkscPFVcQR0kvoIgR13VRH56FmjffU1RcHhXHTMe/QKZnAzNCgVPx7uOpHX6Sm2x
+gI4JVrmcGmD+XcHXetwReNDWXcG31a0ymQM6isxUJTkxgXsTIlG6Rmyhu576BGxJJnSP0nPrzDCi
+5upZIof4l/UO/erMkqQWxFIY6iHOsfHmhIHluqmGKPJDWl0Snawe2ajlCmqnf6CHKc/yiU3U7MXi
+5nrQNiOKSnQ2+Q==
+-----END CERTIFICATE-----
+
+QuoVadis Root CA 2
+==================
+-----BEGIN CERTIFICATE-----
+MIIFtzCCA5+gAwIBAgICBQkwDQYJKoZIhvcNAQEFBQAwRTELMAkGA1UEBhMCQk0xGTAXBgNVBAoT
+EFF1b1ZhZGlzIExpbWl0ZWQxGzAZBgNVBAMTElF1b1ZhZGlzIFJvb3QgQ0EgMjAeFw0wNjExMjQx
+ODI3MDBaFw0zMTExMjQxODIzMzNaMEUxCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBM
+aW1pdGVkMRswGQYDVQQDExJRdW9WYWRpcyBSb290IENBIDIwggIiMA0GCSqGSIb3DQEBAQUAA4IC
+DwAwggIKAoICAQCaGMpLlA0ALa8DKYrwD4HIrkwZhR0In6spRIXzL4GtMh6QRr+jhiYaHv5+HBg6
+XJxgFyo6dIMzMH1hVBHL7avg5tKifvVrbxi3Cgst/ek+7wrGsxDp3MJGF/hd/aTa/55JWpzmM+Yk
+lvc/ulsrHHo1wtZn/qtmUIttKGAr79dgw8eTvI02kfN/+NsRE8Scd3bBrrcCaoF6qUWD4gXmuVbB
+lDePSHFjIuwXZQeVikvfj8ZaCuWw419eaxGrDPmF60Tp+ARz8un+XJiM9XOva7R+zdRcAitMOeGy
+lZUtQofX1bOQQ7dsE/He3fbE+Ik/0XX1ksOR1YqI0JDs3G3eicJlcZaLDQP9nL9bFqyS2+r+eXyt
+66/3FsvbzSUr5R/7mp/iUcw6UwxI5g69ybR2BlLmEROFcmMDBOAENisgGQLodKcftslWZvB1Jdxn
+wQ5hYIizPtGo/KPaHbDRsSNU30R2be1B2MGyIrZTHN81Hdyhdyox5C315eXbyOD/5YDXC2Og/zOh
+D7osFRXql7PSorW+8oyWHhqPHWykYTe5hnMz15eWniN9gqRMgeKh0bpnX5UHoycR7hYQe7xFSkyy
+BNKr79X9DFHOUGoIMfmR2gyPZFwDwzqLID9ujWc9Otb+fVuIyV77zGHcizN300QyNQliBJIWENie
+J0f7OyHj+OsdWwIDAQABo4GwMIGtMA8GA1UdEwEB/wQFMAMBAf8wCwYDVR0PBAQDAgEGMB0GA1Ud
+DgQWBBQahGK8SEwzJQTU7tD2A8QZRtGUazBuBgNVHSMEZzBlgBQahGK8SEwzJQTU7tD2A8QZRtGU
+a6FJpEcwRTELMAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxGzAZBgNVBAMT
+ElF1b1ZhZGlzIFJvb3QgQ0EgMoICBQkwDQYJKoZIhvcNAQEFBQADggIBAD4KFk2fBluornFdLwUv
+Z+YTRYPENvbzwCYMDbVHZF34tHLJRqUDGCdViXh9duqWNIAXINzng/iN/Ae42l9NLmeyhP3ZRPx3
+UIHmfLTJDQtyU/h2BwdBR5YM++CCJpNVjP4iH2BlfF/nJrP3MpCYUNQ3cVX2kiF495V5+vgtJodm
+VjB3pjd4M1IQWK4/YY7yarHvGH5KWWPKjaJW1acvvFYfzznB4vsKqBUsfU16Y8Zsl0Q80m/DShcK
++JDSV6IZUaUtl0HaB0+pUNqQjZRG4T7wlP0QADj1O+hA4bRuVhogzG9Yje0uRY/W6ZM/57Es3zrW
+IozchLsib9D45MY56QSIPMO661V6bYCZJPVsAfv4l7CUW+v90m/xd2gNNWQjrLhVoQPRTUIZ3Ph1
+WVaj+ahJefivDrkRoHy3au000LYmYjgahwz46P0u05B/B5EqHdZ+XIWDmbA4CD/pXvk1B+TJYm5X
+f6dQlfe6yJvmjqIBxdZmv3lh8zwc4bmCXF2gw+nYSL0ZohEUGW6yhhtoPkg3Goi3XZZenMfvJ2II
+4pEZXNLxId26F0KCl3GBUzGpn/Z9Yr9y4aOTHcyKJloJONDO1w2AFrR4pTqHTI2KpdVGl/IsELm8
+VCLAAVBpQ570su9t+Oza8eOx79+Rj1QqCyXBJhnEUhAFZdWCEOrCMc0u
+-----END CERTIFICATE-----
+
+QuoVadis Root CA 3
+==================
+-----BEGIN CERTIFICATE-----
+MIIGnTCCBIWgAwIBAgICBcYwDQYJKoZIhvcNAQEFBQAwRTELMAkGA1UEBhMCQk0xGTAXBgNVBAoT
+EFF1b1ZhZGlzIExpbWl0ZWQxGzAZBgNVBAMTElF1b1ZhZGlzIFJvb3QgQ0EgMzAeFw0wNjExMjQx
+OTExMjNaFw0zMTExMjQxOTA2NDRaMEUxCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBM
+aW1pdGVkMRswGQYDVQQDExJRdW9WYWRpcyBSb290IENBIDMwggIiMA0GCSqGSIb3DQEBAQUAA4IC
+DwAwggIKAoICAQDMV0IWVJzmmNPTTe7+7cefQzlKZbPoFog02w1ZkXTPkrgEQK0CSzGrvI2RaNgg
+DhoB4hp7Thdd4oq3P5kazethq8Jlph+3t723j/z9cI8LoGe+AaJZz3HmDyl2/7FWeUUrH556VOij
+KTVopAFPD6QuN+8bv+OPEKhyq1hX51SGyMnzW9os2l2ObjyjPtr7guXd8lyyBTNvijbO0BNO/79K
+DDRMpsMhvVAEVeuxu537RR5kFd5VAYwCdrXLoT9CabwvvWhDFlaJKjdhkf2mrk7AyxRllDdLkgbv
+BNDInIjbC3uBr7E9KsRlOni27tyAsdLTmZw67mtaa7ONt9XOnMK+pUsvFrGeaDsGb659n/je7Mwp
+p5ijJUMv7/FfJuGITfhebtfZFG4ZM2mnO4SJk8RTVROhUXhA+LjJou57ulJCg54U7QVSWllWp5f8
+nT8KKdjcT5EOE7zelaTfi5m+rJsziO+1ga8bxiJTyPbH7pcUsMV8eFLI8M5ud2CEpukqdiDtWAEX
+MJPpGovgc2PZapKUSU60rUqFxKMiMPwJ7Wgic6aIDFUhWMXhOp8q3crhkODZc6tsgLjoC2SToJyM
+Gf+z0gzskSaHirOi4XCPLArlzW1oUevaPwV/izLmE1xr/l9A4iLItLRkT9a6fUg+qGkM17uGcclz
+uD87nSVL2v9A6wIDAQABo4IBlTCCAZEwDwYDVR0TAQH/BAUwAwEB/zCB4QYDVR0gBIHZMIHWMIHT
+BgkrBgEEAb5YAAMwgcUwgZMGCCsGAQUFBwICMIGGGoGDQW55IHVzZSBvZiB0aGlzIENlcnRpZmlj
+YXRlIGNvbnN0aXR1dGVzIGFjY2VwdGFuY2Ugb2YgdGhlIFF1b1ZhZGlzIFJvb3QgQ0EgMyBDZXJ0
+aWZpY2F0ZSBQb2xpY3kgLyBDZXJ0aWZpY2F0aW9uIFByYWN0aWNlIFN0YXRlbWVudC4wLQYIKwYB
+BQUHAgEWIWh0dHA6Ly93d3cucXVvdmFkaXNnbG9iYWwuY29tL2NwczALBgNVHQ8EBAMCAQYwHQYD
+VR0OBBYEFPLAE+CCQz777i9nMpY1XNu4ywLQMG4GA1UdIwRnMGWAFPLAE+CCQz777i9nMpY1XNu4
+ywLQoUmkRzBFMQswCQYDVQQGEwJCTTEZMBcGA1UEChMQUXVvVmFkaXMgTGltaXRlZDEbMBkGA1UE
+AxMSUXVvVmFkaXMgUm9vdCBDQSAzggIFxjANBgkqhkiG9w0BAQUFAAOCAgEAT62gLEz6wPJv92ZV
+qyM07ucp2sNbtrCD2dDQ4iH782CnO11gUyeim/YIIirnv6By5ZwkajGxkHon24QRiSemd1o417+s
+hvzuXYO8BsbRd2sPbSQvS3pspweWyuOEn62Iix2rFo1bZhfZFvSLgNLd+LJ2w/w4E6oM3kJpK27z
+POuAJ9v1pkQNn1pVWQvVDVJIxa6f8i+AxeoyUDUSly7B4f/xI4hROJ/yZlZ25w9Rl6VSDE1JUZU2
+Pb+iSwwQHYaZTKrzchGT5Or2m9qoXadNt54CrnMAyNojA+j56hl0YgCUyyIgvpSnWbWCar6ZeXqp
+8kokUvd0/bpO5qgdAm6xDYBEwa7TIzdfu4V8K5Iu6H6li92Z4b8nby1dqnuH/grdS/yO9SbkbnBC
+bjPsMZ57k8HkyWkaPcBrTiJt7qtYTcbQQcEr6k8Sh17rRdhs9ZgC06DYVYoGmRmioHfRMJ6szHXu
+g/WwYjnPbFfiTNKRCw51KBuav/0aQ/HKd/s7j2G4aSgWQgRecCocIdiP4b0jWy10QJLZYxkNc91p
+vGJHvOB0K7Lrfb5BG7XARsWhIstfTsEokt4YutUqKLsRixeTmJlglFwjz1onl14LBQaTNx47aTbr
+qZ5hHY8y2o4M1nQ+ewkk2gF3R8Q7zTSMmfXK4SVhM7JZG+Ju1zdXtg2pEto=
+-----END CERTIFICATE-----
+
+Security Communication Root CA
+==============================
+-----BEGIN CERTIFICATE-----
+MIIDWjCCAkKgAwIBAgIBADANBgkqhkiG9w0BAQUFADBQMQswCQYDVQQGEwJKUDEYMBYGA1UEChMP
+U0VDT00gVHJ1c3QubmV0MScwJQYDVQQLEx5TZWN1cml0eSBDb21tdW5pY2F0aW9uIFJvb3RDQTEw
+HhcNMDMwOTMwMDQyMDQ5WhcNMjMwOTMwMDQyMDQ5WjBQMQswCQYDVQQGEwJKUDEYMBYGA1UEChMP
+U0VDT00gVHJ1c3QubmV0MScwJQYDVQQLEx5TZWN1cml0eSBDb21tdW5pY2F0aW9uIFJvb3RDQTEw
+ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCzs/5/022x7xZ8V6UMbXaKL0u/ZPtM7orw
+8yl89f/uKuDp6bpbZCKamm8sOiZpUQWZJtzVHGpxxpp9Hp3dfGzGjGdnSj74cbAZJ6kJDKaVv0uM
+DPpVmDvY6CKhS3E4eayXkmmziX7qIWgGmBSWh9JhNrxtJ1aeV+7AwFb9Ms+k2Y7CI9eNqPPYJayX
+5HA49LY6tJ07lyZDo6G8SVlyTCMwhwFY9k6+HGhWZq/NQV3Is00qVUarH9oe4kA92819uZKAnDfd
+DJZkndwi92SL32HeFZRSFaB9UslLqCHJxrHty8OVYNEP8Ktw+N/LTX7s1vqr2b1/VPKl6Xn62dZ2
+JChzAgMBAAGjPzA9MB0GA1UdDgQWBBSgc0mZaNyFW2XjmygvV5+9M7wHSDALBgNVHQ8EBAMCAQYw
+DwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQUFAAOCAQEAaECpqLvkT115swW1F7NgE+vGkl3g
+0dNq/vu+m22/xwVtWSDEHPC32oRYAmP6SBbvT6UL90qY8j+eG61Ha2POCEfrUj94nK9NrvjVT8+a
+mCoQQTlSxN3Zmw7vkwGusi7KaEIkQmywszo+zenaSMQVy+n5Bw+SUEmK3TGXX8npN6o7WWWXlDLJ
+s58+OmJYxUmtYg5xpTKqL8aJdkNAExNnPaJUJRDL8Try2frbSVa7pv6nQTXD4IhhyYjH3zYQIphZ
+6rBK+1YWc26sTfcioU+tHXotRSflMMFe8toTyyVCUZVHA4xsIcx0Qu1T/zOLjw9XARYvz6buyXAi
+FL39vmwLAw==
+-----END CERTIFICATE-----
+
+Sonera Class 2 Root CA
+======================
+-----BEGIN CERTIFICATE-----
+MIIDIDCCAgigAwIBAgIBHTANBgkqhkiG9w0BAQUFADA5MQswCQYDVQQGEwJGSTEPMA0GA1UEChMG
+U29uZXJhMRkwFwYDVQQDExBTb25lcmEgQ2xhc3MyIENBMB4XDTAxMDQwNjA3Mjk0MFoXDTIxMDQw
+NjA3Mjk0MFowOTELMAkGA1UEBhMCRkkxDzANBgNVBAoTBlNvbmVyYTEZMBcGA1UEAxMQU29uZXJh
+IENsYXNzMiBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJAXSjWdyvANlsdE+hY3
+/Ei9vX+ALTU74W+oZ6m/AxxNjG8yR9VBaKQTBME1DJqEQ/xcHf+Js+gXGM2RX/uJ4+q/Tl18GybT
+dXnt5oTjV+WtKcT0OijnpXuENmmz/V52vaMtmdOQTiMofRhj8VQ7Jp12W5dCsv+u8E7s3TmVToMG
+f+dJQMjFAbJUWmYdPfz56TwKnoG4cPABi+QjVHzIrviQHgCWctRUz2EjvOr7nQKV0ba5cTppCD8P
+tOFCx4j1P5iop7oc4HFx71hXgVB6XGt0Rg6DA5jDjqhu8nYybieDwnPz3BjotJPqdURrBGAgcVeH
+nfO+oJAjPYok4doh28MCAwEAAaMzMDEwDwYDVR0TAQH/BAUwAwEB/zARBgNVHQ4ECgQISqCqWITT
+XjwwCwYDVR0PBAQDAgEGMA0GCSqGSIb3DQEBBQUAA4IBAQBazof5FnIVV0sd2ZvnoiYw7JNn39Yt
+0jSv9zilzqsWuasvfDXLrNAPtEwr/IDva4yRXzZ299uzGxnq9LIR/WFxRL8oszodv7ND6J+/3DEI
+cbCdjdY0RzKQxmUk96BKfARzjzlvF4xytb1LyHr4e4PDKE6cCepnP7JnBBvDFNr450kkkdAdavph
+Oe9r5yF1BgfYErQhIHBCcYHaPJo2vqZbDWpsmh+Re/n570K6Tk6ezAyNlNzZRZxe7EJQY670XcSx
+EtzKO6gunRRaBXW37Ndj4ro1tgQIkejanZz2ZrUYrAqmVCY0M9IbwdR/GjqOC6oybtv8TyWf2TLH
+llpwrN9M
+-----END CERTIFICATE-----
+
+XRamp Global CA Root
+====================
+-----BEGIN CERTIFICATE-----
+MIIEMDCCAxigAwIBAgIQUJRs7Bjq1ZxN1ZfvdY+grTANBgkqhkiG9w0BAQUFADCBgjELMAkGA1UE
+BhMCVVMxHjAcBgNVBAsTFXd3dy54cmFtcHNlY3VyaXR5LmNvbTEkMCIGA1UEChMbWFJhbXAgU2Vj
+dXJpdHkgU2VydmljZXMgSW5jMS0wKwYDVQQDEyRYUmFtcCBHbG9iYWwgQ2VydGlmaWNhdGlvbiBB
+dXRob3JpdHkwHhcNMDQxMTAxMTcxNDA0WhcNMzUwMTAxMDUzNzE5WjCBgjELMAkGA1UEBhMCVVMx
+HjAcBgNVBAsTFXd3dy54cmFtcHNlY3VyaXR5LmNvbTEkMCIGA1UEChMbWFJhbXAgU2VjdXJpdHkg
+U2VydmljZXMgSW5jMS0wKwYDVQQDEyRYUmFtcCBHbG9iYWwgQ2VydGlmaWNhdGlvbiBBdXRob3Jp
+dHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCYJB69FbS638eMpSe2OAtp87ZOqCwu
+IR1cRN8hXX4jdP5efrRKt6atH67gBhbim1vZZ3RrXYCPKZ2GG9mcDZhtdhAoWORlsH9KmHmf4MMx
+foArtYzAQDsRhtDLooY2YKTVMIJt2W7QDxIEM5dfT2Fa8OT5kavnHTu86M/0ay00fOJIYRyO82FE
+zG+gSqmUsE3a56k0enI4qEHMPJQRfevIpoy3hsvKMzvZPTeL+3o+hiznc9cKV6xkmxnr9A8ECIqs
+AxcZZPRaJSKNNCyy9mgdEm3Tih4U2sSPpuIjhdV6Db1q4Ons7Be7QhtnqiXtRYMh/MHJfNViPvry
+xS3T/dRlAgMBAAGjgZ8wgZwwEwYJKwYBBAGCNxQCBAYeBABDAEEwCwYDVR0PBAQDAgGGMA8GA1Ud
+EwEB/wQFMAMBAf8wHQYDVR0OBBYEFMZPoj0GY4QJnM5i5ASsjVy16bYbMDYGA1UdHwQvMC0wK6Ap
+oCeGJWh0dHA6Ly9jcmwueHJhbXBzZWN1cml0eS5jb20vWEdDQS5jcmwwEAYJKwYBBAGCNxUBBAMC
+AQEwDQYJKoZIhvcNAQEFBQADggEBAJEVOQMBG2f7Shz5CmBbodpNl2L5JFMn14JkTpAuw0kbK5rc
+/Kh4ZzXxHfARvbdI4xD2Dd8/0sm2qlWkSLoC295ZLhVbO50WfUfXN+pfTXYSNrsf16GBBEYgoyxt
+qZ4Bfj8pzgCT3/3JknOJiWSe5yvkHJEs0rnOfc5vMZnT5r7SHpDwCRR5XCOrTdLaIR9NmXmd4c8n
+nxCbHIgNsIpkQTG4DmyQJKSbXHGPurt+HBvbaoAPIbzp26a3QPSyi6mx5O+aGtA9aZnuqCij4Tyz
+8LIRnM98QObd50N9otg6tamN8jSZxNQQ4Qb9CYQQO+7ETPTsJ3xCwnR8gooJybQDJbw=
+-----END CERTIFICATE-----
+
+Go Daddy Class 2 CA
+===================
+-----BEGIN CERTIFICATE-----
+MIIEADCCAuigAwIBAgIBADANBgkqhkiG9w0BAQUFADBjMQswCQYDVQQGEwJVUzEhMB8GA1UEChMY
+VGhlIEdvIERhZGR5IEdyb3VwLCBJbmMuMTEwLwYDVQQLEyhHbyBEYWRkeSBDbGFzcyAyIENlcnRp
+ZmljYXRpb24gQXV0aG9yaXR5MB4XDTA0MDYyOTE3MDYyMFoXDTM0MDYyOTE3MDYyMFowYzELMAkG
+A1UEBhMCVVMxITAfBgNVBAoTGFRoZSBHbyBEYWRkeSBHcm91cCwgSW5jLjExMC8GA1UECxMoR28g
+RGFkZHkgQ2xhc3MgMiBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTCCASAwDQYJKoZIhvcNAQEBBQAD
+ggENADCCAQgCggEBAN6d1+pXGEmhW+vXX0iG6r7d/+TvZxz0ZWizV3GgXne77ZtJ6XCAPVYYYwhv
+2vLM0D9/AlQiVBDYsoHUwHU9S3/Hd8M+eKsaA7Ugay9qK7HFiH7Eux6wwdhFJ2+qN1j3hybX2C32
+qRe3H3I2TqYXP2WYktsqbl2i/ojgC95/5Y0V4evLOtXiEqITLdiOr18SPaAIBQi2XKVlOARFmR6j
+YGB0xUGlcmIbYsUfb18aQr4CUWWoriMYavx4A6lNf4DD+qta/KFApMoZFv6yyO9ecw3ud72a9nmY
+vLEHZ6IVDd2gWMZEewo+YihfukEHU1jPEX44dMX4/7VpkI+EdOqXG68CAQOjgcAwgb0wHQYDVR0O
+BBYEFNLEsNKR1EwRcbNhyz2h/t2oatTjMIGNBgNVHSMEgYUwgYKAFNLEsNKR1EwRcbNhyz2h/t2o
+atTjoWekZTBjMQswCQYDVQQGEwJVUzEhMB8GA1UEChMYVGhlIEdvIERhZGR5IEdyb3VwLCBJbmMu
+MTEwLwYDVQQLEyhHbyBEYWRkeSBDbGFzcyAyIENlcnRpZmljYXRpb24gQXV0aG9yaXR5ggEAMAwG
+A1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBADJL87LKPpH8EsahB4yOd6AzBhRckB4Y9wim
+PQoZ+YeAEW5p5JYXMP80kWNyOO7MHAGjHZQopDH2esRU1/blMVgDoszOYtuURXO1v0XJJLXVggKt
+I3lpjbi2Tc7PTMozI+gciKqdi0FuFskg5YmezTvacPd+mSYgFFQlq25zheabIZ0KbIIOqPjCDPoQ
+HmyW74cNxA9hi63ugyuV+I6ShHI56yDqg+2DzZduCLzrTia2cyvk0/ZM/iZx4mERdEr/VxqHD3VI
+Ls9RaRegAhJhldXRQLIQTO7ErBBDpqWeCtWVYpoNz4iCxTIM5CufReYNnyicsbkqWletNw+vHX/b
+vZ8=
+-----END CERTIFICATE-----
+
+Starfield Class 2 CA
+====================
+-----BEGIN CERTIFICATE-----
+MIIEDzCCAvegAwIBAgIBADANBgkqhkiG9w0BAQUFADBoMQswCQYDVQQGEwJVUzElMCMGA1UEChMc
+U3RhcmZpZWxkIFRlY2hub2xvZ2llcywgSW5jLjEyMDAGA1UECxMpU3RhcmZpZWxkIENsYXNzIDIg
+Q2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMDQwNjI5MTczOTE2WhcNMzQwNjI5MTczOTE2WjBo
+MQswCQYDVQQGEwJVUzElMCMGA1UEChMcU3RhcmZpZWxkIFRlY2hub2xvZ2llcywgSW5jLjEyMDAG
+A1UECxMpU3RhcmZpZWxkIENsYXNzIDIgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwggEgMA0GCSqG
+SIb3DQEBAQUAA4IBDQAwggEIAoIBAQC3Msj+6XGmBIWtDBFk385N78gDGIc/oav7PKaf8MOh2tTY
+bitTkPskpD6E8J7oX+zlJ0T1KKY/e97gKvDIr1MvnsoFAZMej2YcOadN+lq2cwQlZut3f+dZxkqZ
+JRRU6ybH838Z1TBwj6+wRir/resp7defqgSHo9T5iaU0X9tDkYI22WY8sbi5gv2cOj4QyDvvBmVm
+epsZGD3/cVE8MC5fvj13c7JdBmzDI1aaK4UmkhynArPkPw2vCHmCuDY96pzTNbO8acr1zJ3o/WSN
+F4Azbl5KXZnJHoe0nRrA1W4TNSNe35tfPe/W93bC6j67eA0cQmdrBNj41tpvi/JEoAGrAgEDo4HF
+MIHCMB0GA1UdDgQWBBS/X7fRzt0fhvRbVazc1xDCDqmI5zCBkgYDVR0jBIGKMIGHgBS/X7fRzt0f
+hvRbVazc1xDCDqmI56FspGowaDELMAkGA1UEBhMCVVMxJTAjBgNVBAoTHFN0YXJmaWVsZCBUZWNo
+bm9sb2dpZXMsIEluYy4xMjAwBgNVBAsTKVN0YXJmaWVsZCBDbGFzcyAyIENlcnRpZmljYXRpb24g
+QXV0aG9yaXR5ggEAMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAWdP4id0ckaVaGs
+afPzWdqbAYcaT1epoXkJKtv3L7IezMdeatiDh6GX70k1PncGQVhiv45YuApnP+yz3SFmH8lU+nLM
+PUxA2IGvd56Deruix/U0F47ZEUD0/CwqTRV/p2JdLiXTAAsgGh1o+Re49L2L7ShZ3U0WixeDyLJl
+xy16paq8U4Zt3VekyvggQQto8PT7dL5WXXp59fkdheMtlb71cZBDzI0fmgAKhynpVSJYACPq4xJD
+KVtHCN2MQWplBqjlIapBtJUhlbl90TSrE9atvNziPTnNvT51cKEYWQPJIrSPnNVeKtelttQKbfi3
+QBFGmh95DmK/D5fs4C8fF5Q=
+-----END CERTIFICATE-----
+
+DigiCert Assured ID Root CA
+===========================
+-----BEGIN CERTIFICATE-----
+MIIDtzCCAp+gAwIBAgIQDOfg5RfYRv6P5WD8G/AwOTANBgkqhkiG9w0BAQUFADBlMQswCQYDVQQG
+EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSQw
+IgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgQ0EwHhcNMDYxMTEwMDAwMDAwWhcNMzEx
+MTEwMDAwMDAwWjBlMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQL
+ExB3d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgQ0Ew
+ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtDhXO5EOAXLGH87dg+XESpa7cJpSIqvTO
+9SA5KFhgDPiA2qkVlTJhPLWxKISKityfCgyDF3qPkKyK53lTXDGEKvYPmDI2dsze3Tyoou9q+yHy
+UmHfnyDXH+Kx2f4YZNISW1/5WBg1vEfNoTb5a3/UsDg+wRvDjDPZ2C8Y/igPs6eD1sNuRMBhNZYW
+/lmci3Zt1/GiSw0r/wty2p5g0I6QNcZ4VYcgoc/lbQrISXwxmDNsIumH0DJaoroTghHtORedmTpy
+oeb6pNnVFzF1roV9Iq4/AUaG9ih5yLHa5FcXxH4cDrC0kqZWs72yl+2qp/C3xag/lRbQ/6GW6whf
+GHdPAgMBAAGjYzBhMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRF
+66Kv9JLLgjEtUYunpyGd823IDzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzANBgkq
+hkiG9w0BAQUFAAOCAQEAog683+Lt8ONyc3pklL/3cmbYMuRCdWKuh+vy1dneVrOfzM4UKLkNl2Bc
+EkxY5NM9g0lFWJc1aRqoR+pWxnmrEthngYTffwk8lOa4JiwgvT2zKIn3X/8i4peEH+ll74fg38Fn
+SbNd67IJKusm7Xi+fT8r87cmNW1fiQG2SVufAQWbqz0lwcy2f8Lxb4bG+mRo64EtlOtCt/qMHt1i
+8b5QZ7dsvfPxH2sMNgcWfzd8qVttevESRmCD1ycEvkvOl77DZypoEd+A5wwzZr8TDRRu838fYxAe
++o0bJW1sj6W3YQGx0qMmoRBxna3iw/nDmVG3KwcIzi7mULKn+gpFL6Lw8g==
+-----END CERTIFICATE-----
+
+DigiCert Global Root CA
+=======================
+-----BEGIN CERTIFICATE-----
+MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBhMQswCQYDVQQG
+EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSAw
+HgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBDQTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAw
+MDAwMDBaMGExCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3
+dy5kaWdpY2VydC5jb20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkq
+hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsBCSDMAZOn
+TjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97nh6Vfe63SKMI2tavegw5
+BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt43C/dxC//AH2hdmoRBBYMql1GNXRor5H
+4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7PT19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y
+7vrTC0LUq7dBMtoM1O/4gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQAB
+o2MwYTAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbRTLtm
+8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUwDQYJKoZIhvcNAQEF
+BQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/EsrhMAtudXH/vTBH1jLuG2cenTnmCmr
+EbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIt
+tep3Sp+dWOIrWcBAI+0tKIJFPnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886
+UAb3LujEV0lsYSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk
+CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4=
+-----END CERTIFICATE-----
+
+DigiCert High Assurance EV Root CA
+==================================
+-----BEGIN CERTIFICATE-----
+MIIDxTCCAq2gAwIBAgIQAqxcJmoLQJuPC3nyrkYldzANBgkqhkiG9w0BAQUFADBsMQswCQYDVQQG
+EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSsw
+KQYDVQQDEyJEaWdpQ2VydCBIaWdoIEFzc3VyYW5jZSBFViBSb290IENBMB4XDTA2MTExMDAwMDAw
+MFoXDTMxMTExMDAwMDAwMFowbDELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZ
+MBcGA1UECxMQd3d3LmRpZ2ljZXJ0LmNvbTErMCkGA1UEAxMiRGlnaUNlcnQgSGlnaCBBc3N1cmFu
+Y2UgRVYgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMbM5XPm+9S75S0t
+Mqbf5YE/yc0lSbZxKsPVlDRnogocsF9ppkCxxLeyj9CYpKlBWTrT3JTWPNt0OKRKzE0lgvdKpVMS
+OO7zSW1xkX5jtqumX8OkhPhPYlG++MXs2ziS4wblCJEMxChBVfvLWokVfnHoNb9Ncgk9vjo4UFt3
+MRuNs8ckRZqnrG0AFFoEt7oT61EKmEFBIk5lYYeBQVCmeVyJ3hlKV9Uu5l0cUyx+mM0aBhakaHPQ
+NAQTXKFx01p8VdteZOE3hzBWBOURtCmAEvF5OYiiAhF8J2a3iLd48soKqDirCmTCv2ZdlYTBoSUe
+h10aUAsgEsxBu24LUTi4S8sCAwEAAaNjMGEwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMB
+Af8wHQYDVR0OBBYEFLE+w2kD+L9HAdSYJhoIAu9jZCvDMB8GA1UdIwQYMBaAFLE+w2kD+L9HAdSY
+JhoIAu9jZCvDMA0GCSqGSIb3DQEBBQUAA4IBAQAcGgaX3NecnzyIZgYIVyHbIUf4KmeqvxgydkAQ
+V8GK83rZEWWONfqe/EW1ntlMMUu4kehDLI6zeM7b41N5cdblIZQB2lWHmiRk9opmzN6cN82oNLFp
+myPInngiK3BD41VHMWEZ71jFhS9OMPagMRYjyOfiZRYzy78aG6A9+MpeizGLYAiJLQwGXFK3xPkK
+mNEVX58Svnw2Yzi9RKR/5CYrCsSXaQ3pjOLAEFe4yHYSkVXySGnYvCoCWw9E1CAx2/S6cCZdkGCe
+vEsXCS+0yx5DaMkHJ8HSXPfqIbloEpw8nL+e/IBcm2PN7EeqJSdnoDfzAIJ9VNep+OkuE6N36B9K
+-----END CERTIFICATE-----
+
+DST Root CA X3
+==============
+-----BEGIN CERTIFICATE-----
+MIIDSjCCAjKgAwIBAgIQRK+wgNajJ7qJMDmGLvhAazANBgkqhkiG9w0BAQUFADA/MSQwIgYDVQQK
+ExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMTDkRTVCBSb290IENBIFgzMB4X
+DTAwMDkzMDIxMTIxOVoXDTIxMDkzMDE0MDExNVowPzEkMCIGA1UEChMbRGlnaXRhbCBTaWduYXR1
+cmUgVHJ1c3QgQ28uMRcwFQYDVQQDEw5EU1QgUm9vdCBDQSBYMzCCASIwDQYJKoZIhvcNAQEBBQAD
+ggEPADCCAQoCggEBAN+v6ZdQCINXtMxiZfaQguzH0yxrMMpb7NnDfcdAwRgUi+DoM3ZJKuM/IUmT
+rE4Orz5Iy2Xu/NMhD2XSKtkyj4zl93ewEnu1lcCJo6m67XMuegwGMoOifooUMM0RoOEqOLl5CjH9
+UL2AZd+3UWODyOKIYepLYYHsUmu5ouJLGiifSKOeDNoJjj4XLh7dIN9bxiqKqy69cK3FCxolkHRy
+xXtqqzTWMIn/5WgTe1QLyNau7Fqckh49ZLOMxt+/yUFw7BZy1SbsOFU5Q9D8/RhcQPGX69Wam40d
+utolucbY38EVAjqr2m7xPi71XAicPNaDaeQQmxkqtilX4+U9m5/wAl0CAwEAAaNCMEAwDwYDVR0T
+AQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFMSnsaR7LHH62+FLkHX/xBVghYkQ
+MA0GCSqGSIb3DQEBBQUAA4IBAQCjGiybFwBcqR7uKGY3Or+Dxz9LwwmglSBd49lZRNI+DT69ikug
+dB/OEIKcdBodfpga3csTS7MgROSR6cz8faXbauX+5v3gTt23ADq1cEmv8uXrAvHRAosZy5Q6XkjE
+GB5YGV8eAlrwDPGxrancWYaLbumR9YbK+rlmM6pZW87ipxZzR8srzJmwN0jP41ZL9c8PDHIyh8bw
+RLtTcm1D9SZImlJnt1ir/md2cXjbDaJWFBM5JDGFoqgCWjBH4d1QB7wCCZAA62RjYJsWvIjJEubS
+fZGL+T0yjWW06XyxV3bqxbYoOb8VZRzI9neWagqNdwvYkQsEjgfbKbYK7p2CNTUQ
+-----END CERTIFICATE-----
+
+SwissSign Gold CA - G2
+======================
+-----BEGIN CERTIFICATE-----
+MIIFujCCA6KgAwIBAgIJALtAHEP1Xk+wMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNVBAYTAkNIMRUw
+EwYDVQQKEwxTd2lzc1NpZ24gQUcxHzAdBgNVBAMTFlN3aXNzU2lnbiBHb2xkIENBIC0gRzIwHhcN
+MDYxMDI1MDgzMDM1WhcNMzYxMDI1MDgzMDM1WjBFMQswCQYDVQQGEwJDSDEVMBMGA1UEChMMU3dp
+c3NTaWduIEFHMR8wHQYDVQQDExZTd2lzc1NpZ24gR29sZCBDQSAtIEcyMIICIjANBgkqhkiG9w0B
+AQEFAAOCAg8AMIICCgKCAgEAr+TufoskDhJuqVAtFkQ7kpJcyrhdhJJCEyq8ZVeCQD5XJM1QiyUq
+t2/876LQwB8CJEoTlo8jE+YoWACjR8cGp4QjK7u9lit/VcyLwVcfDmJlD909Vopz2q5+bbqBHH5C
+jCA12UNNhPqE21Is8w4ndwtrvxEvcnifLtg+5hg3Wipy+dpikJKVyh+c6bM8K8vzARO/Ws/BtQpg
+vd21mWRTuKCWs2/iJneRjOBiEAKfNA+k1ZIzUd6+jbqEemA8atufK+ze3gE/bk3lUIbLtK/tREDF
+ylqM2tIrfKjuvqblCqoOpd8FUrdVxyJdMmqXl2MT28nbeTZ7hTpKxVKJ+STnnXepgv9VHKVxaSvR
+AiTysybUa9oEVeXBCsdtMDeQKuSeFDNeFhdVxVu1yzSJkvGdJo+hB9TGsnhQ2wwMC3wLjEHXuend
+jIj3o02yMszYF9rNt85mndT9Xv+9lz4pded+p2JYryU0pUHHPbwNUMoDAw8IWh+Vc3hiv69yFGkO
+peUDDniOJihC8AcLYiAQZzlG+qkDzAQ4embvIIO1jEpWjpEA/I5cgt6IoMPiaG59je883WX0XaxR
+7ySArqpWl2/5rX3aYT+YdzylkbYcjCbaZaIJbcHiVOO5ykxMgI93e2CaHt+28kgeDrpOVG2Y4OGi
+GqJ3UM/EY5LsRxmd6+ZrzsECAwEAAaOBrDCBqTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUw
+AwEB/zAdBgNVHQ4EFgQUWyV7lqRlUX64OfPAeGZe6Drn8O4wHwYDVR0jBBgwFoAUWyV7lqRlUX64
+OfPAeGZe6Drn8O4wRgYDVR0gBD8wPTA7BglghXQBWQECAQEwLjAsBggrBgEFBQcCARYgaHR0cDov
+L3JlcG9zaXRvcnkuc3dpc3NzaWduLmNvbS8wDQYJKoZIhvcNAQEFBQADggIBACe645R88a7A3hfm
+5djV9VSwg/S7zV4Fe0+fdWavPOhWfvxyeDgD2StiGwC5+OlgzczOUYrHUDFu4Up+GC9pWbY9ZIEr
+44OE5iKHjn3g7gKZYbge9LgriBIWhMIxkziWMaa5O1M/wySTVltpkuzFwbs4AOPsF6m43Md8AYOf
+Mke6UiI0HTJ6CVanfCU2qT1L2sCCbwq7EsiHSycR+R4tx5M/nttfJmtS2S6K8RTGRI0Vqbe/vd6m
+Gu6uLftIdxf+u+yvGPUqUfA5hJeVbG4bwyvEdGB5JbAKJ9/fXtI5z0V9QkvfsywexcZdylU6oJxp
+mo/a77KwPJ+HbBIrZXAVUjEaJM9vMSNQH4xPjyPDdEFjHFWoFN0+4FFQz/EbMFYOkrCChdiDyyJk
+vC24JdVUorgG6q2SpCSgwYa1ShNqR88uC1aVVMvOmttqtKay20EIhid392qgQmwLOM7XdVAyksLf
+KzAiSNDVQTglXaTpXZ/GlHXQRf0wl0OPkKsKx4ZzYEppLd6leNcG2mqeSz53OiATIgHQv2ieY2Br
+NU0LbbqhPcCT4H8js1WtciVORvnSFu+wZMEBnunKoGqYDs/YYPIvSbjkQuE4NRb0yG5P94FW6Lqj
+viOvrv1vA+ACOzB2+httQc8Bsem4yWb02ybzOqR08kkkW8mw0FfB+j564ZfJ
+-----END CERTIFICATE-----
+
+SwissSign Silver CA - G2
+========================
+-----BEGIN CERTIFICATE-----
+MIIFvTCCA6WgAwIBAgIITxvUL1S7L0swDQYJKoZIhvcNAQEFBQAwRzELMAkGA1UEBhMCQ0gxFTAT
+BgNVBAoTDFN3aXNzU2lnbiBBRzEhMB8GA1UEAxMYU3dpc3NTaWduIFNpbHZlciBDQSAtIEcyMB4X
+DTA2MTAyNTA4MzI0NloXDTM2MTAyNTA4MzI0NlowRzELMAkGA1UEBhMCQ0gxFTATBgNVBAoTDFN3
+aXNzU2lnbiBBRzEhMB8GA1UEAxMYU3dpc3NTaWduIFNpbHZlciBDQSAtIEcyMIICIjANBgkqhkiG
+9w0BAQEFAAOCAg8AMIICCgKCAgEAxPGHf9N4Mfc4yfjDmUO8x/e8N+dOcbpLj6VzHVxumK4DV644
+N0MvFz0fyM5oEMF4rhkDKxD6LHmD9ui5aLlV8gREpzn5/ASLHvGiTSf5YXu6t+WiE7brYT7QbNHm
++/pe7R20nqA1W6GSy/BJkv6FCgU+5tkL4k+73JU3/JHpMjUi0R86TieFnbAVlDLaYQ1HTWBCrpJH
+6INaUFjpiou5XaHc3ZlKHzZnu0jkg7Y360g6rw9njxcH6ATK72oxh9TAtvmUcXtnZLi2kUpCe2Uu
+MGoM9ZDulebyzYLs2aFK7PayS+VFheZteJMELpyCbTapxDFkH4aDCyr0NQp4yVXPQbBH6TCfmb5h
+qAaEuSh6XzjZG6k4sIN/c8HDO0gqgg8hm7jMqDXDhBuDsz6+pJVpATqJAHgE2cn0mRmrVn5bi4Y5
+FZGkECwJMoBgs5PAKrYYC51+jUnyEEp/+dVGLxmSo5mnJqy7jDzmDrxHB9xzUfFwZC8I+bRHHTBs
+ROopN4WSaGa8gzj+ezku01DwH/teYLappvonQfGbGHLy9YR0SslnxFSuSGTfjNFusB3hB48IHpmc
+celM2KX3RxIfdNFRnobzwqIjQAtz20um53MGjMGg6cFZrEb65i/4z3GcRm25xBWNOHkDRUjvxF3X
+CO6HOSKGsg0PWEP3calILv3q1h8CAwEAAaOBrDCBqTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/
+BAUwAwEB/zAdBgNVHQ4EFgQUF6DNweRBtjpbO8tFnb0cwpj6hlgwHwYDVR0jBBgwFoAUF6DNweRB
+tjpbO8tFnb0cwpj6hlgwRgYDVR0gBD8wPTA7BglghXQBWQEDAQEwLjAsBggrBgEFBQcCARYgaHR0
+cDovL3JlcG9zaXRvcnkuc3dpc3NzaWduLmNvbS8wDQYJKoZIhvcNAQEFBQADggIBAHPGgeAn0i0P
+4JUw4ppBf1AsX19iYamGamkYDHRJ1l2E6kFSGG9YrVBWIGrGvShpWJHckRE1qTodvBqlYJ7YH39F
+kWnZfrt4csEGDyrOj4VwYaygzQu4OSlWhDJOhrs9xCrZ1x9y7v5RoSJBsXECYxqCsGKrXlcSH9/L
+3XWgwF15kIwb4FDm3jH+mHtwX6WQ2K34ArZv02DdQEsixT2tOnqfGhpHkXkzuoLcMmkDlm4fS/Bx
+/uNncqCxv1yL5PqZIseEuRuNI5c/7SXgz2W79WEE790eslpBIlqhn10s6FvJbakMDHiqYMZWjwFa
+DGi8aRl5xB9+lwW/xekkUV7U1UtT7dkjWjYDZaPBA61BMPNGG4WQr2W11bHkFlt4dR2Xem1ZqSqP
+e97Dh4kQmUlzeMg9vVE1dCrV8X5pGyq7O70luJpaPXJhkGaH7gzWTdQRdAtq/gsD/KNVV4n+Ssuu
+WxcFyPKNIzFTONItaj+CuY0IavdeQXRuwxF+B6wpYJE/OMpXEA29MC/HpeZBoNquBYeaoKRlbEwJ
+DIm6uNO5wJOKMPqN5ZprFQFOZ6raYlY+hAhm0sQ2fac+EPyI4NSA5QC9qvNOBqN6avlicuMJT+ub
+DgEj8Z+7fNzcbBGXJbLytGMU0gYqZ4yD9c7qB9iaah7s5Aq7KkzrCWA5zspi2C5u
+-----END CERTIFICATE-----
+
+SecureTrust CA
+==============
+-----BEGIN CERTIFICATE-----
+MIIDuDCCAqCgAwIBAgIQDPCOXAgWpa1Cf/DrJxhZ0DANBgkqhkiG9w0BAQUFADBIMQswCQYDVQQG
+EwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24xFzAVBgNVBAMTDlNlY3VyZVRy
+dXN0IENBMB4XDTA2MTEwNzE5MzExOFoXDTI5MTIzMTE5NDA1NVowSDELMAkGA1UEBhMCVVMxIDAe
+BgNVBAoTF1NlY3VyZVRydXN0IENvcnBvcmF0aW9uMRcwFQYDVQQDEw5TZWN1cmVUcnVzdCBDQTCC
+ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKukgeWVzfX2FI7CT8rU4niVWJxB4Q2ZQCQX
+OZEzZum+4YOvYlyJ0fwkW2Gz4BERQRwdbvC4u/jep4G6pkjGnx29vo6pQT64lO0pGtSO0gMdA+9t
+DWccV9cGrcrI9f4Or2YlSASWC12juhbDCE/RRvgUXPLIXgGZbf2IzIaowW8xQmxSPmjL8xk037uH
+GFaAJsTQ3MBv396gwpEWoGQRS0S8Hvbn+mPeZqx2pHGj7DaUaHp3pLHnDi+BeuK1cobvomuL8A/b
+01k/unK8RCSc43Oz969XL0Imnal0ugBS8kvNU3xHCzaFDmapCJcWNFfBZveA4+1wVMeT4C4oFVmH
+ursCAwEAAaOBnTCBmjATBgkrBgEEAYI3FAIEBh4EAEMAQTALBgNVHQ8EBAMCAYYwDwYDVR0TAQH/
+BAUwAwEB/zAdBgNVHQ4EFgQUQjK2FvoE/f5dS3rD/fdMQB1aQ68wNAYDVR0fBC0wKzApoCegJYYj
+aHR0cDovL2NybC5zZWN1cmV0cnVzdC5jb20vU1RDQS5jcmwwEAYJKwYBBAGCNxUBBAMCAQAwDQYJ
+KoZIhvcNAQEFBQADggEBADDtT0rhWDpSclu1pqNlGKa7UTt36Z3q059c4EVlew3KW+JwULKUBRSu
+SceNQQcSc5R+DCMh/bwQf2AQWnL1mA6s7Ll/3XpvXdMc9P+IBWlCqQVxyLesJugutIxq/3HcuLHf
+mbx8IVQr5Fiiu1cprp6poxkmD5kuCLDv/WnPmRoJjeOnnyvJNjR7JLN4TJUXpAYmHrZkUjZfYGfZ
+nMUFdAvnZyPSCPyI6a6Lf+Ew9Dd+/cYy2i2eRDAwbO4H3tI0/NL/QPZL9GZGBlSm8jIKYyYwa5vR
+3ItHuuG51WLQoqD0ZwV4KWMabwTW+MZMo5qxN7SN5ShLHZ4swrhovO0C7jE=
+-----END CERTIFICATE-----
+
+Secure Global CA
+================
+-----BEGIN CERTIFICATE-----
+MIIDvDCCAqSgAwIBAgIQB1YipOjUiolN9BPI8PjqpTANBgkqhkiG9w0BAQUFADBKMQswCQYDVQQG
+EwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24xGTAXBgNVBAMTEFNlY3VyZSBH
+bG9iYWwgQ0EwHhcNMDYxMTA3MTk0MjI4WhcNMjkxMjMxMTk1MjA2WjBKMQswCQYDVQQGEwJVUzEg
+MB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24xGTAXBgNVBAMTEFNlY3VyZSBHbG9iYWwg
+Q0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCvNS7YrGxVaQZx5RNoJLNP2MwhR/jx
+YDiJiQPpvepeRlMJ3Fz1Wuj3RSoC6zFh1ykzTM7HfAo3fg+6MpjhHZevj8fcyTiW89sa/FHtaMbQ
+bqR8JNGuQsiWUGMu4P51/pinX0kuleM5M2SOHqRfkNJnPLLZ/kG5VacJjnIFHovdRIWCQtBJwB1g
+8NEXLJXr9qXBkqPFwqcIYA1gBBCWeZ4WNOaptvolRTnIHmX5k/Wq8VLcmZg9pYYaDDUz+kulBAYV
+HDGA76oYa8J719rO+TMg1fW9ajMtgQT7sFzUnKPiXB3jqUJ1XnvUd+85VLrJChgbEplJL4hL/VBi
+0XPnj3pDAgMBAAGjgZ0wgZowEwYJKwYBBAGCNxQCBAYeBABDAEEwCwYDVR0PBAQDAgGGMA8GA1Ud
+EwEB/wQFMAMBAf8wHQYDVR0OBBYEFK9EBMJBfkiD2045AuzshHrmzsmkMDQGA1UdHwQtMCswKaAn
+oCWGI2h0dHA6Ly9jcmwuc2VjdXJldHJ1c3QuY29tL1NHQ0EuY3JsMBAGCSsGAQQBgjcVAQQDAgEA
+MA0GCSqGSIb3DQEBBQUAA4IBAQBjGghAfaReUw132HquHw0LURYD7xh8yOOvaliTFGCRsoTciE6+
+OYo68+aCiV0BN7OrJKQVDpI1WkpEXk5X+nXOH0jOZvQ8QCaSmGwb7iRGDBezUqXbpZGRzzfTb+cn
+CDpOGR86p1hcF895P4vkp9MmI50mD1hp/Ed+stCNi5O/KU9DaXR2Z0vPB4zmAve14bRDtUstFJ/5
+3CYNv6ZHdAbYiNE6KTCEztI5gGIbqMdXSbxqVVFnFUq+NQfk1XWYN3kwFNspnWzFacxHVaIw98xc
+f8LDmBxrThaA63p4ZUWiABqvDA1VZDRIuJK58bRQKfJPIx/abKwfROHdI3hRW8cW
+-----END CERTIFICATE-----
+
+COMODO Certification Authority
+==============================
+-----BEGIN CERTIFICATE-----
+MIIEHTCCAwWgAwIBAgIQToEtioJl4AsC7j41AkblPTANBgkqhkiG9w0BAQUFADCBgTELMAkGA1UE
+BhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgG
+A1UEChMRQ09NT0RPIENBIExpbWl0ZWQxJzAlBgNVBAMTHkNPTU9ETyBDZXJ0aWZpY2F0aW9uIEF1
+dGhvcml0eTAeFw0wNjEyMDEwMDAwMDBaFw0yOTEyMzEyMzU5NTlaMIGBMQswCQYDVQQGEwJHQjEb
+MBkGA1UECBMSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHEwdTYWxmb3JkMRowGAYDVQQKExFD
+T01PRE8gQ0EgTGltaXRlZDEnMCUGA1UEAxMeQ09NT0RPIENlcnRpZmljYXRpb24gQXV0aG9yaXR5
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0ECLi3LjkRv3UcEbVASY06m/weaKXTuH
++7uIzg3jLz8GlvCiKVCZrts7oVewdFFxze1CkU1B/qnI2GqGd0S7WWaXUF601CxwRM/aN5VCaTww
+xHGzUvAhTaHYujl8HJ6jJJ3ygxaYqhZ8Q5sVW7euNJH+1GImGEaaP+vB+fGQV+useg2L23IwambV
+4EajcNxo2f8ESIl33rXp+2dtQem8Ob0y2WIC8bGoPW43nOIv4tOiJovGuFVDiOEjPqXSJDlqR6sA
+1KGzqSX+DT+nHbrTUcELpNqsOO9VUCQFZUaTNE8tja3G1CEZ0o7KBWFxB3NH5YoZEr0ETc5OnKVI
+rLsm9wIDAQABo4GOMIGLMB0GA1UdDgQWBBQLWOWLxkwVN6RAqTCpIb5HNlpW/zAOBgNVHQ8BAf8E
+BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zBJBgNVHR8EQjBAMD6gPKA6hjhodHRwOi8vY3JsLmNvbW9k
+b2NhLmNvbS9DT01PRE9DZXJ0aWZpY2F0aW9uQXV0aG9yaXR5LmNybDANBgkqhkiG9w0BAQUFAAOC
+AQEAPpiem/Yb6dc5t3iuHXIYSdOH5EOC6z/JqvWote9VfCFSZfnVDeFs9D6Mk3ORLgLETgdxb8CP
+OGEIqB6BCsAvIC9Bi5HcSEW88cbeunZrM8gALTFGTO3nnc+IlP8zwFboJIYmuNg4ON8qa90SzMc/
+RxdMosIGlgnW2/4/PEZB31jiVg88O8EckzXZOFKs7sjsLjBOlDW0JB9LeGna8gI4zJVSk/BwJVmc
+IGfE7vmLV2H0knZ9P4SNVbfo5azV8fUZVqZa+5Acr5Pr5RzUZ5ddBA6+C4OmF4O5MBKgxTMVBbkN
++8cFduPYSo38NBejxiEovjBFMR7HeL5YYTisO+IBZQ==
+-----END CERTIFICATE-----
+
+Network Solutions Certificate Authority
+=======================================
+-----BEGIN CERTIFICATE-----
+MIID5jCCAs6gAwIBAgIQV8szb8JcFuZHFhfjkDFo4DANBgkqhkiG9w0BAQUFADBiMQswCQYDVQQG
+EwJVUzEhMB8GA1UEChMYTmV0d29yayBTb2x1dGlvbnMgTC5MLkMuMTAwLgYDVQQDEydOZXR3b3Jr
+IFNvbHV0aW9ucyBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkwHhcNMDYxMjAxMDAwMDAwWhcNMjkxMjMx
+MjM1OTU5WjBiMQswCQYDVQQGEwJVUzEhMB8GA1UEChMYTmV0d29yayBTb2x1dGlvbnMgTC5MLkMu
+MTAwLgYDVQQDEydOZXR3b3JrIFNvbHV0aW9ucyBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkwggEiMA0G
+CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDkvH6SMG3G2I4rC7xGzuAnlt7e+foS0zwzc7MEL7xx
+jOWftiJgPl9dzgn/ggwbmlFQGiaJ3dVhXRncEg8tCqJDXRfQNJIg6nPPOCwGJgl6cvf6UDL4wpPT
+aaIjzkGxzOTVHzbRijr4jGPiFFlp7Q3Tf2vouAPlT2rlmGNpSAW+Lv8ztumXWWn4Zxmuk2GWRBXT
+crA/vGp97Eh/jcOrqnErU2lBUzS1sLnFBgrEsEX1QV1uiUV7PTsmjHTC5dLRfbIR1PtYMiKagMnc
+/Qzpf14Dl847ABSHJ3A4qY5usyd2mFHgBeMhqxrVhSI8KbWaFsWAqPS7azCPL0YCorEMIuDTAgMB
+AAGjgZcwgZQwHQYDVR0OBBYEFCEwyfsA106Y2oeqKtCnLrFAMadMMA4GA1UdDwEB/wQEAwIBBjAP
+BgNVHRMBAf8EBTADAQH/MFIGA1UdHwRLMEkwR6BFoEOGQWh0dHA6Ly9jcmwubmV0c29sc3NsLmNv
+bS9OZXR3b3JrU29sdXRpb25zQ2VydGlmaWNhdGVBdXRob3JpdHkuY3JsMA0GCSqGSIb3DQEBBQUA
+A4IBAQC7rkvnt1frf6ott3NHhWrB5KUd5Oc86fRZZXe1eltajSU24HqXLjjAV2CDmAaDn7l2em5Q
+4LqILPxFzBiwmZVRDuwduIj/h1AcgsLj4DKAv6ALR8jDMe+ZZzKATxcheQxpXN5eNK4CtSbqUN9/
+GGUsyfJj4akH/nxxH2szJGoeBfcFaMBqEssuXmHLrijTfsK0ZpEmXzwuJF/LWA/rKOyvEZbz3Htv
+wKeI8lN3s2Berq4o2jUsbzRF0ybh3uxbTydrFny9RAQYgrOJeRcQcT16ohZO9QHNpGxlaKFJdlxD
+ydi8NmdspZS11My5vWo1ViHe2MPr+8ukYEywVaCge1ey
+-----END CERTIFICATE-----
+
+COMODO ECC Certification Authority
+==================================
+-----BEGIN CERTIFICATE-----
+MIICiTCCAg+gAwIBAgIQH0evqmIAcFBUTAGem2OZKjAKBggqhkjOPQQDAzCBhTELMAkGA1UEBhMC
+R0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UE
+ChMRQ09NT0RPIENBIExpbWl0ZWQxKzApBgNVBAMTIkNPTU9ETyBFQ0MgQ2VydGlmaWNhdGlvbiBB
+dXRob3JpdHkwHhcNMDgwMzA2MDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBhTELMAkGA1UEBhMCR0Ix
+GzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UEChMR
+Q09NT0RPIENBIExpbWl0ZWQxKzApBgNVBAMTIkNPTU9ETyBFQ0MgQ2VydGlmaWNhdGlvbiBBdXRo
+b3JpdHkwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQDR3svdcmCFYX7deSRFtSrYpn1PlILBs5BAH+X
+4QokPB0BBO490o0JlwzgdeT6+3eKKvUDYEs2ixYjFq0JcfRK9ChQtP6IHG4/bC8vCVlbpVsLM5ni
+wz2J+Wos77LTBumjQjBAMB0GA1UdDgQWBBR1cacZSBm8nZ3qQUfflMRId5nTeTAOBgNVHQ8BAf8E
+BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjEA7wNbeqy3eApyt4jf/7VG
+FAkK+qDmfQjGGoe9GKhzvSbKYAydzpmfz1wPMOG+FDHqAjAU9JM8SaczepBGR7NjfRObTrdvGDeA
+U/7dIOA1mjbRxwG55tzd8/8dLDoWV9mSOdY=
+-----END CERTIFICATE-----
+
+Certigna
+========
+-----BEGIN CERTIFICATE-----
+MIIDqDCCApCgAwIBAgIJAP7c4wEPyUj/MA0GCSqGSIb3DQEBBQUAMDQxCzAJBgNVBAYTAkZSMRIw
+EAYDVQQKDAlEaGlteW90aXMxETAPBgNVBAMMCENlcnRpZ25hMB4XDTA3MDYyOTE1MTMwNVoXDTI3
+MDYyOTE1MTMwNVowNDELMAkGA1UEBhMCRlIxEjAQBgNVBAoMCURoaW15b3RpczERMA8GA1UEAwwI
+Q2VydGlnbmEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDIaPHJ1tazNHUmgh7stL7q
+XOEm7RFHYeGifBZ4QCHkYJ5ayGPhxLGWkv8YbWkj4Sti993iNi+RB7lIzw7sebYs5zRLcAglozyH
+GxnygQcPOJAZ0xH+hrTy0V4eHpbNgGzOOzGTtvKg0KmVEn2lmsxryIRWijOp5yIVUxbwzBfsV1/p
+ogqYCd7jX5xv3EjjhQsVWqa6n6xI4wmy9/Qy3l40vhx4XUJbzg4ij02Q130yGLMLLGq/jj8UEYkg
+DncUtT2UCIf3JR7VsmAA7G8qKCVuKj4YYxclPz5EIBb2JsglrgVKtOdjLPOMFlN+XPsRGgjBRmKf
+Irjxwo1p3Po6WAbfAgMBAAGjgbwwgbkwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUGu3+QTmQ
+tCRZvgHyUtVF9lo53BEwZAYDVR0jBF0wW4AUGu3+QTmQtCRZvgHyUtVF9lo53BGhOKQ2MDQxCzAJ
+BgNVBAYTAkZSMRIwEAYDVQQKDAlEaGlteW90aXMxETAPBgNVBAMMCENlcnRpZ25hggkA/tzjAQ/J
+SP8wDgYDVR0PAQH/BAQDAgEGMBEGCWCGSAGG+EIBAQQEAwIABzANBgkqhkiG9w0BAQUFAAOCAQEA
+hQMeknH2Qq/ho2Ge6/PAD/Kl1NqV5ta+aDY9fm4fTIrv0Q8hbV6lUmPOEvjvKtpv6zf+EwLHyzs+
+ImvaYS5/1HI93TDhHkxAGYwP15zRgzB7mFncfca5DClMoTOi62c6ZYTTluLtdkVwj7Ur3vkj1klu
+PBS1xp81HlDQwY9qcEQCYsuuHWhBp6pX6FOqB9IG9tUUBguRA3UsbHK1YZWaDYu5Def131TN3ubY
+1gkIl2PlwS6wt0QmwCbAr1UwnjvVNioZBPRcHv/PLLf/0P2HQBHVESO7SMAhqaQoLf0V+LBOK/Qw
+WyH8EZE0vkHve52Xdf+XlcCWWC/qu0bXu+TZLg==
+-----END CERTIFICATE-----
+
+Cybertrust Global Root
+======================
+-----BEGIN CERTIFICATE-----
+MIIDoTCCAomgAwIBAgILBAAAAAABD4WqLUgwDQYJKoZIhvcNAQEFBQAwOzEYMBYGA1UEChMPQ3li
+ZXJ0cnVzdCwgSW5jMR8wHQYDVQQDExZDeWJlcnRydXN0IEdsb2JhbCBSb290MB4XDTA2MTIxNTA4
+MDAwMFoXDTIxMTIxNTA4MDAwMFowOzEYMBYGA1UEChMPQ3liZXJ0cnVzdCwgSW5jMR8wHQYDVQQD
+ExZDeWJlcnRydXN0IEdsb2JhbCBSb290MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA
++Mi8vRRQZhP/8NN57CPytxrHjoXxEnOmGaoQ25yiZXRadz5RfVb23CO21O1fWLE3TdVJDm71aofW
+0ozSJ8bi/zafmGWgE07GKmSb1ZASzxQG9Dvj1Ci+6A74q05IlG2OlTEQXO2iLb3VOm2yHLtgwEZL
+AfVJrn5GitB0jaEMAs7u/OePuGtm839EAL9mJRQr3RAwHQeWP032a7iPt3sMpTjr3kfb1V05/Iin
+89cqdPHoWqI7n1C6poxFNcJQZZXcY4Lv3b93TZxiyWNzFtApD0mpSPCzqrdsxacwOUBdrsTiXSZT
+8M4cIwhhqJQZugRiQOwfOHB3EgZxpzAYXSUnpQIDAQABo4GlMIGiMA4GA1UdDwEB/wQEAwIBBjAP
+BgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBS2CHsNesysIEyGVjJez6tuhS1wVzA/BgNVHR8EODA2
+MDSgMqAwhi5odHRwOi8vd3d3Mi5wdWJsaWMtdHJ1c3QuY29tL2NybC9jdC9jdHJvb3QuY3JsMB8G
+A1UdIwQYMBaAFLYIew16zKwgTIZWMl7Pq26FLXBXMA0GCSqGSIb3DQEBBQUAA4IBAQBW7wojoFRO
+lZfJ+InaRcHUowAl9B8Tq7ejhVhpwjCt2BWKLePJzYFa+HMjWqd8BfP9IjsO0QbE2zZMcwSO5bAi
+5MXzLqXZI+O4Tkogp24CJJ8iYGd7ix1yCcUxXOl5n4BHPa2hCwcUPUf/A2kaDAtE52Mlp3+yybh2
+hO0j9n0Hq0V+09+zv+mKts2oomcrUtW3ZfA5TGOgkXmTUg9U3YO7n9GPp1Nzw8v/MOx8BLjYRB+T
+X3EJIrduPuocA06dGiBh+4E37F78CkWr1+cXVdCg6mCbpvbjjFspwgZgFJ0tl0ypkxWdYcQBX0jW
+WL1WMRJOEcgh4LMRkWXbtKaIOM5V
+-----END CERTIFICATE-----
+
+ePKI Root Certification Authority
+=================================
+-----BEGIN CERTIFICATE-----
+MIIFsDCCA5igAwIBAgIQFci9ZUdcr7iXAF7kBtK8nTANBgkqhkiG9w0BAQUFADBeMQswCQYDVQQG
+EwJUVzEjMCEGA1UECgwaQ2h1bmdod2EgVGVsZWNvbSBDby4sIEx0ZC4xKjAoBgNVBAsMIWVQS0kg
+Um9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw0wNDEyMjAwMjMxMjdaFw0zNDEyMjAwMjMx
+MjdaMF4xCzAJBgNVBAYTAlRXMSMwIQYDVQQKDBpDaHVuZ2h3YSBUZWxlY29tIENvLiwgTHRkLjEq
+MCgGA1UECwwhZVBLSSBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIICIjANBgkqhkiG9w0B
+AQEFAAOCAg8AMIICCgKCAgEA4SUP7o3biDN1Z82tH306Tm2d0y8U82N0ywEhajfqhFAHSyZbCUNs
+IZ5qyNUD9WBpj8zwIuQf5/dqIjG3LBXy4P4AakP/h2XGtRrBp0xtInAhijHyl3SJCRImHJ7K2RKi
+lTza6We/CKBk49ZCt0Xvl/T29de1ShUCWH2YWEtgvM3XDZoTM1PRYfl61dd4s5oz9wCGzh1NlDiv
+qOx4UXCKXBCDUSH3ET00hl7lSM2XgYI1TBnsZfZrxQWh7kcT1rMhJ5QQCtkkO7q+RBNGMD+XPNjX
+12ruOzjjK9SXDrkb5wdJfzcq+Xd4z1TtW0ado4AOkUPB1ltfFLqfpo0kR0BZv3I4sjZsN/+Z0V0O
+WQqraffAsgRFelQArr5T9rXn4fg8ozHSqf4hUmTFpmfwdQcGlBSBVcYn5AGPF8Fqcde+S/uUWH1+
+ETOxQvdibBjWzwloPn9s9h6PYq2lY9sJpx8iQkEeb5mKPtf5P0B6ebClAZLSnT0IFaUQAS2zMnao
+lQ2zepr7BxB4EW/hj8e6DyUadCrlHJhBmd8hh+iVBmoKs2pHdmX2Os+PYhcZewoozRrSgx4hxyy/
+vv9haLdnG7t4TY3OZ+XkwY63I2binZB1NJipNiuKmpS5nezMirH4JYlcWrYvjB9teSSnUmjDhDXi
+Zo1jDiVN1Rmy5nk3pyKdVDECAwEAAaNqMGgwHQYDVR0OBBYEFB4M97Zn8uGSJglFwFU5Lnc/Qkqi
+MAwGA1UdEwQFMAMBAf8wOQYEZyoHAAQxMC8wLQIBADAJBgUrDgMCGgUAMAcGBWcqAwAABBRFsMLH
+ClZ87lt4DJX5GFPBphzYEDANBgkqhkiG9w0BAQUFAAOCAgEACbODU1kBPpVJufGBuvl2ICO1J2B0
+1GqZNF5sAFPZn/KmsSQHRGoqxqWOeBLoR9lYGxMqXnmbnwoqZ6YlPwZpVnPDimZI+ymBV3QGypzq
+KOg4ZyYr8dW1P2WT+DZdjo2NQCCHGervJ8A9tDkPJXtoUHRVnAxZfVo9QZQlUgjgRywVMRnVvwdV
+xrsStZf0X4OFunHB2WyBEXYKCrC/gpf36j36+uwtqSiUO1bd0lEursC9CBWMd1I0ltabrNMdjmEP
+NXubrjlpC2JgQCA2j6/7Nu4tCEoduL+bXPjqpRugc6bY+G7gMwRfaKonh+3ZwZCc7b3jajWvY9+r
+GNm65ulK6lCKD2GTHuItGeIwlDWSXQ62B68ZgI9HkFFLLk3dheLSClIKF5r8GrBQAuUBo2M3IUxE
+xJtRmREOc5wGj1QupyheRDmHVi03vYVElOEMSyycw5KFNGHLD7ibSkNS/jQ6fbjpKdx2qcgw+BRx
+gMYeNkh0IkFch4LoGHGLQYlE535YW6i4jRPpp2zDR+2zGp1iro2C6pSe3VkQw63d4k3jMdXH7Ojy
+sP6SHhYKGvzZ8/gntsm+HbRsZJB/9OTEW9c3rkIO3aQab3yIVMUWbuF6aC74Or8NpDyJO3inTmOD
+BCEIZ43ygknQW/2xzQ+DhNQ+IIX3Sj0rnP0qCglN6oH4EZw=
+-----END CERTIFICATE-----
+
+certSIGN ROOT CA
+================
+-----BEGIN CERTIFICATE-----
+MIIDODCCAiCgAwIBAgIGIAYFFnACMA0GCSqGSIb3DQEBBQUAMDsxCzAJBgNVBAYTAlJPMREwDwYD
+VQQKEwhjZXJ0U0lHTjEZMBcGA1UECxMQY2VydFNJR04gUk9PVCBDQTAeFw0wNjA3MDQxNzIwMDRa
+Fw0zMTA3MDQxNzIwMDRaMDsxCzAJBgNVBAYTAlJPMREwDwYDVQQKEwhjZXJ0U0lHTjEZMBcGA1UE
+CxMQY2VydFNJR04gUk9PVCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALczuX7I
+JUqOtdu0KBuqV5Do0SLTZLrTk+jUrIZhQGpgV2hUhE28alQCBf/fm5oqrl0Hj0rDKH/v+yv6efHH
+rfAQUySQi2bJqIirr1qjAOm+ukbuW3N7LBeCgV5iLKECZbO9xSsAfsT8AzNXDe3i+s5dRdY4zTW2
+ssHQnIFKquSyAVwdj1+ZxLGt24gh65AIgoDzMKND5pCCrlUoSe1b16kQOA7+j0xbm0bqQfWwCHTD
+0IgztnzXdN/chNFDDnU5oSVAKOp4yw4sLjmdjItuFhwvJoIQ4uNllAoEwF73XVv4EOLQunpL+943
+AAAaWyjj0pxzPjKHmKHJUS/X3qwzs08CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8B
+Af8EBAMCAcYwHQYDVR0OBBYEFOCMm9slSbPxfIbWskKHC9BroNnkMA0GCSqGSIb3DQEBBQUAA4IB
+AQA+0hyJLjX8+HXd5n9liPRyTMks1zJO890ZeUe9jjtbkw9QSSQTaxQGcu8J06Gh40CEyecYMnQ8
+SG4Pn0vU9x7Tk4ZkVJdjclDVVc/6IJMCopvDI5NOFlV2oHB5bc0hH88vLbwZ44gx+FkagQnIl6Z0
+x2DEW8xXjrJ1/RsCCdtZb3KTafcxQdaIOL+Hsr0Wefmq5L6IJd1hJyMctTEHBDa0GpC9oHRxUIlt
+vBTjD4au8as+x6AJzKNI0eDbZOeStc+vckNwi/nDhDwTqn6Sm1dTk/pwwpEOMfmbZ13pljheX7Nz
+TogVZ96edhBiIL5VaZVDADlN9u6wWk5JRFRYX0KD
+-----END CERTIFICATE-----
+
+GeoTrust Primary Certification Authority - G2
+=============================================
+-----BEGIN CERTIFICATE-----
+MIICrjCCAjWgAwIBAgIQPLL0SAoA4v7rJDteYD7DazAKBggqhkjOPQQDAzCBmDELMAkGA1UEBhMC
+VVMxFjAUBgNVBAoTDUdlb1RydXN0IEluYy4xOTA3BgNVBAsTMChjKSAyMDA3IEdlb1RydXN0IElu
+Yy4gLSBGb3IgYXV0aG9yaXplZCB1c2Ugb25seTE2MDQGA1UEAxMtR2VvVHJ1c3QgUHJpbWFyeSBD
+ZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAtIEcyMB4XDTA3MTEwNTAwMDAwMFoXDTM4MDExODIzNTk1
+OVowgZgxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1HZW9UcnVzdCBJbmMuMTkwNwYDVQQLEzAoYykg
+MjAwNyBHZW9UcnVzdCBJbmMuIC0gRm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxNjA0BgNVBAMTLUdl
+b1RydXN0IFByaW1hcnkgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgLSBHMjB2MBAGByqGSM49AgEG
+BSuBBAAiA2IABBWx6P0DFUPlrOuHNxFi79KDNlJ9RVcLSo17VDs6bl8VAsBQps8lL33KSLjHUGMc
+KiEIfJo22Av+0SbFWDEwKCXzXV2juLaltJLtbCyf691DiaI8S0iRHVDsJt/WYC69IaNCMEAwDwYD
+VR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFBVfNVdRVfslsq0DafwBo/q+
+EVXVMAoGCCqGSM49BAMDA2cAMGQCMGSWWaboCd6LuvpaiIjwH5HTRqjySkwCY/tsXzjbLkGTqQ7m
+ndwxHLKgpxgceeHHNgIwOlavmnRs9vuD4DPTCF+hnMJbn0bWtsuRBmOiBuczrD6ogRLQy7rQkgu2
+npaqBA+K
+-----END CERTIFICATE-----
+
+VeriSign Universal Root Certification Authority
+===============================================
+-----BEGIN CERTIFICATE-----
+MIIEuTCCA6GgAwIBAgIQQBrEZCGzEyEDDrvkEhrFHTANBgkqhkiG9w0BAQsFADCBvTELMAkGA1UE
+BhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMR8wHQYDVQQLExZWZXJpU2lnbiBUcnVzdCBO
+ZXR3b3JrMTowOAYDVQQLEzEoYykgMjAwOCBWZXJpU2lnbiwgSW5jLiAtIEZvciBhdXRob3JpemVk
+IHVzZSBvbmx5MTgwNgYDVQQDEy9WZXJpU2lnbiBVbml2ZXJzYWwgUm9vdCBDZXJ0aWZpY2F0aW9u
+IEF1dGhvcml0eTAeFw0wODA0MDIwMDAwMDBaFw0zNzEyMDEyMzU5NTlaMIG9MQswCQYDVQQGEwJV
+UzEXMBUGA1UEChMOVmVyaVNpZ24sIEluYy4xHzAdBgNVBAsTFlZlcmlTaWduIFRydXN0IE5ldHdv
+cmsxOjA4BgNVBAsTMShjKSAyMDA4IFZlcmlTaWduLCBJbmMuIC0gRm9yIGF1dGhvcml6ZWQgdXNl
+IG9ubHkxODA2BgNVBAMTL1ZlcmlTaWduIFVuaXZlcnNhbCBSb290IENlcnRpZmljYXRpb24gQXV0
+aG9yaXR5MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx2E3XrEBNNti1xWb/1hajCMj
+1mCOkdeQmIN65lgZOIzF9uVkhbSicfvtvbnazU0AtMgtc6XHaXGVHzk8skQHnOgO+k1KxCHfKWGP
+MiJhgsWHH26MfF8WIFFE0XBPV+rjHOPMee5Y2A7Cs0WTwCznmhcrewA3ekEzeOEz4vMQGn+HLL72
+9fdC4uW/h2KJXwBL38Xd5HVEMkE6HnFuacsLdUYI0crSK5XQz/u5QGtkjFdN/BMReYTtXlT2NJ8I
+AfMQJQYXStrxHXpma5hgZqTZ79IugvHw7wnqRMkVauIDbjPTrJ9VAMf2CGqUuV/c4DPxhGD5WycR
+tPwW8rtWaoAljQIDAQABo4GyMIGvMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMG0G
+CCsGAQUFBwEMBGEwX6FdoFswWTBXMFUWCWltYWdlL2dpZjAhMB8wBwYFKw4DAhoEFI/l0xqGrI2O
+a8PPgGrUSBgsexkuMCUWI2h0dHA6Ly9sb2dvLnZlcmlzaWduLmNvbS92c2xvZ28uZ2lmMB0GA1Ud
+DgQWBBS2d/ppSEefUxLVwuoHMnYH0ZcHGTANBgkqhkiG9w0BAQsFAAOCAQEASvj4sAPmLGd75JR3
+Y8xuTPl9Dg3cyLk1uXBPY/ok+myDjEedO2Pzmvl2MpWRsXe8rJq+seQxIcaBlVZaDrHC1LGmWazx
+Y8u4TB1ZkErvkBYoH1quEPuBUDgMbMzxPcP1Y+Oz4yHJJDnp/RVmRvQbEdBNc6N9Rvk97ahfYtTx
+P/jgdFcrGJ2BtMQo2pSXpXDrrB2+BxHw1dvd5Yzw1TKwg+ZX4o+/vqGqvz0dtdQ46tewXDpPaj+P
+wGZsY6rp2aQW9IHRlRQOfc2VNNnSj3BzgXucfr2YYdhFh5iQxeuGMMY1v/D/w1WIg0vvBZIGcfK4
+mJO37M2CYfE45k+XmCpajQ==
+-----END CERTIFICATE-----
+
+NetLock Arany (Class Gold) Főtanúsítvány
+========================================
+-----BEGIN CERTIFICATE-----
+MIIEFTCCAv2gAwIBAgIGSUEs5AAQMA0GCSqGSIb3DQEBCwUAMIGnMQswCQYDVQQGEwJIVTERMA8G
+A1UEBwwIQnVkYXBlc3QxFTATBgNVBAoMDE5ldExvY2sgS2Z0LjE3MDUGA1UECwwuVGFuw7pzw610
+dsOhbnlraWFkw7NrIChDZXJ0aWZpY2F0aW9uIFNlcnZpY2VzKTE1MDMGA1UEAwwsTmV0TG9jayBB
+cmFueSAoQ2xhc3MgR29sZCkgRsWRdGFuw7pzw610dsOhbnkwHhcNMDgxMjExMTUwODIxWhcNMjgx
+MjA2MTUwODIxWjCBpzELMAkGA1UEBhMCSFUxETAPBgNVBAcMCEJ1ZGFwZXN0MRUwEwYDVQQKDAxO
+ZXRMb2NrIEtmdC4xNzA1BgNVBAsMLlRhbsO6c8OtdHbDoW55a2lhZMOzayAoQ2VydGlmaWNhdGlv
+biBTZXJ2aWNlcykxNTAzBgNVBAMMLE5ldExvY2sgQXJhbnkgKENsYXNzIEdvbGQpIEbFkXRhbsO6
+c8OtdHbDoW55MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxCRec75LbRTDofTjl5Bu
+0jBFHjzuZ9lk4BqKf8owyoPjIMHj9DrTlF8afFttvzBPhCf2nx9JvMaZCpDyD/V/Q4Q3Y1GLeqVw
+/HpYzY6b7cNGbIRwXdrzAZAj/E4wqX7hJ2Pn7WQ8oLjJM2P+FpD/sLj916jAwJRDC7bVWaaeVtAk
+H3B5r9s5VA1lddkVQZQBr17s9o3x/61k/iCa11zr/qYfCGSji3ZVrR47KGAuhyXoqq8fxmRGILdw
+fzzeSNuWU7c5d+Qa4scWhHaXWy+7GRWF+GmF9ZmnqfI0p6m2pgP8b4Y9VHx2BJtr+UBdADTHLpl1
+neWIA6pN+APSQnbAGwIDAKiLo0UwQzASBgNVHRMBAf8ECDAGAQH/AgEEMA4GA1UdDwEB/wQEAwIB
+BjAdBgNVHQ4EFgQUzPpnk/C2uNClwB7zU/2MU9+D15YwDQYJKoZIhvcNAQELBQADggEBAKt/7hwW
+qZw8UQCgwBEIBaeZ5m8BiFRhbvG5GK1Krf6BQCOUL/t1fC8oS2IkgYIL9WHxHG64YTjrgfpioTta
+YtOUZcTh5m2C+C8lcLIhJsFyUR+MLMOEkMNaj7rP9KdlpeuY0fsFskZ1FSNqb4VjMIDw1Z4fKRzC
+bLBQWV2QWzuoDTDPv31/zvGdg73JRm4gpvlhUbohL3u+pRVjodSVh/GeufOJ8z2FuLjbvrW5Kfna
+NwUASZQDhETnv0Mxz3WLJdH0pmT1kvarBes96aULNmLazAZfNou2XjG4Kvte9nHfRCaexOYNkbQu
+dZWAUWpLMKawYqGT8ZvYzsRjdT9ZR7E=
+-----END CERTIFICATE-----
+
+Hongkong Post Root CA 1
+=======================
+-----BEGIN CERTIFICATE-----
+MIIDMDCCAhigAwIBAgICA+gwDQYJKoZIhvcNAQEFBQAwRzELMAkGA1UEBhMCSEsxFjAUBgNVBAoT
+DUhvbmdrb25nIFBvc3QxIDAeBgNVBAMTF0hvbmdrb25nIFBvc3QgUm9vdCBDQSAxMB4XDTAzMDUx
+NTA1MTMxNFoXDTIzMDUxNTA0NTIyOVowRzELMAkGA1UEBhMCSEsxFjAUBgNVBAoTDUhvbmdrb25n
+IFBvc3QxIDAeBgNVBAMTF0hvbmdrb25nIFBvc3QgUm9vdCBDQSAxMIIBIjANBgkqhkiG9w0BAQEF
+AAOCAQ8AMIIBCgKCAQEArP84tulmAknjorThkPlAj3n54r15/gK97iSSHSL22oVyaf7XPwnU3ZG1
+ApzQjVrhVcNQhrkpJsLj2aDxaQMoIIBFIi1WpztUlVYiWR8o3x8gPW2iNr4joLFutbEnPzlTCeqr
+auh0ssJlXI6/fMN4hM2eFvz1Lk8gKgifd/PFHsSaUmYeSF7jEAaPIpjhZY4bXSNmO7ilMlHIhqqh
+qZ5/dpTCpmy3QfDVyAY45tQM4vM7TG1QjMSDJ8EThFk9nnV0ttgCXjqQesBCNnLsak3c78QA3xMY
+V18meMjWCnl3v/evt3a5pQuEF10Q6m/hq5URX208o1xNg1vysxmKgIsLhwIDAQABoyYwJDASBgNV
+HRMBAf8ECDAGAQH/AgEDMA4GA1UdDwEB/wQEAwIBxjANBgkqhkiG9w0BAQUFAAOCAQEADkbVPK7i
+h9legYsCmEEIjEy82tvuJxuC52pF7BaLT4Wg87JwvVqWuspube5Gi27nKi6Wsxkz67SfqLI37pio
+l7Yutmcn1KZJ/RyTZXaeQi/cImyaT/JaFTmxcdcrUehtHJjA2Sr0oYJ71clBoiMBdDhViw+5Lmei
+IAQ32pwL0xch4I+XeTRvhEgCIDMb5jREn5Fw9IBehEPCKdJsEhTkYY2sEJCehFC78JZvRZ+K88ps
+T/oROhUVRsPNH4NbLUES7VBnQRM9IauUiqpOfMGx+6fWtScvl6tu4B3i0RwsH0Ti/L6RoZz71ilT
+c4afU9hDDl3WY4JxHYB0yvbiAmvZWg==
+-----END CERTIFICATE-----
+
+SecureSign RootCA11
+===================
+-----BEGIN CERTIFICATE-----
+MIIDbTCCAlWgAwIBAgIBATANBgkqhkiG9w0BAQUFADBYMQswCQYDVQQGEwJKUDErMCkGA1UEChMi
+SmFwYW4gQ2VydGlmaWNhdGlvbiBTZXJ2aWNlcywgSW5jLjEcMBoGA1UEAxMTU2VjdXJlU2lnbiBS
+b290Q0ExMTAeFw0wOTA0MDgwNDU2NDdaFw0yOTA0MDgwNDU2NDdaMFgxCzAJBgNVBAYTAkpQMSsw
+KQYDVQQKEyJKYXBhbiBDZXJ0aWZpY2F0aW9uIFNlcnZpY2VzLCBJbmMuMRwwGgYDVQQDExNTZWN1
+cmVTaWduIFJvb3RDQTExMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA/XeqpRyQBTvL
+TJszi1oURaTnkBbR31fSIRCkF/3frNYfp+TbfPfs37gD2pRY/V1yfIw/XwFndBWW4wI8h9uuywGO
+wvNmxoVF9ALGOrVisq/6nL+k5tSAMJjzDbaTj6nU2DbysPyKyiyhFTOVMdrAG/LuYpmGYz+/3ZMq
+g6h2uRMft85OQoWPIucuGvKVCbIFtUROd6EgvanyTgp9UK31BQ1FT0Zx/Sg+U/sE2C3XZR1KG/rP
+O7AxmjVuyIsG0wCR8pQIZUyxNAYAeoni8McDWc/V1uinMrPmmECGxc0nEovMe863ETxiYAcjPitA
+bpSACW22s293bzUIUPsCh8U+iQIDAQABo0IwQDAdBgNVHQ4EFgQUW/hNT7KlhtQ60vFjmqC+CfZX
+t94wDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAKCh
+OBZmLqdWHyGcBvod7bkixTgm2E5P7KN/ed5GIaGHd48HCJqypMWvDzKYC3xmKbabfSVSSUOrTC4r
+bnpwrxYO4wJs+0LmGJ1F2FXI6Dvd5+H0LgscNFxsWEr7jIhQX5Ucv+2rIrVls4W6ng+4reV6G4pQ
+Oh29Dbx7VFALuUKvVaAYga1lme++5Jy/xIWrQbJUb9wlze144o4MjQlJ3WN7WmmWAiGovVJZ6X01
+y8hSyn+B/tlr0/cR7SXf+Of5pPpyl4RTDaXQMhhRdlkUbA/r7F+AjHVDg8OFmP9Mni0N5HeDk061
+lgeLKBObjBmNQSdJQO7e5iNEOdyhIta6A/I=
+-----END CERTIFICATE-----
+
+Microsec e-Szigno Root CA 2009
+==============================
+-----BEGIN CERTIFICATE-----
+MIIECjCCAvKgAwIBAgIJAMJ+QwRORz8ZMA0GCSqGSIb3DQEBCwUAMIGCMQswCQYDVQQGEwJIVTER
+MA8GA1UEBwwIQnVkYXBlc3QxFjAUBgNVBAoMDU1pY3Jvc2VjIEx0ZC4xJzAlBgNVBAMMHk1pY3Jv
+c2VjIGUtU3ppZ25vIFJvb3QgQ0EgMjAwOTEfMB0GCSqGSIb3DQEJARYQaW5mb0BlLXN6aWduby5o
+dTAeFw0wOTA2MTYxMTMwMThaFw0yOTEyMzAxMTMwMThaMIGCMQswCQYDVQQGEwJIVTERMA8GA1UE
+BwwIQnVkYXBlc3QxFjAUBgNVBAoMDU1pY3Jvc2VjIEx0ZC4xJzAlBgNVBAMMHk1pY3Jvc2VjIGUt
+U3ppZ25vIFJvb3QgQ0EgMjAwOTEfMB0GCSqGSIb3DQEJARYQaW5mb0BlLXN6aWduby5odTCCASIw
+DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOn4j/NjrdqG2KfgQvvPkd6mJviZpWNwrZuuyjNA
+fW2WbqEORO7hE52UQlKavXWFdCyoDh2Tthi3jCyoz/tccbna7P7ofo/kLx2yqHWH2Leh5TvPmUpG
+0IMZfcChEhyVbUr02MelTTMuhTlAdX4UfIASmFDHQWe4oIBhVKZsTh/gnQ4H6cm6M+f+wFUoLAKA
+pxn1ntxVUwOXewdI/5n7N4okxFnMUBBjjqqpGrCEGob5X7uxUG6k0QrM1XF+H6cbfPVTbiJfyyvm
+1HxdrtbCxkzlBQHZ7Vf8wSN5/PrIJIOV87VqUQHQd9bpEqH5GoP7ghu5sJf0dgYzQ0mg/wu1+rUC
+AwEAAaOBgDB+MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBTLD8bf
+QkPMPcu1SCOhGnqmKrs0aDAfBgNVHSMEGDAWgBTLD8bfQkPMPcu1SCOhGnqmKrs0aDAbBgNVHREE
+FDASgRBpbmZvQGUtc3ppZ25vLmh1MA0GCSqGSIb3DQEBCwUAA4IBAQDJ0Q5eLtXMs3w+y/w9/w0o
+lZMEyL/azXm4Q5DwpL7v8u8hmLzU1F0G9u5C7DBsoKqpyvGvivo/C3NqPuouQH4frlRheesuCDfX
+I/OMn74dseGkddug4lQUsbocKaQY9hK6ohQU4zE1yED/t+AFdlfBHFny+L/k7SViXITwfn4fs775
+tyERzAMBVnCnEJIeGzSBHq2cGsMEPO0CYdYeBvNfOofyK/FFh+U9rNHHV4S9a67c2Pm2G2JwCz02
+yULyMtd6YebS2z3PyKnJm9zbWETXbzivf3jTo60adbocwTZ8jx5tHMN1Rq41Bab2XD0h7lbwyYIi
+LXpUq3DDfSJlgnCW
+-----END CERTIFICATE-----
+
+GlobalSign Root CA - R3
+=======================
+-----BEGIN CERTIFICATE-----
+MIIDXzCCAkegAwIBAgILBAAAAAABIVhTCKIwDQYJKoZIhvcNAQELBQAwTDEgMB4GA1UECxMXR2xv
+YmFsU2lnbiBSb290IENBIC0gUjMxEzARBgNVBAoTCkdsb2JhbFNpZ24xEzARBgNVBAMTCkdsb2Jh
+bFNpZ24wHhcNMDkwMzE4MTAwMDAwWhcNMjkwMzE4MTAwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxT
+aWduIFJvb3QgQ0EgLSBSMzETMBEGA1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2ln
+bjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMwldpB5BngiFvXAg7aEyiie/QV2EcWt
+iHL8RgJDx7KKnQRfJMsuS+FggkbhUqsMgUdwbN1k0ev1LKMPgj0MK66X17YUhhB5uzsTgHeMCOFJ
+0mpiLx9e+pZo34knlTifBtc+ycsmWQ1z3rDI6SYOgxXG71uL0gRgykmmKPZpO/bLyCiR5Z2KYVc3
+rHQU3HTgOu5yLy6c+9C7v/U9AOEGM+iCK65TpjoWc4zdQQ4gOsC0p6Hpsk+QLjJg6VfLuQSSaGjl
+OCZgdbKfd/+RFO+uIEn8rUAVSNECMWEZXriX7613t2Saer9fwRPvm2L7DWzgVGkWqQPabumDk3F2
+xmmFghcCAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYE
+FI/wS3+oLkUkrk1Q+mOai97i3Ru8MA0GCSqGSIb3DQEBCwUAA4IBAQBLQNvAUKr+yAzv95ZURUm7
+lgAJQayzE4aGKAczymvmdLm6AC2upArT9fHxD4q/c2dKg8dEe3jgr25sbwMpjjM5RcOO5LlXbKr8
+EpbsU8Yt5CRsuZRj+9xTaGdWPoO4zzUhw8lo/s7awlOqzJCK6fBdRoyV3XpYKBovHd7NADdBj+1E
+bddTKJd+82cEHhXXipa0095MJ6RMG3NzdvQXmcIfeg7jLQitChws/zyrVQ4PkX4268NXSb7hLi18
+YIvDQVETI53O9zJrlAGomecsMx86OyXShkDOOyyGeMlhLxS67ttVb9+E7gUJTb0o2HLO02JQZR7r
+kpeDMdmztcpHWD9f
+-----END CERTIFICATE-----
+
+Autoridad de Certificacion Firmaprofesional CIF A62634068
+=========================================================
+-----BEGIN CERTIFICATE-----
+MIIGFDCCA/ygAwIBAgIIU+w77vuySF8wDQYJKoZIhvcNAQEFBQAwUTELMAkGA1UEBhMCRVMxQjBA
+BgNVBAMMOUF1dG9yaWRhZCBkZSBDZXJ0aWZpY2FjaW9uIEZpcm1hcHJvZmVzaW9uYWwgQ0lGIEE2
+MjYzNDA2ODAeFw0wOTA1MjAwODM4MTVaFw0zMDEyMzEwODM4MTVaMFExCzAJBgNVBAYTAkVTMUIw
+QAYDVQQDDDlBdXRvcmlkYWQgZGUgQ2VydGlmaWNhY2lvbiBGaXJtYXByb2Zlc2lvbmFsIENJRiBB
+NjI2MzQwNjgwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDKlmuO6vj78aI14H9M2uDD
+Utd9thDIAl6zQyrET2qyyhxdKJp4ERppWVevtSBC5IsP5t9bpgOSL/UR5GLXMnE42QQMcas9UX4P
+B99jBVzpv5RvwSmCwLTaUbDBPLutN0pcyvFLNg4kq7/DhHf9qFD0sefGL9ItWY16Ck6WaVICqjaY
+7Pz6FIMMNx/Jkjd/14Et5cS54D40/mf0PmbR0/RAz15iNA9wBj4gGFrO93IbJWyTdBSTo3OxDqqH
+ECNZXyAFGUftaI6SEspd/NYrspI8IM/hX68gvqB2f3bl7BqGYTM+53u0P6APjqK5am+5hyZvQWyI
+plD9amML9ZMWGxmPsu2bm8mQ9QEM3xk9Dz44I8kvjwzRAv4bVdZO0I08r0+k8/6vKtMFnXkIoctX
+MbScyJCyZ/QYFpM6/EfY0XiWMR+6KwxfXZmtY4laJCB22N/9q06mIqqdXuYnin1oKaPnirjaEbsX
+LZmdEyRG98Xi2J+Of8ePdG1asuhy9azuJBCtLxTa/y2aRnFHvkLfuwHb9H/TKI8xWVvTyQKmtFLK
+bpf7Q8UIJm+K9Lv9nyiqDdVF8xM6HdjAeI9BZzwelGSuewvF6NkBiDkal4ZkQdU7hwxu+g/GvUgU
+vzlN1J5Bto+WHWOWk9mVBngxaJ43BjuAiUVhOSPHG0SjFeUc+JIwuwIDAQABo4HvMIHsMBIGA1Ud
+EwEB/wQIMAYBAf8CAQEwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBRlzeurNR4APn7VdMActHNH
+DhpkLzCBpgYDVR0gBIGeMIGbMIGYBgRVHSAAMIGPMC8GCCsGAQUFBwIBFiNodHRwOi8vd3d3LmZp
+cm1hcHJvZmVzaW9uYWwuY29tL2NwczBcBggrBgEFBQcCAjBQHk4AUABhAHMAZQBvACAAZABlACAA
+bABhACAAQgBvAG4AYQBuAG8AdgBhACAANAA3ACAAQgBhAHIAYwBlAGwAbwBuAGEAIAAwADgAMAAx
+ADcwDQYJKoZIhvcNAQEFBQADggIBABd9oPm03cXF661LJLWhAqvdpYhKsg9VSytXjDvlMd3+xDLx
+51tkljYyGOylMnfX40S2wBEqgLk9am58m9Ot/MPWo+ZkKXzR4Tgegiv/J2Wv+xYVxC5xhOW1//qk
+R71kMrv2JYSiJ0L1ILDCExARzRAVukKQKtJE4ZYm6zFIEv0q2skGz3QeqUvVhyj5eTSSPi5E6PaP
+T481PyWzOdxjKpBrIF/EUhJOlywqrJ2X3kjyo2bbwtKDlaZmp54lD+kLM5FlClrD2VQS3a/DTg4f
+Jl4N3LON7NWBcN7STyQF82xO9UxJZo3R/9ILJUFI/lGExkKvgATP0H5kSeTy36LssUzAKh3ntLFl
+osS88Zj0qnAHY7S42jtM+kAiMFsRpvAFDsYCA0irhpuF3dvd6qJ2gHN99ZwExEWN57kci57q13XR
+crHedUTnQn3iV2t93Jm8PYMo6oCTjcVMZcFwgbg4/EMxsvYDNEeyrPsiBsse3RdHHF9mudMaotoR
+saS8I8nkvof/uZS2+F0gStRf571oe2XyFR7SOqkt6dhrJKyXWERHrVkY8SFlcN7ONGCoQPHzPKTD
+KCOM/iczQ0CgFzzr6juwcqajuUpLXhZI9LK8yIySxZ2frHI2vDSANGupi5LAuBft7HZT9SQBjLMi
+6Et8Vcad+qMUu2WFbm5PEn4KPJ2V
+-----END CERTIFICATE-----
+
+Izenpe.com
+==========
+-----BEGIN CERTIFICATE-----
+MIIF8TCCA9mgAwIBAgIQALC3WhZIX7/hy/WL1xnmfTANBgkqhkiG9w0BAQsFADA4MQswCQYDVQQG
+EwJFUzEUMBIGA1UECgwLSVpFTlBFIFMuQS4xEzARBgNVBAMMCkl6ZW5wZS5jb20wHhcNMDcxMjEz
+MTMwODI4WhcNMzcxMjEzMDgyNzI1WjA4MQswCQYDVQQGEwJFUzEUMBIGA1UECgwLSVpFTlBFIFMu
+QS4xEzARBgNVBAMMCkl6ZW5wZS5jb20wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDJ
+03rKDx6sp4boFmVqscIbRTJxldn+EFvMr+eleQGPicPK8lVx93e+d5TzcqQsRNiekpsUOqHnJJAK
+ClaOxdgmlOHZSOEtPtoKct2jmRXagaKH9HtuJneJWK3W6wyyQXpzbm3benhB6QiIEn6HLmYRY2xU
++zydcsC8Lv/Ct90NduM61/e0aL6i9eOBbsFGb12N4E3GVFWJGjMxCrFXuaOKmMPsOzTFlUFpfnXC
+PCDFYbpRR6AgkJOhkEvzTnyFRVSa0QUmQbC1TR0zvsQDyCV8wXDbO/QJLVQnSKwv4cSsPsjLkkxT
+OTcj7NMB+eAJRE1NZMDhDVqHIrytG6P+JrUV86f8hBnp7KGItERphIPzidF0BqnMC9bC3ieFUCbK
+F7jJeodWLBoBHmy+E60QrLUk9TiRodZL2vG70t5HtfG8gfZZa88ZU+mNFctKy6lvROUbQc/hhqfK
+0GqfvEyNBjNaooXlkDWgYlwWTvDjovoDGrQscbNYLN57C9saD+veIR8GdwYDsMnvmfzAuU8Lhij+
+0rnq49qlw0dpEuDb8PYZi+17cNcC1u2HGCgsBCRMd+RIihrGO5rUD8r6ddIBQFqNeb+Lz0vPqhbB
+leStTIo+F5HUsWLlguWABKQDfo2/2n+iD5dPDNMN+9fR5XJ+HMh3/1uaD7euBUbl8agW7EekFwID
+AQABo4H2MIHzMIGwBgNVHREEgagwgaWBD2luZm9AaXplbnBlLmNvbaSBkTCBjjFHMEUGA1UECgw+
+SVpFTlBFIFMuQS4gLSBDSUYgQTAxMzM3MjYwLVJNZXJjLlZpdG9yaWEtR2FzdGVpeiBUMTA1NSBG
+NjIgUzgxQzBBBgNVBAkMOkF2ZGEgZGVsIE1lZGl0ZXJyYW5lbyBFdG9yYmlkZWEgMTQgLSAwMTAx
+MCBWaXRvcmlhLUdhc3RlaXowDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0O
+BBYEFB0cZQ6o8iV7tJHP5LGx5r1VdGwFMA0GCSqGSIb3DQEBCwUAA4ICAQB4pgwWSp9MiDrAyw6l
+Fn2fuUhfGI8NYjb2zRlrrKvV9pF9rnHzP7MOeIWblaQnIUdCSnxIOvVFfLMMjlF4rJUT3sb9fbga
+kEyrkgPH7UIBzg/YsfqikuFgba56awmqxinuaElnMIAkejEWOVt+8Rwu3WwJrfIxwYJOubv5vr8q
+hT/AQKM6WfxZSzwoJNu0FXWuDYi6LnPAvViH5ULy617uHjAimcs30cQhbIHsvm0m5hzkQiCeR7Cs
+g1lwLDXWrzY0tM07+DKo7+N4ifuNRSzanLh+QBxh5z6ikixL8s36mLYp//Pye6kfLqCTVyvehQP5
+aTfLnnhqBbTFMXiJ7HqnheG5ezzevh55hM6fcA5ZwjUukCox2eRFekGkLhObNA5me0mrZJfQRsN5
+nXJQY6aYWwa9SG3YOYNw6DXwBdGqvOPbyALqfP2C2sJbUjWumDqtujWTI6cfSN01RpiyEGjkpTHC
+ClguGYEQyVB1/OpaFs4R1+7vUIgtYf8/QnMFlEPVjjxOAToZpR9GTnfQXeWBIiGH/pR9hNiTrdZo
+Q0iy2+tzJOeRf1SktoA+naM8THLCV8Sg1Mw4J87VBp6iSNnpn86CcDaTmjvfliHjWbcM2pE38P1Z
+WrOZyGlsQyYBNWNgVYkDOnXYukrZVP/u3oDYLdE41V4tC5h9Pmzb/CaIxw==
+-----END CERTIFICATE-----
+
+Chambers of Commerce Root - 2008
+================================
+-----BEGIN CERTIFICATE-----
+MIIHTzCCBTegAwIBAgIJAKPaQn6ksa7aMA0GCSqGSIb3DQEBBQUAMIGuMQswCQYDVQQGEwJFVTFD
+MEEGA1UEBxM6TWFkcmlkIChzZWUgY3VycmVudCBhZGRyZXNzIGF0IHd3dy5jYW1lcmZpcm1hLmNv
+bS9hZGRyZXNzKTESMBAGA1UEBRMJQTgyNzQzMjg3MRswGQYDVQQKExJBQyBDYW1lcmZpcm1hIFMu
+QS4xKTAnBgNVBAMTIENoYW1iZXJzIG9mIENvbW1lcmNlIFJvb3QgLSAyMDA4MB4XDTA4MDgwMTEy
+Mjk1MFoXDTM4MDczMTEyMjk1MFowga4xCzAJBgNVBAYTAkVVMUMwQQYDVQQHEzpNYWRyaWQgKHNl
+ZSBjdXJyZW50IGFkZHJlc3MgYXQgd3d3LmNhbWVyZmlybWEuY29tL2FkZHJlc3MpMRIwEAYDVQQF
+EwlBODI3NDMyODcxGzAZBgNVBAoTEkFDIENhbWVyZmlybWEgUy5BLjEpMCcGA1UEAxMgQ2hhbWJl
+cnMgb2YgQ29tbWVyY2UgUm9vdCAtIDIwMDgwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoIC
+AQCvAMtwNyuAWko6bHiUfaN/Gh/2NdW928sNRHI+JrKQUrpjOyhYb6WzbZSm891kDFX29ufyIiKA
+XuFixrYp4YFs8r/lfTJqVKAyGVn+H4vXPWCGhSRv4xGzdz4gljUha7MI2XAuZPeEklPWDrCQiorj
+h40G072QDuKZoRuGDtqaCrsLYVAGUvGef3bsyw/QHg3PmTA9HMRFEFis1tPo1+XqxQEHd9ZR5gN/
+ikilTWh1uem8nk4ZcfUyS5xtYBkL+8ydddy/Js2Pk3g5eXNeJQ7KXOt3EgfLZEFHcpOrUMPrCXZk
+NNI5t3YRCQ12RcSprj1qr7V9ZS+UWBDsXHyvfuK2GNnQm05aSd+pZgvMPMZ4fKecHePOjlO+Bd5g
+D2vlGts/4+EhySnB8esHnFIbAURRPHsl18TlUlRdJQfKFiC4reRB7noI/plvg6aRArBsNlVq5331
+lubKgdaX8ZSD6e2wsWsSaR6s+12pxZjptFtYer49okQ6Y1nUCyXeG0+95QGezdIp1Z8XGQpvvwyQ
+0wlf2eOKNcx5Wk0ZN5K3xMGtr/R5JJqyAQuxr1yW84Ay+1w9mPGgP0revq+ULtlVmhduYJ1jbLhj
+ya6BXBg14JC7vjxPNyK5fuvPnnchpj04gftI2jE9K+OJ9dC1vX7gUMQSibMjmhAxhduub+84Mxh2
+EQIDAQABo4IBbDCCAWgwEgYDVR0TAQH/BAgwBgEB/wIBDDAdBgNVHQ4EFgQU+SSsD7K1+HnA+mCI
+G8TZTQKeFxkwgeMGA1UdIwSB2zCB2IAU+SSsD7K1+HnA+mCIG8TZTQKeFxmhgbSkgbEwga4xCzAJ
+BgNVBAYTAkVVMUMwQQYDVQQHEzpNYWRyaWQgKHNlZSBjdXJyZW50IGFkZHJlc3MgYXQgd3d3LmNh
+bWVyZmlybWEuY29tL2FkZHJlc3MpMRIwEAYDVQQFEwlBODI3NDMyODcxGzAZBgNVBAoTEkFDIENh
+bWVyZmlybWEgUy5BLjEpMCcGA1UEAxMgQ2hhbWJlcnMgb2YgQ29tbWVyY2UgUm9vdCAtIDIwMDiC
+CQCj2kJ+pLGu2jAOBgNVHQ8BAf8EBAMCAQYwPQYDVR0gBDYwNDAyBgRVHSAAMCowKAYIKwYBBQUH
+AgEWHGh0dHA6Ly9wb2xpY3kuY2FtZXJmaXJtYS5jb20wDQYJKoZIhvcNAQEFBQADggIBAJASryI1
+wqM58C7e6bXpeHxIvj99RZJe6dqxGfwWPJ+0W2aeaufDuV2I6A+tzyMP3iU6XsxPpcG1Lawk0lgH
+3qLPaYRgM+gQDROpI9CF5Y57pp49chNyM/WqfcZjHwj0/gF/JM8rLFQJ3uIrbZLGOU8W6jx+ekbU
+RWpGqOt1glanq6B8aBMz9p0w8G8nOSQjKpD9kCk18pPfNKXG9/jvjA9iSnyu0/VU+I22mlaHFoI6
+M6taIgj3grrqLuBHmrS1RaMFO9ncLkVAO+rcf+g769HsJtg1pDDFOqxXnrN2pSB7+R5KBWIBpih1
+YJeSDW4+TTdDDZIVnBgizVGZoCkaPF+KMjNbMMeJL0eYD6MDxvbxrN8y8NmBGuScvfaAFPDRLLmF
+9dijscilIeUcE5fuDr3fKanvNFNb0+RqE4QGtjICxFKuItLcsiFCGtpA8CnJ7AoMXOLQusxI0zcK
+zBIKinmwPQN/aUv0NCB9szTqjktk9T79syNnFQ0EuPAtwQlRPLJsFfClI9eDdOTlLsn+mCdCxqvG
+nrDQWzilm1DefhiYtUU79nm06PcaewaD+9CL2rvHvRirCG88gGtAPxkZumWK5r7VXNM21+9AUiRg
+OGcEMeyP84LG3rlV8zsxkVrctQgVrXYlCg17LofiDKYGvCYQbTed7N14jHyAxfDZd0jQ
+-----END CERTIFICATE-----
+
+Global Chambersign Root - 2008
+==============================
+-----BEGIN CERTIFICATE-----
+MIIHSTCCBTGgAwIBAgIJAMnN0+nVfSPOMA0GCSqGSIb3DQEBBQUAMIGsMQswCQYDVQQGEwJFVTFD
+MEEGA1UEBxM6TWFkcmlkIChzZWUgY3VycmVudCBhZGRyZXNzIGF0IHd3dy5jYW1lcmZpcm1hLmNv
+bS9hZGRyZXNzKTESMBAGA1UEBRMJQTgyNzQzMjg3MRswGQYDVQQKExJBQyBDYW1lcmZpcm1hIFMu
+QS4xJzAlBgNVBAMTHkdsb2JhbCBDaGFtYmVyc2lnbiBSb290IC0gMjAwODAeFw0wODA4MDExMjMx
+NDBaFw0zODA3MzExMjMxNDBaMIGsMQswCQYDVQQGEwJFVTFDMEEGA1UEBxM6TWFkcmlkIChzZWUg
+Y3VycmVudCBhZGRyZXNzIGF0IHd3dy5jYW1lcmZpcm1hLmNvbS9hZGRyZXNzKTESMBAGA1UEBRMJ
+QTgyNzQzMjg3MRswGQYDVQQKExJBQyBDYW1lcmZpcm1hIFMuQS4xJzAlBgNVBAMTHkdsb2JhbCBD
+aGFtYmVyc2lnbiBSb290IC0gMjAwODCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMDf
+VtPkOpt2RbQT2//BthmLN0EYlVJH6xedKYiONWwGMi5HYvNJBL99RDaxccy9Wglz1dmFRP+RVyXf
+XjaOcNFccUMd2drvXNL7G706tcuto8xEpw2uIRU/uXpbknXYpBI4iRmKt4DS4jJvVpyR1ogQC7N0
+ZJJ0YPP2zxhPYLIj0Mc7zmFLmY/CDNBAspjcDahOo7kKrmCgrUVSY7pmvWjg+b4aqIG7HkF4ddPB
+/gBVsIdU6CeQNR1MM62X/JcumIS/LMmjv9GYERTtY/jKmIhYF5ntRQOXfjyGHoiMvvKRhI9lNNgA
+TH23MRdaKXoKGCQwoze1eqkBfSbW+Q6OWfH9GzO1KTsXO0G2Id3UwD2ln58fQ1DJu7xsepeY7s2M
+H/ucUa6LcL0nn3HAa6x9kGbo1106DbDVwo3VyJ2dwW3Q0L9R5OP4wzg2rtandeavhENdk5IMagfe
+Ox2YItaswTXbo6Al/3K1dh3ebeksZixShNBFks4c5eUzHdwHU1SjqoI7mjcv3N2gZOnm3b2u/GSF
+HTynyQbehP9r6GsaPMWis0L7iwk+XwhSx2LE1AVxv8Rk5Pihg+g+EpuoHtQ2TS9x9o0o9oOpE9Jh
+wZG7SMA0j0GMS0zbaRL/UJScIINZc+18ofLx/d33SdNDWKBWY8o9PeU1VlnpDsogzCtLkykPAgMB
+AAGjggFqMIIBZjASBgNVHRMBAf8ECDAGAQH/AgEMMB0GA1UdDgQWBBS5CcqcHtvTbDprru1U8VuT
+BjUuXjCB4QYDVR0jBIHZMIHWgBS5CcqcHtvTbDprru1U8VuTBjUuXqGBsqSBrzCBrDELMAkGA1UE
+BhMCRVUxQzBBBgNVBAcTOk1hZHJpZCAoc2VlIGN1cnJlbnQgYWRkcmVzcyBhdCB3d3cuY2FtZXJm
+aXJtYS5jb20vYWRkcmVzcykxEjAQBgNVBAUTCUE4Mjc0MzI4NzEbMBkGA1UEChMSQUMgQ2FtZXJm
+aXJtYSBTLkEuMScwJQYDVQQDEx5HbG9iYWwgQ2hhbWJlcnNpZ24gUm9vdCAtIDIwMDiCCQDJzdPp
+1X0jzjAOBgNVHQ8BAf8EBAMCAQYwPQYDVR0gBDYwNDAyBgRVHSAAMCowKAYIKwYBBQUHAgEWHGh0
+dHA6Ly9wb2xpY3kuY2FtZXJmaXJtYS5jb20wDQYJKoZIhvcNAQEFBQADggIBAICIf3DekijZBZRG
+/5BXqfEv3xoNa/p8DhxJJHkn2EaqbylZUohwEurdPfWbU1Rv4WCiqAm57OtZfMY18dwY6fFn5a+6
+ReAJ3spED8IXDneRRXozX1+WLGiLwUePmJs9wOzL9dWCkoQ10b42OFZyMVtHLaoXpGNR6woBrX/s
+dZ7LoR/xfxKxueRkf2fWIyr0uDldmOghp+G9PUIadJpwr2hsUF1Jz//7Dl3mLEfXgTpZALVza2Mg
+9jFFCDkO9HB+QHBaP9BrQql0PSgvAm11cpUJjUhjxsYjV5KTXjXBjfkK9yydYhz2rXzdpjEetrHH
+foUm+qRqtdpjMNHvkzeyZi99Bffnt0uYlDXA2TopwZ2yUDMdSqlapskD7+3056huirRXhOukP9Du
+qqqHW2Pok+JrqNS4cnhrG+055F3Lm6qH1U9OAP7Zap88MQ8oAgF9mOinsKJknnn4SPIVqczmyETr
+P3iZ8ntxPjzxmKfFGBI/5rsoM0LpRQp8bfKGeS/Fghl9CYl8slR2iK7ewfPM4W7bMdaTrpmg7yVq
+c5iJWzouE4gev8CSlDQb4ye3ix5vQv/n6TebUB0tovkC7stYWDpxvGjjqsGvHCgfotwjZT+B6q6Z
+09gwzxMNTxXJhLynSC34MCN32EZLeW32jO06f2ARePTpm67VVMB0gNELQp/B
+-----END CERTIFICATE-----
+
+Go Daddy Root Certificate Authority - G2
+========================================
+-----BEGIN CERTIFICATE-----
+MIIDxTCCAq2gAwIBAgIBADANBgkqhkiG9w0BAQsFADCBgzELMAkGA1UEBhMCVVMxEDAOBgNVBAgT
+B0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxGjAYBgNVBAoTEUdvRGFkZHkuY29tLCBJbmMu
+MTEwLwYDVQQDEyhHbyBEYWRkeSBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5
+MDkwMTAwMDAwMFoXDTM3MTIzMTIzNTk1OVowgYMxCzAJBgNVBAYTAlVTMRAwDgYDVQQIEwdBcml6
+b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMRowGAYDVQQKExFHb0RhZGR5LmNvbSwgSW5jLjExMC8G
+A1UEAxMoR28gRGFkZHkgUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZI
+hvcNAQEBBQADggEPADCCAQoCggEBAL9xYgjx+lk09xvJGKP3gElY6SKDE6bFIEMBO4Tx5oVJnyfq
+9oQbTqC023CYxzIBsQU+B07u9PpPL1kwIuerGVZr4oAH/PMWdYA5UXvl+TW2dE6pjYIT5LY/qQOD
++qK+ihVqf94Lw7YZFAXK6sOoBJQ7RnwyDfMAZiLIjWltNowRGLfTshxgtDj6AozO091GB94KPutd
+fMh8+7ArU6SSYmlRJQVhGkSBjCypQ5Yj36w6gZoOKcUcqeldHraenjAKOc7xiID7S13MMuyFYkMl
+NAJWJwGRtDtwKj9useiciAF9n9T521NtYJ2/LOdYq7hfRvzOxBsDPAnrSTFcaUaz4EcCAwEAAaNC
+MEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFDqahQcQZyi27/a9
+BUFuIMGU2g/eMA0GCSqGSIb3DQEBCwUAA4IBAQCZ21151fmXWWcDYfF+OwYxdS2hII5PZYe096ac
+vNjpL9DbWu7PdIxztDhC2gV7+AJ1uP2lsdeu9tfeE8tTEH6KRtGX+rcuKxGrkLAngPnon1rpN5+r
+5N9ss4UXnT3ZJE95kTXWXwTrgIOrmgIttRD02JDHBHNA7XIloKmf7J6raBKZV8aPEjoJpL1E/QYV
+N8Gb5DKj7Tjo2GTzLH4U/ALqn83/B2gX2yKQOC16jdFU8WnjXzPKej17CuPKf1855eJ1usV2GDPO
+LPAvTK33sefOT6jEm0pUBsV/fdUID+Ic/n4XuKxe9tQWskMJDE32p2u0mYRlynqI4uJEvlz36hz1
+-----END CERTIFICATE-----
+
+Starfield Root Certificate Authority - G2
+=========================================
+-----BEGIN CERTIFICATE-----
+MIID3TCCAsWgAwIBAgIBADANBgkqhkiG9w0BAQsFADCBjzELMAkGA1UEBhMCVVMxEDAOBgNVBAgT
+B0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxJTAjBgNVBAoTHFN0YXJmaWVsZCBUZWNobm9s
+b2dpZXMsIEluYy4xMjAwBgNVBAMTKVN0YXJmaWVsZCBSb290IENlcnRpZmljYXRlIEF1dGhvcml0
+eSAtIEcyMB4XDTA5MDkwMTAwMDAwMFoXDTM3MTIzMTIzNTk1OVowgY8xCzAJBgNVBAYTAlVTMRAw
+DgYDVQQIEwdBcml6b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMSUwIwYDVQQKExxTdGFyZmllbGQg
+VGVjaG5vbG9naWVzLCBJbmMuMTIwMAYDVQQDEylTdGFyZmllbGQgUm9vdCBDZXJ0aWZpY2F0ZSBB
+dXRob3JpdHkgLSBHMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL3twQP89o/8ArFv
+W59I2Z154qK3A2FWGMNHttfKPTUuiUP3oWmb3ooa/RMgnLRJdzIpVv257IzdIvpy3Cdhl+72WoTs
+bhm5iSzchFvVdPtrX8WJpRBSiUZV9Lh1HOZ/5FSuS/hVclcCGfgXcVnrHigHdMWdSL5stPSksPNk
+N3mSwOxGXn/hbVNMYq/NHwtjuzqd+/x5AJhhdM8mgkBj87JyahkNmcrUDnXMN/uLicFZ8WJ/X7Nf
+ZTD4p7dNdloedl40wOiWVpmKs/B/pM293DIxfJHP4F8R+GuqSVzRmZTRouNjWwl2tVZi4Ut0HZbU
+JtQIBFnQmA4O5t78w+wfkPECAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC
+AQYwHQYDVR0OBBYEFHwMMh+n2TB/xH1oo2Kooc6rB1snMA0GCSqGSIb3DQEBCwUAA4IBAQARWfol
+TwNvlJk7mh+ChTnUdgWUXuEok21iXQnCoKjUsHU48TRqneSfioYmUeYs0cYtbpUgSpIB7LiKZ3sx
+4mcujJUDJi5DnUox9g61DLu34jd/IroAow57UvtruzvE03lRTs2Q9GcHGcg8RnoNAX3FWOdt5oUw
+F5okxBDgBPfg8n/Uqgr/Qh037ZTlZFkSIHc40zI+OIF1lnP6aI+xy84fxez6nH7PfrHxBy22/L/K
+pL/QlwVKvOoYKAKQvVR4CSFx09F9HdkWsKlhPdAKACL8x3vLCWRFCztAgfd9fDL1mMpYjn0q7pBZ
+c2T5NnReJaH1ZgUufzkVqSr7UIuOhWn0
+-----END CERTIFICATE-----
+
+Starfield Services Root Certificate Authority - G2
+==================================================
+-----BEGIN CERTIFICATE-----
+MIID7zCCAtegAwIBAgIBADANBgkqhkiG9w0BAQsFADCBmDELMAkGA1UEBhMCVVMxEDAOBgNVBAgT
+B0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxJTAjBgNVBAoTHFN0YXJmaWVsZCBUZWNobm9s
+b2dpZXMsIEluYy4xOzA5BgNVBAMTMlN0YXJmaWVsZCBTZXJ2aWNlcyBSb290IENlcnRpZmljYXRl
+IEF1dGhvcml0eSAtIEcyMB4XDTA5MDkwMTAwMDAwMFoXDTM3MTIzMTIzNTk1OVowgZgxCzAJBgNV
+BAYTAlVTMRAwDgYDVQQIEwdBcml6b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMSUwIwYDVQQKExxT
+dGFyZmllbGQgVGVjaG5vbG9naWVzLCBJbmMuMTswOQYDVQQDEzJTdGFyZmllbGQgU2VydmljZXMg
+Um9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC
+AQoCggEBANUMOsQq+U7i9b4Zl1+OiFOxHz/Lz58gE20pOsgPfTz3a3Y4Y9k2YKibXlwAgLIvWX/2
+h/klQ4bnaRtSmpDhcePYLQ1Ob/bISdm28xpWriu2dBTrz/sm4xq6HZYuajtYlIlHVv8loJNwU4Pa
+hHQUw2eeBGg6345AWh1KTs9DkTvnVtYAcMtS7nt9rjrnvDH5RfbCYM8TWQIrgMw0R9+53pBlbQLP
+LJGmpufehRhJfGZOozptqbXuNC66DQO4M99H67FrjSXZm86B0UVGMpZwh94CDklDhbZsc7tk6mFB
+rMnUVN+HL8cisibMn1lUaJ/8viovxFUcdUBgF4UCVTmLfwUCAwEAAaNCMEAwDwYDVR0TAQH/BAUw
+AwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFJxfAN+qAdcwKziIorhtSpzyEZGDMA0GCSqG
+SIb3DQEBCwUAA4IBAQBLNqaEd2ndOxmfZyMIbw5hyf2E3F/YNoHN2BtBLZ9g3ccaaNnRbobhiCPP
+E95Dz+I0swSdHynVv/heyNXBve6SbzJ08pGCL72CQnqtKrcgfU28elUSwhXqvfdqlS5sdJ/PHLTy
+xQGjhdByPq1zqwubdQxtRbeOlKyWN7Wg0I8VRw7j6IPdj/3vQQF3zCepYoUz8jcI73HPdwbeyBkd
+iEDPfUYd/x7H4c7/I9vG+o1VTqkC50cRRj70/b17KSa7qWFiNyi2LSr2EIZkyXCn0q23KXB56jza
+YyWf/Wi3MOxw+3WKt21gZ7IeyLnp2KhvAotnDU0mV3HaIPzBSlCNsSi6
+-----END CERTIFICATE-----
+
+AffirmTrust Commercial
+======================
+-----BEGIN CERTIFICATE-----
+MIIDTDCCAjSgAwIBAgIId3cGJyapsXwwDQYJKoZIhvcNAQELBQAwRDELMAkGA1UEBhMCVVMxFDAS
+BgNVBAoMC0FmZmlybVRydXN0MR8wHQYDVQQDDBZBZmZpcm1UcnVzdCBDb21tZXJjaWFsMB4XDTEw
+MDEyOTE0MDYwNloXDTMwMTIzMTE0MDYwNlowRDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC0FmZmly
+bVRydXN0MR8wHQYDVQQDDBZBZmZpcm1UcnVzdCBDb21tZXJjaWFsMIIBIjANBgkqhkiG9w0BAQEF
+AAOCAQ8AMIIBCgKCAQEA9htPZwcroRX1BiLLHwGy43NFBkRJLLtJJRTWzsO3qyxPxkEylFf6Eqdb
+DuKPHx6GGaeqtS25Xw2Kwq+FNXkyLbscYjfysVtKPcrNcV/pQr6U6Mje+SJIZMblq8Yrba0F8PrV
+C8+a5fBQpIs7R6UjW3p6+DM/uO+Zl+MgwdYoic+U+7lF7eNAFxHUdPALMeIrJmqbTFeurCA+ukV6
+BfO9m2kVrn1OIGPENXY6BwLJN/3HR+7o8XYdcxXyl6S1yHp52UKqK39c/s4mT6NmgTWvRLpUHhww
+MmWd5jyTXlBOeuM61G7MGvv50jeuJCqrVwMiKA1JdX+3KNp1v47j3A55MQIDAQABo0IwQDAdBgNV
+HQ4EFgQUnZPGU4teyq8/nx4P5ZmVvCT2lI8wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC
+AQYwDQYJKoZIhvcNAQELBQADggEBAFis9AQOzcAN/wr91LoWXym9e2iZWEnStB03TX8nfUYGXUPG
+hi4+c7ImfU+TqbbEKpqrIZcUsd6M06uJFdhrJNTxFq7YpFzUf1GO7RgBsZNjvbz4YYCanrHOQnDi
+qX0GJX0nof5v7LMeJNrjS1UaADs1tDvZ110w/YETifLCBivtZ8SOyUOyXGsViQK8YvxO8rUzqrJv
+0wqiUOP2O+guRMLbZjipM1ZI8W0bM40NjD9gN53Tym1+NH4Nn3J2ixufcv1SNUFFApYvHLKac0kh
+sUlHRUe072o0EclNmsxZt9YCnlpOZbWUrhvfKbAW8b8Angc6F2S1BLUjIZkKlTuXfO8=
+-----END CERTIFICATE-----
+
+AffirmTrust Networking
+======================
+-----BEGIN CERTIFICATE-----
+MIIDTDCCAjSgAwIBAgIIfE8EORzUmS0wDQYJKoZIhvcNAQEFBQAwRDELMAkGA1UEBhMCVVMxFDAS
+BgNVBAoMC0FmZmlybVRydXN0MR8wHQYDVQQDDBZBZmZpcm1UcnVzdCBOZXR3b3JraW5nMB4XDTEw
+MDEyOTE0MDgyNFoXDTMwMTIzMTE0MDgyNFowRDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC0FmZmly
+bVRydXN0MR8wHQYDVQQDDBZBZmZpcm1UcnVzdCBOZXR3b3JraW5nMIIBIjANBgkqhkiG9w0BAQEF
+AAOCAQ8AMIIBCgKCAQEAtITMMxcua5Rsa2FSoOujz3mUTOWUgJnLVWREZY9nZOIG41w3SfYvm4SE
+Hi3yYJ0wTsyEheIszx6e/jarM3c1RNg1lho9Nuh6DtjVR6FqaYvZ/Ls6rnla1fTWcbuakCNrmreI
+dIcMHl+5ni36q1Mr3Lt2PpNMCAiMHqIjHNRqrSK6mQEubWXLviRmVSRLQESxG9fhwoXA3hA/Pe24
+/PHxI1Pcv2WXb9n5QHGNfb2V1M6+oF4nI979ptAmDgAp6zxG8D1gvz9Q0twmQVGeFDdCBKNwV6gb
+h+0t+nvujArjqWaJGctB+d1ENmHP4ndGyH329JKBNv3bNPFyfvMMFr20FQIDAQABo0IwQDAdBgNV
+HQ4EFgQUBx/S55zawm6iQLSwelAQUHTEyL0wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC
+AQYwDQYJKoZIhvcNAQEFBQADggEBAIlXshZ6qML91tmbmzTCnLQyFE2npN/svqe++EPbkTfOtDIu
+UFUaNU52Q3Eg75N3ThVwLofDwR1t3Mu1J9QsVtFSUzpE0nPIxBsFZVpikpzuQY0x2+c06lkh1QF6
+12S4ZDnNye2v7UsDSKegmQGA3GWjNq5lWUhPgkvIZfFXHeVZLgo/bNjR9eUJtGxUAArgFU2HdW23
+WJZa3W3SAKD0m0i+wzekujbgfIeFlxoVot4uolu9rxj5kFDNcFn4J2dHy8egBzp90SxdbBk6ZrV9
+/ZFvgrG+CJPbFEfxojfHRZ48x3evZKiT3/Zpg4Jg8klCNO1aAFSFHBY2kgxc+qatv9s=
+-----END CERTIFICATE-----
+
+AffirmTrust Premium
+===================
+-----BEGIN CERTIFICATE-----
+MIIFRjCCAy6gAwIBAgIIbYwURrGmCu4wDQYJKoZIhvcNAQEMBQAwQTELMAkGA1UEBhMCVVMxFDAS
+BgNVBAoMC0FmZmlybVRydXN0MRwwGgYDVQQDDBNBZmZpcm1UcnVzdCBQcmVtaXVtMB4XDTEwMDEy
+OTE0MTAzNloXDTQwMTIzMTE0MTAzNlowQTELMAkGA1UEBhMCVVMxFDASBgNVBAoMC0FmZmlybVRy
+dXN0MRwwGgYDVQQDDBNBZmZpcm1UcnVzdCBQcmVtaXVtMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A
+MIICCgKCAgEAxBLfqV/+Qd3d9Z+K4/as4Tx4mrzY8H96oDMq3I0gW64tb+eT2TZwamjPjlGjhVtn
+BKAQJG9dKILBl1fYSCkTtuG+kU3fhQxTGJoeJKJPj/CihQvL9Cl/0qRY7iZNyaqoe5rZ+jjeRFcV
+5fiMyNlI4g0WJx0eyIOFJbe6qlVBzAMiSy2RjYvmia9mx+n/K+k8rNrSs8PhaJyJ+HoAVt70VZVs
++7pk3WKL3wt3MutizCaam7uqYoNMtAZ6MMgpv+0GTZe5HMQxK9VfvFMSF5yZVylmd2EhMQcuJUmd
+GPLu8ytxjLW6OQdJd/zvLpKQBY0tL3d770O/Nbua2Plzpyzy0FfuKE4mX4+QaAkvuPjcBukumj5R
+p9EixAqnOEhss/n/fauGV+O61oV4d7pD6kh/9ti+I20ev9E2bFhc8e6kGVQa9QPSdubhjL08s9NI
+S+LI+H+SqHZGnEJlPqQewQcDWkYtuJfzt9WyVSHvutxMAJf7FJUnM7/oQ0dG0giZFmA7mn7S5u04
+6uwBHjxIVkkJx0w3AJ6IDsBz4W9m6XJHMD4Q5QsDyZpCAGzFlH5hxIrff4IaC1nEWTJ3s7xgaVY5
+/bQGeyzWZDbZvUjthB9+pSKPKrhC9IK31FOQeE4tGv2Bb0TXOwF0lkLgAOIua+rF7nKsu7/+6qqo
++Nz2snmKtmcCAwEAAaNCMEAwHQYDVR0OBBYEFJ3AZ6YMItkm9UWrpmVSESfYRaxjMA8GA1UdEwEB
+/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBDAUAA4ICAQCzV00QYk465KzquByv
+MiPIs0laUZx2KI15qldGF9X1Uva3ROgIRL8YhNILgM3FEv0AVQVhh0HctSSePMTYyPtwni94loMg
+Nt58D2kTiKV1NpgIpsbfrM7jWNa3Pt668+s0QNiigfV4Py/VpfzZotReBA4Xrf5B8OWycvpEgjNC
+6C1Y91aMYj+6QrCcDFx+LmUmXFNPALJ4fqENmS2NuB2OosSw/WDQMKSOyARiqcTtNd56l+0OOF6S
+L5Nwpamcb6d9Ex1+xghIsV5n61EIJenmJWtSKZGc0jlzCFfemQa0W50QBuHCAKi4HEoCChTQwUHK
++4w1IX2COPKpVJEZNZOUbWo6xbLQu4mGk+ibyQ86p3q4ofB4Rvr8Ny/lioTz3/4E2aFooC8k4gmV
+BtWVyuEklut89pMFu+1z6S3RdTnX5yTb2E5fQ4+e0BQ5v1VwSJlXMbSc7kqYA5YwH2AG7hsj/oFg
+IxpHYoWlzBk0gG+zrBrjn/B7SK3VAdlntqlyk+otZrWyuOQ9PLLvTIzq6we/qzWaVYa8GKa1qF60
+g2xraUDTn9zxw2lrueFtCfTxqlB2Cnp9ehehVZZCmTEJ3WARjQUwfuaORtGdFNrHF+QFlozEJLUb
+zxQHskD4o55BhrwE0GuWyCqANP2/7waj3VjFhT0+j/6eKeC2uAloGRwYQw==
+-----END CERTIFICATE-----
+
+AffirmTrust Premium ECC
+=======================
+-----BEGIN CERTIFICATE-----
+MIIB/jCCAYWgAwIBAgIIdJclisc/elQwCgYIKoZIzj0EAwMwRTELMAkGA1UEBhMCVVMxFDASBgNV
+BAoMC0FmZmlybVRydXN0MSAwHgYDVQQDDBdBZmZpcm1UcnVzdCBQcmVtaXVtIEVDQzAeFw0xMDAx
+MjkxNDIwMjRaFw00MDEyMzExNDIwMjRaMEUxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtBZmZpcm1U
+cnVzdDEgMB4GA1UEAwwXQWZmaXJtVHJ1c3QgUHJlbWl1bSBFQ0MwdjAQBgcqhkjOPQIBBgUrgQQA
+IgNiAAQNMF4bFZ0D0KF5Nbc6PJJ6yhUczWLznCZcBz3lVPqj1swS6vQUX+iOGasvLkjmrBhDeKzQ
+N8O9ss0s5kfiGuZjuD0uL3jET9v0D6RoTFVya5UdThhClXjMNzyR4ptlKymjQjBAMB0GA1UdDgQW
+BBSaryl6wBE1NSZRMADDav5A1a7WPDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAK
+BggqhkjOPQQDAwNnADBkAjAXCfOHiFBar8jAQr9HX/VsaobgxCd05DhT1wV/GzTjxi+zygk8N53X
+57hG8f2h4nECMEJZh0PUUd+60wkyWs6Iflc9nF9Ca/UHLbXwgpP5WW+uZPpY5Yse42O+tYHNbwKM
+eQ==
+-----END CERTIFICATE-----
+
+Certum Trusted Network CA
+=========================
+-----BEGIN CERTIFICATE-----
+MIIDuzCCAqOgAwIBAgIDBETAMA0GCSqGSIb3DQEBBQUAMH4xCzAJBgNVBAYTAlBMMSIwIAYDVQQK
+ExlVbml6ZXRvIFRlY2hub2xvZ2llcyBTLkEuMScwJQYDVQQLEx5DZXJ0dW0gQ2VydGlmaWNhdGlv
+biBBdXRob3JpdHkxIjAgBgNVBAMTGUNlcnR1bSBUcnVzdGVkIE5ldHdvcmsgQ0EwHhcNMDgxMDIy
+MTIwNzM3WhcNMjkxMjMxMTIwNzM3WjB+MQswCQYDVQQGEwJQTDEiMCAGA1UEChMZVW5pemV0byBU
+ZWNobm9sb2dpZXMgUy5BLjEnMCUGA1UECxMeQ2VydHVtIENlcnRpZmljYXRpb24gQXV0aG9yaXR5
+MSIwIAYDVQQDExlDZXJ0dW0gVHJ1c3RlZCBOZXR3b3JrIENBMIIBIjANBgkqhkiG9w0BAQEFAAOC
+AQ8AMIIBCgKCAQEA4/t9o3K6wvDJFIf1awFO4W5AB7ptJ11/91sts1rHUV+rpDKmYYe2bg+G0jAC
+l/jXaVehGDldamR5xgFZrDwxSjh80gTSSyjoIF87B6LMTXPb865Px1bVWqeWifrzq2jUI4ZZJ88J
+J7ysbnKDHDBy3+Ci6dLhdHUZvSqeexVUBBvXQzmtVSjF4hq79MDkrjhJM8x2hZ85RdKknvISjFH4
+fOQtf/WsX+sWn7Et0brMkUJ3TCXJkDhv2/DM+44el1k+1WBO5gUo7Ul5E0u6SNsv+XLTOcr+H9g0
+cvW0QM8xAcPs3hEtF10fuFDRXhmnad4HMyjKUJX5p1TLVIZQRan5SQIDAQABo0IwQDAPBgNVHRMB
+Af8EBTADAQH/MB0GA1UdDgQWBBQIds3LB/8k9sXN7buQvOKEN0Z19zAOBgNVHQ8BAf8EBAMCAQYw
+DQYJKoZIhvcNAQEFBQADggEBAKaorSLOAT2mo/9i0Eidi15ysHhE49wcrwn9I0j6vSrEuVUEtRCj
+jSfeC4Jj0O7eDDd5QVsisrCaQVymcODU0HfLI9MA4GxWL+FpDQ3Zqr8hgVDZBqWo/5U30Kr+4rP1
+mS1FhIrlQgnXdAIv94nYmem8J9RHjboNRhx3zxSkHLmkMcScKHQDNP8zGSal6Q10tz6XxnboJ5aj
+Zt3hrvJBW8qYVoNzcOSGGtIxQbovvi0TWnZvTuhOgQ4/WwMioBK+ZlgRSssDxLQqKi2WF+A5VLxI
+03YnnZotBqbJ7DnSq9ufmgsnAjUpsUCV5/nonFWIGUbWtzT1fs45mtk48VH3Tyw=
+-----END CERTIFICATE-----
+
+TWCA Root Certification Authority
+=================================
+-----BEGIN CERTIFICATE-----
+MIIDezCCAmOgAwIBAgIBATANBgkqhkiG9w0BAQUFADBfMQswCQYDVQQGEwJUVzESMBAGA1UECgwJ
+VEFJV0FOLUNBMRAwDgYDVQQLDAdSb290IENBMSowKAYDVQQDDCFUV0NBIFJvb3QgQ2VydGlmaWNh
+dGlvbiBBdXRob3JpdHkwHhcNMDgwODI4MDcyNDMzWhcNMzAxMjMxMTU1OTU5WjBfMQswCQYDVQQG
+EwJUVzESMBAGA1UECgwJVEFJV0FOLUNBMRAwDgYDVQQLDAdSb290IENBMSowKAYDVQQDDCFUV0NB
+IFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
+AoIBAQCwfnK4pAOU5qfeCTiRShFAh6d8WWQUe7UREN3+v9XAu1bihSX0NXIP+FPQQeFEAcK0HMMx
+QhZHhTMidrIKbw/lJVBPhYa+v5guEGcevhEFhgWQxFnQfHgQsIBct+HHK3XLfJ+utdGdIzdjp9xC
+oi2SBBtQwXu4PhvJVgSLL1KbralW6cH/ralYhzC2gfeXRfwZVzsrb+RH9JlF/h3x+JejiB03HFyP
+4HYlmlD4oFT/RJB2I9IyxsOrBr/8+7/zrX2SYgJbKdM1o5OaQ2RgXbL6Mv87BK9NQGr5x+PvI/1r
+y+UPizgN7gr8/g+YnzAx3WxSZfmLgb4i4RxYA7qRG4kHAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIB
+BjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRqOFsmjd6LWvJPelSDGRjjCDWmujANBgkqhkiG
+9w0BAQUFAAOCAQEAPNV3PdrfibqHDAhUaiBQkr6wQT25JmSDCi/oQMCXKCeCMErJk/9q56YAf4lC
+mtYR5VPOL8zy2gXE/uJQxDqGfczafhAJO5I1KlOy/usrBdlsXebQ79NqZp4VKIV66IIArB6nCWlW
+QtNoURi+VJq/REG6Sb4gumlc7rh3zc5sH62Dlhh9DrUUOYTxKOkto557HnpyWoOzeW/vtPzQCqVY
+T0bf+215WfKEIlKuD8z7fDvnaspHYcN6+NOSBB+4IIThNlQWx0DeO4pz3N/GCUzf7Nr/1FNCocny
+Yh0igzyXxfkZYiesZSLX0zzG5Y6yU8xJzrww/nsOM5D77dIUkR8Hrw==
+-----END CERTIFICATE-----
+
+Security Communication RootCA2
+==============================
+-----BEGIN CERTIFICATE-----
+MIIDdzCCAl+gAwIBAgIBADANBgkqhkiG9w0BAQsFADBdMQswCQYDVQQGEwJKUDElMCMGA1UEChMc
+U0VDT00gVHJ1c3QgU3lzdGVtcyBDTy4sTFRELjEnMCUGA1UECxMeU2VjdXJpdHkgQ29tbXVuaWNh
+dGlvbiBSb290Q0EyMB4XDTA5MDUyOTA1MDAzOVoXDTI5MDUyOTA1MDAzOVowXTELMAkGA1UEBhMC
+SlAxJTAjBgNVBAoTHFNFQ09NIFRydXN0IFN5c3RlbXMgQ08uLExURC4xJzAlBgNVBAsTHlNlY3Vy
+aXR5IENvbW11bmljYXRpb24gUm9vdENBMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
+ANAVOVKxUrO6xVmCxF1SrjpDZYBLx/KWvNs2l9amZIyoXvDjChz335c9S672XewhtUGrzbl+dp++
++T42NKA7wfYxEUV0kz1XgMX5iZnK5atq1LXaQZAQwdbWQonCv/Q4EpVMVAX3NuRFg3sUZdbcDE3R
+3n4MqzvEFb46VqZab3ZpUql6ucjrappdUtAtCms1FgkQhNBqyjoGADdH5H5XTz+L62e4iKrFvlNV
+spHEfbmwhRkGeC7bYRr6hfVKkaHnFtWOojnflLhwHyg/i/xAXmODPIMqGplrz95Zajv8bxbXH/1K
+EOtOghY6rCcMU/Gt1SSwawNQwS08Ft1ENCcadfsCAwEAAaNCMEAwHQYDVR0OBBYEFAqFqXdlBZh8
+QIH4D5csOPEK7DzPMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEB
+CwUAA4IBAQBMOqNErLlFsceTfsgLCkLfZOoc7llsCLqJX2rKSpWeeo8HxdpFcoJxDjrSzG+ntKEj
+u/Ykn8sX/oymzsLS28yN/HH8AynBbF0zX2S2ZTuJbxh2ePXcokgfGT+Ok+vx+hfuzU7jBBJV1uXk
+3fs+BXziHV7Gp7yXT2g69ekuCkO2r1dcYmh8t/2jioSgrGK+KwmHNPBqAbubKVY8/gA3zyNs8U6q
+tnRGEmyR7jTV7JqR50S+kDFy1UkC9gLl9B/rfNmWVan/7Ir5mUf/NVoCqgTLiluHcSmRvaS0eg29
+mvVXIwAHIRc/SjnRBUkLp7Y3gaVdjKozXoEofKd9J+sAro03
+-----END CERTIFICATE-----
+
+EC-ACC
+======
+-----BEGIN CERTIFICATE-----
+MIIFVjCCBD6gAwIBAgIQ7is969Qh3hSoYqwE893EATANBgkqhkiG9w0BAQUFADCB8zELMAkGA1UE
+BhMCRVMxOzA5BgNVBAoTMkFnZW5jaWEgQ2F0YWxhbmEgZGUgQ2VydGlmaWNhY2lvIChOSUYgUS0w
+ODAxMTc2LUkpMSgwJgYDVQQLEx9TZXJ2ZWlzIFB1YmxpY3MgZGUgQ2VydGlmaWNhY2lvMTUwMwYD
+VQQLEyxWZWdldSBodHRwczovL3d3dy5jYXRjZXJ0Lm5ldC92ZXJhcnJlbCAoYykwMzE1MDMGA1UE
+CxMsSmVyYXJxdWlhIEVudGl0YXRzIGRlIENlcnRpZmljYWNpbyBDYXRhbGFuZXMxDzANBgNVBAMT
+BkVDLUFDQzAeFw0wMzAxMDcyMzAwMDBaFw0zMTAxMDcyMjU5NTlaMIHzMQswCQYDVQQGEwJFUzE7
+MDkGA1UEChMyQWdlbmNpYSBDYXRhbGFuYSBkZSBDZXJ0aWZpY2FjaW8gKE5JRiBRLTA4MDExNzYt
+SSkxKDAmBgNVBAsTH1NlcnZlaXMgUHVibGljcyBkZSBDZXJ0aWZpY2FjaW8xNTAzBgNVBAsTLFZl
+Z2V1IGh0dHBzOi8vd3d3LmNhdGNlcnQubmV0L3ZlcmFycmVsIChjKTAzMTUwMwYDVQQLEyxKZXJh
+cnF1aWEgRW50aXRhdHMgZGUgQ2VydGlmaWNhY2lvIENhdGFsYW5lczEPMA0GA1UEAxMGRUMtQUND
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsyLHT+KXQpWIR4NA9h0X84NzJB5R85iK
+w5K4/0CQBXCHYMkAqbWUZRkiFRfCQ2xmRJoNBD45b6VLeqpjt4pEndljkYRm4CgPukLjbo73FCeT
+ae6RDqNfDrHrZqJyTxIThmV6PttPB/SnCWDaOkKZx7J/sxaVHMf5NLWUhdWZXqBIoH7nF2W4onW4
+HvPlQn2v7fOKSGRdghST2MDk/7NQcvJ29rNdQlB50JQ+awwAvthrDk4q7D7SzIKiGGUzE3eeml0a
+E9jD2z3Il3rucO2n5nzbcc8tlGLfbdb1OL4/pYUKGbio2Al1QnDE6u/LDsg0qBIimAy4E5S2S+zw
+0JDnJwIDAQABo4HjMIHgMB0GA1UdEQQWMBSBEmVjX2FjY0BjYXRjZXJ0Lm5ldDAPBgNVHRMBAf8E
+BTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUoMOLRKo3pUW/l4Ba0fF4opvpXY0wfwYD
+VR0gBHgwdjB0BgsrBgEEAfV4AQMBCjBlMCwGCCsGAQUFBwIBFiBodHRwczovL3d3dy5jYXRjZXJ0
+Lm5ldC92ZXJhcnJlbDA1BggrBgEFBQcCAjApGidWZWdldSBodHRwczovL3d3dy5jYXRjZXJ0Lm5l
+dC92ZXJhcnJlbCAwDQYJKoZIhvcNAQEFBQADggEBAKBIW4IB9k1IuDlVNZyAelOZ1Vr/sXE7zDkJ
+lF7W2u++AVtd0x7Y/X1PzaBB4DSTv8vihpw3kpBWHNzrKQXlxJ7HNd+KDM3FIUPpqojlNcAZQmNa
+Al6kSBg6hW/cnbw/nZzBh7h6YQjpdwt/cKt63dmXLGQehb+8dJahw3oS7AwaboMMPOhyRp/7SNVe
+l+axofjk70YllJyJ22k4vuxcDlbHZVHlUIiIv0LVKz3l+bqeLrPK9HOSAgu+TGbrIP65y7WZf+a2
+E/rKS03Z7lNGBjvGTq2TWoF+bCpLagVFjPIhpDGQh2xlnJ2lYJU6Un/10asIbvPuW/mIPX64b24D
+5EI=
+-----END CERTIFICATE-----
+
+Hellenic Academic and Research Institutions RootCA 2011
+=======================================================
+-----BEGIN CERTIFICATE-----
+MIIEMTCCAxmgAwIBAgIBADANBgkqhkiG9w0BAQUFADCBlTELMAkGA1UEBhMCR1IxRDBCBgNVBAoT
+O0hlbGxlbmljIEFjYWRlbWljIGFuZCBSZXNlYXJjaCBJbnN0aXR1dGlvbnMgQ2VydC4gQXV0aG9y
+aXR5MUAwPgYDVQQDEzdIZWxsZW5pYyBBY2FkZW1pYyBhbmQgUmVzZWFyY2ggSW5zdGl0dXRpb25z
+IFJvb3RDQSAyMDExMB4XDTExMTIwNjEzNDk1MloXDTMxMTIwMTEzNDk1MlowgZUxCzAJBgNVBAYT
+AkdSMUQwQgYDVQQKEztIZWxsZW5pYyBBY2FkZW1pYyBhbmQgUmVzZWFyY2ggSW5zdGl0dXRpb25z
+IENlcnQuIEF1dGhvcml0eTFAMD4GA1UEAxM3SGVsbGVuaWMgQWNhZGVtaWMgYW5kIFJlc2VhcmNo
+IEluc3RpdHV0aW9ucyBSb290Q0EgMjAxMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
+AKlTAOMupvaO+mDYLZU++CwqVE7NuYRhlFhPjz2L5EPzdYmNUeTDN9KKiE15HrcS3UN4SoqS5tdI
+1Q+kOilENbgH9mgdVc04UfCMJDGFr4PJfel3r+0ae50X+bOdOFAPplp5kYCvN66m0zH7tSYJnTxa
+71HFK9+WXesyHgLacEnsbgzImjeN9/E2YEsmLIKe0HjzDQ9jpFEw4fkrJxIH2Oq9GGKYsFk3fb7u
+8yBRQlqD75O6aRXxYp2fmTmCobd0LovUxQt7L/DICto9eQqakxylKHJzkUOap9FNhYS5qXSPFEDH
+3N6sQWRstBmbAmNtJGSPRLIl6s5ddAxjMlyNh+UCAwEAAaOBiTCBhjAPBgNVHRMBAf8EBTADAQH/
+MAsGA1UdDwQEAwIBBjAdBgNVHQ4EFgQUppFC/RNhSiOeCKQp5dgTBCPuQSUwRwYDVR0eBEAwPqA8
+MAWCAy5ncjAFggMuZXUwBoIELmVkdTAGggQub3JnMAWBAy5ncjAFgQMuZXUwBoEELmVkdTAGgQQu
+b3JnMA0GCSqGSIb3DQEBBQUAA4IBAQAf73lB4XtuP7KMhjdCSk4cNx6NZrokgclPEg8hwAOXhiVt
+XdMiKahsog2p6z0GW5k6x8zDmjR/qw7IThzh+uTczQ2+vyT+bOdrwg3IBp5OjWEopmr95fZi6hg8
+TqBTnbI6nOulnJEWtk2C4AwFSKls9cz4y51JtPACpf1wA+2KIaWuE4ZJwzNzvoc7dIsXRSZMFpGD
+/md9zU1jZ/rzAxKWeAaNsWftjj++n08C9bMJL/NMh98qy5V8AcysNnq/onN694/BtZqhFLKPM58N
+7yLcZnuEvUUXBj08yrl3NI/K6s8/MT7jiOOASSXIl7WdmplNsDz4SgCbZN2fOUvRJ9e4
+-----END CERTIFICATE-----
+
+Actalis Authentication Root CA
+==============================
+-----BEGIN CERTIFICATE-----
+MIIFuzCCA6OgAwIBAgIIVwoRl0LE48wwDQYJKoZIhvcNAQELBQAwazELMAkGA1UEBhMCSVQxDjAM
+BgNVBAcMBU1pbGFuMSMwIQYDVQQKDBpBY3RhbGlzIFMucC5BLi8wMzM1ODUyMDk2NzEnMCUGA1UE
+AwweQWN0YWxpcyBBdXRoZW50aWNhdGlvbiBSb290IENBMB4XDTExMDkyMjExMjIwMloXDTMwMDky
+MjExMjIwMlowazELMAkGA1UEBhMCSVQxDjAMBgNVBAcMBU1pbGFuMSMwIQYDVQQKDBpBY3RhbGlz
+IFMucC5BLi8wMzM1ODUyMDk2NzEnMCUGA1UEAwweQWN0YWxpcyBBdXRoZW50aWNhdGlvbiBSb290
+IENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAp8bEpSmkLO/lGMWwUKNvUTufClrJ
+wkg4CsIcoBh/kbWHuUA/3R1oHwiD1S0eiKD4j1aPbZkCkpAW1V8IbInX4ay8IMKx4INRimlNAJZa
+by/ARH6jDuSRzVju3PvHHkVH3Se5CAGfpiEd9UEtL0z9KK3giq0itFZljoZUj5NDKd45RnijMCO6
+zfB9E1fAXdKDa0hMxKufgFpbOr3JpyI/gCczWw63igxdBzcIy2zSekciRDXFzMwujt0q7bd9Zg1f
+YVEiVRvjRuPjPdA1YprbrxTIW6HMiRvhMCb8oJsfgadHHwTrozmSBp+Z07/T6k9QnBn+locePGX2
+oxgkg4YQ51Q+qDp2JE+BIcXjDwL4k5RHILv+1A7TaLndxHqEguNTVHnd25zS8gebLra8Pu2Fbe8l
+EfKXGkJh90qX6IuxEAf6ZYGyojnP9zz/GPvG8VqLWeICrHuS0E4UT1lF9gxeKF+w6D9Fz8+vm2/7
+hNN3WpVvrJSEnu68wEqPSpP4RCHiMUVhUE4Q2OM1fEwZtN4Fv6MGn8i1zeQf1xcGDXqVdFUNaBr8
+EBtiZJ1t4JWgw5QHVw0U5r0F+7if5t+L4sbnfpb2U8WANFAoWPASUHEXMLrmeGO89LKtmyuy/uE5
+jF66CyCU3nuDuP/jVo23Eek7jPKxwV2dpAtMK9myGPW1n0sCAwEAAaNjMGEwHQYDVR0OBBYEFFLY
+iDrIn3hm7YnzezhwlMkCAjbQMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUUtiIOsifeGbt
+ifN7OHCUyQICNtAwDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBCwUAA4ICAQALe3KHwGCmSUyI
+WOYdiPcUZEim2FgKDk8TNd81HdTtBjHIgT5q1d07GjLukD0R0i70jsNjLiNmsGe+b7bAEzlgqqI0
+JZN1Ut6nna0Oh4lScWoWPBkdg/iaKWW+9D+a2fDzWochcYBNy+A4mz+7+uAwTc+G02UQGRjRlwKx
+K3JCaKygvU5a2hi/a5iB0P2avl4VSM0RFbnAKVy06Ij3Pjaut2L9HmLecHgQHEhb2rykOLpn7VU+
+Xlff1ANATIGk0k9jpwlCCRT8AKnCgHNPLsBA2RF7SOp6AsDT6ygBJlh0wcBzIm2Tlf05fbsq4/aC
+4yyXX04fkZT6/iyj2HYauE2yOE+b+h1IYHkm4vP9qdCa6HCPSXrW5b0KDtst842/6+OkfcvHlXHo
+2qN8xcL4dJIEG4aspCJTQLas/kx2z/uUMsA1n3Y/buWQbqCmJqK4LL7RK4X9p2jIugErsWx0Hbhz
+lefut8cl8ABMALJ+tguLHPPAUJ4lueAI3jZm/zel0btUZCzJJ7VLkn5l/9Mt4blOvH+kQSGQQXem
+OR/qnuOf0GZvBeyqdn6/axag67XH/JJULysRJyU3eExRarDzzFhdFPFqSBX/wge2sY0PjlxQRrM9
+vwGYT7JZVEc+NHt4bVaTLnPqZih4zR0Uv6CPLy64Lo7yFIrM6bV8+2ydDKXhlg==
+-----END CERTIFICATE-----
+
+Trustis FPS Root CA
+===================
+-----BEGIN CERTIFICATE-----
+MIIDZzCCAk+gAwIBAgIQGx+ttiD5JNM2a/fH8YygWTANBgkqhkiG9w0BAQUFADBFMQswCQYDVQQG
+EwJHQjEYMBYGA1UEChMPVHJ1c3RpcyBMaW1pdGVkMRwwGgYDVQQLExNUcnVzdGlzIEZQUyBSb290
+IENBMB4XDTAzMTIyMzEyMTQwNloXDTI0MDEyMTExMzY1NFowRTELMAkGA1UEBhMCR0IxGDAWBgNV
+BAoTD1RydXN0aXMgTGltaXRlZDEcMBoGA1UECxMTVHJ1c3RpcyBGUFMgUm9vdCBDQTCCASIwDQYJ
+KoZIhvcNAQEBBQADggEPADCCAQoCggEBAMVQe547NdDfxIzNjpvto8A2mfRC6qc+gIMPpqdZh8mQ
+RUN+AOqGeSoDvT03mYlmt+WKVoaTnGhLaASMk5MCPjDSNzoiYYkchU59j9WvezX2fihHiTHcDnlk
+H5nSW7r+f2C/revnPDgpai/lkQtV/+xvWNUtyd5MZnGPDNcE2gfmHhjjvSkCqPoc4Vu5g6hBSLwa
+cY3nYuUtsuvffM/bq1rKMfFMIvMFE/eC+XN5DL7XSxzA0RU8k0Fk0ea+IxciAIleH2ulrG6nS4zt
+o3Lmr2NNL4XSFDWaLk6M6jKYKIahkQlBOrTh4/L68MkKokHdqeMDx4gVOxzUGpTXn2RZEm0CAwEA
+AaNTMFEwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBS6+nEleYtXQSUhhgtx67JkDoshZzAd
+BgNVHQ4EFgQUuvpxJXmLV0ElIYYLceuyZA6LIWcwDQYJKoZIhvcNAQEFBQADggEBAH5Y//01GX2c
+GE+esCu8jowU/yyg2kdbw++BLa8F6nRIW/M+TgfHbcWzk88iNVy2P3UnXwmWzaD+vkAMXBJV+JOC
+yinpXj9WV4s4NvdFGkwozZ5BuO1WTISkQMi4sKUraXAEasP41BIy+Q7DsdwyhEQsb8tGD+pmQQ9P
+8Vilpg0ND2HepZ5dfWWhPBfnqFVO76DH7cZEf1T1o+CP8HxVIo8ptoGj4W1OLBuAZ+ytIJ8MYmHV
+l/9D7S3B2l0pKoU/rGXuhg8FjZBf3+6f9L/uHfuY5H+QK4R4EA5sSVPvFVtlRkpdr7r7OnIdzfYl
+iB6XzCGcKQENZetX2fNXlrtIzYE=
+-----END CERTIFICATE-----
+
+Buypass Class 2 Root CA
+=======================
+-----BEGIN CERTIFICATE-----
+MIIFWTCCA0GgAwIBAgIBAjANBgkqhkiG9w0BAQsFADBOMQswCQYDVQQGEwJOTzEdMBsGA1UECgwU
+QnV5cGFzcyBBUy05ODMxNjMzMjcxIDAeBgNVBAMMF0J1eXBhc3MgQ2xhc3MgMiBSb290IENBMB4X
+DTEwMTAyNjA4MzgwM1oXDTQwMTAyNjA4MzgwM1owTjELMAkGA1UEBhMCTk8xHTAbBgNVBAoMFEJ1
+eXBhc3MgQVMtOTgzMTYzMzI3MSAwHgYDVQQDDBdCdXlwYXNzIENsYXNzIDIgUm9vdCBDQTCCAiIw
+DQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANfHXvfBB9R3+0Mh9PT1aeTuMgHbo4Yf5FkNuud1
+g1Lr6hxhFUi7HQfKjK6w3Jad6sNgkoaCKHOcVgb/S2TwDCo3SbXlzwx87vFKu3MwZfPVL4O2fuPn
+9Z6rYPnT8Z2SdIrkHJasW4DptfQxh6NR/Md+oW+OU3fUl8FVM5I+GC911K2GScuVr1QGbNgGE41b
+/+EmGVnAJLqBcXmQRFBoJJRfuLMR8SlBYaNByyM21cHxMlAQTn/0hpPshNOOvEu/XAFOBz3cFIqU
+CqTqc/sLUegTBxj6DvEr0VQVfTzh97QZQmdiXnfgolXsttlpF9U6r0TtSsWe5HonfOV116rLJeff
+awrbD02TTqigzXsu8lkBarcNuAeBfos4GzjmCleZPe4h6KP1DBbdi+w0jpwqHAAVF41og9JwnxgI
+zRFo1clrUs3ERo/ctfPYV3Me6ZQ5BL/T3jjetFPsaRyifsSP5BtwrfKi+fv3FmRmaZ9JUaLiFRhn
+Bkp/1Wy1TbMz4GHrXb7pmA8y1x1LPC5aAVKRCfLf6o3YBkBjqhHk/sM3nhRSP/TizPJhk9H9Z2vX
+Uq6/aKtAQ6BXNVN48FP4YUIHZMbXb5tMOA1jrGKvNouicwoN9SG9dKpN6nIDSdvHXx1iY8f93ZHs
+M+71bbRuMGjeyNYmsHVee7QHIJihdjK4TWxPAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYD
+VR0OBBYEFMmAd+BikoL1RpzzuvdMw964o605MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsF
+AAOCAgEAU18h9bqwOlI5LJKwbADJ784g7wbylp7ppHR/ehb8t/W2+xUbP6umwHJdELFx7rxP462s
+A20ucS6vxOOto70MEae0/0qyexAQH6dXQbLArvQsWdZHEIjzIVEpMMpghq9Gqx3tOluwlN5E40EI
+osHsHdb9T7bWR9AUC8rmyrV7d35BH16Dx7aMOZawP5aBQW9gkOLo+fsicdl9sz1Gv7SEr5AcD48S
+aq/v7h56rgJKihcrdv6sVIkkLE8/trKnToyokZf7KcZ7XC25y2a2t6hbElGFtQl+Ynhw/qlqYLYd
+DnkM/crqJIByw5c/8nerQyIKx+u2DISCLIBrQYoIwOula9+ZEsuK1V6ADJHgJgg2SMX6OBE1/yWD
+LfJ6v9r9jv6ly0UsH8SIU653DtmadsWOLB2jutXsMq7Aqqz30XpN69QH4kj3Io6wpJ9qzo6ysmD0
+oyLQI+uUWnpp3Q+/QFesa1lQ2aOZ4W7+jQF5JyMV3pKdewlNWudLSDBaGOYKbeaP4NK75t98biGC
+wWg5TbSYWGZizEqQXsP6JwSxeRV0mcy+rSDeJmAc61ZRpqPq5KM/p/9h3PFaTWwyI0PurKju7koS
+CTxdccK+efrCh2gdC/1cacwG0Jp9VJkqyTkaGa9LKkPzY11aWOIv4x3kqdbQCtCev9eBCfHJxyYN
+rJgWVqA=
+-----END CERTIFICATE-----
+
+Buypass Class 3 Root CA
+=======================
+-----BEGIN CERTIFICATE-----
+MIIFWTCCA0GgAwIBAgIBAjANBgkqhkiG9w0BAQsFADBOMQswCQYDVQQGEwJOTzEdMBsGA1UECgwU
+QnV5cGFzcyBBUy05ODMxNjMzMjcxIDAeBgNVBAMMF0J1eXBhc3MgQ2xhc3MgMyBSb290IENBMB4X
+DTEwMTAyNjA4Mjg1OFoXDTQwMTAyNjA4Mjg1OFowTjELMAkGA1UEBhMCTk8xHTAbBgNVBAoMFEJ1
+eXBhc3MgQVMtOTgzMTYzMzI3MSAwHgYDVQQDDBdCdXlwYXNzIENsYXNzIDMgUm9vdCBDQTCCAiIw
+DQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAKXaCpUWUOOV8l6ddjEGMnqb8RB2uACatVI2zSRH
+sJ8YZLya9vrVediQYkwiL944PdbgqOkcLNt4EemOaFEVcsfzM4fkoF0LXOBXByow9c3EN3coTRiR
+5r/VUv1xLXA+58bEiuPwKAv0dpihi4dVsjoT/Lc+JzeOIuOoTyrvYLs9tznDDgFHmV0ST9tD+leh
+7fmdvhFHJlsTmKtdFoqwNxxXnUX/iJY2v7vKB3tvh2PX0DJq1l1sDPGzbjniazEuOQAnFN44wOwZ
+ZoYS6J1yFhNkUsepNxz9gjDthBgd9K5c/3ATAOux9TN6S9ZV+AWNS2mw9bMoNlwUxFFzTWsL8TQH
+2xc519woe2v1n/MuwU8XKhDzzMro6/1rqy6any2CbgTUUgGTLT2G/H783+9CHaZr77kgxve9oKeV
+/afmiSTYzIw0bOIjL9kSGiG5VZFvC5F5GQytQIgLcOJ60g7YaEi7ghM5EFjp2CoHxhLbWNvSO1UQ
+RwUVZ2J+GGOmRj8JDlQyXr8NYnon74Do29lLBlo3WiXQCBJ31G8JUJc9yB3D34xFMFbG02SrZvPA
+Xpacw8Tvw3xrizp5f7NJzz3iiZ+gMEuFuZyUJHmPfWupRWgPK9Dx2hzLabjKSWJtyNBjYt1gD1iq
+j6G8BaVmos8bdrKEZLFMOVLAMLrwjEsCsLa3AgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYD
+VR0OBBYEFEe4zf/lb+74suwvTg75JbCOPGvDMA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsF
+AAOCAgEAACAjQTUEkMJAYmDv4jVM1z+s4jSQuKFvdvoWFqRINyzpkMLyPPgKn9iB5btb2iUspKdV
+cSQy9sgL8rxq+JOssgfCX5/bzMiKqr5qb+FJEMwx14C7u8jYog5kV+qi9cKpMRXSIGrs/CIBKM+G
+uIAeqcwRpTzyFrNHnfzSgCHEy9BHcEGhyoMZCCxt8l13nIoUE9Q2HJLw5QY33KbmkJs4j1xrG0aG
+Q0JfPgEHU1RdZX33inOhmlRaHylDFCfChQ+1iHsaO5S3HWCntZznKWlXWpuTekMwGwPXYshApqr8
+ZORK15FTAaggiG6cX0S5y2CBNOxv033aSF/rtJC8LakcC6wc1aJoIIAE1vyxjy+7SjENSoYc6+I2
+KSb12tjE8nVhz36udmNKekBlk4f4HoCMhuWG1o8O/FMsYOgWYRqiPkN7zTlgVGr18okmAWiDSKIz
+6MkEkbIRNBE+6tBDGR8Dk5AM/1E9V/RBbuHLoL7ryWPNbczk+DaqaJ3tvV2XcEQNtg413OEMXbug
+UZTLfhbrES+jkkXITHHZvMmZUldGL1DPvTVp9D0VzgalLA8+9oG6lLvDu79leNKGef9JOxqDDPDe
+eOzI8k1MGt6CKfjBWtrt7uYnXuhF0J0cUahoq0Tj0Itq4/g7u9xN12TyUb7mqqta6THuBrxzvxNi
+Cp/HuZc=
+-----END CERTIFICATE-----
+
+T-TeleSec GlobalRoot Class 3
+============================
+-----BEGIN CERTIFICATE-----
+MIIDwzCCAqugAwIBAgIBATANBgkqhkiG9w0BAQsFADCBgjELMAkGA1UEBhMCREUxKzApBgNVBAoM
+IlQtU3lzdGVtcyBFbnRlcnByaXNlIFNlcnZpY2VzIEdtYkgxHzAdBgNVBAsMFlQtU3lzdGVtcyBU
+cnVzdCBDZW50ZXIxJTAjBgNVBAMMHFQtVGVsZVNlYyBHbG9iYWxSb290IENsYXNzIDMwHhcNMDgx
+MDAxMTAyOTU2WhcNMzMxMDAxMjM1OTU5WjCBgjELMAkGA1UEBhMCREUxKzApBgNVBAoMIlQtU3lz
+dGVtcyBFbnRlcnByaXNlIFNlcnZpY2VzIEdtYkgxHzAdBgNVBAsMFlQtU3lzdGVtcyBUcnVzdCBD
+ZW50ZXIxJTAjBgNVBAMMHFQtVGVsZVNlYyBHbG9iYWxSb290IENsYXNzIDMwggEiMA0GCSqGSIb3
+DQEBAQUAA4IBDwAwggEKAoIBAQC9dZPwYiJvJK7genasfb3ZJNW4t/zN8ELg63iIVl6bmlQdTQyK
+9tPPcPRStdiTBONGhnFBSivwKixVA9ZIw+A5OO3yXDw/RLyTPWGrTs0NvvAgJ1gORH8EGoel15YU
+NpDQSXuhdfsaa3Ox+M6pCSzyU9XDFES4hqX2iys52qMzVNn6chr3IhUciJFrf2blw2qAsCTz34ZF
+iP0Zf3WHHx+xGwpzJFu5ZeAsVMhg02YXP+HMVDNzkQI6pn97djmiH5a2OK61yJN0HZ65tOVgnS9W
+0eDrXltMEnAMbEQgqxHY9Bn20pxSN+f6tsIxO0rUFJmtxxr1XV/6B7h8DR/Wgx6zAgMBAAGjQjBA
+MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBS1A/d2O2GCahKqGFPr
+AyGUv/7OyjANBgkqhkiG9w0BAQsFAAOCAQEAVj3vlNW92nOyWL6ukK2YJ5f+AbGwUgC4TeQbIXQb
+fsDuXmkqJa9c1h3a0nnJ85cp4IaH3gRZD/FZ1GSFS5mvJQQeyUapl96Cshtwn5z2r3Ex3XsFpSzT
+ucpH9sry9uetuUg/vBa3wW306gmv7PO15wWeph6KU1HWk4HMdJP2udqmJQV0eVp+QD6CSyYRMG7h
+P0HHRwA11fXT91Q+gT3aSWqas+8QPebrb9HIIkfLzM8BMZLZGOMivgkeGj5asuRrDFR6fUNOuIml
+e9eiPZaGzPImNC1qkp2aGtAw4l1OBLBfiyB+d8E9lYLRRpo7PHi4b6HQDWSieB4pTpPDpFQUWw==
+-----END CERTIFICATE-----
+
+D-TRUST Root Class 3 CA 2 2009
+==============================
+-----BEGIN CERTIFICATE-----
+MIIEMzCCAxugAwIBAgIDCYPzMA0GCSqGSIb3DQEBCwUAME0xCzAJBgNVBAYTAkRFMRUwEwYDVQQK
+DAxELVRydXN0IEdtYkgxJzAlBgNVBAMMHkQtVFJVU1QgUm9vdCBDbGFzcyAzIENBIDIgMjAwOTAe
+Fw0wOTExMDUwODM1NThaFw0yOTExMDUwODM1NThaME0xCzAJBgNVBAYTAkRFMRUwEwYDVQQKDAxE
+LVRydXN0IEdtYkgxJzAlBgNVBAMMHkQtVFJVU1QgUm9vdCBDbGFzcyAzIENBIDIgMjAwOTCCASIw
+DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANOySs96R+91myP6Oi/WUEWJNTrGa9v+2wBoqOAD
+ER03UAifTUpolDWzU9GUY6cgVq/eUXjsKj3zSEhQPgrfRlWLJ23DEE0NkVJD2IfgXU42tSHKXzlA
+BF9bfsyjxiupQB7ZNoTWSPOSHjRGICTBpFGOShrvUD9pXRl/RcPHAY9RySPocq60vFYJfxLLHLGv
+KZAKyVXMD9O0Gu1HNVpK7ZxzBCHQqr0ME7UAyiZsxGsMlFqVlNpQmvH/pStmMaTJOKDfHR+4CS7z
+p+hnUquVH+BGPtikw8paxTGA6Eian5Rp/hnd2HN8gcqW3o7tszIFZYQ05ub9VxC1X3a/L7AQDcUC
+AwEAAaOCARowggEWMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFP3aFMSfMN4hvR5COfyrYyNJ
+4PGEMA4GA1UdDwEB/wQEAwIBBjCB0wYDVR0fBIHLMIHIMIGAoH6gfIZ6bGRhcDovL2RpcmVjdG9y
+eS5kLXRydXN0Lm5ldC9DTj1ELVRSVVNUJTIwUm9vdCUyMENsYXNzJTIwMyUyMENBJTIwMiUyMDIw
+MDksTz1ELVRydXN0JTIwR21iSCxDPURFP2NlcnRpZmljYXRlcmV2b2NhdGlvbmxpc3QwQ6BBoD+G
+PWh0dHA6Ly93d3cuZC10cnVzdC5uZXQvY3JsL2QtdHJ1c3Rfcm9vdF9jbGFzc18zX2NhXzJfMjAw
+OS5jcmwwDQYJKoZIhvcNAQELBQADggEBAH+X2zDI36ScfSF6gHDOFBJpiBSVYEQBrLLpME+bUMJm
+2H6NMLVwMeniacfzcNsgFYbQDfC+rAF1hM5+n02/t2A7nPPKHeJeaNijnZflQGDSNiH+0LS4F9p0
+o3/U37CYAqxva2ssJSRyoWXuJVrl5jLn8t+rSfrzkGkj2wTZ51xY/GXUl77M/C4KzCUqNQT4YJEV
+dT1B/yMfGchs64JTBKbkTCJNjYy6zltz7GRUUG3RnFX7acM2w4y8PIWmawomDeCTmGCufsYkl4ph
+X5GOZpIJhzbNi5stPvZR1FDUWSi9g/LMKHtThm3YJohw1+qRzT65ysCQblrGXnRl11z+o+I=
+-----END CERTIFICATE-----
+
+D-TRUST Root Class 3 CA 2 EV 2009
+=================================
+-----BEGIN CERTIFICATE-----
+MIIEQzCCAyugAwIBAgIDCYP0MA0GCSqGSIb3DQEBCwUAMFAxCzAJBgNVBAYTAkRFMRUwEwYDVQQK
+DAxELVRydXN0IEdtYkgxKjAoBgNVBAMMIUQtVFJVU1QgUm9vdCBDbGFzcyAzIENBIDIgRVYgMjAw
+OTAeFw0wOTExMDUwODUwNDZaFw0yOTExMDUwODUwNDZaMFAxCzAJBgNVBAYTAkRFMRUwEwYDVQQK
+DAxELVRydXN0IEdtYkgxKjAoBgNVBAMMIUQtVFJVU1QgUm9vdCBDbGFzcyAzIENBIDIgRVYgMjAw
+OTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJnxhDRwui+3MKCOvXwEz75ivJn9gpfS
+egpnljgJ9hBOlSJzmY3aFS3nBfwZcyK3jpgAvDw9rKFs+9Z5JUut8Mxk2og+KbgPCdM03TP1YtHh
+zRnp7hhPTFiu4h7WDFsVWtg6uMQYZB7jM7K1iXdODL/ZlGsTl28So/6ZqQTMFexgaDbtCHu39b+T
+7WYxg4zGcTSHThfqr4uRjRxWQa4iN1438h3Z0S0NL2lRp75mpoo6Kr3HGrHhFPC+Oh25z1uxav60
+sUYgovseO3Dvk5h9jHOW8sXvhXCtKSb8HgQ+HKDYD8tSg2J87otTlZCpV6LqYQXY+U3EJ/pure35
+11H3a6UCAwEAAaOCASQwggEgMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFNOUikxiEyoZLsyv
+cop9NteaHNxnMA4GA1UdDwEB/wQEAwIBBjCB3QYDVR0fBIHVMIHSMIGHoIGEoIGBhn9sZGFwOi8v
+ZGlyZWN0b3J5LmQtdHJ1c3QubmV0L0NOPUQtVFJVU1QlMjBSb290JTIwQ2xhc3MlMjAzJTIwQ0El
+MjAyJTIwRVYlMjAyMDA5LE89RC1UcnVzdCUyMEdtYkgsQz1ERT9jZXJ0aWZpY2F0ZXJldm9jYXRp
+b25saXN0MEagRKBChkBodHRwOi8vd3d3LmQtdHJ1c3QubmV0L2NybC9kLXRydXN0X3Jvb3RfY2xh
+c3NfM19jYV8yX2V2XzIwMDkuY3JsMA0GCSqGSIb3DQEBCwUAA4IBAQA07XtaPKSUiO8aEXUHL7P+
+PPoeUSbrh/Yp3uDx1MYkCenBz1UbtDDZzhr+BlGmFaQt77JLvyAoJUnRpjZ3NOhk31KxEcdzes05
+nsKtjHEh8lprr988TlWvsoRlFIm5d8sqMb7Po23Pb0iUMkZv53GMoKaEGTcH8gNFCSuGdXzfX2lX
+ANtu2KZyIktQ1HWYVt+3GP9DQ1CuekR78HlR10M9p9OB0/DJT7naxpeG0ILD5EJt/rDiZE4OJudA
+NCa1CInXCGNjOCd1HjPqbqjdn5lPdE2BiYBL3ZqXKVwvvoFBuYz/6n1gBp7N1z3TLqMVvKjmJuVv
+w9y4AyHqnxbxLFS1
+-----END CERTIFICATE-----
+
+CA Disig Root R2
+================
+-----BEGIN CERTIFICATE-----
+MIIFaTCCA1GgAwIBAgIJAJK4iNuwisFjMA0GCSqGSIb3DQEBCwUAMFIxCzAJBgNVBAYTAlNLMRMw
+EQYDVQQHEwpCcmF0aXNsYXZhMRMwEQYDVQQKEwpEaXNpZyBhLnMuMRkwFwYDVQQDExBDQSBEaXNp
+ZyBSb290IFIyMB4XDTEyMDcxOTA5MTUzMFoXDTQyMDcxOTA5MTUzMFowUjELMAkGA1UEBhMCU0sx
+EzARBgNVBAcTCkJyYXRpc2xhdmExEzARBgNVBAoTCkRpc2lnIGEucy4xGTAXBgNVBAMTEENBIERp
+c2lnIFJvb3QgUjIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCio8QACdaFXS1tFPbC
+w3OeNcJxVX6B+6tGUODBfEl45qt5WDza/3wcn9iXAng+a0EE6UG9vgMsRfYvZNSrXaNHPWSb6Wia
+xswbP7q+sos0Ai6YVRn8jG+qX9pMzk0DIaPY0jSTVpbLTAwAFjxfGs3Ix2ymrdMxp7zo5eFm1tL7
+A7RBZckQrg4FY8aAamkw/dLukO8NJ9+flXP04SXabBbeQTg06ov80egEFGEtQX6sx3dOy1FU+16S
+GBsEWmjGycT6txOgmLcRK7fWV8x8nhfRyyX+hk4kLlYMeE2eARKmK6cBZW58Yh2EhN/qwGu1pSqV
+g8NTEQxzHQuyRpDRQjrOQG6Vrf/GlK1ul4SOfW+eioANSW1z4nuSHsPzwfPrLgVv2RvPN3YEyLRa
+5Beny912H9AZdugsBbPWnDTYltxhh5EF5EQIM8HauQhl1K6yNg3ruji6DOWbnuuNZt2Zz9aJQfYE
+koopKW1rOhzndX0CcQ7zwOe9yxndnWCywmZgtrEE7snmhrmaZkCo5xHtgUUDi/ZnWejBBhG93c+A
+Ak9lQHhcR1DIm+YfgXvkRKhbhZri3lrVx/k6RGZL5DJUfORsnLMOPReisjQS1n6yqEm70XooQL6i
+Fh/f5DcfEXP7kAplQ6INfPgGAVUzfbANuPT1rqVCV3w2EYx7XsQDnYx5nQIDAQABo0IwQDAPBgNV
+HRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUtZn4r7CU9eMg1gqtzk5WpC5u
+Qu0wDQYJKoZIhvcNAQELBQADggIBACYGXnDnZTPIgm7ZnBc6G3pmsgH2eDtpXi/q/075KMOYKmFM
+tCQSin1tERT3nLXK5ryeJ45MGcipvXrA1zYObYVybqjGom32+nNjf7xueQgcnYqfGopTpti72TVV
+sRHFqQOzVju5hJMiXn7B9hJSi+osZ7z+Nkz1uM/Rs0mSO9MpDpkblvdhuDvEK7Z4bLQjb/D907Je
+dR+Zlais9trhxTF7+9FGs9K8Z7RiVLoJ92Owk6Ka+elSLotgEqv89WBW7xBci8QaQtyDW2QOy7W8
+1k/BfDxujRNt+3vrMNDcTa/F1balTFtxyegxvug4BkihGuLq0t4SOVga/4AOgnXmt8kHbA7v/zjx
+mHHEt38OFdAlab0inSvtBfZGR6ztwPDUO+Ls7pZbkBNOHlY667DvlruWIxG68kOGdGSVyCh13x01
+utI3gzhTODY7z2zp+WsO0PsE6E9312UBeIYMej4hYvF/Y3EMyZ9E26gnonW+boE+18DrG5gPcFw0
+sorMwIUY6256s/daoQe/qUKS82Ail+QUoQebTnbAjn39pCXHR+3/H3OszMOl6W8KjptlwlCFtaOg
+UxLMVYdh84GuEEZhvUQhuMI9dM9+JDX6HAcOmz0iyu8xL4ysEr3vQCj8KWefshNPZiTEUxnpHikV
+7+ZtsH8tZ/3zbBt1RqPlShfppNcL
+-----END CERTIFICATE-----
+
+ACCVRAIZ1
+=========
+-----BEGIN CERTIFICATE-----
+MIIH0zCCBbugAwIBAgIIXsO3pkN/pOAwDQYJKoZIhvcNAQEFBQAwQjESMBAGA1UEAwwJQUNDVlJB
+SVoxMRAwDgYDVQQLDAdQS0lBQ0NWMQ0wCwYDVQQKDARBQ0NWMQswCQYDVQQGEwJFUzAeFw0xMTA1
+MDUwOTM3MzdaFw0zMDEyMzEwOTM3MzdaMEIxEjAQBgNVBAMMCUFDQ1ZSQUlaMTEQMA4GA1UECwwH
+UEtJQUNDVjENMAsGA1UECgwEQUNDVjELMAkGA1UEBhMCRVMwggIiMA0GCSqGSIb3DQEBAQUAA4IC
+DwAwggIKAoICAQCbqau/YUqXry+XZpp0X9DZlv3P4uRm7x8fRzPCRKPfmt4ftVTdFXxpNRFvu8gM
+jmoYHtiP2Ra8EEg2XPBjs5BaXCQ316PWywlxufEBcoSwfdtNgM3802/J+Nq2DoLSRYWoG2ioPej0
+RGy9ocLLA76MPhMAhN9KSMDjIgro6TenGEyxCQ0jVn8ETdkXhBilyNpAlHPrzg5XPAOBOp0KoVdD
+aaxXbXmQeOW1tDvYvEyNKKGno6e6Ak4l0Squ7a4DIrhrIA8wKFSVf+DuzgpmndFALW4ir50awQUZ
+0m/A8p/4e7MCQvtQqR0tkw8jq8bBD5L/0KIV9VMJcRz/RROE5iZe+OCIHAr8Fraocwa48GOEAqDG
+WuzndN9wrqODJerWx5eHk6fGioozl2A3ED6XPm4pFdahD9GILBKfb6qkxkLrQaLjlUPTAYVtjrs7
+8yM2x/474KElB0iryYl0/wiPgL/AlmXz7uxLaL2diMMxs0Dx6M/2OLuc5NF/1OVYm3z61PMOm3WR
+5LpSLhl+0fXNWhn8ugb2+1KoS5kE3fj5tItQo05iifCHJPqDQsGH+tUtKSpacXpkatcnYGMN285J
+9Y0fkIkyF/hzQ7jSWpOGYdbhdQrqeWZ2iE9x6wQl1gpaepPluUsXQA+xtrn13k/c4LOsOxFwYIRK
+Q26ZIMApcQrAZQIDAQABo4ICyzCCAscwfQYIKwYBBQUHAQEEcTBvMEwGCCsGAQUFBzAChkBodHRw
+Oi8vd3d3LmFjY3YuZXMvZmlsZWFkbWluL0FyY2hpdm9zL2NlcnRpZmljYWRvcy9yYWl6YWNjdjEu
+Y3J0MB8GCCsGAQUFBzABhhNodHRwOi8vb2NzcC5hY2N2LmVzMB0GA1UdDgQWBBTSh7Tj3zcnk1X2
+VuqB5TbMjB4/vTAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFNKHtOPfNyeTVfZW6oHlNsyM
+Hj+9MIIBcwYDVR0gBIIBajCCAWYwggFiBgRVHSAAMIIBWDCCASIGCCsGAQUFBwICMIIBFB6CARAA
+QQB1AHQAbwByAGkAZABhAGQAIABkAGUAIABDAGUAcgB0AGkAZgBpAGMAYQBjAGkA8wBuACAAUgBh
+AO0AegAgAGQAZQAgAGwAYQAgAEEAQwBDAFYAIAAoAEEAZwBlAG4AYwBpAGEAIABkAGUAIABUAGUA
+YwBuAG8AbABvAGcA7QBhACAAeQAgAEMAZQByAHQAaQBmAGkAYwBhAGMAaQDzAG4AIABFAGwAZQBj
+AHQAcgDzAG4AaQBjAGEALAAgAEMASQBGACAAUQA0ADYAMAAxADEANQA2AEUAKQAuACAAQwBQAFMA
+IABlAG4AIABoAHQAdABwADoALwAvAHcAdwB3AC4AYQBjAGMAdgAuAGUAczAwBggrBgEFBQcCARYk
+aHR0cDovL3d3dy5hY2N2LmVzL2xlZ2lzbGFjaW9uX2MuaHRtMFUGA1UdHwROMEwwSqBIoEaGRGh0
+dHA6Ly93d3cuYWNjdi5lcy9maWxlYWRtaW4vQXJjaGl2b3MvY2VydGlmaWNhZG9zL3JhaXphY2N2
+MV9kZXIuY3JsMA4GA1UdDwEB/wQEAwIBBjAXBgNVHREEEDAOgQxhY2N2QGFjY3YuZXMwDQYJKoZI
+hvcNAQEFBQADggIBAJcxAp/n/UNnSEQU5CmH7UwoZtCPNdpNYbdKl02125DgBS4OxnnQ8pdpD70E
+R9m+27Up2pvZrqmZ1dM8MJP1jaGo/AaNRPTKFpV8M9xii6g3+CfYCS0b78gUJyCpZET/LtZ1qmxN
+YEAZSUNUY9rizLpm5U9EelvZaoErQNV/+QEnWCzI7UiRfD+mAM/EKXMRNt6GGT6d7hmKG9Ww7Y49
+nCrADdg9ZuM8Db3VlFzi4qc1GwQA9j9ajepDvV+JHanBsMyZ4k0ACtrJJ1vnE5Bc5PUzolVt3OAJ
+TS+xJlsndQAJxGJ3KQhfnlmstn6tn1QwIgPBHnFk/vk4CpYY3QIUrCPLBhwepH2NDd4nQeit2hW3
+sCPdK6jT2iWH7ehVRE2I9DZ+hJp4rPcOVkkO1jMl1oRQQmwgEh0q1b688nCBpHBgvgW1m54ERL5h
+I6zppSSMEYCUWqKiuUnSwdzRp+0xESyeGabu4VXhwOrPDYTkF7eifKXeVSUG7szAh1xA2syVP1Xg
+Nce4hL60Xc16gwFy7ofmXx2utYXGJt/mwZrpHgJHnyqobalbz+xFd3+YJ5oyXSrjhO7FmGYvliAd
+3djDJ9ew+f7Zfc3Qn48LFFhRny+Lwzgt3uiP1o2HpPVWQxaZLPSkVrQ0uGE3ycJYgBugl6H8WY3p
+EfbRD0tVNEYqi4Y7
+-----END CERTIFICATE-----
+
+TWCA Global Root CA
+===================
+-----BEGIN CERTIFICATE-----
+MIIFQTCCAymgAwIBAgICDL4wDQYJKoZIhvcNAQELBQAwUTELMAkGA1UEBhMCVFcxEjAQBgNVBAoT
+CVRBSVdBTi1DQTEQMA4GA1UECxMHUm9vdCBDQTEcMBoGA1UEAxMTVFdDQSBHbG9iYWwgUm9vdCBD
+QTAeFw0xMjA2MjcwNjI4MzNaFw0zMDEyMzExNTU5NTlaMFExCzAJBgNVBAYTAlRXMRIwEAYDVQQK
+EwlUQUlXQU4tQ0ExEDAOBgNVBAsTB1Jvb3QgQ0ExHDAaBgNVBAMTE1RXQ0EgR2xvYmFsIFJvb3Qg
+Q0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCwBdvI64zEbooh745NnHEKH1Jw7W2C
+nJfF10xORUnLQEK1EjRsGcJ0pDFfhQKX7EMzClPSnIyOt7h52yvVavKOZsTuKwEHktSz0ALfUPZV
+r2YOy+BHYC8rMjk1Ujoog/h7FsYYuGLWRyWRzvAZEk2tY/XTP3VfKfChMBwqoJimFb3u/Rk28OKR
+Q4/6ytYQJ0lM793B8YVwm8rqqFpD/G2Gb3PpN0Wp8DbHzIh1HrtsBv+baz4X7GGqcXzGHaL3SekV
+tTzWoWH1EfcFbx39Eb7QMAfCKbAJTibc46KokWofwpFFiFzlmLhxpRUZyXx1EcxwdE8tmx2RRP1W
+KKD+u4ZqyPpcC1jcxkt2yKsi2XMPpfRaAok/T54igu6idFMqPVMnaR1sjjIsZAAmY2E2TqNGtz99
+sy2sbZCilaLOz9qC5wc0GZbpuCGqKX6mOL6OKUohZnkfs8O1CWfe1tQHRvMq2uYiN2DLgbYPoA/p
+yJV/v1WRBXrPPRXAb94JlAGD1zQbzECl8LibZ9WYkTunhHiVJqRaCPgrdLQABDzfuBSO6N+pjWxn
+kjMdwLfS7JLIvgm/LCkFbwJrnu+8vyq8W8BQj0FwcYeyTbcEqYSjMq+u7msXi7Kx/mzhkIyIqJdI
+zshNy/MGz19qCkKxHh53L46g5pIOBvwFItIm4TFRfTLcDwIDAQABoyMwITAOBgNVHQ8BAf8EBAMC
+AQYwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAgEAXzSBdu+WHdXltdkCY4QWwa6g
+cFGn90xHNcgL1yg9iXHZqjNB6hQbbCEAwGxCGX6faVsgQt+i0trEfJdLjbDorMjupWkEmQqSpqsn
+LhpNgb+E1HAerUf+/UqdM+DyucRFCCEK2mlpc3INvjT+lIutwx4116KD7+U4x6WFH6vPNOw/KP4M
+8VeGTslV9xzU2KV9Bnpv1d8Q34FOIWWxtuEXeZVFBs5fzNxGiWNoRI2T9GRwoD2dKAXDOXC4Ynsg
+/eTb6QihuJ49CcdP+yz4k3ZB3lLg4VfSnQO8d57+nile98FRYB/e2guyLXW3Q0iT5/Z5xoRdgFlg
+lPx4mI88k1HtQJAH32RjJMtOcQWh15QaiDLxInQirqWm2BJpTGCjAu4r7NRjkgtevi92a6O2JryP
+A9gK8kxkRr05YuWW6zRjESjMlfGt7+/cgFhI6Uu46mWs6fyAtbXIRfmswZ/ZuepiiI7E8UuDEq3m
+i4TWnsLrgxifarsbJGAzcMzs9zLzXNl5fe+epP7JI8Mk7hWSsT2RTyaGvWZzJBPqpK5jwa19hAM8
+EHiGG3njxPPyBJUgriOCxLM6AGK/5jYk4Ve6xx6QddVfP5VhK8E7zeWzaGHQRiapIVJpLesux+t3
+zqY6tQMzT3bR51xUAV3LePTJDL/PEo4XLSNolOer/qmyKwbQBM0=
+-----END CERTIFICATE-----
+
+TeliaSonera Root CA v1
+======================
+-----BEGIN CERTIFICATE-----
+MIIFODCCAyCgAwIBAgIRAJW+FqD3LkbxezmCcvqLzZYwDQYJKoZIhvcNAQEFBQAwNzEUMBIGA1UE
+CgwLVGVsaWFTb25lcmExHzAdBgNVBAMMFlRlbGlhU29uZXJhIFJvb3QgQ0EgdjEwHhcNMDcxMDE4
+MTIwMDUwWhcNMzIxMDE4MTIwMDUwWjA3MRQwEgYDVQQKDAtUZWxpYVNvbmVyYTEfMB0GA1UEAwwW
+VGVsaWFTb25lcmEgUm9vdCBDQSB2MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMK+
+6yfwIaPzaSZVfp3FVRaRXP3vIb9TgHot0pGMYzHw7CTww6XScnwQbfQ3t+XmfHnqjLWCi65ItqwA
+3GV17CpNX8GH9SBlK4GoRz6JI5UwFpB/6FcHSOcZrr9FZ7E3GwYq/t75rH2D+1665I+XZ75Ljo1k
+B1c4VWk0Nj0TSO9P4tNmHqTPGrdeNjPUtAa9GAH9d4RQAEX1jF3oI7x+/jXh7VB7qTCNGdMJjmhn
+Xb88lxhTuylixcpecsHHltTbLaC0H2kD7OriUPEMPPCs81Mt8Bz17Ww5OXOAFshSsCPN4D7c3TxH
+oLs1iuKYaIu+5b9y7tL6pe0S7fyYGKkmdtwoSxAgHNN/Fnct7W+A90m7UwW7XWjH1Mh1Fj+JWov3
+F0fUTPHSiXk+TT2YqGHeOh7S+F4D4MHJHIzTjU3TlTazN19jY5szFPAtJmtTfImMMsJu7D0hADnJ
+oWjiUIMusDor8zagrC/kb2HCUQk5PotTubtn2txTuXZZNp1D5SDgPTJghSJRt8czu90VL6R4pgd7
+gUY2BIbdeTXHlSw7sKMXNeVzH7RcWe/a6hBle3rQf5+ztCo3O3CLm1u5K7fsslESl1MpWtTwEhDc
+TwK7EpIvYtQ/aUN8Ddb8WHUBiJ1YFkveupD/RwGJBmr2X7KQarMCpgKIv7NHfirZ1fpoeDVNAgMB
+AAGjPzA9MA8GA1UdEwEB/wQFMAMBAf8wCwYDVR0PBAQDAgEGMB0GA1UdDgQWBBTwj1k4ALP1j5qW
+DNXr+nuqF+gTEjANBgkqhkiG9w0BAQUFAAOCAgEAvuRcYk4k9AwI//DTDGjkk0kiP0Qnb7tt3oNm
+zqjMDfz1mgbldxSR651Be5kqhOX//CHBXfDkH1e3damhXwIm/9fH907eT/j3HEbAek9ALCI18Bmx
+0GtnLLCo4MBANzX2hFxc469CeP6nyQ1Q6g2EdvZR74NTxnr/DlZJLo961gzmJ1TjTQpgcmLNkQfW
+pb/ImWvtxBnmq0wROMVvMeJuScg/doAmAyYp4Db29iBT4xdwNBedY2gea+zDTYa4EzAvXUYNR0PV
+G6pZDrlcjQZIrXSHX8f8MVRBE+LHIQ6e4B4N4cB7Q4WQxYpYxmUKeFfyxiMPAdkgS94P+5KFdSpc
+c41teyWRyu5FrgZLAMzTsVlQ2jqIOylDRl6XK1TOU2+NSueW+r9xDkKLfP0ooNBIytrEgUy7onOT
+JsjrDNYmiLbAJM+7vVvrdX3pCI6GMyx5dwlppYn8s3CQh3aP0yK7Qs69cwsgJirQmz1wHiRszYd2
+qReWt88NkvuOGKmYSdGe/mBEciG5Ge3C9THxOUiIkCR1VBatzvT4aRRkOfujuLpwQMcnHL/EVlP6
+Y2XQ8xwOFvVrhlhNGNTkDY6lnVuR3HYkUD/GKvvZt5y11ubQ2egZixVxSK236thZiNSQvxaz2ems
+WWFUyBy6ysHK4bkgTI86k4mloMy/0/Z1pHWWbVY=
+-----END CERTIFICATE-----
+
+E-Tugra Certification Authority
+===============================
+-----BEGIN CERTIFICATE-----
+MIIGSzCCBDOgAwIBAgIIamg+nFGby1MwDQYJKoZIhvcNAQELBQAwgbIxCzAJBgNVBAYTAlRSMQ8w
+DQYDVQQHDAZBbmthcmExQDA+BgNVBAoMN0UtVHXEn3JhIEVCRyBCaWxpxZ9pbSBUZWtub2xvamls
+ZXJpIHZlIEhpem1ldGxlcmkgQS7Fni4xJjAkBgNVBAsMHUUtVHVncmEgU2VydGlmaWthc3lvbiBN
+ZXJrZXppMSgwJgYDVQQDDB9FLVR1Z3JhIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTEzMDMw
+NTEyMDk0OFoXDTIzMDMwMzEyMDk0OFowgbIxCzAJBgNVBAYTAlRSMQ8wDQYDVQQHDAZBbmthcmEx
+QDA+BgNVBAoMN0UtVHXEn3JhIEVCRyBCaWxpxZ9pbSBUZWtub2xvamlsZXJpIHZlIEhpem1ldGxl
+cmkgQS7Fni4xJjAkBgNVBAsMHUUtVHVncmEgU2VydGlmaWthc3lvbiBNZXJrZXppMSgwJgYDVQQD
+DB9FLVR1Z3JhIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIICIjANBgkqhkiG9w0BAQEFAAOCAg8A
+MIICCgKCAgEA4vU/kwVRHoViVF56C/UYB4Oufq9899SKa6VjQzm5S/fDxmSJPZQuVIBSOTkHS0vd
+hQd2h8y/L5VMzH2nPbxHD5hw+IyFHnSOkm0bQNGZDbt1bsipa5rAhDGvykPL6ys06I+XawGb1Q5K
+CKpbknSFQ9OArqGIW66z6l7LFpp3RMih9lRozt6Plyu6W0ACDGQXwLWTzeHxE2bODHnv0ZEoq1+g
+ElIwcxmOj+GMB6LDu0rw6h8VqO4lzKRG+Bsi77MOQ7osJLjFLFzUHPhdZL3Dk14opz8n8Y4e0ypQ
+BaNV2cvnOVPAmJ6MVGKLJrD3fY185MaeZkJVgkfnsliNZvcHfC425lAcP9tDJMW/hkd5s3kc91r0
+E+xs+D/iWR+V7kI+ua2oMoVJl0b+SzGPWsutdEcf6ZG33ygEIqDUD13ieU/qbIWGvaimzuT6w+Gz
+rt48Ue7LE3wBf4QOXVGUnhMMti6lTPk5cDZvlsouDERVxcr6XQKj39ZkjFqzAQqptQpHF//vkUAq
+jqFGOjGY5RH8zLtJVor8udBhmm9lbObDyz51Sf6Pp+KJxWfXnUYTTjF2OySznhFlhqt/7x3U+Lzn
+rFpct1pHXFXOVbQicVtbC/DP3KBhZOqp12gKY6fgDT+gr9Oq0n7vUaDmUStVkhUXU8u3Zg5mTPj5
+dUyQ5xJwx0UCAwEAAaNjMGEwHQYDVR0OBBYEFC7j27JJ0JxUeVz6Jyr+zE7S6E5UMA8GA1UdEwEB
+/wQFMAMBAf8wHwYDVR0jBBgwFoAULuPbsknQnFR5XPonKv7MTtLoTlQwDgYDVR0PAQH/BAQDAgEG
+MA0GCSqGSIb3DQEBCwUAA4ICAQAFNzr0TbdF4kV1JI+2d1LoHNgQk2Xz8lkGpD4eKexd0dCrfOAK
+kEh47U6YA5n+KGCRHTAduGN8qOY1tfrTYXbm1gdLymmasoR6d5NFFxWfJNCYExL/u6Au/U5Mh/jO
+XKqYGwXgAEZKgoClM4so3O0409/lPun++1ndYYRP0lSWE2ETPo+Aab6TR7U1Q9Jauz1c77NCR807
+VRMGsAnb/WP2OogKmW9+4c4bU2pEZiNRCHu8W1Ki/QY3OEBhj0qWuJA3+GbHeJAAFS6LrVE1Uweo
+a2iu+U48BybNCAVwzDk/dr2l02cmAYamU9JgO3xDf1WKvJUawSg5TB9D0pH0clmKuVb8P7Sd2nCc
+dlqMQ1DujjByTd//SffGqWfZbawCEeI6FiWnWAjLb1NBnEg4R2gz0dfHj9R0IdTDBZB6/86WiLEV
+KV0jq9BgoRJP3vQXzTLlyb/IQ639Lo7xr+L0mPoSHyDYwKcMhcWQ9DstliaxLL5Mq+ux0orJ23gT
+Dx4JnW2PAJ8C2sH6H3p6CcRK5ogql5+Ji/03X186zjhZhkuvcQu02PJwT58yE+Owp1fl2tpDy4Q0
+8ijE6m30Ku/Ba3ba+367hTzSU8JNvnHhRdH9I2cNE3X7z2VnIp2usAnRCf8dNL/+I5c30jn6PQ0G
+C7TbO6Orb1wdtn7os4I07QZcJA==
+-----END CERTIFICATE-----
+
+T-TeleSec GlobalRoot Class 2
+============================
+-----BEGIN CERTIFICATE-----
+MIIDwzCCAqugAwIBAgIBATANBgkqhkiG9w0BAQsFADCBgjELMAkGA1UEBhMCREUxKzApBgNVBAoM
+IlQtU3lzdGVtcyBFbnRlcnByaXNlIFNlcnZpY2VzIEdtYkgxHzAdBgNVBAsMFlQtU3lzdGVtcyBU
+cnVzdCBDZW50ZXIxJTAjBgNVBAMMHFQtVGVsZVNlYyBHbG9iYWxSb290IENsYXNzIDIwHhcNMDgx
+MDAxMTA0MDE0WhcNMzMxMDAxMjM1OTU5WjCBgjELMAkGA1UEBhMCREUxKzApBgNVBAoMIlQtU3lz
+dGVtcyBFbnRlcnByaXNlIFNlcnZpY2VzIEdtYkgxHzAdBgNVBAsMFlQtU3lzdGVtcyBUcnVzdCBD
+ZW50ZXIxJTAjBgNVBAMMHFQtVGVsZVNlYyBHbG9iYWxSb290IENsYXNzIDIwggEiMA0GCSqGSIb3
+DQEBAQUAA4IBDwAwggEKAoIBAQCqX9obX+hzkeXaXPSi5kfl82hVYAUdAqSzm1nzHoqvNK38DcLZ
+SBnuaY/JIPwhqgcZ7bBcrGXHX+0CfHt8LRvWurmAwhiCFoT6ZrAIxlQjgeTNuUk/9k9uN0goOA/F
+vudocP05l03Sx5iRUKrERLMjfTlH6VJi1hKTXrcxlkIF+3anHqP1wvzpesVsqXFP6st4vGCvx970
+2cu+fjOlbpSD8DT6IavqjnKgP6TeMFvvhk1qlVtDRKgQFRzlAVfFmPHmBiiRqiDFt1MmUUOyCxGV
+WOHAD3bZwI18gfNycJ5v/hqO2V81xrJvNHy+SE/iWjnX2J14np+GPgNeGYtEotXHAgMBAAGjQjBA
+MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBS/WSA2AHmgoCJrjNXy
+YdK4LMuCSjANBgkqhkiG9w0BAQsFAAOCAQEAMQOiYQsfdOhyNsZt+U2e+iKo4YFWz827n+qrkRk4
+r6p8FU3ztqONpfSO9kSpp+ghla0+AGIWiPACuvxhI+YzmzB6azZie60EI4RYZeLbK4rnJVM3YlNf
+vNoBYimipidx5joifsFvHZVwIEoHNN/q/xWA5brXethbdXwFeilHfkCoMRN3zUA7tFFHei4R40cR
+3p1m0IvVVGb6g1XqfMIpiRvpb7PO4gWEyS8+eIVibslfwXhjdFjASBgMmTnrpMwatXlajRWc2BQN
+9noHV8cigwUtPJslJj0Ys6lDfMjIq2SPDqO/nBudMNva0Bkuqjzx+zOAduTNrRlPBSeOE6Fuwg==
+-----END CERTIFICATE-----
+
+Atos TrustedRoot 2011
+=====================
+-----BEGIN CERTIFICATE-----
+MIIDdzCCAl+gAwIBAgIIXDPLYixfszIwDQYJKoZIhvcNAQELBQAwPDEeMBwGA1UEAwwVQXRvcyBU
+cnVzdGVkUm9vdCAyMDExMQ0wCwYDVQQKDARBdG9zMQswCQYDVQQGEwJERTAeFw0xMTA3MDcxNDU4
+MzBaFw0zMDEyMzEyMzU5NTlaMDwxHjAcBgNVBAMMFUF0b3MgVHJ1c3RlZFJvb3QgMjAxMTENMAsG
+A1UECgwEQXRvczELMAkGA1UEBhMCREUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCV
+hTuXbyo7LjvPpvMpNb7PGKw+qtn4TaA+Gke5vJrf8v7MPkfoepbCJI419KkM/IL9bcFyYie96mvr
+54rMVD6QUM+A1JX76LWC1BTFtqlVJVfbsVD2sGBkWXppzwO3bw2+yj5vdHLqqjAqc2K+SZFhyBH+
+DgMq92og3AIVDV4VavzjgsG1xZ1kCWyjWZgHJ8cblithdHFsQ/H3NYkQ4J7sVaE3IqKHBAUsR320
+HLliKWYoyrfhk/WklAOZuXCFteZI6o1Q/NnezG8HDt0Lcp2AMBYHlT8oDv3FdU9T1nSatCQujgKR
+z3bFmx5VdJx4IbHwLfELn8LVlhgf8FQieowHAgMBAAGjfTB7MB0GA1UdDgQWBBSnpQaxLKYJYO7R
+l+lwrrw7GWzbITAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFKelBrEspglg7tGX6XCuvDsZ
+bNshMBgGA1UdIAQRMA8wDQYLKwYBBAGwLQMEAQEwDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEB
+CwUAA4IBAQAmdzTblEiGKkGdLD4GkGDEjKwLVLgfuXvTBznk+j57sj1O7Z8jvZfza1zv7v1Apt+h
+k6EKhqzvINB5Ab149xnYJDE0BAGmuhWawyfc2E8PzBhj/5kPDpFrdRbhIfzYJsdHt6bPWHJxfrrh
+TZVHO8mvbaG0weyJ9rQPOLXiZNwlz6bb65pcmaHFCN795trV1lpFDMS3wrUU77QR/w4VtfX128a9
+61qn8FYiqTxlVMYVqL2Gns2Dlmh6cYGJ4Qvh6hEbaAjMaZ7snkGeRDImeuKHCnE96+RapNLbxc3G
+3mB/ufNPRJLvKrcYPqcZ2Qt9sTdBQrC6YB3y/gkRsPCHe6ed
+-----END CERTIFICATE-----
+
+QuoVadis Root CA 1 G3
+=====================
+-----BEGIN CERTIFICATE-----
+MIIFYDCCA0igAwIBAgIUeFhfLq0sGUvjNwc1NBMotZbUZZMwDQYJKoZIhvcNAQELBQAwSDELMAkG
+A1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAcBgNVBAMTFVF1b1ZhZGlzIFJv
+b3QgQ0EgMSBHMzAeFw0xMjAxMTIxNzI3NDRaFw00MjAxMTIxNzI3NDRaMEgxCzAJBgNVBAYTAkJN
+MRkwFwYDVQQKExBRdW9WYWRpcyBMaW1pdGVkMR4wHAYDVQQDExVRdW9WYWRpcyBSb290IENBIDEg
+RzMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCgvlAQjunybEC0BJyFuTHK3C3kEakE
+PBtVwedYMB0ktMPvhd6MLOHBPd+C5k+tR4ds7FtJwUrVu4/sh6x/gpqG7D0DmVIB0jWerNrwU8lm
+PNSsAgHaJNM7qAJGr6Qc4/hzWHa39g6QDbXwz8z6+cZM5cOGMAqNF34168Xfuw6cwI2H44g4hWf6
+Pser4BOcBRiYz5P1sZK0/CPTz9XEJ0ngnjybCKOLXSoh4Pw5qlPafX7PGglTvF0FBM+hSo+LdoIN
+ofjSxxR3W5A2B4GbPgb6Ul5jxaYA/qXpUhtStZI5cgMJYr2wYBZupt0lwgNm3fME0UDiTouG9G/l
+g6AnhF4EwfWQvTA9xO+oabw4m6SkltFi2mnAAZauy8RRNOoMqv8hjlmPSlzkYZqn0ukqeI1RPToV
+7qJZjqlc3sX5kCLliEVx3ZGZbHqfPT2YfF72vhZooF6uCyP8Wg+qInYtyaEQHeTTRCOQiJ/GKubX
+9ZqzWB4vMIkIG1SitZgj7Ah3HJVdYdHLiZxfokqRmu8hqkkWCKi9YSgxyXSthfbZxbGL0eUQMk1f
+iyA6PEkfM4VZDdvLCXVDaXP7a3F98N/ETH3Goy7IlXnLc6KOTk0k+17kBL5yG6YnLUlamXrXXAkg
+t3+UuU/xDRxeiEIbEbfnkduebPRq34wGmAOtzCjvpUfzUwIDAQABo0IwQDAPBgNVHRMBAf8EBTAD
+AQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUo5fW816iEOGrRZ88F2Q87gFwnMwwDQYJKoZI
+hvcNAQELBQADggIBABj6W3X8PnrHX3fHyt/PX8MSxEBd1DKquGrX1RUVRpgjpeaQWxiZTOOtQqOC
+MTaIzen7xASWSIsBx40Bz1szBpZGZnQdT+3Btrm0DWHMY37XLneMlhwqI2hrhVd2cDMT/uFPpiN3
+GPoajOi9ZcnPP/TJF9zrx7zABC4tRi9pZsMbj/7sPtPKlL92CiUNqXsCHKnQO18LwIE6PWThv6ct
+Tr1NxNgpxiIY0MWscgKCP6o6ojoilzHdCGPDdRS5YCgtW2jgFqlmgiNR9etT2DGbe+m3nUvriBbP
++V04ikkwj+3x6xn0dxoxGE1nVGwvb2X52z3sIexe9PSLymBlVNFxZPT5pqOBMzYzcfCkeF9OrYMh
+3jRJjehZrJ3ydlo28hP0r+AJx2EqbPfgna67hkooby7utHnNkDPDs3b69fBsnQGQ+p6Q9pxyz0fa
+wx/kNSBT8lTR32GDpgLiJTjehTItXnOQUl1CxM49S+H5GYQd1aJQzEH7QRTDvdbJWqNjZgKAvQU6
+O0ec7AAmTPWIUb+oI38YB7AL7YsmoWTTYUrrXJ/es69nA7Mf3W1daWhpq1467HxpvMc7hU6eFbm0
+FU/DlXpY18ls6Wy58yljXrQs8C097Vpl4KlbQMJImYFtnh8GKjwStIsPm6Ik8KaN1nrgS7ZklmOV
+hMJKzRwuJIczYOXD
+-----END CERTIFICATE-----
+
+QuoVadis Root CA 2 G3
+=====================
+-----BEGIN CERTIFICATE-----
+MIIFYDCCA0igAwIBAgIURFc0JFuBiZs18s64KztbpybwdSgwDQYJKoZIhvcNAQELBQAwSDELMAkG
+A1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAcBgNVBAMTFVF1b1ZhZGlzIFJv
+b3QgQ0EgMiBHMzAeFw0xMjAxMTIxODU5MzJaFw00MjAxMTIxODU5MzJaMEgxCzAJBgNVBAYTAkJN
+MRkwFwYDVQQKExBRdW9WYWRpcyBMaW1pdGVkMR4wHAYDVQQDExVRdW9WYWRpcyBSb290IENBIDIg
+RzMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQChriWyARjcV4g/Ruv5r+LrI3HimtFh
+ZiFfqq8nUeVuGxbULX1QsFN3vXg6YOJkApt8hpvWGo6t/x8Vf9WVHhLL5hSEBMHfNrMWn4rjyduY
+NM7YMxcoRvynyfDStNVNCXJJ+fKH46nafaF9a7I6JaltUkSs+L5u+9ymc5GQYaYDFCDy54ejiK2t
+oIz/pgslUiXnFgHVy7g1gQyjO/Dh4fxaXc6AcW34Sas+O7q414AB+6XrW7PFXmAqMaCvN+ggOp+o
+MiwMzAkd056OXbxMmO7FGmh77FOm6RQ1o9/NgJ8MSPsc9PG/Srj61YxxSscfrf5BmrODXfKEVu+l
+V0POKa2Mq1W/xPtbAd0jIaFYAI7D0GoT7RPjEiuA3GfmlbLNHiJuKvhB1PLKFAeNilUSxmn1uIZo
+L1NesNKqIcGY5jDjZ1XHm26sGahVpkUG0CM62+tlXSoREfA7T8pt9DTEceT/AFr2XK4jYIVz8eQQ
+sSWu1ZK7E8EM4DnatDlXtas1qnIhO4M15zHfeiFuuDIIfR0ykRVKYnLP43ehvNURG3YBZwjgQQvD
+6xVu+KQZ2aKrr+InUlYrAoosFCT5v0ICvybIxo/gbjh9Uy3l7ZizlWNof/k19N+IxWA1ksB8aRxh
+lRbQ694Lrz4EEEVlWFA4r0jyWbYW8jwNkALGcC4BrTwV1wIDAQABo0IwQDAPBgNVHRMBAf8EBTAD
+AQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQU7edvdlq/YOxJW8ald7tyFnGbxD0wDQYJKoZI
+hvcNAQELBQADggIBAJHfgD9DCX5xwvfrs4iP4VGyvD11+ShdyLyZm3tdquXK4Qr36LLTn91nMX66
+AarHakE7kNQIXLJgapDwyM4DYvmL7ftuKtwGTTwpD4kWilhMSA/ohGHqPHKmd+RCroijQ1h5fq7K
+pVMNqT1wvSAZYaRsOPxDMuHBR//47PERIjKWnML2W2mWeyAMQ0GaW/ZZGYjeVYg3UQt4XAoeo0L9
+x52ID8DyeAIkVJOviYeIyUqAHerQbj5hLja7NQ4nlv1mNDthcnPxFlxHBlRJAHpYErAK74X9sbgz
+dWqTHBLmYF5vHX/JHyPLhGGfHoJE+V+tYlUkmlKY7VHnoX6XOuYvHxHaU4AshZ6rNRDbIl9qxV6X
+U/IyAgkwo1jwDQHVcsaxfGl7w/U2Rcxhbl5MlMVerugOXou/983g7aEOGzPuVBj+D77vfoRrQ+Nw
+mNtddbINWQeFFSM51vHfqSYP1kjHs6Yi9TM3WpVHn3u6GBVv/9YUZINJ0gpnIdsPNWNgKCLjsZWD
+zYWm3S8P52dSbrsvhXz1SnPnxT7AvSESBT/8twNJAlvIJebiVDj1eYeMHVOyToV7BjjHLPj4sHKN
+JeV3UvQDHEimUF+IIDBu8oJDqz2XhOdT+yHBTw8imoa4WSr2Rz0ZiC3oheGe7IUIarFsNMkd7Egr
+O3jtZsSOeWmD3n+M
+-----END CERTIFICATE-----
+
+QuoVadis Root CA 3 G3
+=====================
+-----BEGIN CERTIFICATE-----
+MIIFYDCCA0igAwIBAgIULvWbAiin23r/1aOp7r0DoM8Sah0wDQYJKoZIhvcNAQELBQAwSDELMAkG
+A1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAcBgNVBAMTFVF1b1ZhZGlzIFJv
+b3QgQ0EgMyBHMzAeFw0xMjAxMTIyMDI2MzJaFw00MjAxMTIyMDI2MzJaMEgxCzAJBgNVBAYTAkJN
+MRkwFwYDVQQKExBRdW9WYWRpcyBMaW1pdGVkMR4wHAYDVQQDExVRdW9WYWRpcyBSb290IENBIDMg
+RzMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCzyw4QZ47qFJenMioKVjZ/aEzHs286
+IxSR/xl/pcqs7rN2nXrpixurazHb+gtTTK/FpRp5PIpM/6zfJd5O2YIyC0TeytuMrKNuFoM7pmRL
+Mon7FhY4futD4tN0SsJiCnMK3UmzV9KwCoWdcTzeo8vAMvMBOSBDGzXRU7Ox7sWTaYI+FrUoRqHe
+6okJ7UO4BUaKhvVZR74bbwEhELn9qdIoyhA5CcoTNs+cra1AdHkrAj80//ogaX3T7mH1urPnMNA3
+I4ZyYUUpSFlob3emLoG+B01vr87ERRORFHAGjx+f+IdpsQ7vw4kZ6+ocYfx6bIrc1gMLnia6Et3U
+VDmrJqMz6nWB2i3ND0/kA9HvFZcba5DFApCTZgIhsUfei5pKgLlVj7WiL8DWM2fafsSntARE60f7
+5li59wzweyuxwHApw0BiLTtIadwjPEjrewl5qW3aqDCYz4ByA4imW0aucnl8CAMhZa634RylsSqi
+Md5mBPfAdOhx3v89WcyWJhKLhZVXGqtrdQtEPREoPHtht+KPZ0/l7DxMYIBpVzgeAVuNVejH38DM
+dyM0SXV89pgR6y3e7UEuFAUCf+D+IOs15xGsIs5XPd7JMG0QA4XN8f+MFrXBsj6IbGB/kE+V9/Yt
+rQE5BwT6dYB9v0lQ7e/JxHwc64B+27bQ3RP+ydOc17KXqQIDAQABo0IwQDAPBgNVHRMBAf8EBTAD
+AQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUxhfQvKjqAkPyGwaZXSuQILnXnOQwDQYJKoZI
+hvcNAQELBQADggIBADRh2Va1EodVTd2jNTFGu6QHcrxfYWLopfsLN7E8trP6KZ1/AvWkyaiTt3px
+KGmPc+FSkNrVvjrlt3ZqVoAh313m6Tqe5T72omnHKgqwGEfcIHB9UqM+WXzBusnIFUBhynLWcKzS
+t/Ac5IYp8M7vaGPQtSCKFWGafoaYtMnCdvvMujAWzKNhxnQT5WvvoxXqA/4Ti2Tk08HS6IT7SdEQ
+TXlm66r99I0xHnAUrdzeZxNMgRVhvLfZkXdxGYFgu/BYpbWcC/ePIlUnwEsBbTuZDdQdm2NnL9Du
+DcpmvJRPpq3t/O5jrFc/ZSXPsoaP0Aj/uHYUbt7lJ+yreLVTubY/6CD50qi+YUbKh4yE8/nxoGib
+Ih6BJpsQBJFxwAYf3KDTuVan45gtf4Od34wrnDKOMpTwATwiKp9Dwi7DmDkHOHv8XgBCH/MyJnmD
+hPbl8MFREsALHgQjDFSlTC9JxUrRtm5gDWv8a4uFJGS3iQ6rJUdbPM9+Sb3H6QrG2vd+DhcI00iX
+0HGS8A85PjRqHH3Y8iKuu2n0M7SmSFXRDw4m6Oy2Cy2nhTXN/VnIn9HNPlopNLk9hM6xZdRZkZFW
+dSHBd575euFgndOtBBj0fOtek49TSiIp+EgrPk2GrFt/ywaZWWDYWGWVjUTR939+J399roD1B0y2
+PpxxVJkES/1Y+Zj0
+-----END CERTIFICATE-----
+
+DigiCert Assured ID Root G2
+===========================
+-----BEGIN CERTIFICATE-----
+MIIDljCCAn6gAwIBAgIQC5McOtY5Z+pnI7/Dr5r0SzANBgkqhkiG9w0BAQsFADBlMQswCQYDVQQG
+EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSQw
+IgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgRzIwHhcNMTMwODAxMTIwMDAwWhcNMzgw
+MTE1MTIwMDAwWjBlMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQL
+ExB3d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgRzIw
+ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDZ5ygvUj82ckmIkzTz+GoeMVSAn61UQbVH
+35ao1K+ALbkKz3X9iaV9JPrjIgwrvJUXCzO/GU1BBpAAvQxNEP4HteccbiJVMWWXvdMX0h5i89vq
+bFCMP4QMls+3ywPgym2hFEwbid3tALBSfK+RbLE4E9HpEgjAALAcKxHad3A2m67OeYfcgnDmCXRw
+VWmvo2ifv922ebPynXApVfSr/5Vh88lAbx3RvpO704gqu52/clpWcTs/1PPRCv4o76Pu2ZmvA9OP
+YLfykqGxvYmJHzDNw6YuYjOuFgJ3RFrngQo8p0Quebg/BLxcoIfhG69Rjs3sLPr4/m3wOnyqi+Rn
+lTGNAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTO
+w0q5mVXyuNtgv6l+vVa1lzan1jANBgkqhkiG9w0BAQsFAAOCAQEAyqVVjOPIQW5pJ6d1Ee88hjZv
+0p3GeDgdaZaikmkuOGybfQTUiaWxMTeKySHMq2zNixya1r9I0jJmwYrA8y8678Dj1JGG0VDjA9tz
+d29KOVPt3ibHtX2vK0LRdWLjSisCx1BL4GnilmwORGYQRI+tBev4eaymG+g3NJ1TyWGqolKvSnAW
+hsI6yLETcDbYz+70CjTVW0z9B5yiutkBclzzTcHdDrEcDcRjvq30FPuJ7KJBDkzMyFdA0G4Dqs0M
+jomZmWzwPDCvON9vvKO+KSAnq3T/EyJ43pdSVR6DtVQgA+6uwE9W3jfMw3+qBCe703e4YtsXfJwo
+IhNzbM8m9Yop5w==
+-----END CERTIFICATE-----
+
+DigiCert Assured ID Root G3
+===========================
+-----BEGIN CERTIFICATE-----
+MIICRjCCAc2gAwIBAgIQC6Fa+h3foLVJRK/NJKBs7DAKBggqhkjOPQQDAzBlMQswCQYDVQQGEwJV
+UzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSQwIgYD
+VQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgRzMwHhcNMTMwODAxMTIwMDAwWhcNMzgwMTE1
+MTIwMDAwWjBlMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
+d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgRzMwdjAQ
+BgcqhkjOPQIBBgUrgQQAIgNiAAQZ57ysRGXtzbg/WPuNsVepRC0FFfLvC/8QdJ+1YlJfZn4f5dwb
+RXkLzMZTCp2NXQLZqVneAlr2lSoOjThKiknGvMYDOAdfVdp+CW7if17QRSAPWXYQ1qAk8C3eNvJs
+KTmjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTL0L2p4ZgF
+UaFNN6KDec6NHSrkhDAKBggqhkjOPQQDAwNnADBkAjAlpIFFAmsSS3V0T8gj43DydXLefInwz5Fy
+YZ5eEJJZVrmDxxDnOOlYJjZ91eQ0hjkCMHw2U/Aw5WJjOpnitqM7mzT6HtoQknFekROn3aRukswy
+1vUhZscv6pZjamVFkpUBtA==
+-----END CERTIFICATE-----
+
+DigiCert Global Root G2
+=======================
+-----BEGIN CERTIFICATE-----
+MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBhMQswCQYDVQQG
+EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSAw
+HgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBHMjAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUx
+MjAwMDBaMGExCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3
+dy5kaWdpY2VydC5jb20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEcyMIIBIjANBgkq
+hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/RrohCgiN9RlUyfuI2/Ou8jqJ
+kTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULecYxpsMNzaHxmx1x7e/dfgy5SDN67sH0NO
+3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQq2EGnI/yuum06ZIya7XzV+hdG82MHauV
+BJVJ8zUtluNJbd134/tJS7SsVQepj5WztCO7TG1F8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyM
+UNGPHgm+F6HmIcr9g+UQvIOlCsRnKPZzFBQ9RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQAB
+o0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUTiJUIBiV5uNu
+5g/6+rkS7QYXjzkwDQYJKoZIhvcNAQELBQADggEBAGBnKJRvDkhj6zHd6mcY1Yl9PMWLSn/pvtsr
+F9+wX3N3KjITOYFnQoQj8kVnNeyIv/iPsGEMNKSuIEyExtv4NeF22d+mQrvHRAiGfzZ0JFrabA0U
+WTW98kndth/Jsw1HKj2ZL7tcu7XUIOGZX1NGFdtom/DzMNU+MeKNhJ7jitralj41E6Vf8PlwUHBH
+QRFXGU7Aj64GxJUTFy8bJZ918rGOmaFvE7FBcf6IKshPECBV1/MUReXgRPTqh5Uykw7+U0b6LJ3/
+iyK5S9kJRaTepLiaWN0bfVKfjllDiIGknibVb63dDcY3fe0Dkhvld1927jyNxF1WW6LZZm6zNTfl
+MrY=
+-----END CERTIFICATE-----
+
+DigiCert Global Root G3
+=======================
+-----BEGIN CERTIFICATE-----
+MIICPzCCAcWgAwIBAgIQBVVWvPJepDU1w6QP1atFcjAKBggqhkjOPQQDAzBhMQswCQYDVQQGEwJV
+UzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSAwHgYD
+VQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBHMzAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAw
+MDBaMGExCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5k
+aWdpY2VydC5jb20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEczMHYwEAYHKoZIzj0C
+AQYFK4EEACIDYgAE3afZu4q4C/sLfyHS8L6+c/MzXRq8NOrexpu80JX28MzQC7phW1FGfp4tn+6O
+YwwX7Adw9c+ELkCDnOg/QW07rdOkFFk2eJ0DQ+4QE2xy3q6Ip6FrtUPOZ9wj/wMco+I+o0IwQDAP
+BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUs9tIpPmhxdiuNkHMEWNp
+Yim8S8YwCgYIKoZIzj0EAwMDaAAwZQIxAK288mw/EkrRLTnDCgmXc/SINoyIJ7vmiI1Qhadj+Z4y
+3maTD/HMsQmP3Wyr+mt/oAIwOWZbwmSNuJ5Q3KjVSaLtx9zRSX8XAbjIho9OjIgrqJqpisXRAL34
+VOKa5Vt8sycX
+-----END CERTIFICATE-----
+
+DigiCert Trusted Root G4
+========================
+-----BEGIN CERTIFICATE-----
+MIIFkDCCA3igAwIBAgIQBZsbV56OITLiOQe9p3d1XDANBgkqhkiG9w0BAQwFADBiMQswCQYDVQQG
+EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSEw
+HwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwHhcNMTMwODAxMTIwMDAwWhcNMzgwMTE1
+MTIwMDAwWjBiMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
+d3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwggIiMA0G
+CSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC/5pBzaN675F1KPDAiMGkz7MKnJS7JIT3yithZwuEp
+pz1Yq3aaza57G4QNxDAf8xukOBbrVsaXbR2rsnnyyhHS5F/WBTxSD1Ifxp4VpX6+n6lXFllVcq9o
+k3DCsrp1mWpzMpTREEQQLt+C8weE5nQ7bXHiLQwb7iDVySAdYyktzuxeTsiT+CFhmzTrBcZe7Fsa
+vOvJz82sNEBfsXpm7nfISKhmV1efVFiODCu3T6cw2Vbuyntd463JT17lNecxy9qTXtyOj4DatpGY
+QJB5w3jHtrHEtWoYOAMQjdjUN6QuBX2I9YI+EJFwq1WCQTLX2wRzKm6RAXwhTNS8rhsDdV14Ztk6
+MUSaM0C/CNdaSaTC5qmgZ92kJ7yhTzm1EVgX9yRcRo9k98FpiHaYdj1ZXUJ2h4mXaXpI8OCiEhtm
+mnTK3kse5w5jrubU75KSOp493ADkRSWJtppEGSt+wJS00mFt6zPZxd9LBADMfRyVw4/3IbKyEbe7
+f/LVjHAsQWCqsWMYRJUadmJ+9oCw++hkpjPRiQfhvbfmQ6QYuKZ3AeEPlAwhHbJUKSWJbOUOUlFH
+dL4mrLZBdd56rF+NP8m800ERElvlEFDrMcXKchYiCd98THU/Y+whX8QgUWtvsauGi0/C1kVfnSD8
+oR7FwI+isX4KJpn15GkvmB0t9dmpsh3lGwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1Ud
+DwEB/wQEAwIBhjAdBgNVHQ4EFgQU7NfjgtJxXWRM3y5nP+e6mK4cD08wDQYJKoZIhvcNAQEMBQAD
+ggIBALth2X2pbL4XxJEbw6GiAI3jZGgPVs93rnD5/ZpKmbnJeFwMDF/k5hQpVgs2SV1EY+CtnJYY
+ZhsjDT156W1r1lT40jzBQ0CuHVD1UvyQO7uYmWlrx8GnqGikJ9yd+SeuMIW59mdNOj6PWTkiU0Tr
+yF0Dyu1Qen1iIQqAyHNm0aAFYF/opbSnr6j3bTWcfFqK1qI4mfN4i/RN0iAL3gTujJtHgXINwBQy
+7zBZLq7gcfJW5GqXb5JQbZaNaHqasjYUegbyJLkJEVDXCLG4iXqEI2FCKeWjzaIgQdfRnGTZ6iah
+ixTXTBmyUEFxPT9NcCOGDErcgdLMMpSEDQgJlxxPwO5rIHQw0uA5NBCFIRUBCOhVMt5xSdkoF1BN
+5r5N0XWs0Mr7QbhDparTwwVETyw2m+L64kW4I1NsBm9nVX9GtUw/bihaeSbSpKhil9Ie4u1Ki7wb
+/UdKDd9nZn6yW0HQO+T0O/QEY+nvwlQAUaCKKsnOeMzV6ocEGLPOr0mIr/OSmbaz5mEP0oUA51Aa
+5BuVnRmhuZyxm7EAHu/QD09CbMkKvO5D+jpxpchNJqU1/YldvIViHTLSoCtU7ZpXwdv6EM8Zt4tK
+G48BtieVU+i2iW1bvGjUI+iLUaJW+fCmgKDWHrO8Dw9TdSmq6hN35N6MgSGtBxBHEa2HPQfRdbzP
+82Z+
+-----END CERTIFICATE-----
+
+COMODO RSA Certification Authority
+==================================
+-----BEGIN CERTIFICATE-----
+MIIF2DCCA8CgAwIBAgIQTKr5yttjb+Af907YWwOGnTANBgkqhkiG9w0BAQwFADCBhTELMAkGA1UE
+BhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgG
+A1UEChMRQ09NT0RPIENBIExpbWl0ZWQxKzApBgNVBAMTIkNPTU9ETyBSU0EgQ2VydGlmaWNhdGlv
+biBBdXRob3JpdHkwHhcNMTAwMTE5MDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBhTELMAkGA1UEBhMC
+R0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UE
+ChMRQ09NT0RPIENBIExpbWl0ZWQxKzApBgNVBAMTIkNPTU9ETyBSU0EgQ2VydGlmaWNhdGlvbiBB
+dXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCR6FSS0gpWsawNJN3Fz0Rn
+dJkrN6N9I3AAcbxT38T6KhKPS38QVr2fcHK3YX/JSw8Xpz3jsARh7v8Rl8f0hj4K+j5c+ZPmNHrZ
+FGvnnLOFoIJ6dq9xkNfs/Q36nGz637CC9BR++b7Epi9Pf5l/tfxnQ3K9DADWietrLNPtj5gcFKt+
+5eNu/Nio5JIk2kNrYrhV/erBvGy2i/MOjZrkm2xpmfh4SDBF1a3hDTxFYPwyllEnvGfDyi62a+pG
+x8cgoLEfZd5ICLqkTqnyg0Y3hOvozIFIQ2dOciqbXL1MGyiKXCJ7tKuY2e7gUYPDCUZObT6Z+pUX
+2nwzV0E8jVHtC7ZcryxjGt9XyD+86V3Em69FmeKjWiS0uqlWPc9vqv9JWL7wqP/0uK3pN/u6uPQL
+OvnoQ0IeidiEyxPx2bvhiWC4jChWrBQdnArncevPDt09qZahSL0896+1DSJMwBGB7FY79tOi4lu3
+sgQiUpWAk2nojkxl8ZEDLXB0AuqLZxUpaVICu9ffUGpVRr+goyhhf3DQw6KqLCGqR84onAZFdr+C
+GCe01a60y1Dma/RMhnEw6abfFobg2P9A3fvQQoh/ozM6LlweQRGBY84YcWsr7KaKtzFcOmpH4MN5
+WdYgGq/yapiqcrxXStJLnbsQ/LBMQeXtHT1eKJ2czL+zUdqnR+WEUwIDAQABo0IwQDAdBgNVHQ4E
+FgQUu69+Aj36pvE8hI6t7jiY7NkyMtQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8w
+DQYJKoZIhvcNAQEMBQADggIBAArx1UaEt65Ru2yyTUEUAJNMnMvlwFTPoCWOAvn9sKIN9SCYPBMt
+rFaisNZ+EZLpLrqeLppysb0ZRGxhNaKatBYSaVqM4dc+pBroLwP0rmEdEBsqpIt6xf4FpuHA1sj+
+nq6PK7o9mfjYcwlYRm6mnPTXJ9OV2jeDchzTc+CiR5kDOF3VSXkAKRzH7JsgHAckaVd4sjn8OoSg
+tZx8jb8uk2IntznaFxiuvTwJaP+EmzzV1gsD41eeFPfR60/IvYcjt7ZJQ3mFXLrrkguhxuhoqEwW
+sRqZCuhTLJK7oQkYdQxlqHvLI7cawiiFwxv/0Cti76R7CZGYZ4wUAc1oBmpjIXUDgIiKboHGhfKp
+pC3n9KUkEEeDys30jXlYsQab5xoq2Z0B15R97QNKyvDb6KkBPvVWmckejkk9u+UJueBPSZI9FoJA
+zMxZxuY67RIuaTxslbH9qh17f4a+Hg4yRvv7E491f0yLS0Zj/gA0QHDBw7mh3aZw4gSzQbzpgJHq
+ZJx64SIDqZxubw5lT2yHh17zbqD5daWbQOhTsiedSrnAdyGN/4fy3ryM7xfft0kL0fJuMAsaDk52
+7RH89elWsn2/x20Kk4yl0MC2Hb46TpSi125sC8KKfPog88Tk5c0NqMuRkrF8hey1FGlmDoLnzc7I
+LaZRfyHBNVOFBkpdn627G190
+-----END CERTIFICATE-----
+
+USERTrust RSA Certification Authority
+=====================================
+-----BEGIN CERTIFICATE-----
+MIIF3jCCA8agAwIBAgIQAf1tMPyjylGoG7xkDjUDLTANBgkqhkiG9w0BAQwFADCBiDELMAkGA1UE
+BhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQK
+ExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBSU0EgQ2VydGlmaWNh
+dGlvbiBBdXRob3JpdHkwHhcNMTAwMjAxMDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkGA1UE
+BhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQK
+ExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBSU0EgQ2VydGlmaWNh
+dGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCAEmUXNg7D2wiz
+0KxXDXbtzSfTTK1Qg2HiqiBNCS1kCdzOiZ/MPans9s/B3PHTsdZ7NygRK0faOca8Ohm0X6a9fZ2j
+Y0K2dvKpOyuR+OJv0OwWIJAJPuLodMkYtJHUYmTbf6MG8YgYapAiPLz+E/CHFHv25B+O1ORRxhFn
+RghRy4YUVD+8M/5+bJz/Fp0YvVGONaanZshyZ9shZrHUm3gDwFA66Mzw3LyeTP6vBZY1H1dat//O
++T23LLb2VN3I5xI6Ta5MirdcmrS3ID3KfyI0rn47aGYBROcBTkZTmzNg95S+UzeQc0PzMsNT79uq
+/nROacdrjGCT3sTHDN/hMq7MkztReJVni+49Vv4M0GkPGw/zJSZrM233bkf6c0Plfg6lZrEpfDKE
+Y1WJxA3Bk1QwGROs0303p+tdOmw1XNtB1xLaqUkL39iAigmTYo61Zs8liM2EuLE/pDkP2QKe6xJM
+lXzzawWpXhaDzLhn4ugTncxbgtNMs+1b/97lc6wjOy0AvzVVdAlJ2ElYGn+SNuZRkg7zJn0cTRe8
+yexDJtC/QV9AqURE9JnnV4eeUB9XVKg+/XRjL7FQZQnmWEIuQxpMtPAlR1n6BB6T1CZGSlCBst6+
+eLf8ZxXhyVeEHg9j1uliutZfVS7qXMYoCAQlObgOK6nyTJccBz8NUvXt7y+CDwIDAQABo0IwQDAd
+BgNVHQ4EFgQUU3m/WqorSs9UgOHYm8Cd8rIDZsswDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF
+MAMBAf8wDQYJKoZIhvcNAQEMBQADggIBAFzUfA3P9wF9QZllDHPFUp/L+M+ZBn8b2kMVn54CVVeW
+FPFSPCeHlCjtHzoBN6J2/FNQwISbxmtOuowhT6KOVWKR82kV2LyI48SqC/3vqOlLVSoGIG1VeCkZ
+7l8wXEskEVX/JJpuXior7gtNn3/3ATiUFJVDBwn7YKnuHKsSjKCaXqeYalltiz8I+8jRRa8YFWSQ
+Eg9zKC7F4iRO/Fjs8PRF/iKz6y+O0tlFYQXBl2+odnKPi4w2r78NBc5xjeambx9spnFixdjQg3IM
+8WcRiQycE0xyNN+81XHfqnHd4blsjDwSXWXavVcStkNr/+XeTWYRUc+ZruwXtuhxkYzeSf7dNXGi
+FSeUHM9h4ya7b6NnJSFd5t0dCy5oGzuCr+yDZ4XUmFF0sbmZgIn/f3gZXHlKYC6SQK5MNyosycdi
+yA5d9zZbyuAlJQG03RoHnHcAP9Dc1ew91Pq7P8yF1m9/qS3fuQL39ZeatTXaw2ewh0qpKJ4jjv9c
+J2vhsE/zB+4ALtRZh8tSQZXq9EfX7mRBVXyNWQKV3WKdwrnuWih0hKWbt5DHDAff9Yk2dDLWKMGw
+sAvgnEzDHNb842m1R0aBL6KCq9NjRHDEjf8tM7qtj3u1cIiuPhnPQCjY/MiQu12ZIvVS5ljFH4gx
+Q+6IHdfGjjxDah2nGN59PRbxYvnKkKj9
+-----END CERTIFICATE-----
+
+USERTrust ECC Certification Authority
+=====================================
+-----BEGIN CERTIFICATE-----
+MIICjzCCAhWgAwIBAgIQXIuZxVqUxdJxVt7NiYDMJjAKBggqhkjOPQQDAzCBiDELMAkGA1UEBhMC
+VVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVU
+aGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBFQ0MgQ2VydGlmaWNhdGlv
+biBBdXRob3JpdHkwHhcNMTAwMjAxMDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkGA1UEBhMC
+VVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVU
+aGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBFQ0MgQ2VydGlmaWNhdGlv
+biBBdXRob3JpdHkwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQarFRaqfloI+d61SRvU8Za2EurxtW2
+0eZzca7dnNYMYf3boIkDuAUU7FfO7l0/4iGzzvfUinngo4N+LZfQYcTxmdwlkWOrfzCjtHDix6Ez
+nPO/LlxTsV+zfTJ/ijTjeXmjQjBAMB0GA1UdDgQWBBQ64QmG1M8ZwpZ2dEl23OA1xmNjmjAOBgNV
+HQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjA2Z6EWCNzklwBB
+HU6+4WMBzzuqQhFkoJ2UOQIReVx7Hfpkue4WQrO/isIJxOzksU0CMQDpKmFHjFJKS04YcPbWRNZu
+9YO6bVi9JNlWSOrvxKJGgYhqOkbRqZtNyWHa0V1Xahg=
+-----END CERTIFICATE-----
+
+GlobalSign ECC Root CA - R4
+===========================
+-----BEGIN CERTIFICATE-----
+MIIB4TCCAYegAwIBAgIRKjikHJYKBN5CsiilC+g0mAIwCgYIKoZIzj0EAwIwUDEkMCIGA1UECxMb
+R2xvYmFsU2lnbiBFQ0MgUm9vdCBDQSAtIFI0MRMwEQYDVQQKEwpHbG9iYWxTaWduMRMwEQYDVQQD
+EwpHbG9iYWxTaWduMB4XDTEyMTExMzAwMDAwMFoXDTM4MDExOTAzMTQwN1owUDEkMCIGA1UECxMb
+R2xvYmFsU2lnbiBFQ0MgUm9vdCBDQSAtIFI0MRMwEQYDVQQKEwpHbG9iYWxTaWduMRMwEQYDVQQD
+EwpHbG9iYWxTaWduMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEuMZ5049sJQ6fLjkZHAOkrprl
+OQcJFspjsbmG+IpXwVfOQvpzofdlQv8ewQCybnMO/8ch5RikqtlxP6jUuc6MHaNCMEAwDgYDVR0P
+AQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFFSwe61FuOJAf/sKbvu+M8k8o4TV
+MAoGCCqGSM49BAMCA0gAMEUCIQDckqGgE6bPA7DmxCGXkPoUVy0D7O48027KqGx2vKLeuwIgJ6iF
+JzWbVsaj8kfSt24bAgAXqmemFZHe+pTsewv4n4Q=
+-----END CERTIFICATE-----
+
+GlobalSign ECC Root CA - R5
+===========================
+-----BEGIN CERTIFICATE-----
+MIICHjCCAaSgAwIBAgIRYFlJ4CYuu1X5CneKcflK2GwwCgYIKoZIzj0EAwMwUDEkMCIGA1UECxMb
+R2xvYmFsU2lnbiBFQ0MgUm9vdCBDQSAtIFI1MRMwEQYDVQQKEwpHbG9iYWxTaWduMRMwEQYDVQQD
+EwpHbG9iYWxTaWduMB4XDTEyMTExMzAwMDAwMFoXDTM4MDExOTAzMTQwN1owUDEkMCIGA1UECxMb
+R2xvYmFsU2lnbiBFQ0MgUm9vdCBDQSAtIFI1MRMwEQYDVQQKEwpHbG9iYWxTaWduMRMwEQYDVQQD
+EwpHbG9iYWxTaWduMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAER0UOlvt9Xb/pOdEh+J8LttV7HpI6
+SFkc8GIxLcB6KP4ap1yztsyX50XUWPrRd21DosCHZTQKH3rd6zwzocWdTaRvQZU4f8kehOvRnkmS
+h5SHDDqFSmafnVmTTZdhBoZKo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAd
+BgNVHQ4EFgQUPeYpSJvqB8ohREom3m7e0oPQn1kwCgYIKoZIzj0EAwMDaAAwZQIxAOVpEslu28Yx
+uglB4Zf4+/2a4n0Sye18ZNPLBSWLVtmg515dTguDnFt2KaAJJiFqYgIwcdK1j1zqO+F4CYWodZI7
+yFz9SO8NdCKoCOJuxUnOxwy8p2Fp8fc74SrL+SvzZpA3
+-----END CERTIFICATE-----
+
+Staat der Nederlanden Root CA - G3
+==================================
+-----BEGIN CERTIFICATE-----
+MIIFdDCCA1ygAwIBAgIEAJiiOTANBgkqhkiG9w0BAQsFADBaMQswCQYDVQQGEwJOTDEeMBwGA1UE
+CgwVU3RhYXQgZGVyIE5lZGVybGFuZGVuMSswKQYDVQQDDCJTdGFhdCBkZXIgTmVkZXJsYW5kZW4g
+Um9vdCBDQSAtIEczMB4XDTEzMTExNDExMjg0MloXDTI4MTExMzIzMDAwMFowWjELMAkGA1UEBhMC
+TkwxHjAcBgNVBAoMFVN0YWF0IGRlciBOZWRlcmxhbmRlbjErMCkGA1UEAwwiU3RhYXQgZGVyIE5l
+ZGVybGFuZGVuIFJvb3QgQ0EgLSBHMzCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAL4y
+olQPcPssXFnrbMSkUeiFKrPMSjTysF/zDsccPVMeiAho2G89rcKezIJnByeHaHE6n3WWIkYFsO2t
+x1ueKt6c/DrGlaf1F2cY5y9JCAxcz+bMNO14+1Cx3Gsy8KL+tjzk7FqXxz8ecAgwoNzFs21v0IJy
+EavSgWhZghe3eJJg+szeP4TrjTgzkApyI/o1zCZxMdFyKJLZWyNtZrVtB0LrpjPOktvA9mxjeM3K
+Tj215VKb8b475lRgsGYeCasH/lSJEULR9yS6YHgamPfJEf0WwTUaVHXvQ9Plrk7O53vDxk5hUUur
+mkVLoR9BvUhTFXFkC4az5S6+zqQbwSmEorXLCCN2QyIkHxcE1G6cxvx/K2Ya7Irl1s9N9WMJtxU5
+1nus6+N86U78dULI7ViVDAZCopz35HCz33JvWjdAidiFpNfxC95DGdRKWCyMijmev4SH8RY7Ngzp
+07TKbBlBUgmhHbBqv4LvcFEhMtwFdozL92TkA1CvjJFnq8Xy7ljY3r735zHPbMk7ccHViLVlvMDo
+FxcHErVc0qsgk7TmgoNwNsXNo42ti+yjwUOH5kPiNL6VizXtBznaqB16nzaeErAMZRKQFWDZJkBE
+41ZgpRDUajz9QdwOWke275dhdU/Z/seyHdTtXUmzqWrLZoQT1Vyg3N9udwbRcXXIV2+vD3dbAgMB
+AAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBRUrfrHkleu
+yjWcLhL75LpdINyUVzANBgkqhkiG9w0BAQsFAAOCAgEAMJmdBTLIXg47mAE6iqTnB/d6+Oea31BD
+U5cqPco8R5gu4RV78ZLzYdqQJRZlwJ9UXQ4DO1t3ApyEtg2YXzTdO2PCwyiBwpwpLiniyMMB8jPq
+KqrMCQj3ZWfGzd/TtiunvczRDnBfuCPRy5FOCvTIeuXZYzbB1N/8Ipf3YF3qKS9Ysr1YvY2WTxB1
+v0h7PVGHoTx0IsL8B3+A3MSs/mrBcDCw6Y5p4ixpgZQJut3+TcCDjJRYwEYgr5wfAvg1VUkvRtTA
+8KCWAg8zxXHzniN9lLf9OtMJgwYh/WA9rjLA0u6NpvDntIJ8CsxwyXmA+P5M9zWEGYox+wrZ13+b
+8KKaa8MFSu1BYBQw0aoRQm7TIwIEC8Zl3d1Sd9qBa7Ko+gE4uZbqKmxnl4mUnrzhVNXkanjvSr0r
+mj1AfsbAddJu+2gw7OyLnflJNZoaLNmzlTnVHpL3prllL+U9bTpITAjc5CgSKL59NVzq4BZ+Extq
+1z7XnvwtdbLBFNUjA9tbbws+eC8N3jONFrdI54OagQ97wUNNVQQXOEpR1VmiiXTTn74eS9fGbbeI
+JG9gkaSChVtWQbzQRKtqE77RLFi3EjNYsjdj3BP1lB0/QFH1T/U67cjF68IeHRaVesd+QnGTbksV
+tzDfqu1XhUisHWrdOWnk4Xl4vs4Fv6EM94B7IWcnMFk=
+-----END CERTIFICATE-----
+
+Staat der Nederlanden EV Root CA
+================================
+-----BEGIN CERTIFICATE-----
+MIIFcDCCA1igAwIBAgIEAJiWjTANBgkqhkiG9w0BAQsFADBYMQswCQYDVQQGEwJOTDEeMBwGA1UE
+CgwVU3RhYXQgZGVyIE5lZGVybGFuZGVuMSkwJwYDVQQDDCBTdGFhdCBkZXIgTmVkZXJsYW5kZW4g
+RVYgUm9vdCBDQTAeFw0xMDEyMDgxMTE5MjlaFw0yMjEyMDgxMTEwMjhaMFgxCzAJBgNVBAYTAk5M
+MR4wHAYDVQQKDBVTdGFhdCBkZXIgTmVkZXJsYW5kZW4xKTAnBgNVBAMMIFN0YWF0IGRlciBOZWRl
+cmxhbmRlbiBFViBSb290IENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA48d+ifkk
+SzrSM4M1LGns3Amk41GoJSt5uAg94JG6hIXGhaTK5skuU6TJJB79VWZxXSzFYGgEt9nCUiY4iKTW
+O0Cmws0/zZiTs1QUWJZV1VD+hq2kY39ch/aO5ieSZxeSAgMs3NZmdO3dZ//BYY1jTw+bbRcwJu+r
+0h8QoPnFfxZpgQNH7R5ojXKhTbImxrpsX23Wr9GxE46prfNeaXUmGD5BKyF/7otdBwadQ8QpCiv8
+Kj6GyzyDOvnJDdrFmeK8eEEzduG/L13lpJhQDBXd4Pqcfzho0LKmeqfRMb1+ilgnQ7O6M5HTp5gV
+XJrm0w912fxBmJc+qiXbj5IusHsMX/FjqTf5m3VpTCgmJdrV8hJwRVXj33NeN/UhbJCONVrJ0yPr
+08C+eKxCKFhmpUZtcALXEPlLVPxdhkqHz3/KRawRWrUgUY0viEeXOcDPusBCAUCZSCELa6fS/ZbV
+0b5GnUngC6agIk440ME8MLxwjyx1zNDFjFE7PZQIZCZhfbnDZY8UnCHQqv0XcgOPvZuM5l5Tnrmd
+74K74bzickFbIZTTRTeU0d8JOV3nI6qaHcptqAqGhYqCvkIH1vI4gnPah1vlPNOePqc7nvQDs/nx
+fRN0Av+7oeX6AHkcpmZBiFxgV6YuCcS6/ZrPpx9Aw7vMWgpVSzs4dlG4Y4uElBbmVvMCAwEAAaNC
+MEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFP6rAJCYniT8qcwa
+ivsnuL8wbqg7MA0GCSqGSIb3DQEBCwUAA4ICAQDPdyxuVr5Os7aEAJSrR8kN0nbHhp8dB9O2tLsI
+eK9p0gtJ3jPFrK3CiAJ9Brc1AsFgyb/E6JTe1NOpEyVa/m6irn0F3H3zbPB+po3u2dfOWBfoqSmu
+c0iH55vKbimhZF8ZE/euBhD/UcabTVUlT5OZEAFTdfETzsemQUHSv4ilf0X8rLiltTMMgsT7B/Zq
+5SWEXwbKwYY5EdtYzXc7LMJMD16a4/CrPmEbUCTCwPTxGfARKbalGAKb12NMcIxHowNDXLldRqAN
+b/9Zjr7dn3LDWyvfjFvO5QxGbJKyCqNMVEIYFRIYvdr8unRu/8G2oGTYqV9Vrp9canaW2HNnh/tN
+f1zuacpzEPuKqf2evTY4SUmH9A4U8OmHuD+nT3pajnnUk+S7aFKErGzp85hwVXIy+TSrK0m1zSBi
+5Dp6Z2Orltxtrpfs/J92VoguZs9btsmksNcFuuEnL5O7Jiqik7Ab846+HUCjuTaPPoIaGl6I6lD4
+WeKDRikL40Rc4ZW2aZCaFG+XroHPaO+Zmr615+F/+PoTRxZMzG0IQOeLeG9QgkRQP2YGiqtDhFZK
+DyAthg710tvSeopLzaXoTvFeJiUBWSOgftL2fiFX1ye8FVdMpEbB4IMeDExNH08GGeL5qPQ6gqGy
+eUN51q1veieQA6TqJIc/2b3Z6fJfUEkc7uzXLg==
+-----END CERTIFICATE-----
+
+IdenTrust Commercial Root CA 1
+==============================
+-----BEGIN CERTIFICATE-----
+MIIFYDCCA0igAwIBAgIQCgFCgAAAAUUjyES1AAAAAjANBgkqhkiG9w0BAQsFADBKMQswCQYDVQQG
+EwJVUzESMBAGA1UEChMJSWRlblRydXN0MScwJQYDVQQDEx5JZGVuVHJ1c3QgQ29tbWVyY2lhbCBS
+b290IENBIDEwHhcNMTQwMTE2MTgxMjIzWhcNMzQwMTE2MTgxMjIzWjBKMQswCQYDVQQGEwJVUzES
+MBAGA1UEChMJSWRlblRydXN0MScwJQYDVQQDEx5JZGVuVHJ1c3QgQ29tbWVyY2lhbCBSb290IENB
+IDEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCnUBneP5k91DNG8W9RYYKyqU+PZ4ld
+hNlT3Qwo2dfw/66VQ3KZ+bVdfIrBQuExUHTRgQ18zZshq0PirK1ehm7zCYofWjK9ouuU+ehcCuz/
+mNKvcbO0U59Oh++SvL3sTzIwiEsXXlfEU8L2ApeN2WIrvyQfYo3fw7gpS0l4PJNgiCL8mdo2yMKi
+1CxUAGc1bnO/AljwpN3lsKImesrgNqUZFvX9t++uP0D1bVoE/c40yiTcdCMbXTMTEl3EASX2MN0C
+XZ/g1Ue9tOsbobtJSdifWwLziuQkkORiT0/Br4sOdBeo0XKIanoBScy0RnnGF7HamB4HWfp1IYVl
+3ZBWzvurpWCdxJ35UrCLvYf5jysjCiN2O/cz4ckA82n5S6LgTrx+kzmEB/dEcH7+B1rlsazRGMzy
+NeVJSQjKVsk9+w8YfYs7wRPCTY/JTw436R+hDmrfYi7LNQZReSzIJTj0+kuniVyc0uMNOYZKdHzV
+WYfCP04MXFL0PfdSgvHqo6z9STQaKPNBiDoT7uje/5kdX7rL6B7yuVBgwDHTc+XvvqDtMwt0viAg
+xGds8AgDelWAf0ZOlqf0Hj7h9tgJ4TNkK2PXMl6f+cB7D3hvl7yTmvmcEpB4eoCHFddydJxVdHix
+uuFucAS6T6C6aMN7/zHwcz09lCqxC0EOoP5NiGVreTO01wIDAQABo0IwQDAOBgNVHQ8BAf8EBAMC
+AQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU7UQZwNPwBovupHu+QucmVMiONnYwDQYJKoZI
+hvcNAQELBQADggIBAA2ukDL2pkt8RHYZYR4nKM1eVO8lvOMIkPkp165oCOGUAFjvLi5+U1KMtlwH
+6oi6mYtQlNeCgN9hCQCTrQ0U5s7B8jeUeLBfnLOic7iPBZM4zY0+sLj7wM+x8uwtLRvM7Kqas6pg
+ghstO8OEPVeKlh6cdbjTMM1gCIOQ045U8U1mwF10A0Cj7oV+wh93nAbowacYXVKV7cndJZ5t+qnt
+ozo00Fl72u1Q8zW/7esUTTHHYPTa8Yec4kjixsU3+wYQ+nVZZjFHKdp2mhzpgq7vmrlR94gjmmmV
+YjzlVYA211QC//G5Xc7UI2/YRYRKW2XviQzdFKcgyxilJbQN+QHwotL0AMh0jqEqSI5l2xPE4iUX
+feu+h1sXIFRRk0pTAwvsXcoz7WL9RccvW9xYoIA55vrX/hMUpu09lEpCdNTDd1lzzY9GvlU47/ro
+kTLql1gEIt44w8y8bckzOmoKaT+gyOpyj4xjhiO9bTyWnpXgSUyqorkqG5w2gXjtw+hG4iZZRHUe
+2XWJUc0QhJ1hYMtd+ZciTY6Y5uN/9lu7rs3KSoFrXgvzUeF0K+l+J6fZmUlO+KWA2yUPHGNiiskz
+Z2s8EIPGrd6ozRaOjfAHN3Gf8qv8QfXBi+wAN10J5U6A7/qxXDgGpRtK4dw4LTzcqx+QGtVKnO7R
+cGzM7vRX+Bi6hG6H
+-----END CERTIFICATE-----
+
+IdenTrust Public Sector Root CA 1
+=================================
+-----BEGIN CERTIFICATE-----
+MIIFZjCCA06gAwIBAgIQCgFCgAAAAUUjz0Z8AAAAAjANBgkqhkiG9w0BAQsFADBNMQswCQYDVQQG
+EwJVUzESMBAGA1UEChMJSWRlblRydXN0MSowKAYDVQQDEyFJZGVuVHJ1c3QgUHVibGljIFNlY3Rv
+ciBSb290IENBIDEwHhcNMTQwMTE2MTc1MzMyWhcNMzQwMTE2MTc1MzMyWjBNMQswCQYDVQQGEwJV
+UzESMBAGA1UEChMJSWRlblRydXN0MSowKAYDVQQDEyFJZGVuVHJ1c3QgUHVibGljIFNlY3RvciBS
+b290IENBIDEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC2IpT8pEiv6EdrCvsnduTy
+P4o7ekosMSqMjbCpwzFrqHd2hCa2rIFCDQjrVVi7evi8ZX3yoG2LqEfpYnYeEe4IFNGyRBb06tD6
+Hi9e28tzQa68ALBKK0CyrOE7S8ItneShm+waOh7wCLPQ5CQ1B5+ctMlSbdsHyo+1W/CD80/HLaXI
+rcuVIKQxKFdYWuSNG5qrng0M8gozOSI5Cpcu81N3uURF/YTLNiCBWS2ab21ISGHKTN9T0a9SvESf
+qy9rg3LvdYDaBjMbXcjaY8ZNzaxmMc3R3j6HEDbhuaR672BQssvKplbgN6+rNBM5Jeg5ZuSYeqoS
+mJxZZoY+rfGwyj4GD3vwEUs3oERte8uojHH01bWRNszwFcYr3lEXsZdMUD2xlVl8BX0tIdUAvwFn
+ol57plzy9yLxkA2T26pEUWbMfXYD62qoKjgZl3YNa4ph+bz27nb9cCvdKTz4Ch5bQhyLVi9VGxyh
+LrXHFub4qjySjmm2AcG1hp2JDws4lFTo6tyePSW8Uybt1as5qsVATFSrsrTZ2fjXctscvG29ZV/v
+iDUqZi/u9rNl8DONfJhBaUYPQxxp+pu10GFqzcpL2UyQRqsVWaFHVCkugyhfHMKiq3IXAAaOReyL
+4jM9f9oZRORicsPfIsbyVtTdX5Vy7W1f90gDW/3FKqD2cyOEEBsB5wIDAQABo0IwQDAOBgNVHQ8B
+Af8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU43HgntinQtnbcZFrlJPrw6PRFKMw
+DQYJKoZIhvcNAQELBQADggIBAEf63QqwEZE4rU1d9+UOl1QZgkiHVIyqZJnYWv6IAcVYpZmxI1Qj
+t2odIFflAWJBF9MJ23XLblSQdf4an4EKwt3X9wnQW3IV5B4Jaj0z8yGa5hV+rVHVDRDtfULAj+7A
+mgjVQdZcDiFpboBhDhXAuM/FSRJSzL46zNQuOAXeNf0fb7iAaJg9TaDKQGXSc3z1i9kKlT/YPyNt
+GtEqJBnZhbMX73huqVjRI9PHE+1yJX9dsXNw0H8GlwmEKYBhHfpe/3OsoOOJuBxxFcbeMX8S3OFt
+m6/n6J91eEyrRjuazr8FGF1NFTwWmhlQBJqymm9li1JfPFgEKCXAZmExfrngdbkaqIHWchezxQMx
+NRF4eKLg6TCMf4DfWN88uieW4oA0beOY02QnrEh+KHdcxiVhJfiFDGX6xDIvpZgF5PgLZxYWxoK4
+Mhn5+bl53B/N66+rDt0b20XkeucC4pVd/GnwU2lhlXV5C15V5jgclKlZM57IcXR5f1GJtshquDDI
+ajjDbp7hNxbqBWJMWxJH7ae0s1hWx0nzfxJoCTFx8G34Tkf71oXuxVhAGaQdp/lLQzfcaFpPz+vC
+ZHTetBXZ9FRUGi8c15dxVJCO2SCdUyt/q4/i6jC8UDfv8Ue1fXwsBOxonbRJRBD0ckscZOf85muQ
+3Wl9af0AVqW3rLatt8o+Ae+c
+-----END CERTIFICATE-----
+
+Entrust Root Certification Authority - G2
+=========================================
+-----BEGIN CERTIFICATE-----
+MIIEPjCCAyagAwIBAgIESlOMKDANBgkqhkiG9w0BAQsFADCBvjELMAkGA1UEBhMCVVMxFjAUBgNV
+BAoTDUVudHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3d3cuZW50cnVzdC5uZXQvbGVnYWwtdGVy
+bXMxOTA3BgNVBAsTMChjKSAyMDA5IEVudHJ1c3QsIEluYy4gLSBmb3IgYXV0aG9yaXplZCB1c2Ug
+b25seTEyMDAGA1UEAxMpRW50cnVzdCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0gRzIw
+HhcNMDkwNzA3MTcyNTU0WhcNMzAxMjA3MTc1NTU0WjCBvjELMAkGA1UEBhMCVVMxFjAUBgNVBAoT
+DUVudHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3d3cuZW50cnVzdC5uZXQvbGVnYWwtdGVybXMx
+OTA3BgNVBAsTMChjKSAyMDA5IEVudHJ1c3QsIEluYy4gLSBmb3IgYXV0aG9yaXplZCB1c2Ugb25s
+eTEyMDAGA1UEAxMpRW50cnVzdCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0gRzIwggEi
+MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6hLZy254Ma+KZ6TABp3bqMriVQRrJ2mFOWHLP
+/vaCeb9zYQYKpSfYs1/TRU4cctZOMvJyig/3gxnQaoCAAEUesMfnmr8SVycco2gvCoe9amsOXmXz
+HHfV1IWNcCG0szLni6LVhjkCsbjSR87kyUnEO6fe+1R9V77w6G7CebI6C1XiUJgWMhNcL3hWwcKU
+s/Ja5CeanyTXxuzQmyWC48zCxEXFjJd6BmsqEZ+pCm5IO2/b1BEZQvePB7/1U1+cPvQXLOZprE4y
+TGJ36rfo5bs0vBmLrpxR57d+tVOxMyLlbc9wPBr64ptntoP0jaWvYkxN4FisZDQSA/i2jZRjJKRx
+AgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRqciZ6
+0B7vfec7aVHUbI2fkBJmqzANBgkqhkiG9w0BAQsFAAOCAQEAeZ8dlsa2eT8ijYfThwMEYGprmi5Z
+iXMRrEPR9RP/jTkrwPK9T3CMqS/qF8QLVJ7UG5aYMzyorWKiAHarWWluBh1+xLlEjZivEtRh2woZ
+Rkfz6/djwUAFQKXSt/S1mja/qYh2iARVBCuch38aNzx+LaUa2NSJXsq9rD1s2G2v1fN2D807iDgi
+nWyTmsQ9v4IbZT+mD12q/OWyFcq1rca8PdCE6OoGcrBNOTJ4vz4RnAuknZoh8/CbCzB428Hch0P+
+vGOaysXCHMnHjf87ElgI5rY97HosTvuDls4MPGmHVHOkc8KT/1EQrBVUAdj8BbGJoX90g5pJ19xO
+e4pIb4tF9g==
+-----END CERTIFICATE-----
+
+Entrust Root Certification Authority - EC1
+==========================================
+-----BEGIN CERTIFICATE-----
+MIIC+TCCAoCgAwIBAgINAKaLeSkAAAAAUNCR+TAKBggqhkjOPQQDAzCBvzELMAkGA1UEBhMCVVMx
+FjAUBgNVBAoTDUVudHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3d3cuZW50cnVzdC5uZXQvbGVn
+YWwtdGVybXMxOTA3BgNVBAsTMChjKSAyMDEyIEVudHJ1c3QsIEluYy4gLSBmb3IgYXV0aG9yaXpl
+ZCB1c2Ugb25seTEzMDEGA1UEAxMqRW50cnVzdCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5
+IC0gRUMxMB4XDTEyMTIxODE1MjUzNloXDTM3MTIxODE1NTUzNlowgb8xCzAJBgNVBAYTAlVTMRYw
+FAYDVQQKEw1FbnRydXN0LCBJbmMuMSgwJgYDVQQLEx9TZWUgd3d3LmVudHJ1c3QubmV0L2xlZ2Fs
+LXRlcm1zMTkwNwYDVQQLEzAoYykgMjAxMiBFbnRydXN0LCBJbmMuIC0gZm9yIGF1dGhvcml6ZWQg
+dXNlIG9ubHkxMzAxBgNVBAMTKkVudHJ1c3QgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAt
+IEVDMTB2MBAGByqGSM49AgEGBSuBBAAiA2IABIQTydC6bUF74mzQ61VfZgIaJPRbiWlH47jCffHy
+AsWfoPZb1YsGGYZPUxBtByQnoaD41UcZYUx9ypMn6nQM72+WCf5j7HBdNq1nd67JnXxVRDqiY1Ef
+9eNi1KlHBz7MIKNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYE
+FLdj5xrdjekIplWDpOBqUEFlEUJJMAoGCCqGSM49BAMDA2cAMGQCMGF52OVCR98crlOZF7ZvHH3h
+vxGU0QOIdeSNiaSKd0bebWHvAvX7td/M/k7//qnmpwIwW5nXhTcGtXsI/esni0qU+eH6p44mCOh8
+kmhtc9hvJqwhAriZtyZBWyVgrtBIGu4G
+-----END CERTIFICATE-----
+
+CFCA EV ROOT
+============
+-----BEGIN CERTIFICATE-----
+MIIFjTCCA3WgAwIBAgIEGErM1jANBgkqhkiG9w0BAQsFADBWMQswCQYDVQQGEwJDTjEwMC4GA1UE
+CgwnQ2hpbmEgRmluYW5jaWFsIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRUwEwYDVQQDDAxDRkNB
+IEVWIFJPT1QwHhcNMTIwODA4MDMwNzAxWhcNMjkxMjMxMDMwNzAxWjBWMQswCQYDVQQGEwJDTjEw
+MC4GA1UECgwnQ2hpbmEgRmluYW5jaWFsIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRUwEwYDVQQD
+DAxDRkNBIEVWIFJPT1QwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDXXWvNED8fBVnV
+BU03sQ7smCuOFR36k0sXgiFxEFLXUWRwFsJVaU2OFW2fvwwbwuCjZ9YMrM8irq93VCpLTIpTUnrD
+7i7es3ElweldPe6hL6P3KjzJIx1qqx2hp/Hz7KDVRM8Vz3IvHWOX6Jn5/ZOkVIBMUtRSqy5J35DN
+uF++P96hyk0g1CXohClTt7GIH//62pCfCqktQT+x8Rgp7hZZLDRJGqgG16iI0gNyejLi6mhNbiyW
+ZXvKWfry4t3uMCz7zEasxGPrb382KzRzEpR/38wmnvFyXVBlWY9ps4deMm/DGIq1lY+wejfeWkU7
+xzbh72fROdOXW3NiGUgthxwG+3SYIElz8AXSG7Ggo7cbcNOIabla1jj0Ytwli3i/+Oh+uFzJlU9f
+py25IGvPa931DfSCt/SyZi4QKPaXWnuWFo8BGS1sbn85WAZkgwGDg8NNkt0yxoekN+kWzqotaK8K
+gWU6cMGbrU1tVMoqLUuFG7OA5nBFDWteNfB/O7ic5ARwiRIlk9oKmSJgamNgTnYGmE69g60dWIol
+hdLHZR4tjsbftsbhf4oEIRUpdPA+nJCdDC7xij5aqgwJHsfVPKPtl8MeNPo4+QgO48BdK4PRVmrJ
+tqhUUy54Mmc9gn900PvhtgVguXDbjgv5E1hvcWAQUhC5wUEJ73IfZzF4/5YFjQIDAQABo2MwYTAf
+BgNVHSMEGDAWgBTj/i39KNALtbq2osS/BqoFjJP7LzAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB
+/wQEAwIBBjAdBgNVHQ4EFgQU4/4t/SjQC7W6tqLEvwaqBYyT+y8wDQYJKoZIhvcNAQELBQADggIB
+ACXGumvrh8vegjmWPfBEp2uEcwPenStPuiB/vHiyz5ewG5zz13ku9Ui20vsXiObTej/tUxPQ4i9q
+ecsAIyjmHjdXNYmEwnZPNDatZ8POQQaIxffu2Bq41gt/UP+TqhdLjOztUmCypAbqTuv0axn96/Ua
+4CUqmtzHQTb3yHQFhDmVOdYLO6Qn+gjYXB74BGBSESgoA//vU2YApUo0FmZ8/Qmkrp5nGm9BC2sG
+E5uPhnEFtC+NiWYzKXZUmhH4J/qyP5Hgzg0b8zAarb8iXRvTvyUFTeGSGn+ZnzxEk8rUQElsgIfX
+BDrDMlI1Dlb4pd19xIsNER9Tyx6yF7Zod1rg1MvIB671Oi6ON7fQAUtDKXeMOZePglr4UeWJoBjn
+aH9dCi77o0cOPaYjesYBx4/IXr9tgFa+iiS6M+qf4TIRnvHST4D2G0CvOJ4RUHlzEhLN5mydLIhy
+PDCBBpEi6lmt2hkuIsKNuYyH4Ga8cyNfIWRjgEj1oDwYPZTISEEdQLpe/v5WOaHIz16eGWRGENoX
+kbcFgKyLmZJ956LYBws2J+dIeWCKw9cTXPhyQN9Ky8+ZAAoACxGV2lZFA4gKn2fQ1XmxqI1AbQ3C
+ekD6819kR5LLU7m7Wc5P/dAVUwHY3+vZ5nbv0CO7O6l5s9UCKc2Jo5YPSjXnTkLAdc0Hz+Ys63su
+-----END CERTIFICATE-----
+
+OISTE WISeKey Global Root GB CA
+===============================
+-----BEGIN CERTIFICATE-----
+MIIDtTCCAp2gAwIBAgIQdrEgUnTwhYdGs/gjGvbCwDANBgkqhkiG9w0BAQsFADBtMQswCQYDVQQG
+EwJDSDEQMA4GA1UEChMHV0lTZUtleTEiMCAGA1UECxMZT0lTVEUgRm91bmRhdGlvbiBFbmRvcnNl
+ZDEoMCYGA1UEAxMfT0lTVEUgV0lTZUtleSBHbG9iYWwgUm9vdCBHQiBDQTAeFw0xNDEyMDExNTAw
+MzJaFw0zOTEyMDExNTEwMzFaMG0xCzAJBgNVBAYTAkNIMRAwDgYDVQQKEwdXSVNlS2V5MSIwIAYD
+VQQLExlPSVNURSBGb3VuZGF0aW9uIEVuZG9yc2VkMSgwJgYDVQQDEx9PSVNURSBXSVNlS2V5IEds
+b2JhbCBSb290IEdCIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2Be3HEokKtaX
+scriHvt9OO+Y9bI5mE4nuBFde9IllIiCFSZqGzG7qFshISvYD06fWvGxWuR51jIjK+FTzJlFXHtP
+rby/h0oLS5daqPZI7H17Dc0hBt+eFf1Biki3IPShehtX1F1Q/7pn2COZH8g/497/b1t3sWtuuMlk
+9+HKQUYOKXHQuSP8yYFfTvdv37+ErXNku7dCjmn21HYdfp2nuFeKUWdy19SouJVUQHMD9ur06/4o
+Qnc/nSMbsrY9gBQHTC5P99UKFg29ZkM3fiNDecNAhvVMKdqOmq0NpQSHiB6F4+lT1ZvIiwNjeOvg
+GUpuuy9rM2RYk61pv48b74JIxwIDAQABo1EwTzALBgNVHQ8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB
+/zAdBgNVHQ4EFgQUNQ/INmNe4qPs+TtmFc5RUuORmj0wEAYJKwYBBAGCNxUBBAMCAQAwDQYJKoZI
+hvcNAQELBQADggEBAEBM+4eymYGQfp3FsLAmzYh7KzKNbrghcViXfa43FK8+5/ea4n32cZiZBKpD
+dHij40lhPnOMTZTg+XHEthYOU3gf1qKHLwI5gSk8rxWYITD+KJAAjNHhy/peyP34EEY7onhCkRd0
+VQreUGdNZtGn//3ZwLWoo4rOZvUPQ82nK1d7Y0Zqqi5S2PTt4W2tKZB4SLrhI6qjiey1q5bAtEui
+HZeeevJuQHHfaPFlTc58Bd9TZaml8LGXBHAVRgOY1NK/VLSgWH1Sb9pWJmLU2NuJMW8c8CLC02Ic
+Nc1MaRVUGpCY3useX8p3x8uOPUNpnJpY0CQ73xtAln41rYHHTnG6iBM=
+-----END CERTIFICATE-----
+
+SZAFIR ROOT CA2
+===============
+-----BEGIN CERTIFICATE-----
+MIIDcjCCAlqgAwIBAgIUPopdB+xV0jLVt+O2XwHrLdzk1uQwDQYJKoZIhvcNAQELBQAwUTELMAkG
+A1UEBhMCUEwxKDAmBgNVBAoMH0tyYWpvd2EgSXpiYSBSb3psaWN6ZW5pb3dhIFMuQS4xGDAWBgNV
+BAMMD1NaQUZJUiBST09UIENBMjAeFw0xNTEwMTkwNzQzMzBaFw0zNTEwMTkwNzQzMzBaMFExCzAJ
+BgNVBAYTAlBMMSgwJgYDVQQKDB9LcmFqb3dhIEl6YmEgUm96bGljemVuaW93YSBTLkEuMRgwFgYD
+VQQDDA9TWkFGSVIgUk9PVCBDQTIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC3vD5Q
+qEvNQLXOYeeWyrSh2gwisPq1e3YAd4wLz32ohswmUeQgPYUM1ljj5/QqGJ3a0a4m7utT3PSQ1hNK
+DJA8w/Ta0o4NkjrcsbH/ON7Dui1fgLkCvUqdGw+0w8LBZwPd3BucPbOw3gAeqDRHu5rr/gsUvTaE
+2g0gv/pby6kWIK05YO4vdbbnl5z5Pv1+TW9NL++IDWr63fE9biCloBK0TXC5ztdyO4mTp4CEHCdJ
+ckm1/zuVnsHMyAHs6A6KCpbns6aH5db5BSsNl0BwPLqsdVqc1U2dAgrSS5tmS0YHF2Wtn2yIANwi
+ieDhZNRnvDF5YTy7ykHNXGoAyDw4jlivAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0P
+AQH/BAQDAgEGMB0GA1UdDgQWBBQuFqlKGLXLzPVvUPMjX/hd56zwyDANBgkqhkiG9w0BAQsFAAOC
+AQEAtXP4A9xZWx126aMqe5Aosk3AM0+qmrHUuOQn/6mWmc5G4G18TKI4pAZw8PRBEew/R40/cof5
+O/2kbytTAOD/OblqBw7rHRz2onKQy4I9EYKL0rufKq8h5mOGnXkZ7/e7DDWQw4rtTw/1zBLZpD67
+oPwglV9PJi8RI4NOdQcPv5vRtB3pEAT+ymCPoky4rc/hkA/NrgrHXXu3UNLUYfrVFdvXn4dRVOul
+4+vJhaAlIDf7js4MNIThPIGyd05DpYhfhmehPea0XGG2Ptv+tyjFogeutcrKjSoS75ftwjCkySp6
++/NNIxuZMzSgLvWpCz/UXeHPhJ/iGcJfitYgHuNztw==
+-----END CERTIFICATE-----
+
+Certum Trusted Network CA 2
+===========================
+-----BEGIN CERTIFICATE-----
+MIIF0jCCA7qgAwIBAgIQIdbQSk8lD8kyN/yqXhKN6TANBgkqhkiG9w0BAQ0FADCBgDELMAkGA1UE
+BhMCUEwxIjAgBgNVBAoTGVVuaXpldG8gVGVjaG5vbG9naWVzIFMuQS4xJzAlBgNVBAsTHkNlcnR1
+bSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEkMCIGA1UEAxMbQ2VydHVtIFRydXN0ZWQgTmV0d29y
+ayBDQSAyMCIYDzIwMTExMDA2MDgzOTU2WhgPMjA0NjEwMDYwODM5NTZaMIGAMQswCQYDVQQGEwJQ
+TDEiMCAGA1UEChMZVW5pemV0byBUZWNobm9sb2dpZXMgUy5BLjEnMCUGA1UECxMeQ2VydHVtIENl
+cnRpZmljYXRpb24gQXV0aG9yaXR5MSQwIgYDVQQDExtDZXJ0dW0gVHJ1c3RlZCBOZXR3b3JrIENB
+IDIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC9+Xj45tWADGSdhhuWZGc/IjoedQF9
+7/tcZ4zJzFxrqZHmuULlIEub2pt7uZld2ZuAS9eEQCsn0+i6MLs+CRqnSZXvK0AkwpfHp+6bJe+o
+CgCXhVqqndwpyeI1B+twTUrWwbNWuKFBOJvR+zF/j+Bf4bE/D44WSWDXBo0Y+aomEKsq09DRZ40b
+Rr5HMNUuctHFY9rnY3lEfktjJImGLjQ/KUxSiyqnwOKRKIm5wFv5HdnnJ63/mgKXwcZQkpsCLL2p
+uTRZCr+ESv/f/rOf69me4Jgj7KZrdxYq28ytOxykh9xGc14ZYmhFV+SQgkK7QtbwYeDBoz1mo130
+GO6IyY0XRSmZMnUCMe4pJshrAua1YkV/NxVaI2iJ1D7eTiew8EAMvE0Xy02isx7QBlrd9pPPV3WZ
+9fqGGmd4s7+W/jTcvedSVuWz5XV710GRBdxdaeOVDUO5/IOWOZV7bIBaTxNyxtd9KXpEulKkKtVB
+Rgkg/iKgtlswjbyJDNXXcPiHUv3a76xRLgezTv7QCdpw75j6VuZt27VXS9zlLCUVyJ4ueE742pye
+hizKV/Ma5ciSixqClnrDvFASadgOWkaLOusm+iPJtrCBvkIApPjW/jAux9JG9uWOdf3yzLnQh1vM
+BhBgu4M1t15n3kfsmUjxpKEV/q2MYo45VU85FrmxY53/twIDAQABo0IwQDAPBgNVHRMBAf8EBTAD
+AQH/MB0GA1UdDgQWBBS2oVQ5AsOgP46KvPrU+Bym0ToO/TAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZI
+hvcNAQENBQADggIBAHGlDs7k6b8/ONWJWsQCYftMxRQXLYtPU2sQF/xlhMcQSZDe28cmk4gmb3DW
+Al45oPePq5a1pRNcgRRtDoGCERuKTsZPpd1iHkTfCVn0W3cLN+mLIMb4Ck4uWBzrM9DPhmDJ2vuA
+L55MYIR4PSFk1vtBHxgP58l1cb29XN40hz5BsA72udY/CROWFC/emh1auVbONTqwX3BNXuMp8SMo
+clm2q8KMZiYcdywmdjWLKKdpoPk79SPdhRB0yZADVpHnr7pH1BKXESLjokmUbOe3lEu6LaTaM4tM
+pkT/WjzGHWTYtTHkpjx6qFcL2+1hGsvxznN3Y6SHb0xRONbkX8eftoEq5IVIeVheO/jbAoJnwTnb
+w3RLPTYe+SmTiGhbqEQZIfCn6IENLOiTNrQ3ssqwGyZ6miUfmpqAnksqP/ujmv5zMnHCnsZy4Ypo
+J/HkD7TETKVhk/iXEAcqMCWpuchxuO9ozC1+9eB+D4Kob7a6bINDd82Kkhehnlt4Fj1F4jNy3eFm
+ypnTycUm/Q1oBEauttmbjL4ZvrHG8hnjXALKLNhvSgfZyTXaQHXyxKcZb55CEJh15pWLYLztxRLX
+is7VmFxWlgPF7ncGNf/P5O4/E2Hu29othfDNrp2yGAlFw5Khchf8R7agCyzxxN5DaAhqXzvwdmP7
+zAYspsbiDrW5viSP
+-----END CERTIFICATE-----
+
+Hellenic Academic and Research Institutions RootCA 2015
+=======================================================
+-----BEGIN CERTIFICATE-----
+MIIGCzCCA/OgAwIBAgIBADANBgkqhkiG9w0BAQsFADCBpjELMAkGA1UEBhMCR1IxDzANBgNVBAcT
+BkF0aGVuczFEMEIGA1UEChM7SGVsbGVuaWMgQWNhZGVtaWMgYW5kIFJlc2VhcmNoIEluc3RpdHV0
+aW9ucyBDZXJ0LiBBdXRob3JpdHkxQDA+BgNVBAMTN0hlbGxlbmljIEFjYWRlbWljIGFuZCBSZXNl
+YXJjaCBJbnN0aXR1dGlvbnMgUm9vdENBIDIwMTUwHhcNMTUwNzA3MTAxMTIxWhcNNDAwNjMwMTAx
+MTIxWjCBpjELMAkGA1UEBhMCR1IxDzANBgNVBAcTBkF0aGVuczFEMEIGA1UEChM7SGVsbGVuaWMg
+QWNhZGVtaWMgYW5kIFJlc2VhcmNoIEluc3RpdHV0aW9ucyBDZXJ0LiBBdXRob3JpdHkxQDA+BgNV
+BAMTN0hlbGxlbmljIEFjYWRlbWljIGFuZCBSZXNlYXJjaCBJbnN0aXR1dGlvbnMgUm9vdENBIDIw
+MTUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDC+Kk/G4n8PDwEXT2QNrCROnk8Zlrv
+bTkBSRq0t89/TSNTt5AA4xMqKKYx8ZEA4yjsriFBzh/a/X0SWwGDD7mwX5nh8hKDgE0GPt+sr+eh
+iGsxr/CL0BgzuNtFajT0AoAkKAoCFZVedioNmToUW/bLy1O8E00BiDeUJRtCvCLYjqOWXjrZMts+
+6PAQZe104S+nfK8nNLspfZu2zwnI5dMK/IhlZXQK3HMcXM1AsRzUtoSMTFDPaI6oWa7CJ06CojXd
+FPQf/7J31Ycvqm59JCfnxssm5uX+Zwdj2EUN3TpZZTlYepKZcj2chF6IIbjV9Cz82XBST3i4vTwr
+i5WY9bPRaM8gFH5MXF/ni+X1NYEZN9cRCLdmvtNKzoNXADrDgfgXy5I2XdGj2HUb4Ysn6npIQf1F
+GQatJ5lOwXBH3bWfgVMS5bGMSF0xQxfjjMZ6Y5ZLKTBOhE5iGV48zpeQpX8B653g+IuJ3SWYPZK2
+fu/Z8VFRfS0myGlZYeCsargqNhEEelC9MoS+L9xy1dcdFkfkR2YgP/SWxa+OAXqlD3pk9Q0Yh9mu
+iNX6hME6wGkoLfINaFGq46V3xqSQDqE3izEjR8EJCOtu93ib14L8hCCZSRm2Ekax+0VVFqmjZayc
+Bw/qa9wfLgZy7IaIEuQt218FL+TwA9MmM+eAws1CoRc0CwIDAQABo0IwQDAPBgNVHRMBAf8EBTAD
+AQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUcRVnyMjJvXVdctA4GGqd83EkVAswDQYJKoZI
+hvcNAQELBQADggIBAHW7bVRLqhBYRjTyYtcWNl0IXtVsyIe9tC5G8jH4fOpCtZMWVdyhDBKg2mF+
+D1hYc2Ryx+hFjtyp8iY/xnmMsVMIM4GwVhO+5lFc2JsKT0ucVlMC6U/2DWDqTUJV6HwbISHTGzrM
+d/K4kPFox/la/vot9L/J9UUbzjgQKjeKeaO04wlshYaT/4mWJ3iBj2fjRnRUjtkNaeJK9E10A/+y
+d+2VZ5fkscWrv2oj6NSU4kQoYsRL4vDY4ilrGnB+JGGTe08DMiUNRSQrlrRGar9KC/eaj8GsGsVn
+82800vpzY4zvFrCopEYq+OsS7HK07/grfoxSwIuEVPkvPuNVqNxmsdnhX9izjFk0WaSrT2y7Hxjb
+davYy5LNlDhhDgcGH0tGEPEVvo2FXDtKK4F5D7Rpn0lQl033DlZdwJVqwjbDG2jJ9SrcR5q+ss7F
+Jej6A7na+RZukYT1HCjI/CbM1xyQVqdfbzoEvM14iQuODy+jqk+iGxI9FghAD/FGTNeqewjBCvVt
+J94Cj8rDtSvK6evIIVM4pcw72Hc3MKJP2W/R8kCtQXoXxdZKNYm3QdV8hn9VTYNKpXMgwDqvkPGa
+JI7ZjnHKe7iG2rKPmT4dEw0SEe7Uq/DpFXYC5ODfqiAeW2GFZECpkJcNrVPSWh2HagCXZWK0vm9q
+p/UsQu0yrbYhnr68
+-----END CERTIFICATE-----
+
+Hellenic Academic and Research Institutions ECC RootCA 2015
+===========================================================
+-----BEGIN CERTIFICATE-----
+MIICwzCCAkqgAwIBAgIBADAKBggqhkjOPQQDAjCBqjELMAkGA1UEBhMCR1IxDzANBgNVBAcTBkF0
+aGVuczFEMEIGA1UEChM7SGVsbGVuaWMgQWNhZGVtaWMgYW5kIFJlc2VhcmNoIEluc3RpdHV0aW9u
+cyBDZXJ0LiBBdXRob3JpdHkxRDBCBgNVBAMTO0hlbGxlbmljIEFjYWRlbWljIGFuZCBSZXNlYXJj
+aCBJbnN0aXR1dGlvbnMgRUNDIFJvb3RDQSAyMDE1MB4XDTE1MDcwNzEwMzcxMloXDTQwMDYzMDEw
+MzcxMlowgaoxCzAJBgNVBAYTAkdSMQ8wDQYDVQQHEwZBdGhlbnMxRDBCBgNVBAoTO0hlbGxlbmlj
+IEFjYWRlbWljIGFuZCBSZXNlYXJjaCBJbnN0aXR1dGlvbnMgQ2VydC4gQXV0aG9yaXR5MUQwQgYD
+VQQDEztIZWxsZW5pYyBBY2FkZW1pYyBhbmQgUmVzZWFyY2ggSW5zdGl0dXRpb25zIEVDQyBSb290
+Q0EgMjAxNTB2MBAGByqGSM49AgEGBSuBBAAiA2IABJKgQehLgoRc4vgxEZmGZE4JJS+dQS8KrjVP
+dJWyUWRrjWvmP3CV8AVER6ZyOFB2lQJajq4onvktTpnvLEhvTCUp6NFxW98dwXU3tNf6e3pCnGoK
+Vlp8aQuqgAkkbH7BRqNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0O
+BBYEFLQiC4KZJAEOnLvkDv2/+5cgk5kqMAoGCCqGSM49BAMCA2cAMGQCMGfOFmI4oqxiRaeplSTA
+GiecMjvAwNW6qef4BENThe5SId6d9SWDPp5YSy/XZxMOIQIwBeF1Ad5o7SofTUwJCA3sS61kFyjn
+dc5FZXIhF8siQQ6ME5g4mlRtm8rifOoCWCKR
+-----END CERTIFICATE-----
+
+ISRG Root X1
+============
+-----BEGIN CERTIFICATE-----
+MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAwTzELMAkGA1UE
+BhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2VhcmNoIEdyb3VwMRUwEwYDVQQD
+EwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQG
+EwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMT
+DElTUkcgUm9vdCBYMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54r
+Vygch77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+0TM8ukj1
+3Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6UA5/TR5d8mUgjU+g4rk8K
+b4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sWT8KOEUt+zwvo/7V3LvSye0rgTBIlDHCN
+Aymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyHB5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ
+4Q7e2RCOFvu396j3x+UCB5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf
+1b0SHzUvKBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWnOlFu
+hjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTnjh8BCNAw1FtxNrQH
+usEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbwqHyGO0aoSCqI3Haadr8faqU9GY/r
+OPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CIrU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4G
+A1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY
+9umbbjANBgkqhkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
+ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ3BebYhtF8GaV
+0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KKNFtY2PwByVS5uCbMiogziUwt
+hDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJw
+TdwJx4nLCgdNbOhdjsnvzqvHu7UrTkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nx
+e5AW0wdeRlN8NwdCjNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZA
+JzVcoyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq4RgqsahD
+YVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPAmRGunUHBcnWEvgJBQl9n
+JEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57demyPxgcYxn/eR44/KJ4EBs+lVDR3veyJ
+m+kXQ99b21/+jh5Xos1AnX5iItreGCc=
+-----END CERTIFICATE-----
+
+AC RAIZ FNMT-RCM
+================
+-----BEGIN CERTIFICATE-----
+MIIFgzCCA2ugAwIBAgIPXZONMGc2yAYdGsdUhGkHMA0GCSqGSIb3DQEBCwUAMDsxCzAJBgNVBAYT
+AkVTMREwDwYDVQQKDAhGTk1ULVJDTTEZMBcGA1UECwwQQUMgUkFJWiBGTk1ULVJDTTAeFw0wODEw
+MjkxNTU5NTZaFw0zMDAxMDEwMDAwMDBaMDsxCzAJBgNVBAYTAkVTMREwDwYDVQQKDAhGTk1ULVJD
+TTEZMBcGA1UECwwQQUMgUkFJWiBGTk1ULVJDTTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC
+ggIBALpxgHpMhm5/yBNtwMZ9HACXjywMI7sQmkCpGreHiPibVmr75nuOi5KOpyVdWRHbNi63URcf
+qQgfBBckWKo3Shjf5TnUV/3XwSyRAZHiItQDwFj8d0fsjz50Q7qsNI1NOHZnjrDIbzAzWHFctPVr
+btQBULgTfmxKo0nRIBnuvMApGGWn3v7v3QqQIecaZ5JCEJhfTzC8PhxFtBDXaEAUwED653cXeuYL
+j2VbPNmaUtu1vZ5Gzz3rkQUCwJaydkxNEJY7kvqcfw+Z374jNUUeAlz+taibmSXaXvMiwzn15Cou
+08YfxGyqxRxqAQVKL9LFwag0Jl1mpdICIfkYtwb1TplvqKtMUejPUBjFd8g5CSxJkjKZqLsXF3mw
+WsXmo8RZZUc1g16p6DULmbvkzSDGm0oGObVo/CK67lWMK07q87Hj/LaZmtVC+nFNCM+HHmpxffnT
+tOmlcYF7wk5HlqX2doWjKI/pgG6BU6VtX7hI+cL5NqYuSf+4lsKMB7ObiFj86xsc3i1w4peSMKGJ
+47xVqCfWS+2QrYv6YyVZLag13cqXM7zlzced0ezvXg5KkAYmY6252TUtB7p2ZSysV4999AeU14EC
+ll2jB0nVetBX+RvnU0Z1qrB5QstocQjpYL05ac70r8NWQMetUqIJ5G+GR4of6ygnXYMgrwTJbFaa
+i0b1AgMBAAGjgYMwgYAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYE
+FPd9xf3E6Jobd2Sn9R2gzL+HYJptMD4GA1UdIAQ3MDUwMwYEVR0gADArMCkGCCsGAQUFBwIBFh1o
+dHRwOi8vd3d3LmNlcnQuZm5tdC5lcy9kcGNzLzANBgkqhkiG9w0BAQsFAAOCAgEAB5BK3/MjTvDD
+nFFlm5wioooMhfNzKWtN/gHiqQxjAb8EZ6WdmF/9ARP67Jpi6Yb+tmLSbkyU+8B1RXxlDPiyN8+s
+D8+Nb/kZ94/sHvJwnvDKuO+3/3Y3dlv2bojzr2IyIpMNOmqOFGYMLVN0V2Ue1bLdI4E7pWYjJ2cJ
+j+F3qkPNZVEI7VFY/uY5+ctHhKQV8Xa7pO6kO8Rf77IzlhEYt8llvhjho6Tc+hj507wTmzl6NLrT
+Qfv6MooqtyuGC2mDOL7Nii4LcK2NJpLuHvUBKwrZ1pebbuCoGRw6IYsMHkCtA+fdZn71uSANA+iW
++YJF1DngoABd15jmfZ5nc8OaKveri6E6FO80vFIOiZiaBECEHX5FaZNXzuvO+FB8TxxuBEOb+dY7
+Ixjp6o7RTUaN8Tvkasq6+yO3m/qZASlaWFot4/nUbQ4mrcFuNLwy+AwF+mWj2zs3gyLp1txyM/1d
+8iC9djwj2ij3+RvrWWTV3F9yfiD8zYm1kGdNYno/Tq0dwzn+evQoFt9B9kiABdcPUXmsEKvU7ANm
+5mqwujGSQkBqvjrTcuFqN1W8rB2Vt2lh8kORdOag0wokRqEIr9baRRmW1FMdW4R58MD3R++Lj8UG
+rp1MYp3/RgT408m2ECVAdf4WqslKYIYvuu8wd+RU4riEmViAqhOLUTpPSPaLtrM=
+-----END CERTIFICATE-----
+
+Amazon Root CA 1
+================
+-----BEGIN CERTIFICATE-----
+MIIDQTCCAimgAwIBAgITBmyfz5m/jAo54vB4ikPmljZbyjANBgkqhkiG9w0BAQsFADA5MQswCQYD
+VQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24gUm9vdCBDQSAxMB4XDTE1
+MDUyNjAwMDAwMFoXDTM4MDExNzAwMDAwMFowOTELMAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpv
+bjEZMBcGA1UEAxMQQW1hem9uIFJvb3QgQ0EgMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC
+ggEBALJ4gHHKeNXjca9HgFB0fW7Y14h29Jlo91ghYPl0hAEvrAIthtOgQ3pOsqTQNroBvo3bSMgH
+FzZM9O6II8c+6zf1tRn4SWiw3te5djgdYZ6k/oI2peVKVuRF4fn9tBb6dNqcmzU5L/qwIFAGbHrQ
+gLKm+a/sRxmPUDgH3KKHOVj4utWp+UhnMJbulHheb4mjUcAwhmahRWa6VOujw5H5SNz/0egwLX0t
+dHA114gk957EWW67c4cX8jJGKLhD+rcdqsq08p8kDi1L93FcXmn/6pUCyziKrlA4b9v7LWIbxcce
+VOF34GfID5yHI9Y/QCB/IIDEgEw+OyQmjgSubJrIqg0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB
+/zAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0OBBYEFIQYzIU07LwMlJQuCFmcx7IQTgoIMA0GCSqGSIb3
+DQEBCwUAA4IBAQCY8jdaQZChGsV2USggNiMOruYou6r4lK5IpDB/G/wkjUu0yKGX9rbxenDIU5PM
+CCjjmCXPI6T53iHTfIUJrU6adTrCC2qJeHZERxhlbI1Bjjt/msv0tadQ1wUsN+gDS63pYaACbvXy
+8MWy7Vu33PqUXHeeE6V/Uq2V8viTO96LXFvKWlJbYK8U90vvo/ufQJVtMVT8QtPHRh8jrdkPSHCa
+2XV4cdFyQzR1bldZwgJcJmApzyMZFo6IQ6XU5MsI+yMRQ+hDKXJioaldXgjUkK642M4UwtBV8ob2
+xJNDd2ZhwLnoQdeXeGADbkpyrqXRfboQnoZsG4q5WTP468SQvvG5
+-----END CERTIFICATE-----
+
+Amazon Root CA 2
+================
+-----BEGIN CERTIFICATE-----
+MIIFQTCCAymgAwIBAgITBmyf0pY1hp8KD+WGePhbJruKNzANBgkqhkiG9w0BAQwFADA5MQswCQYD
+VQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24gUm9vdCBDQSAyMB4XDTE1
+MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpv
+bjEZMBcGA1UEAxMQQW1hem9uIFJvb3QgQ0EgMjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC
+ggIBAK2Wny2cSkxKgXlRmeyKy2tgURO8TW0G/LAIjd0ZEGrHJgw12MBvIITplLGbhQPDW9tK6Mj4
+kHbZW0/jTOgGNk3Mmqw9DJArktQGGWCsN0R5hYGCrVo34A3MnaZMUnbqQ523BNFQ9lXg1dKmSYXp
+N+nKfq5clU1Imj+uIFptiJXZNLhSGkOQsL9sBbm2eLfq0OQ6PBJTYv9K8nu+NQWpEjTj82R0Yiw9
+AElaKP4yRLuH3WUnAnE72kr3H9rN9yFVkE8P7K6C4Z9r2UXTu/Bfh+08LDmG2j/e7HJV63mjrdvd
+fLC6HM783k81ds8P+HgfajZRRidhW+mez/CiVX18JYpvL7TFz4QuK/0NURBs+18bvBt+xa47mAEx
+kv8LV/SasrlX6avvDXbR8O70zoan4G7ptGmh32n2M8ZpLpcTnqWHsFcQgTfJU7O7f/aS0ZzQGPSS
+btqDT6ZjmUyl+17vIWR6IF9sZIUVyzfpYgwLKhbcAS4y2j5L9Z469hdAlO+ekQiG+r5jqFoz7Mt0
+Q5X5bGlSNscpb/xVA1wf+5+9R+vnSUeVC06JIglJ4PVhHvG/LopyboBZ/1c6+XUyo05f7O0oYtlN
+c/LMgRdg7c3r3NunysV+Ar3yVAhU/bQtCSwXVEqY0VThUWcI0u1ufm8/0i2BWSlmy5A5lREedCf+
+3euvAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSw
+DPBMMPQFWAJI/TPlUq9LhONmUjANBgkqhkiG9w0BAQwFAAOCAgEAqqiAjw54o+Ci1M3m9Zh6O+oA
+A7CXDpO8Wqj2LIxyh6mx/H9z/WNxeKWHWc8w4Q0QshNabYL1auaAn6AFC2jkR2vHat+2/XcycuUY
++gn0oJMsXdKMdYV2ZZAMA3m3MSNjrXiDCYZohMr/+c8mmpJ5581LxedhpxfL86kSk5Nrp+gvU5LE
+YFiwzAJRGFuFjWJZY7attN6a+yb3ACfAXVU3dJnJUH/jWS5E4ywl7uxMMne0nxrpS10gxdr9HIcW
+xkPo1LsmmkVwXqkLN1PiRnsn/eBG8om3zEK2yygmbtmlyTrIQRNg91CMFa6ybRoVGld45pIq2WWQ
+gj9sAq+uEjonljYE1x2igGOpm/HlurR8FLBOybEfdF849lHqm/osohHUqS0nGkWxr7JOcQ3AWEbW
+aQbLU8uz/mtBzUF+fUwPfHJ5elnNXkoOrJupmHN5fLT0zLm4BwyydFy4x2+IoZCn9Kr5v2c69BoV
+Yh63n749sSmvZ6ES8lgQGVMDMBu4Gon2nL2XA46jCfMdiyHxtN/kHNGfZQIG6lzWE7OE76KlXIx3
+KadowGuuQNKotOrN8I1LOJwZmhsoVLiJkO/KdYE+HvJkJMcYr07/R54H9jVlpNMKVv/1F2Rs76gi
+JUmTtt8AF9pYfl3uxRuw0dFfIRDH+fO6AgonB8Xx1sfT4PsJYGw=
+-----END CERTIFICATE-----
+
+Amazon Root CA 3
+================
+-----BEGIN CERTIFICATE-----
+MIIBtjCCAVugAwIBAgITBmyf1XSXNmY/Owua2eiedgPySjAKBggqhkjOPQQDAjA5MQswCQYDVQQG
+EwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24gUm9vdCBDQSAzMB4XDTE1MDUy
+NjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZ
+MBcGA1UEAxMQQW1hem9uIFJvb3QgQ0EgMzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABCmXp8ZB
+f8ANm+gBG1bG8lKlui2yEujSLtf6ycXYqm0fc4E7O5hrOXwzpcVOho6AF2hiRVd9RFgdszflZwjr
+Zt6jQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSrttvXBp43
+rDCGB5Fwx5zEGbF4wDAKBggqhkjOPQQDAgNJADBGAiEA4IWSoxe3jfkrBqWTrBqYaGFy+uGh0Psc
+eGCmQ5nFuMQCIQCcAu/xlJyzlvnrxir4tiz+OpAUFteMYyRIHN8wfdVoOw==
+-----END CERTIFICATE-----
+
+Amazon Root CA 4
+================
+-----BEGIN CERTIFICATE-----
+MIIB8jCCAXigAwIBAgITBmyf18G7EEwpQ+Vxe3ssyBrBDjAKBggqhkjOPQQDAzA5MQswCQYDVQQG
+EwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24gUm9vdCBDQSA0MB4XDTE1MDUy
+NjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZ
+MBcGA1UEAxMQQW1hem9uIFJvb3QgQ0EgNDB2MBAGByqGSM49AgEGBSuBBAAiA2IABNKrijdPo1MN
+/sGKe0uoe0ZLY7Bi9i0b2whxIdIA6GO9mif78DluXeo9pcmBqqNbIJhFXRbb/egQbeOc4OO9X4Ri
+83BkM6DLJC9wuoihKqB1+IGuYgbEgds5bimwHvouXKNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNV
+HQ8BAf8EBAMCAYYwHQYDVR0OBBYEFNPsxzplbszh2naaVvuc84ZtV+WBMAoGCCqGSM49BAMDA2gA
+MGUCMDqLIfG9fhGt0O9Yli/W651+kI0rz2ZVwyzjKKlwCkcO8DdZEv8tmZQoTipPNU0zWgIxAOp1
+AE47xDqUEpHJWEadIRNyp4iciuRMStuW1KyLa2tJElMzrdfkviT8tQp21KW8EA==
+-----END CERTIFICATE-----
+
+TUBITAK Kamu SM SSL Kok Sertifikasi - Surum 1
+=============================================
+-----BEGIN CERTIFICATE-----
+MIIEYzCCA0ugAwIBAgIBATANBgkqhkiG9w0BAQsFADCB0jELMAkGA1UEBhMCVFIxGDAWBgNVBAcT
+D0dlYnplIC0gS29jYWVsaTFCMEAGA1UEChM5VHVya2l5ZSBCaWxpbXNlbCB2ZSBUZWtub2xvamlr
+IEFyYXN0aXJtYSBLdXJ1bXUgLSBUVUJJVEFLMS0wKwYDVQQLEyRLYW11IFNlcnRpZmlrYXN5b24g
+TWVya2V6aSAtIEthbXUgU00xNjA0BgNVBAMTLVRVQklUQUsgS2FtdSBTTSBTU0wgS29rIFNlcnRp
+ZmlrYXNpIC0gU3VydW0gMTAeFw0xMzExMjUwODI1NTVaFw00MzEwMjUwODI1NTVaMIHSMQswCQYD
+VQQGEwJUUjEYMBYGA1UEBxMPR2ViemUgLSBLb2NhZWxpMUIwQAYDVQQKEzlUdXJraXllIEJpbGlt
+c2VsIHZlIFRla25vbG9qaWsgQXJhc3Rpcm1hIEt1cnVtdSAtIFRVQklUQUsxLTArBgNVBAsTJEth
+bXUgU2VydGlmaWthc3lvbiBNZXJrZXppIC0gS2FtdSBTTTE2MDQGA1UEAxMtVFVCSVRBSyBLYW11
+IFNNIFNTTCBLb2sgU2VydGlmaWthc2kgLSBTdXJ1bSAxMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
+MIIBCgKCAQEAr3UwM6q7a9OZLBI3hNmNe5eA027n/5tQlT6QlVZC1xl8JoSNkvoBHToP4mQ4t4y8
+6Ij5iySrLqP1N+RAjhgleYN1Hzv/bKjFxlb4tO2KRKOrbEz8HdDc72i9z+SqzvBV96I01INrN3wc
+wv61A+xXzry0tcXtAA9TNypN9E8Mg/uGz8v+jE69h/mniyFXnHrfA2eJLJ2XYacQuFWQfw4tJzh0
+3+f92k4S400VIgLI4OD8D62K18lUUMw7D8oWgITQUVbDjlZ/iSIzL+aFCr2lqBs23tPcLG07xxO9
+WSMs5uWk99gL7eqQQESolbuT1dCANLZGeA4fAJNG4e7p+exPFwIDAQABo0IwQDAdBgNVHQ4EFgQU
+ZT/HiobGPN08VFw1+DrtUgxHV8gwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJ
+KoZIhvcNAQELBQADggEBACo/4fEyjq7hmFxLXs9rHmoJ0iKpEsdeV31zVmSAhHqT5Am5EM2fKifh
+AHe+SMg1qIGf5LgsyX8OsNJLN13qudULXjS99HMpw+0mFZx+CFOKWI3QSyjfwbPfIPP54+M638yc
+lNhOT8NrF7f3cuitZjO1JVOr4PhMqZ398g26rrnZqsZr+ZO7rqu4lzwDGrpDxpa5RXI4s6ehlj2R
+e37AIVNMh+3yC1SVUZPVIqUNivGTDj5UDrDYyU7c8jEyVupk+eq1nRZmQnLzf9OxMUP8pI4X8W0j
+q5Rm+K37DwhuJi1/FwcJsoz7UMCflo3Ptv0AnVoUmr8CRPXBwp8iXqIPoeM=
+-----END CERTIFICATE-----
+
+GDCA TrustAUTH R5 ROOT
+======================
+-----BEGIN CERTIFICATE-----
+MIIFiDCCA3CgAwIBAgIIfQmX/vBH6nowDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMCQ04xMjAw
+BgNVBAoMKUdVQU5HIERPTkcgQ0VSVElGSUNBVEUgQVVUSE9SSVRZIENPLixMVEQuMR8wHQYDVQQD
+DBZHRENBIFRydXN0QVVUSCBSNSBST09UMB4XDTE0MTEyNjA1MTMxNVoXDTQwMTIzMTE1NTk1OVow
+YjELMAkGA1UEBhMCQ04xMjAwBgNVBAoMKUdVQU5HIERPTkcgQ0VSVElGSUNBVEUgQVVUSE9SSVRZ
+IENPLixMVEQuMR8wHQYDVQQDDBZHRENBIFRydXN0QVVUSCBSNSBST09UMIICIjANBgkqhkiG9w0B
+AQEFAAOCAg8AMIICCgKCAgEA2aMW8Mh0dHeb7zMNOwZ+Vfy1YI92hhJCfVZmPoiC7XJjDp6L3TQs
+AlFRwxn9WVSEyfFrs0yw6ehGXTjGoqcuEVe6ghWinI9tsJlKCvLriXBjTnnEt1u9ol2x8kECK62p
+OqPseQrsXzrj/e+APK00mxqriCZ7VqKChh/rNYmDf1+uKU49tm7srsHwJ5uu4/Ts765/94Y9cnrr
+pftZTqfrlYwiOXnhLQiPzLyRuEH3FMEjqcOtmkVEs7LXLM3GKeJQEK5cy4KOFxg2fZfmiJqwTTQJ
+9Cy5WmYqsBebnh52nUpmMUHfP/vFBu8btn4aRjb3ZGM74zkYI+dndRTVdVeSN72+ahsmUPI2JgaQ
+xXABZG12ZuGR224HwGGALrIuL4xwp9E7PLOR5G62xDtw8mySlwnNR30YwPO7ng/Wi64HtloPzgsM
+R6flPri9fcebNaBhlzpBdRfMK5Z3KpIhHtmVdiBnaM8Nvd/WHwlqmuLMc3GkL30SgLdTMEZeS1SZ
+D2fJpcjyIMGC7J0R38IC+xo70e0gmu9lZJIQDSri3nDxGGeCjGHeuLzRL5z7D9Ar7Rt2ueQ5Vfj4
+oR24qoAATILnsn8JuLwwoC8N9VKejveSswoAHQBUlwbgsQfZxw9cZX08bVlX5O2ljelAU58VS6Bx
+9hoh49pwBiFYFIeFd3mqgnkCAwEAAaNCMEAwHQYDVR0OBBYEFOLJQJ9NzuiaoXzPDj9lxSmIahlR
+MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBCwUAA4ICAQDRSVfg
+p8xoWLoBDysZzY2wYUWsEe1jUGn4H3++Fo/9nesLqjJHdtJnJO29fDMylyrHBYZmDRd9FBUb1Ov9
+H5r2XpdptxolpAqzkT9fNqyL7FeoPueBihhXOYV0GkLH6VsTX4/5COmSdI31R9KrO9b7eGZONn35
+6ZLpBN79SWP8bfsUcZNnL0dKt7n/HipzcEYwv1ryL3ml4Y0M2fmyYzeMN2WFcGpcWwlyua1jPLHd
++PwyvzeG5LuOmCd+uh8W4XAR8gPfJWIyJyYYMoSf/wA6E7qaTfRPuBRwIrHKK5DOKcFw9C+df/KQ
+HtZa37dG/OaG+svgIHZ6uqbL9XzeYqWxi+7egmaKTjowHz+Ay60nugxe19CxVsp3cbK1daFQqUBD
+F8Io2c9Si1vIY9RCPqAzekYu9wogRlR+ak8x8YF+QnQ4ZXMn7sZ8uI7XpTrXmKGcjBBV09tL7ECQ
+8s1uV9JiDnxXk7Gnbc2dg7sq5+W2O3FYrf3RRbxake5TFW/TRQl1brqQXR4EzzffHqhmsYzmIGrv
+/EhOdJhCrylvLmrH+33RZjEizIYAfmaDDEL0vTSSwxrqT8p+ck0LcIymSLumoRT2+1hEmRSuqguT
+aaApJUqlyyvdimYHFngVV3Eb7PVHhPOeMTd61X8kreS8/f3MboPoDKi3QWwH3b08hpcv0g==
+-----END CERTIFICATE-----
+
+TrustCor RootCert CA-1
+======================
+-----BEGIN CERTIFICATE-----
+MIIEMDCCAxigAwIBAgIJANqb7HHzA7AZMA0GCSqGSIb3DQEBCwUAMIGkMQswCQYDVQQGEwJQQTEP
+MA0GA1UECAwGUGFuYW1hMRQwEgYDVQQHDAtQYW5hbWEgQ2l0eTEkMCIGA1UECgwbVHJ1c3RDb3Ig
+U3lzdGVtcyBTLiBkZSBSLkwuMScwJQYDVQQLDB5UcnVzdENvciBDZXJ0aWZpY2F0ZSBBdXRob3Jp
+dHkxHzAdBgNVBAMMFlRydXN0Q29yIFJvb3RDZXJ0IENBLTEwHhcNMTYwMjA0MTIzMjE2WhcNMjkx
+MjMxMTcyMzE2WjCBpDELMAkGA1UEBhMCUEExDzANBgNVBAgMBlBhbmFtYTEUMBIGA1UEBwwLUGFu
+YW1hIENpdHkxJDAiBgNVBAoMG1RydXN0Q29yIFN5c3RlbXMgUy4gZGUgUi5MLjEnMCUGA1UECwwe
+VHJ1c3RDb3IgQ2VydGlmaWNhdGUgQXV0aG9yaXR5MR8wHQYDVQQDDBZUcnVzdENvciBSb290Q2Vy
+dCBDQS0xMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv463leLCJhJrMxnHQFgKq1mq
+jQCj/IDHUHuO1CAmujIS2CNUSSUQIpidRtLByZ5OGy4sDjjzGiVoHKZaBeYei0i/mJZ0PmnK6bV4
+pQa81QBeCQryJ3pS/C3Vseq0iWEk8xoT26nPUu0MJLq5nux+AHT6k61sKZKuUbS701e/s/OojZz0
+JEsq1pme9J7+wH5COucLlVPat2gOkEz7cD+PSiyU8ybdY2mplNgQTsVHCJCZGxdNuWxu72CVEY4h
+gLW9oHPY0LJ3xEXqWib7ZnZ2+AYfYW0PVcWDtxBWcgYHpfOxGgMFZA6dWorWhnAbJN7+KIor0Gqw
+/Hqi3LJ5DotlDwIDAQABo2MwYTAdBgNVHQ4EFgQU7mtJPHo/DeOxCbeKyKsZn3MzUOcwHwYDVR0j
+BBgwFoAU7mtJPHo/DeOxCbeKyKsZn3MzUOcwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC
+AYYwDQYJKoZIhvcNAQELBQADggEBACUY1JGPE+6PHh0RU9otRCkZoB5rMZ5NDp6tPVxBb5UrJKF5
+mDo4Nvu7Zp5I/5CQ7z3UuJu0h3U/IJvOcs+hVcFNZKIZBqEHMwwLKeXx6quj7LUKdJDHfXLy11yf
+ke+Ri7fc7Waiz45mO7yfOgLgJ90WmMCV1Aqk5IGadZQ1nJBfiDcGrVmVCrDRZ9MZyonnMlo2HD6C
+qFqTvsbQZJG2z9m2GM/bftJlo6bEjhcxwft+dtvTheNYsnd6djtsL1Ac59v2Z3kf9YKVmgenFK+P
+3CghZwnS1k1aHBkcjndcw5QkPTJrS37UeJSDvjdNzl/HHk484IkzlQsPpTLWPFp5LBk=
+-----END CERTIFICATE-----
+
+TrustCor RootCert CA-2
+======================
+-----BEGIN CERTIFICATE-----
+MIIGLzCCBBegAwIBAgIIJaHfyjPLWQIwDQYJKoZIhvcNAQELBQAwgaQxCzAJBgNVBAYTAlBBMQ8w
+DQYDVQQIDAZQYW5hbWExFDASBgNVBAcMC1BhbmFtYSBDaXR5MSQwIgYDVQQKDBtUcnVzdENvciBT
+eXN0ZW1zIFMuIGRlIFIuTC4xJzAlBgNVBAsMHlRydXN0Q29yIENlcnRpZmljYXRlIEF1dGhvcml0
+eTEfMB0GA1UEAwwWVHJ1c3RDb3IgUm9vdENlcnQgQ0EtMjAeFw0xNjAyMDQxMjMyMjNaFw0zNDEy
+MzExNzI2MzlaMIGkMQswCQYDVQQGEwJQQTEPMA0GA1UECAwGUGFuYW1hMRQwEgYDVQQHDAtQYW5h
+bWEgQ2l0eTEkMCIGA1UECgwbVHJ1c3RDb3IgU3lzdGVtcyBTLiBkZSBSLkwuMScwJQYDVQQLDB5U
+cnVzdENvciBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkxHzAdBgNVBAMMFlRydXN0Q29yIFJvb3RDZXJ0
+IENBLTIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCnIG7CKqJiJJWQdsg4foDSq8Gb
+ZQWU9MEKENUCrO2fk8eHyLAnK0IMPQo+QVqedd2NyuCb7GgypGmSaIwLgQ5WoD4a3SwlFIIvl9Nk
+RvRUqdw6VC0xK5mC8tkq1+9xALgxpL56JAfDQiDyitSSBBtlVkxs1Pu2YVpHI7TYabS3OtB0PAx1
+oYxOdqHp2yqlO/rOsP9+aij9JxzIsekp8VduZLTQwRVtDr4uDkbIXvRR/u8OYzo7cbrPb1nKDOOb
+XUm4TOJXsZiKQlecdu/vvdFoqNL0Cbt3Nb4lggjEFixEIFapRBF37120Hapeaz6LMvYHL1cEksr1
+/p3C6eizjkxLAjHZ5DxIgif3GIJ2SDpxsROhOdUuxTTCHWKF3wP+TfSvPd9cW436cOGlfifHhi5q
+jxLGhF5DUVCcGZt45vz27Ud+ez1m7xMTiF88oWP7+ayHNZ/zgp6kPwqcMWmLmaSISo5uZk3vFsQP
+eSghYA2FFn3XVDjxklb9tTNMg9zXEJ9L/cb4Qr26fHMC4P99zVvh1Kxhe1fVSntb1IVYJ12/+Ctg
+rKAmrhQhJ8Z3mjOAPF5GP/fDsaOGM8boXg25NSyqRsGFAnWAoOsk+xWq5Gd/bnc/9ASKL3x74xdh
+8N0JqSDIvgmk0H5Ew7IwSjiqqewYmgeCK9u4nBit2uBGF6zPXQIDAQABo2MwYTAdBgNVHQ4EFgQU
+2f4hQG6UnrybPZx9mCAZ5YwwYrIwHwYDVR0jBBgwFoAU2f4hQG6UnrybPZx9mCAZ5YwwYrIwDwYD
+VR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQELBQADggIBAJ5Fngw7tu/h
+Osh80QA9z+LqBrWyOrsGS2h60COXdKcs8AjYeVrXWoSK2BKaG9l9XE1wxaX5q+WjiYndAfrs3fnp
+kpfbsEZC89NiqpX+MWcUaViQCqoL7jcjx1BRtPV+nuN79+TMQjItSQzL/0kMmx40/W5ulop5A7Zv
+2wnL/V9lFDfhOPXzYRZY5LVtDQsEGz9QLX+zx3oaFoBg+Iof6Rsqxvm6ARppv9JYx1RXCI/hOWB3
+S6xZhBqI8d3LT3jX5+EzLfzuQfogsL7L9ziUwOHQhQ+77Sxzq+3+knYaZH9bDTMJBzN7Bj8RpFxw
+PIXAz+OQqIN3+tvmxYxoZxBnpVIt8MSZj3+/0WvitUfW2dCFmU2Umw9Lje4AWkcdEQOsQRivh7dv
+DDqPys/cA8GiCcjl/YBeyGBCARsaU1q7N6a3vLqE6R5sGtRk2tRD/pOLS/IseRYQ1JMLiI+h2IYU
+RpFHmygk71dSTlxCnKr3Sewn6EAes6aJInKc9Q0ztFijMDvd1GpUk74aTfOTlPf8hAs/hCBcNANE
+xdqtvArBAs8e5ZTZ845b2EzwnexhF7sUMlQMAimTHpKG9n/v55IFDlndmQguLvqcAFLTxWYp5KeX
+RKQOKIETNcX2b2TmQcTVL8w0RSXPQQCWPUouwpaYT05KnJe32x+SMsj/D1Fu1uwJ
+-----END CERTIFICATE-----
+
+TrustCor ECA-1
+==============
+-----BEGIN CERTIFICATE-----
+MIIEIDCCAwigAwIBAgIJAISCLF8cYtBAMA0GCSqGSIb3DQEBCwUAMIGcMQswCQYDVQQGEwJQQTEP
+MA0GA1UECAwGUGFuYW1hMRQwEgYDVQQHDAtQYW5hbWEgQ2l0eTEkMCIGA1UECgwbVHJ1c3RDb3Ig
+U3lzdGVtcyBTLiBkZSBSLkwuMScwJQYDVQQLDB5UcnVzdENvciBDZXJ0aWZpY2F0ZSBBdXRob3Jp
+dHkxFzAVBgNVBAMMDlRydXN0Q29yIEVDQS0xMB4XDTE2MDIwNDEyMzIzM1oXDTI5MTIzMTE3Mjgw
+N1owgZwxCzAJBgNVBAYTAlBBMQ8wDQYDVQQIDAZQYW5hbWExFDASBgNVBAcMC1BhbmFtYSBDaXR5
+MSQwIgYDVQQKDBtUcnVzdENvciBTeXN0ZW1zIFMuIGRlIFIuTC4xJzAlBgNVBAsMHlRydXN0Q29y
+IENlcnRpZmljYXRlIEF1dGhvcml0eTEXMBUGA1UEAwwOVHJ1c3RDb3IgRUNBLTEwggEiMA0GCSqG
+SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDPj+ARtZ+odnbb3w9U73NjKYKtR8aja+3+XzP4Q1HpGjOR
+MRegdMTUpwHmspI+ap3tDvl0mEDTPwOABoJA6LHip1GnHYMma6ve+heRK9jGrB6xnhkB1Zem6g23
+xFUfJ3zSCNV2HykVh0A53ThFEXXQmqc04L/NyFIduUd+Dbi7xgz2c1cWWn5DkR9VOsZtRASqnKmc
+p0yJF4OuowReUoCLHhIlERnXDH19MURB6tuvsBzvgdAsxZohmz3tQjtQJvLsznFhBmIhVE5/wZ0+
+fyCMgMsq2JdiyIMzkX2woloPV+g7zPIlstR8L+xNxqE6FXrntl019fZISjZFZtS6mFjBAgMBAAGj
+YzBhMB0GA1UdDgQWBBREnkj1zG1I1KBLf/5ZJC+Dl5mahjAfBgNVHSMEGDAWgBREnkj1zG1I1KBL
+f/5ZJC+Dl5mahjAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsF
+AAOCAQEABT41XBVwm8nHc2FvcivUwo/yQ10CzsSUuZQRg2dd4mdsdXa/uwyqNsatR5Nj3B5+1t4u
+/ukZMjgDfxT2AHMsWbEhBuH7rBiVDKP/mZb3Kyeb1STMHd3BOuCYRLDE5D53sXOpZCz2HAF8P11F
+hcCF5yWPldwX8zyfGm6wyuMdKulMY/okYWLW2n62HGz1Ah3UKt1VkOsqEUc8Ll50soIipX1TH0Xs
+J5F95yIW6MBoNtjG8U+ARDL54dHRHareqKucBK+tIA5kmE2la8BIWJZpTdwHjFGTot+fDz2LYLSC
+jaoITmJF4PkL0uDgPFveXHEnJcLmA4GLEFPjx1WitJ/X5g==
+-----END CERTIFICATE-----
+
+SSL.com Root Certification Authority RSA
+========================================
+-----BEGIN CERTIFICATE-----
+MIIF3TCCA8WgAwIBAgIIeyyb0xaAMpkwDQYJKoZIhvcNAQELBQAwfDELMAkGA1UEBhMCVVMxDjAM
+BgNVBAgMBVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMRgwFgYDVQQKDA9TU0wgQ29ycG9yYXRpb24x
+MTAvBgNVBAMMKFNTTC5jb20gUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSBSU0EwHhcNMTYw
+MjEyMTczOTM5WhcNNDEwMjEyMTczOTM5WjB8MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMx
+EDAOBgNVBAcMB0hvdXN0b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjExMC8GA1UEAwwoU1NM
+LmNvbSBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IFJTQTCCAiIwDQYJKoZIhvcNAQEBBQAD
+ggIPADCCAgoCggIBAPkP3aMrfcvQKv7sZ4Wm5y4bunfh4/WvpOz6Sl2RxFdHaxh3a3by/ZPkPQ/C
+Fp4LZsNWlJ4Xg4XOVu/yFv0AYvUiCVToZRdOQbngT0aXqhvIuG5iXmmxX9sqAn78bMrzQdjt0Oj8
+P2FI7bADFB0QDksZ4LtO7IZl/zbzXmcCC52GVWH9ejjt/uIZALdvoVBidXQ8oPrIJZK0bnoix/ge
+oeOy3ZExqysdBP+lSgQ36YWkMyv94tZVNHwZpEpox7Ko07fKoZOI68GXvIz5HdkihCR0xwQ9aqkp
+k8zruFvh/l8lqjRYyMEjVJ0bmBHDOJx+PYZspQ9AhnwC9FwCTyjLrnGfDzrIM/4RJTXq/LrFYD3Z
+fBjVsqnTdXgDciLKOsMf7yzlLqn6niy2UUb9rwPW6mBo6oUWNmuF6R7As93EJNyAKoFBbZQ+yODJ
+gUEAnl6/f8UImKIYLEJAs/lvOCdLToD0PYFH4Ih86hzOtXVcUS4cK38acijnALXRdMbX5J+tB5O2
+UzU1/Dfkw/ZdFr4hc96SCvigY2q8lpJqPvi8ZVWb3vUNiSYE/CUapiVpy8JtynziWV+XrOvvLsi8
+1xtZPCvM8hnIk2snYxnP/Okm+Mpxm3+T/jRnhE6Z6/yzeAkzcLpmpnbtG3PrGqUNxCITIJRWCk4s
+bE6x/c+cCbqiM+2HAgMBAAGjYzBhMB0GA1UdDgQWBBTdBAkHovV6fVJTEpKV7jiAJQ2mWTAPBgNV
+HRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFN0ECQei9Xp9UlMSkpXuOIAlDaZZMA4GA1UdDwEB/wQE
+AwIBhjANBgkqhkiG9w0BAQsFAAOCAgEAIBgRlCn7Jp0cHh5wYfGVcpNxJK1ok1iOMq8bs3AD/CUr
+dIWQPXhq9LmLpZc7tRiRux6n+UBbkflVma8eEdBcHadm47GUBwwyOabqG7B52B2ccETjit3E+ZUf
+ijhDPwGFpUenPUayvOUiaPd7nNgsPgohyC0zrL/FgZkxdMF1ccW+sfAjRfSda/wZY52jvATGGAsl
+u1OJD7OAUN5F7kR/q5R4ZJjT9ijdh9hwZXT7DrkT66cPYakylszeu+1jTBi7qUD3oFRuIIhxdRjq
+erQ0cuAjJ3dctpDqhiVAq+8zD8ufgr6iIPv2tS0a5sKFsXQP+8hlAqRSAUfdSSLBv9jra6x+3uxj
+MxW3IwiPxg+NQVrdjsW5j+VFP3jbutIbQLH+cU0/4IGiul607BXgk90IH37hVZkLId6Tngr75qNJ
+vTYw/ud3sqB1l7UtgYgXZSD32pAAn8lSzDLKNXz1PQ/YK9f1JmzJBjSWFupwWRoyeXkLtoh/D1JI
+Pb9s2KJELtFOt3JY04kTlf5Eq/jXixtunLwsoFvVagCvXzfh1foQC5ichucmj87w7G6KVwuA406y
+wKBjYZC6VWg3dGq2ktufoYYitmUnDuy2n0Jg5GfCtdpBC8TTi2EbvPofkSvXRAdeuims2cXp71NI
+WuuA8ShYIc2wBlX7Jz9TkHCpBB5XJ7k=
+-----END CERTIFICATE-----
+
+SSL.com Root Certification Authority ECC
+========================================
+-----BEGIN CERTIFICATE-----
+MIICjTCCAhSgAwIBAgIIdebfy8FoW6gwCgYIKoZIzj0EAwIwfDELMAkGA1UEBhMCVVMxDjAMBgNV
+BAgMBVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMRgwFgYDVQQKDA9TU0wgQ29ycG9yYXRpb24xMTAv
+BgNVBAMMKFNTTC5jb20gUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSBFQ0MwHhcNMTYwMjEy
+MTgxNDAzWhcNNDEwMjEyMTgxNDAzWjB8MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMxEDAO
+BgNVBAcMB0hvdXN0b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjExMC8GA1UEAwwoU1NMLmNv
+bSBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IEVDQzB2MBAGByqGSM49AgEGBSuBBAAiA2IA
+BEVuqVDEpiM2nl8ojRfLliJkP9x6jh3MCLOicSS6jkm5BBtHllirLZXI7Z4INcgn64mMU1jrYor+
+8FsPazFSY0E7ic3s7LaNGdM0B9y7xgZ/wkWV7Mt/qCPgCemB+vNH06NjMGEwHQYDVR0OBBYEFILR
+hXMw5zUE044CkvvlpNHEIejNMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUgtGFczDnNQTT
+jgKS++Wk0cQh6M0wDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA2cAMGQCMG/n61kRpGDPYbCW
+e+0F+S8Tkdzt5fxQaxFGRrMcIQBiu77D5+jNB5n5DQtdcj7EqgIwH7y6C+IwJPt8bYBVCpk+gA0z
+5Wajs6O7pdWLjwkspl1+4vAHCGht0nxpbl/f5Wpl
+-----END CERTIFICATE-----
+
+SSL.com EV Root Certification Authority RSA R2
+==============================================
+-----BEGIN CERTIFICATE-----
+MIIF6zCCA9OgAwIBAgIIVrYpzTS8ePYwDQYJKoZIhvcNAQELBQAwgYIxCzAJBgNVBAYTAlVTMQ4w
+DAYDVQQIDAVUZXhhczEQMA4GA1UEBwwHSG91c3RvbjEYMBYGA1UECgwPU1NMIENvcnBvcmF0aW9u
+MTcwNQYDVQQDDC5TU0wuY29tIEVWIFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgUlNBIFIy
+MB4XDTE3MDUzMTE4MTQzN1oXDTQyMDUzMDE4MTQzN1owgYIxCzAJBgNVBAYTAlVTMQ4wDAYDVQQI
+DAVUZXhhczEQMA4GA1UEBwwHSG91c3RvbjEYMBYGA1UECgwPU1NMIENvcnBvcmF0aW9uMTcwNQYD
+VQQDDC5TU0wuY29tIEVWIFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgUlNBIFIyMIICIjAN
+BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAjzZlQOHWTcDXtOlG2mvqM0fNTPl9fb69LT3w23jh
+hqXZuglXaO1XPqDQCEGD5yhBJB/jchXQARr7XnAjssufOePPxU7Gkm0mxnu7s9onnQqG6YE3Bf7w
+cXHswxzpY6IXFJ3vG2fThVUCAtZJycxa4bH3bzKfydQ7iEGonL3Lq9ttewkfokxykNorCPzPPFTO
+Zw+oz12WGQvE43LrrdF9HSfvkusQv1vrO6/PgN3B0pYEW3p+pKk8OHakYo6gOV7qd89dAFmPZiw+
+B6KjBSYRaZfqhbcPlgtLyEDhULouisv3D5oi53+aNxPN8k0TayHRwMwi8qFG9kRpnMphNQcAb9Zh
+CBHqurj26bNg5U257J8UZslXWNvNh2n4ioYSA0e/ZhN2rHd9NCSFg83XqpyQGp8hLH94t2S42Oim
+9HizVcuE0jLEeK6jj2HdzghTreyI/BXkmg3mnxp3zkyPuBQVPWKchjgGAGYS5Fl2WlPAApiiECto
+RHuOec4zSnaqW4EWG7WK2NAAe15itAnWhmMOpgWVSbooi4iTsjQc2KRVbrcc0N6ZVTsj9CLg+Slm
+JuwgUHfbSguPvuUCYHBBXtSuUDkiFCbLsjtzdFVHB3mBOagwE0TlBIqulhMlQg+5U8Sb/M3kHN48
++qvWBkofZ6aYMBzdLNvcGJVXZsb/XItW9XcCAwEAAaNjMGEwDwYDVR0TAQH/BAUwAwEB/zAfBgNV
+HSMEGDAWgBT5YLvU49U09rj1BoAlp3PbRmmonjAdBgNVHQ4EFgQU+WC71OPVNPa49QaAJadz20Zp
+qJ4wDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBCwUAA4ICAQBWs47LCp1Jjr+kxJG7ZhcFUZh1
+++VQLHqe8RT6q9OKPv+RKY9ji9i0qVQBDb6Thi/5Sm3HXvVX+cpVHBK+Rw82xd9qt9t1wkclf7nx
+Y/hoLVUE0fKNsKTPvDxeH3jnpaAgcLAExbf3cqfeIg29MyVGjGSSJuM+LmOW2puMPfgYCdcDzH2G
+guDKBAdRUNf/ktUM79qGn5nX67evaOI5JpS6aLe/g9Pqemc9YmeuJeVy6OLk7K4S9ksrPJ/psEDz
+OFSz/bdoyNrGj1E8svuR3Bznm53htw1yj+KkxKl4+esUrMZDBcJlOSgYAsOCsp0FvmXtll9ldDz7
+CTUue5wT/RsPXcdtgTpWD8w74a8CLyKsRspGPKAcTNZEtF4uXBVmCeEmKf7GUmG6sXP/wwyc5Wxq
+lD8UykAWlYTzWamsX0xhk23RO8yilQwipmdnRC652dKKQbNmC1r7fSOl8hqw/96bg5Qu0T/fkreR
+rwU7ZcegbLHNYhLDkBvjJc40vG93drEQw/cFGsDWr3RiSBd3kmmQYRzelYB0VI8YHMPzA9C/pEN1
+hlMYegouCRw2n5H9gooiS9EOUCXdywMMF8mDAAhONU2Ki+3wApRmLER/y5UnlhetCTCstnEXbosX
+9hwJ1C07mKVx01QT2WDz9UtmT/rx7iASjbSsV7FFY6GsdqnC+w==
+-----END CERTIFICATE-----
+
+SSL.com EV Root Certification Authority ECC
+===========================================
+-----BEGIN CERTIFICATE-----
+MIIClDCCAhqgAwIBAgIILCmcWxbtBZUwCgYIKoZIzj0EAwIwfzELMAkGA1UEBhMCVVMxDjAMBgNV
+BAgMBVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMRgwFgYDVQQKDA9TU0wgQ29ycG9yYXRpb24xNDAy
+BgNVBAMMK1NTTC5jb20gRVYgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSBFQ0MwHhcNMTYw
+MjEyMTgxNTIzWhcNNDEwMjEyMTgxNTIzWjB/MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMx
+EDAOBgNVBAcMB0hvdXN0b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjE0MDIGA1UEAwwrU1NM
+LmNvbSBFViBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IEVDQzB2MBAGByqGSM49AgEGBSuB
+BAAiA2IABKoSR5CYG/vvw0AHgyBO8TCCogbR8pKGYfL2IWjKAMTH6kMAVIbc/R/fALhBYlzccBYy
+3h+Z1MzFB8gIH2EWB1E9fVwHU+M1OIzfzZ/ZLg1KthkuWnBaBu2+8KGwytAJKaNjMGEwHQYDVR0O
+BBYEFFvKXuXe0oGqzagtZFG22XKbl+ZPMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUW8pe
+5d7SgarNqC1kUbbZcpuX5k8wDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA2gAMGUCMQCK5kCJ
+N+vp1RPZytRrJPOwPYdGWBrssd9v+1a6cGvHOMzosYxPD/fxZ3YOg9AeUY8CMD32IygmTMZgh5Mm
+m7I1HrrW9zzRHM76JTymGoEVW/MSD2zuZYrJh6j5B+BimoxcSg==
+-----END CERTIFICATE-----
+
+GlobalSign Root CA - R6
+=======================
+-----BEGIN CERTIFICATE-----
+MIIFgzCCA2ugAwIBAgIORea7A4Mzw4VlSOb/RVEwDQYJKoZIhvcNAQEMBQAwTDEgMB4GA1UECxMX
+R2xvYmFsU2lnbiBSb290IENBIC0gUjYxEzARBgNVBAoTCkdsb2JhbFNpZ24xEzARBgNVBAMTCkds
+b2JhbFNpZ24wHhcNMTQxMjEwMDAwMDAwWhcNMzQxMjEwMDAwMDAwWjBMMSAwHgYDVQQLExdHbG9i
+YWxTaWduIFJvb3QgQ0EgLSBSNjETMBEGA1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFs
+U2lnbjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAJUH6HPKZvnsFMp7PPcNCPG0RQss
+grRIxutbPK6DuEGSMxSkb3/pKszGsIhrxbaJ0cay/xTOURQh7ErdG1rG1ofuTToVBu1kZguSgMpE
+3nOUTvOniX9PeGMIyBJQbUJmL025eShNUhqKGoC3GYEOfsSKvGRMIRxDaNc9PIrFsmbVkJq3MQbF
+vuJtMgamHvm566qjuL++gmNQ0PAYid/kD3n16qIfKtJwLnvnvJO7bVPiSHyMEAc4/2ayd2F+4OqM
+PKq0pPbzlUoSB239jLKJz9CgYXfIWHSw1CM69106yqLbnQneXUQtkPGBzVeS+n68UARjNN9rkxi+
+azayOeSsJDa38O+2HBNXk7besvjihbdzorg1qkXy4J02oW9UivFyVm4uiMVRQkQVlO6jxTiWm05O
+WgtH8wY2SXcwvHE35absIQh1/OZhFj931dmRl4QKbNQCTXTAFO39OfuD8l4UoQSwC+n+7o/hbguy
+CLNhZglqsQY6ZZZZwPA1/cnaKI0aEYdwgQqomnUdnjqGBQCe24DWJfncBZ4nWUx2OVvq+aWh2IMP
+0f/fMBH5hc8zSPXKbWQULHpYT9NLCEnFlWQaYw55PfWzjMpYrZxCRXluDocZXFSxZba/jJvcE+kN
+b7gu3GduyYsRtYQUigAZcIN5kZeR1BonvzceMgfYFGM8KEyvAgMBAAGjYzBhMA4GA1UdDwEB/wQE
+AwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBSubAWjkxPioufi1xzWx/B/yGdToDAfBgNV
+HSMEGDAWgBSubAWjkxPioufi1xzWx/B/yGdToDANBgkqhkiG9w0BAQwFAAOCAgEAgyXt6NH9lVLN
+nsAEoJFp5lzQhN7craJP6Ed41mWYqVuoPId8AorRbrcWc+ZfwFSY1XS+wc3iEZGtIxg93eFyRJa0
+lV7Ae46ZeBZDE1ZXs6KzO7V33EByrKPrmzU+sQghoefEQzd5Mr6155wsTLxDKZmOMNOsIeDjHfrY
+BzN2VAAiKrlNIC5waNrlU/yDXNOd8v9EDERm8tLjvUYAGm0CuiVdjaExUd1URhxN25mW7xocBFym
+Fe944Hn+Xds+qkxV/ZoVqW/hpvvfcDDpw+5CRu3CkwWJ+n1jez/QcYF8AOiYrg54NMMl+68KnyBr
+3TsTjxKM4kEaSHpzoHdpx7Zcf4LIHv5YGygrqGytXm3ABdJ7t+uA/iU3/gKbaKxCXcPu9czc8FB1
+0jZpnOZ7BN9uBmm23goJSFmH63sUYHpkqmlD75HHTOwY3WzvUy2MmeFe8nI+z1TIvWfspA9MRf/T
+uTAjB0yPEL+GltmZWrSZVxykzLsViVO6LAUP5MSeGbEYNNVMnbrt9x+vJJUEeKgDu+6B5dpffItK
+oZB0JaezPkvILFa9x8jvOOJckvB595yEunQtYQEgfn7R8k8HWV+LLUNS60YMlOH1Zkd5d9VUWx+t
+JDfLRVpOoERIyNiwmcUVhAn21klJwGW45hpxbqCo8YLoRT5s1gLXCmeDBVrJpBA=
+-----END CERTIFICATE-----
+
+OISTE WISeKey Global Root GC CA
+===============================
+-----BEGIN CERTIFICATE-----
+MIICaTCCAe+gAwIBAgIQISpWDK7aDKtARb8roi066jAKBggqhkjOPQQDAzBtMQswCQYDVQQGEwJD
+SDEQMA4GA1UEChMHV0lTZUtleTEiMCAGA1UECxMZT0lTVEUgRm91bmRhdGlvbiBFbmRvcnNlZDEo
+MCYGA1UEAxMfT0lTVEUgV0lTZUtleSBHbG9iYWwgUm9vdCBHQyBDQTAeFw0xNzA1MDkwOTQ4MzRa
+Fw00MjA1MDkwOTU4MzNaMG0xCzAJBgNVBAYTAkNIMRAwDgYDVQQKEwdXSVNlS2V5MSIwIAYDVQQL
+ExlPSVNURSBGb3VuZGF0aW9uIEVuZG9yc2VkMSgwJgYDVQQDEx9PSVNURSBXSVNlS2V5IEdsb2Jh
+bCBSb290IEdDIENBMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAETOlQwMYPchi82PG6s4nieUqjFqdr
+VCTbUf/q9Akkwwsin8tqJ4KBDdLArzHkdIJuyiXZjHWd8dvQmqJLIX4Wp2OQ0jnUsYd4XxiWD1Ab
+NTcPasbc2RNNpI6QN+a9WzGRo1QwUjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAd
+BgNVHQ4EFgQUSIcUrOPDnpBgOtfKie7TrYy0UGYwEAYJKwYBBAGCNxUBBAMCAQAwCgYIKoZIzj0E
+AwMDaAAwZQIwJsdpW9zV57LnyAyMjMPdeYwbY9XJUpROTYJKcx6ygISpJcBMWm1JKWB4E+J+SOtk
+AjEA2zQgMgj/mkkCtojeFK9dbJlxjRo/i9fgojaGHAeCOnZT/cKi7e97sIBPWA9LUzm9
+-----END CERTIFICATE-----
+
+GTS Root R1
+===========
+-----BEGIN CERTIFICATE-----
+MIIFWjCCA0KgAwIBAgIQbkepxUtHDA3sM9CJuRz04TANBgkqhkiG9w0BAQwFADBHMQswCQYDVQQG
+EwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJv
+b3QgUjEwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAwWjBHMQswCQYDVQQGEwJVUzEiMCAG
+A1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwggIi
+MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC2EQKLHuOhd5s73L+UPreVp0A8of2C+X0yBoJx
+9vaMf/vo27xqLpeXo4xL+Sv2sfnOhB2x+cWX3u+58qPpvBKJXqeqUqv4IyfLpLGcY9vXmX7wCl7r
+aKb0xlpHDU0QM+NOsROjyBhsS+z8CZDfnWQpJSMHobTSPS5g4M/SCYe7zUjwTcLCeoiKu7rPWRnW
+r4+wB7CeMfGCwcDfLqZtbBkOtdh+JhpFAz2weaSUKK0PfyblqAj+lug8aJRT7oM6iCsVlgmy4HqM
+LnXWnOunVmSPlk9orj2XwoSPwLxAwAtcvfaHszVsrBhQf4TgTM2S0yDpM7xSma8ytSmzJSq0SPly
+4cpk9+aCEI3oncKKiPo4Zor8Y/kB+Xj9e1x3+naH+uzfsQ55lVe0vSbv1gHR6xYKu44LtcXFilWr
+06zqkUspzBmkMiVOKvFlRNACzqrOSbTqn3yDsEB750Orp2yjj32JgfpMpf/VjsPOS+C12LOORc92
+wO1AK/1TD7Cn1TsNsYqiA94xrcx36m97PtbfkSIS5r762DL8EGMUUXLeXdYWk70paDPvOmbsB4om
+3xPXV2V4J95eSRQAogB/mqghtqmxlbCluQ0WEdrHbEg8QOB+DVrNVjzRlwW5y0vtOUucxD/SVRNu
+JLDWcfr0wbrM7Rv1/oFB2ACYPTrIrnqYNxgFlQIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYD
+VR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU5K8rJnEaK0gnhS9SZizv8IkTcT4wDQYJKoZIhvcNAQEM
+BQADggIBADiWCu49tJYeX++dnAsznyvgyv3SjgofQXSlfKqE1OXyHuY3UjKcC9FhHb8owbZEKTV1
+d5iyfNm9dKyKaOOpMQkpAWBz40d8U6iQSifvS9efk+eCNs6aaAyC58/UEBZvXw6ZXPYfcX3v73sv
+fuo21pdwCxXu11xWajOl40k4DLh9+42FpLFZXvRq4d2h9mREruZRgyFmxhE+885H7pwoHyXa/6xm
+ld01D1zvICxi/ZG6qcz8WpyTgYMpl0p8WnK0OdC3d8t5/Wk6kjftbjhlRn7pYL15iJdfOBL07q9b
+gsiG1eGZbYwE8na6SfZu6W0eX6DvJ4J2QPim01hcDyxC2kLGe4g0x8HYRZvBPsVhHdljUEn2NIVq
+4BjFbkerQUIpm/ZgDdIx02OYI5NaAIFItO/Nis3Jz5nu2Z6qNuFoS3FJFDYoOj0dzpqPJeaAcWEr
+tXvM+SUWgeExX6GjfhaknBZqlxi9dnKlC54dNuYvoS++cJEPqOba+MSSQGwlfnuzCdyyF62ARPBo
+pY+Udf90WuioAnwMCeKpSwughQtiue+hMZL77/ZRBIls6Kl0obsXs7X9SQ98POyDGCBDTtWTurQ0
+sR8WNh8M5mQ5Fkzc4P4dyKliPUDqysU0ArSuiYgzNdwsE3PYJ/HQcu51OyLemGhmW/HGY0dVHLql
+CFF1pkgl
+-----END CERTIFICATE-----
+
+GTS Root R2
+===========
+-----BEGIN CERTIFICATE-----
+MIIFWjCCA0KgAwIBAgIQbkepxlqz5yDFMJo/aFLybzANBgkqhkiG9w0BAQwFADBHMQswCQYDVQQG
+EwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJv
+b3QgUjIwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAwWjBHMQswCQYDVQQGEwJVUzEiMCAG
+A1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjIwggIi
+MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDO3v2m++zsFDQ8BwZabFn3GTXd98GdVarTzTuk
+k3LvCvptnfbwhYBboUhSnznFt+4orO/LdmgUud+tAWyZH8QiHZ/+cnfgLFuv5AS/T3KgGjSY6Dlo
+7JUle3ah5mm5hRm9iYz+re026nO8/4Piy33B0s5Ks40FnotJk9/BW9BuXvAuMC6C/Pq8tBcKSOWI
+m8Wba96wyrQD8Nr0kLhlZPdcTK3ofmZemde4wj7I0BOdre7kRXuJVfeKH2JShBKzwkCX44ofR5Gm
+dFrS+LFjKBC4swm4VndAoiaYecb+3yXuPuWgf9RhD1FLPD+M2uFwdNjCaKH5wQzpoeJ/u1U8dgbu
+ak7MkogwTZq9TwtImoS1mKPV+3PBV2HdKFZ1E66HjucMUQkQdYhMvI35ezzUIkgfKtzra7tEscsz
+cTJGr61K8YzodDqs5xoic4DSMPclQsciOzsSrZYuxsN2B6ogtzVJV+mSSeh2FnIxZyuWfoqjx5RW
+Ir9qS34BIbIjMt/kmkRtWVtd9QCgHJvGeJeNkP+byKq0rxFROV7Z+2et1VsRnTKaG73Vululycsl
+aVNVJ1zgyjbLiGH7HrfQy+4W+9OmTN6SpdTi3/UGVN4unUu0kzCqgc7dGtxRcw1PcOnlthYhGXmy
+5okLdWTK1au8CcEYof/UVKGFPP0UJAOyh9OktwIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYD
+VR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUu//KjiOfT5nK2+JopqUVJxce2Q4wDQYJKoZIhvcNAQEM
+BQADggIBALZp8KZ3/p7uC4Gt4cCpx/k1HUCCq+YEtN/L9x0Pg/B+E02NjO7jMyLDOfxA325BS0JT
+vhaI8dI4XsRomRyYUpOM52jtG2pzegVATX9lO9ZY8c6DR2Dj/5epnGB3GFW1fgiTz9D2PGcDFWEJ
++YF59exTpJ/JjwGLc8R3dtyDovUMSRqodt6Sm2T4syzFJ9MHwAiApJiS4wGWAqoC7o87xdFtCjMw
+c3i5T1QWvwsHoaRc5svJXISPD+AVdyx+Jn7axEvbpxZ3B7DNdehyQtaVhJ2Gg/LkkM0JR9SLA3Da
+WsYDQvTtN6LwG1BUSw7YhN4ZKJmBR64JGz9I0cNv4rBgF/XuIwKl2gBbbZCr7qLpGzvpx0QnRY5r
+n/WkhLx3+WuXrD5RRaIRpsyF7gpo8j5QOHokYh4XIDdtak23CZvJ/KRY9bb7nE4Yu5UC56Gtmwfu
+Nmsk0jmGwZODUNKBRqhfYlcsu2xkiAhu7xNUX90txGdj08+JN7+dIPT7eoOboB6BAFDC5AwiWVIQ
+7UNWhwD4FFKnHYuTjKJNRn8nxnGbJN7k2oaLDX5rIMHAnuFl2GqjpuiFizoHCBy69Y9Vmhh1fuXs
+gWbRIXOhNUQLgD1bnF5vKheW0YMjiGZt5obicDIvUiLnyOd/xCxgXS/Dr55FBcOEArf9LAhST4Ld
+o/DUhgkC
+-----END CERTIFICATE-----
+
+GTS Root R3
+===========
+-----BEGIN CERTIFICATE-----
+MIICDDCCAZGgAwIBAgIQbkepx2ypcyRAiQ8DVd2NHTAKBggqhkjOPQQDAzBHMQswCQYDVQQGEwJV
+UzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3Qg
+UjMwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UE
+ChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjMwdjAQBgcq
+hkjOPQIBBgUrgQQAIgNiAAQfTzOHMymKoYTey8chWEGJ6ladK0uFxh1MJ7x/JlFyb+Kf1qPKzEUU
+Rout736GjOyxfi//qXGdGIRFBEFVbivqJn+7kAHjSxm65FSWRQmx1WyRRK2EE46ajA2ADDL24Cej
+QjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTB8Sa6oC2uhYHP
+0/EqEr24Cmf9vDAKBggqhkjOPQQDAwNpADBmAjEAgFukfCPAlaUs3L6JbyO5o91lAFJekazInXJ0
+glMLfalAvWhgxeG4VDvBNhcl2MG9AjEAnjWSdIUlUfUk7GRSJFClH9voy8l27OyCbvWFGFPouOOa
+KaqW04MjyaR7YbPMAuhd
+-----END CERTIFICATE-----
+
+GTS Root R4
+===========
+-----BEGIN CERTIFICATE-----
+MIICCjCCAZGgAwIBAgIQbkepyIuUtui7OyrYorLBmTAKBggqhkjOPQQDAzBHMQswCQYDVQQGEwJV
+UzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3Qg
+UjQwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UE
+ChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQwdjAQBgcq
+hkjOPQIBBgUrgQQAIgNiAATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvVYRg1rkDdc/eJkTBa
+6zzuhXyiQHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHci8nHc8iMai/lxKvRHYqj
+QjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBSATNbrdP9JNqPV
+2Py1PsVq8JQdjDAKBggqhkjOPQQDAwNnADBkAjBqUFJ0CMRw3J5QdCHojXohw0+WbhXRIjVhLfoI
+N+4Zba3bssx9BzT1YBkstTTZbyACMANxsbqjYAuG7ZoIapVon+Kz4ZNkfF6Tpt95LY2F45TPI11x
+zPKwTdb+mciUqXWi4w==
+-----END CERTIFICATE-----
+
+UCA Global G2 Root
+==================
+-----BEGIN CERTIFICATE-----
+MIIFRjCCAy6gAwIBAgIQXd+x2lqj7V2+WmUgZQOQ7zANBgkqhkiG9w0BAQsFADA9MQswCQYDVQQG
+EwJDTjERMA8GA1UECgwIVW5pVHJ1c3QxGzAZBgNVBAMMElVDQSBHbG9iYWwgRzIgUm9vdDAeFw0x
+NjAzMTEwMDAwMDBaFw00MDEyMzEwMDAwMDBaMD0xCzAJBgNVBAYTAkNOMREwDwYDVQQKDAhVbmlU
+cnVzdDEbMBkGA1UEAwwSVUNBIEdsb2JhbCBHMiBSb290MIICIjANBgkqhkiG9w0BAQEFAAOCAg8A
+MIICCgKCAgEAxeYrb3zvJgUno4Ek2m/LAfmZmqkywiKHYUGRO8vDaBsGxUypK8FnFyIdK+35KYmT
+oni9kmugow2ifsqTs6bRjDXVdfkX9s9FxeV67HeToI8jrg4aA3++1NDtLnurRiNb/yzmVHqUwCoV
+8MmNsHo7JOHXaOIxPAYzRrZUEaalLyJUKlgNAQLx+hVRZ2zA+te2G3/RVogvGjqNO7uCEeBHANBS
+h6v7hn4PJGtAnTRnvI3HLYZveT6OqTwXS3+wmeOwcWDcC/Vkw85DvG1xudLeJ1uK6NjGruFZfc8o
+LTW4lVYa8bJYS7cSN8h8s+1LgOGN+jIjtm+3SJUIsUROhYw6AlQgL9+/V087OpAh18EmNVQg7Mc/
+R+zvWr9LesGtOxdQXGLYD0tK3Cv6brxzks3sx1DoQZbXqX5t2Okdj4q1uViSukqSKwxW/YDrCPBe
+KW4bHAyvj5OJrdu9o54hyokZ7N+1wxrrFv54NkzWbtA+FxyQF2smuvt6L78RHBgOLXMDj6DlNaBa
+4kx1HXHhOThTeEDMg5PXCp6dW4+K5OXgSORIskfNTip1KnvyIvbJvgmRlld6iIis7nCs+dwp4wwc
+OxJORNanTrAmyPPZGpeRaOrvjUYG0lZFWJo8DA+DuAUlwznPO6Q0ibd5Ei9Hxeepl2n8pndntd97
+8XplFeRhVmUCAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0O
+BBYEFIHEjMz15DD/pQwIX4wVZyF0Ad/fMA0GCSqGSIb3DQEBCwUAA4ICAQATZSL1jiutROTL/7lo
+5sOASD0Ee/ojL3rtNtqyzm325p7lX1iPyzcyochltq44PTUbPrw7tgTQvPlJ9Zv3hcU2tsu8+Mg5
+1eRfB70VVJd0ysrtT7q6ZHafgbiERUlMjW+i67HM0cOU2kTC5uLqGOiiHycFutfl1qnN3e92mI0A
+Ds0b+gO3joBYDic/UvuUospeZcnWhNq5NXHzJsBPd+aBJ9J3O5oUb3n09tDh05S60FdRvScFDcH9
+yBIw7m+NESsIndTUv4BFFJqIRNow6rSn4+7vW4LVPtateJLbXDzz2K36uGt/xDYotgIVilQsnLAX
+c47QN6MUPJiVAAwpBVueSUmxX8fjy88nZY41F7dXyDDZQVu5FLbowg+UMaeUmMxq67XhJ/UQqAHo
+jhJi6IjMtX9Gl8CbEGY4GjZGXyJoPd/JxhMnq1MGrKI8hgZlb7F+sSlEmqO6SWkoaY/X5V+tBIZk
+bxqgDMUIYs6Ao9Dz7GjevjPHF1t/gMRMTLGmhIrDO7gJzRSBuhjjVFc2/tsvfEehOjPI+Vg7RE+x
+ygKJBJYoaMVLuCaJu9YzL1DV/pqJuhgyklTGW+Cd+V7lDSKb9triyCGyYiGqhkCyLmTTX8jjfhFn
+RR8F/uOi77Oos/N9j/gMHyIfLXC0uAE0djAA5SN4p1bXUB+K+wb1whnw0A==
+-----END CERTIFICATE-----
+
+UCA Extended Validation Root
+============================
+-----BEGIN CERTIFICATE-----
+MIIFWjCCA0KgAwIBAgIQT9Irj/VkyDOeTzRYZiNwYDANBgkqhkiG9w0BAQsFADBHMQswCQYDVQQG
+EwJDTjERMA8GA1UECgwIVW5pVHJ1c3QxJTAjBgNVBAMMHFVDQSBFeHRlbmRlZCBWYWxpZGF0aW9u
+IFJvb3QwHhcNMTUwMzEzMDAwMDAwWhcNMzgxMjMxMDAwMDAwWjBHMQswCQYDVQQGEwJDTjERMA8G
+A1UECgwIVW5pVHJ1c3QxJTAjBgNVBAMMHFVDQSBFeHRlbmRlZCBWYWxpZGF0aW9uIFJvb3QwggIi
+MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCpCQcoEwKwmeBkqh5DFnpzsZGgdT6o+uM4AHrs
+iWogD4vFsJszA1qGxliG1cGFu0/GnEBNyr7uaZa4rYEwmnySBesFK5pI0Lh2PpbIILvSsPGP2KxF
+Rv+qZ2C0d35qHzwaUnoEPQc8hQ2E0B92CvdqFN9y4zR8V05WAT558aopO2z6+I9tTcg1367r3CTu
+eUWnhbYFiN6IXSV8l2RnCdm/WhUFhvMJHuxYMjMR83dksHYf5BA1FxvyDrFspCqjc/wJHx4yGVMR
+59mzLC52LqGj3n5qiAno8geK+LLNEOfic0CTuwjRP+H8C5SzJe98ptfRr5//lpr1kXuYC3fUfugH
+0mK1lTnj8/FtDw5lhIpjVMWAtuCeS31HJqcBCF3RiJ7XwzJE+oJKCmhUfzhTA8ykADNkUVkLo4KR
+el7sFsLzKuZi2irbWWIQJUoqgQtHB0MGcIfS+pMRKXpITeuUx3BNr2fVUbGAIAEBtHoIppB/TuDv
+B0GHr2qlXov7z1CymlSvw4m6WC31MJixNnI5fkkE/SmnTHnkBVfblLkWU41Gsx2VYVdWf6/wFlth
+WG82UBEL2KwrlRYaDh8IzTY0ZRBiZtWAXxQgXy0MoHgKaNYs1+lvK9JKBZP8nm9rZ/+I8U6laUpS
+NwXqxhaN0sSZ0YIrO7o1dfdRUVjzyAfd5LQDfwIDAQABo0IwQDAdBgNVHQ4EFgQU2XQ65DA9DfcS
+3H5aBZ8eNJr34RQwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQEL
+BQADggIBADaNl8xCFWQpN5smLNb7rhVpLGsaGvdftvkHTFnq88nIua7Mui563MD1sC3AO6+fcAUR
+ap8lTwEpcOPlDOHqWnzcSbvBHiqB9RZLcpHIojG5qtr8nR/zXUACE/xOHAbKsxSQVBcZEhrxH9cM
+aVr2cXj0lH2RC47skFSOvG+hTKv8dGT9cZr4QQehzZHkPJrgmzI5c6sq1WnIeJEmMX3ixzDx/BR4
+dxIOE/TdFpS/S2d7cFOFyrC78zhNLJA5wA3CXWvp4uXViI3WLL+rG761KIcSF3Ru/H38j9CHJrAb
++7lsq+KePRXBOy5nAliRn+/4Qh8st2j1da3Ptfb/EX3C8CSlrdP6oDyp+l3cpaDvRKS+1ujl5BOW
+F3sGPjLtx7dCvHaj2GU4Kzg1USEODm8uNBNA4StnDG1KQTAYI1oyVZnJF+A83vbsea0rWBmirSwi
+GpWOvpaQXUJXxPkUAzUrHC1RVwinOt4/5Mi0A3PCwSaAuwtCH60NryZy2sy+s6ODWA2CxR9GUeOc
+GMyNm43sSet1UNWMKFnKdDTajAshqx7qG+XH/RU+wBeq+yNuJkbL+vmxcmtpzyKEC2IPrNkZAJSi
+djzULZrtBJ4tBmIQN1IchXIbJ+XMxjHsN+xjWZsLHXbMfjKaiJUINlK73nZfdklJrX+9ZSCyycEr
+dhh2n1ax
+-----END CERTIFICATE-----
+
+Certigna Root CA
+================
+-----BEGIN CERTIFICATE-----
+MIIGWzCCBEOgAwIBAgIRAMrpG4nxVQMNo+ZBbcTjpuEwDQYJKoZIhvcNAQELBQAwWjELMAkGA1UE
+BhMCRlIxEjAQBgNVBAoMCURoaW15b3RpczEcMBoGA1UECwwTMDAwMiA0ODE0NjMwODEwMDAzNjEZ
+MBcGA1UEAwwQQ2VydGlnbmEgUm9vdCBDQTAeFw0xMzEwMDEwODMyMjdaFw0zMzEwMDEwODMyMjda
+MFoxCzAJBgNVBAYTAkZSMRIwEAYDVQQKDAlEaGlteW90aXMxHDAaBgNVBAsMEzAwMDIgNDgxNDYz
+MDgxMDAwMzYxGTAXBgNVBAMMEENlcnRpZ25hIFJvb3QgQ0EwggIiMA0GCSqGSIb3DQEBAQUAA4IC
+DwAwggIKAoICAQDNGDllGlmx6mQWDoyUJJV8g9PFOSbcDO8WV43X2KyjQn+Cyu3NW9sOty3tRQgX
+stmzy9YXUnIo245Onoq2C/mehJpNdt4iKVzSs9IGPjA5qXSjklYcoW9MCiBtnyN6tMbaLOQdLNyz
+KNAT8kxOAkmhVECe5uUFoC2EyP+YbNDrihqECB63aCPuI9Vwzm1RaRDuoXrC0SIxwoKF0vJVdlB8
+JXrJhFwLrN1CTivngqIkicuQstDuI7pmTLtipPlTWmR7fJj6o0ieD5Wupxj0auwuA0Wv8HT4Ks16
+XdG+RCYyKfHx9WzMfgIhC59vpD++nVPiz32pLHxYGpfhPTc3GGYo0kDFUYqMwy3OU4gkWGQwFsWq
+4NYKpkDfePb1BHxpE4S80dGnBs8B92jAqFe7OmGtBIyT46388NtEbVncSVmurJqZNjBBe3YzIoej
+wpKGbvlw7q6Hh5UbxHq9MfPU0uWZ/75I7HX1eBYdpnDBfzwboZL7z8g81sWTCo/1VTp2lc5ZmIoJ
+lXcymoO6LAQ6l73UL77XbJuiyn1tJslV1c/DeVIICZkHJC1kJWumIWmbat10TWuXekG9qxf5kBdI
+jzb5LdXF2+6qhUVB+s06RbFo5jZMm5BX7CO5hwjCxAnxl4YqKE3idMDaxIzb3+KhF1nOJFl0Mdp/
+/TBt2dzhauH8XwIDAQABo4IBGjCCARYwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYw
+HQYDVR0OBBYEFBiHVuBud+4kNTxOc5of1uHieX4rMB8GA1UdIwQYMBaAFBiHVuBud+4kNTxOc5of
+1uHieX4rMEQGA1UdIAQ9MDswOQYEVR0gADAxMC8GCCsGAQUFBwIBFiNodHRwczovL3d3d3cuY2Vy
+dGlnbmEuZnIvYXV0b3JpdGVzLzBtBgNVHR8EZjBkMC+gLaArhilodHRwOi8vY3JsLmNlcnRpZ25h
+LmZyL2NlcnRpZ25hcm9vdGNhLmNybDAxoC+gLYYraHR0cDovL2NybC5kaGlteW90aXMuY29tL2Nl
+cnRpZ25hcm9vdGNhLmNybDANBgkqhkiG9w0BAQsFAAOCAgEAlLieT/DjlQgi581oQfccVdV8AOIt
+OoldaDgvUSILSo3L6btdPrtcPbEo/uRTVRPPoZAbAh1fZkYJMyjhDSSXcNMQH+pkV5a7XdrnxIxP
+TGRGHVyH41neQtGbqH6mid2PHMkwgu07nM3A6RngatgCdTer9zQoKJHyBApPNeNgJgH60BGM+RFq
+7q89w1DTj18zeTyGqHNFkIwgtnJzFyO+B2XleJINugHA64wcZr+shncBlA2c5uk5jR+mUYyZDDl3
+4bSb+hxnV29qao6pK0xXeXpXIs/NX2NGjVxZOob4Mkdio2cNGJHc+6Zr9UhhcyNZjgKnvETq9Emd
+8VRY+WCv2hikLyhF3HqgiIZd8zvn/yk1gPxkQ5Tm4xxvvq0OKmOZK8l+hfZx6AYDlf7ej0gcWtSS
+6Cvu5zHbugRqh5jnxV/vfaci9wHYTfmJ0A6aBVmknpjZbyvKcL5kwlWj9Omvw5Ip3IgWJJk8jSaY
+tlu3zM63Nwf9JtmYhST/WSMDmu2dnajkXjjO11INb9I/bbEFa0nOipFGc/T2L/Coc3cOZayhjWZS
+aX5LaAzHHjcng6WMxwLkFM1JAbBzs/3GkDpv0mztO+7skb6iQ12LAEpmJURw3kAP+HwV96LOPNde
+E4yBFxgX0b3xdxA61GU5wSesVywlVP+i2k+KYTlerj1KjL0=
+-----END CERTIFICATE-----
+
+emSign Root CA - G1
+===================
+-----BEGIN CERTIFICATE-----
+MIIDlDCCAnygAwIBAgIKMfXkYgxsWO3W2DANBgkqhkiG9w0BAQsFADBnMQswCQYDVQQGEwJJTjET
+MBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl
+ZDEcMBoGA1UEAxMTZW1TaWduIFJvb3QgQ0EgLSBHMTAeFw0xODAyMTgxODMwMDBaFw00MzAyMTgx
+ODMwMDBaMGcxCzAJBgNVBAYTAklOMRMwEQYDVQQLEwplbVNpZ24gUEtJMSUwIwYDVQQKExxlTXVk
+aHJhIFRlY2hub2xvZ2llcyBMaW1pdGVkMRwwGgYDVQQDExNlbVNpZ24gUm9vdCBDQSAtIEcxMIIB
+IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAk0u76WaK7p1b1TST0Bsew+eeuGQzf2N4aLTN
+LnF115sgxk0pvLZoYIr3IZpWNVrzdr3YzZr/k1ZLpVkGoZM0Kd0WNHVO8oG0x5ZOrRkVUkr+PHB1
+cM2vK6sVmjM8qrOLqs1D/fXqcP/tzxE7lM5OMhbTI0Aqd7OvPAEsbO2ZLIvZTmmYsvePQbAyeGHW
+DV/D+qJAkh1cF+ZwPjXnorfCYuKrpDhMtTk1b+oDafo6VGiFbdbyL0NVHpENDtjVaqSW0RM8LHhQ
+6DqS0hdW5TUaQBw+jSztOd9C4INBdN+jzcKGYEho42kLVACL5HZpIQ15TjQIXhTCzLG3rdd8cIrH
+hQIDAQABo0IwQDAdBgNVHQ4EFgQU++8Nhp6w492pufEhF38+/PB3KxowDgYDVR0PAQH/BAQDAgEG
+MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAFn/8oz1h31xPaOfG1vR2vjTnGs2
+vZupYeveFix0PZ7mddrXuqe8QhfnPZHr5X3dPpzxz5KsbEjMwiI/aTvFthUvozXGaCocV685743Q
+NcMYDHsAVhzNixl03r4PEuDQqqE/AjSxcM6dGNYIAwlG7mDgfrbESQRRfXBgvKqy/3lyeqYdPV8q
++Mri/Tm3R7nrft8EI6/6nAYH6ftjk4BAtcZsCjEozgyfz7MjNYBBjWzEN3uBL4ChQEKF6dk4jeih
+U80Bv2noWgbyRQuQ+q7hv53yrlc8pa6yVvSLZUDp/TGBLPQ5Cdjua6e0ph0VpZj3AYHYhX3zUVxx
+iN66zB+Afko=
+-----END CERTIFICATE-----
+
+emSign ECC Root CA - G3
+=======================
+-----BEGIN CERTIFICATE-----
+MIICTjCCAdOgAwIBAgIKPPYHqWhwDtqLhDAKBggqhkjOPQQDAzBrMQswCQYDVQQGEwJJTjETMBEG
+A1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEg
+MB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0gRzMwHhcNMTgwMjE4MTgzMDAwWhcNNDMwMjE4
+MTgzMDAwWjBrMQswCQYDVQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11
+ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0g
+RzMwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQjpQy4LRL1KPOxst3iAhKAnjlfSU2fySU0WXTsuwYc
+58Byr+iuL+FBVIcUqEqy6HyC5ltqtdyzdc6LBtCGI79G1Y4PPwT01xySfvalY8L1X44uT6EYGQIr
+MgqCZH0Wk9GjQjBAMB0GA1UdDgQWBBR8XQKEE9TMipuBzhccLikenEhjQjAOBgNVHQ8BAf8EBAMC
+AQYwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNpADBmAjEAvvNhzwIQHWSVB7gYboiFBS+D
+CBeQyh+KTOgNG3qxrdWBCUfvO6wIBHxcmbHtRwfSAjEAnbpV/KlK6O3t5nYBQnvI+GDZjVGLVTv7
+jHvrZQnD+JbNR6iC8hZVdyR+EhCVBCyj
+-----END CERTIFICATE-----
+
+emSign Root CA - C1
+===================
+-----BEGIN CERTIFICATE-----
+MIIDczCCAlugAwIBAgILAK7PALrEzzL4Q7IwDQYJKoZIhvcNAQELBQAwVjELMAkGA1UEBhMCVVMx
+EzARBgNVBAsTCmVtU2lnbiBQS0kxFDASBgNVBAoTC2VNdWRocmEgSW5jMRwwGgYDVQQDExNlbVNp
+Z24gUm9vdCBDQSAtIEMxMB4XDTE4MDIxODE4MzAwMFoXDTQzMDIxODE4MzAwMFowVjELMAkGA1UE
+BhMCVVMxEzARBgNVBAsTCmVtU2lnbiBQS0kxFDASBgNVBAoTC2VNdWRocmEgSW5jMRwwGgYDVQQD
+ExNlbVNpZ24gUm9vdCBDQSAtIEMxMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz+up
+ufGZBczYKCFK83M0UYRWEPWgTywS4/oTmifQz/l5GnRfHXk5/Fv4cI7gklL35CX5VIPZHdPIWoU/
+Xse2B+4+wM6ar6xWQio5JXDWv7V7Nq2s9nPczdcdioOl+yuQFTdrHCZH3DspVpNqs8FqOp099cGX
+OFgFixwR4+S0uF2FHYP+eF8LRWgYSKVGczQ7/g/IdrvHGPMF0Ybzhe3nudkyrVWIzqa2kbBPrH4V
+I5b2P/AgNBbeCsbEBEV5f6f9vtKppa+cxSMq9zwhbL2vj07FOrLzNBL834AaSaTUqZX3noleooms
+lMuoaJuvimUnzYnu3Yy1aylwQ6BpC+S5DwIDAQABo0IwQDAdBgNVHQ4EFgQU/qHgcB4qAzlSWkK+
+XJGFehiqTbUwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQAD
+ggEBAMJKVvoVIXsoounlHfv4LcQ5lkFMOycsxGwYFYDGrK9HWS8mC+M2sO87/kOXSTKZEhVb3xEp
+/6tT+LvBeA+snFOvV71ojD1pM/CjoCNjO2RnIkSt1XHLVip4kqNPEjE2NuLe/gDEo2APJ62gsIq1
+NnpSob0n9CAnYuhNlCQT5AoE6TyrLshDCUrGYQTlSTR+08TI9Q/Aqum6VF7zYytPT1DU/rl7mYw9
+wC68AivTxEDkigcxHpvOJpkT+xHqmiIMERnHXhuBUDDIlhJu58tBf5E7oke3VIAb3ADMmpDqw8NQ
+BmIMMMAVSKeoWXzhriKi4gp6D/piq1JM4fHfyr6DDUI=
+-----END CERTIFICATE-----
+
+emSign ECC Root CA - C3
+=======================
+-----BEGIN CERTIFICATE-----
+MIICKzCCAbGgAwIBAgIKe3G2gla4EnycqDAKBggqhkjOPQQDAzBaMQswCQYDVQQGEwJVUzETMBEG
+A1UECxMKZW1TaWduIFBLSTEUMBIGA1UEChMLZU11ZGhyYSBJbmMxIDAeBgNVBAMTF2VtU2lnbiBF
+Q0MgUm9vdCBDQSAtIEMzMB4XDTE4MDIxODE4MzAwMFoXDTQzMDIxODE4MzAwMFowWjELMAkGA1UE
+BhMCVVMxEzARBgNVBAsTCmVtU2lnbiBQS0kxFDASBgNVBAoTC2VNdWRocmEgSW5jMSAwHgYDVQQD
+ExdlbVNpZ24gRUNDIFJvb3QgQ0EgLSBDMzB2MBAGByqGSM49AgEGBSuBBAAiA2IABP2lYa57JhAd
+6bciMK4G9IGzsUJxlTm801Ljr6/58pc1kjZGDoeVjbk5Wum739D+yAdBPLtVb4OjavtisIGJAnB9
+SMVK4+kiVCJNk7tCDK93nCOmfddhEc5lx/h//vXyqaNCMEAwHQYDVR0OBBYEFPtaSNCAIEDyqOkA
+B2kZd6fmw/TPMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMDA2gA
+MGUCMQC02C8Cif22TGK6Q04ThHK1rt0c3ta13FaPWEBaLd4gTCKDypOofu4SQMfWh0/434UCMBwU
+ZOR8loMRnLDRWmFLpg9J0wD8ofzkpf9/rdcw0Md3f76BB1UwUCAU9Vc4CqgxUQ==
+-----END CERTIFICATE-----
+
+Hongkong Post Root CA 3
+=======================
+-----BEGIN CERTIFICATE-----
+MIIFzzCCA7egAwIBAgIUCBZfikyl7ADJk0DfxMauI7gcWqQwDQYJKoZIhvcNAQELBQAwbzELMAkG
+A1UEBhMCSEsxEjAQBgNVBAgTCUhvbmcgS29uZzESMBAGA1UEBxMJSG9uZyBLb25nMRYwFAYDVQQK
+Ew1Ib25na29uZyBQb3N0MSAwHgYDVQQDExdIb25na29uZyBQb3N0IFJvb3QgQ0EgMzAeFw0xNzA2
+MDMwMjI5NDZaFw00MjA2MDMwMjI5NDZaMG8xCzAJBgNVBAYTAkhLMRIwEAYDVQQIEwlIb25nIEtv
+bmcxEjAQBgNVBAcTCUhvbmcgS29uZzEWMBQGA1UEChMNSG9uZ2tvbmcgUG9zdDEgMB4GA1UEAxMX
+SG9uZ2tvbmcgUG9zdCBSb290IENBIDMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCz
+iNfqzg8gTr7m1gNt7ln8wlffKWihgw4+aMdoWJwcYEuJQwy51BWy7sFOdem1p+/l6TWZ5Mwc50tf
+jTMwIDNT2aa71T4Tjukfh0mtUC1Qyhi+AViiE3CWu4mIVoBc+L0sPOFMV4i707mV78vH9toxdCim
+5lSJ9UExyuUmGs2C4HDaOym71QP1mbpV9WTRYA6ziUm4ii8F0oRFKHyPaFASePwLtVPLwpgchKOe
+sL4jpNrcyCse2m5FHomY2vkALgbpDDtw1VAliJnLzXNg99X/NWfFobxeq81KuEXryGgeDQ0URhLj
+0mRiikKYvLTGCAj4/ahMZJx2Ab0vqWwzD9g/KLg8aQFChn5pwckGyuV6RmXpwtZQQS4/t+TtbNe/
+JgERohYpSms0BpDsE9K2+2p20jzt8NYt3eEV7KObLyzJPivkaTv/ciWxNoZbx39ri1UbSsUgYT2u
+y1DhCDq+sI9jQVMwCFk8mB13umOResoQUGC/8Ne8lYePl8X+l2oBlKN8W4UdKjk60FSh0Tlxnf0h
++bV78OLgAo9uliQlLKAeLKjEiafv7ZkGL7YKTE/bosw3Gq9HhS2KX8Q0NEwA/RiTZxPRN+ZItIsG
+xVd7GYYKecsAyVKvQv83j+GjHno9UKtjBucVtT+2RTeUN7F+8kjDf8V1/peNRY8apxpyKBpADwID
+AQABo2MwYTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAfBgNVHSMEGDAWgBQXnc0e
+i9Y5K3DTXNSguB+wAPzFYTAdBgNVHQ4EFgQUF53NHovWOStw01zUoLgfsAD8xWEwDQYJKoZIhvcN
+AQELBQADggIBAFbVe27mIgHSQpsY1Q7XZiNc4/6gx5LS6ZStS6LG7BJ8dNVI0lkUmcDrudHr9Egw
+W62nV3OZqdPlt9EuWSRY3GguLmLYauRwCy0gUCCkMpXRAJi70/33MvJJrsZ64Ee+bs7Lo3I6LWld
+y8joRTnU+kLBEUx3XZL7av9YROXrgZ6voJmtvqkBZss4HTzfQx/0TW60uhdG/H39h4F5ag0zD/ov
++BS5gLNdTaqX4fnkGMX41TiMJjz98iji7lpJiCzfeT2OnpA8vUFKOt1b9pq0zj8lMH8yfaIDlNDc
+eqFS3m6TjRgm/VWsvY+b0s+v54Ysyx8Jb6NvqYTUc79NoXQbTiNg8swOqn+knEwlqLJmOzj/2ZQw
+9nKEvmhVEA/GcywWaZMH/rFF7buiVWqw2rVKAiUnhde3t4ZEFolsgCs+l6mc1X5VTMbeRRAc6uk7
+nwNT7u56AQIWeNTowr5GdogTPyK7SBIdUgC0An4hGh6cJfTzPV4e0hz5sy229zdcxsshTrD3mUcY
+hcErulWuBurQB7Lcq9CClnXO0lD+mefPL5/ndtFhKvshuzHQqp9HpLIiyhY6UFfEW0NnxWViA0kB
+60PZ2Pierc+xYw5F9KBaLJstxabArahH9CdMOA0uG0k7UvToiIMrVCjU8jVStDKDYmlkDJGcn5fq
+dBb9HxEGmpv0
+-----END CERTIFICATE-----
+
+Entrust Root Certification Authority - G4
+=========================================
+-----BEGIN CERTIFICATE-----
+MIIGSzCCBDOgAwIBAgIRANm1Q3+vqTkPAAAAAFVlrVgwDQYJKoZIhvcNAQELBQAwgb4xCzAJBgNV
+BAYTAlVTMRYwFAYDVQQKEw1FbnRydXN0LCBJbmMuMSgwJgYDVQQLEx9TZWUgd3d3LmVudHJ1c3Qu
+bmV0L2xlZ2FsLXRlcm1zMTkwNwYDVQQLEzAoYykgMjAxNSBFbnRydXN0LCBJbmMuIC0gZm9yIGF1
+dGhvcml6ZWQgdXNlIG9ubHkxMjAwBgNVBAMTKUVudHJ1c3QgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1
+dGhvcml0eSAtIEc0MB4XDTE1MDUyNzExMTExNloXDTM3MTIyNzExNDExNlowgb4xCzAJBgNVBAYT
+AlVTMRYwFAYDVQQKEw1FbnRydXN0LCBJbmMuMSgwJgYDVQQLEx9TZWUgd3d3LmVudHJ1c3QubmV0
+L2xlZ2FsLXRlcm1zMTkwNwYDVQQLEzAoYykgMjAxNSBFbnRydXN0LCBJbmMuIC0gZm9yIGF1dGhv
+cml6ZWQgdXNlIG9ubHkxMjAwBgNVBAMTKUVudHJ1c3QgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhv
+cml0eSAtIEc0MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAsewsQu7i0TD/pZJH4i3D
+umSXbcr3DbVZwbPLqGgZ2K+EbTBwXX7zLtJTmeH+H17ZSK9dE43b/2MzTdMAArzE+NEGCJR5WIoV
+3imz/f3ET+iq4qA7ec2/a0My3dl0ELn39GjUu9CH1apLiipvKgS1sqbHoHrmSKvS0VnM1n4j5pds
+8ELl3FFLFUHtSUrJ3hCX1nbB76W1NhSXNdh4IjVS70O92yfbYVaCNNzLiGAMC1rlLAHGVK/XqsEQ
+e9IFWrhAnoanw5CGAlZSCXqc0ieCU0plUmr1POeo8pyvi73TDtTUXm6Hnmo9RR3RXRv06QqsYJn7
+ibT/mCzPfB3pAqoEmh643IhuJbNsZvc8kPNXwbMv9W3y+8qh+CmdRouzavbmZwe+LGcKKh9asj5X
+xNMhIWNlUpEbsZmOeX7m640A2Vqq6nPopIICR5b+W45UYaPrL0swsIsjdXJ8ITzI9vF01Bx7owVV
+7rtNOzK+mndmnqxpkCIHH2E6lr7lmk/MBTwoWdPBDFSoWWG9yHJM6Nyfh3+9nEg2XpWjDrk4JFX8
+dWbrAuMINClKxuMrLzOg2qOGpRKX/YAr2hRC45K9PvJdXmd0LhyIRyk0X+IyqJwlN4y6mACXi0mW
+Hv0liqzc2thddG5msP9E36EYxr5ILzeUePiVSj9/E15dWf10hkNjc0kCAwEAAaNCMEAwDwYDVR0T
+AQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFJ84xFYjwznooHFs6FRM5Og6sb9n
+MA0GCSqGSIb3DQEBCwUAA4ICAQAS5UKme4sPDORGpbZgQIeMJX6tuGguW8ZAdjwD+MlZ9POrYs4Q
+jbRaZIxowLByQzTSGwv2LFPSypBLhmb8qoMi9IsabyZIrHZ3CL/FmFz0Jomee8O5ZDIBf9PD3Vht
+7LGrhFV0d4QEJ1JrhkzO3bll/9bGXp+aEJlLdWr+aumXIOTkdnrG0CSqkM0gkLpHZPt/B7NTeLUK
+YvJzQ85BK4FqLoUWlFPUa19yIqtRLULVAJyZv967lDtX/Zr1hstWO1uIAeV8KEsD+UmDfLJ/fOPt
+jqF/YFOOVZ1QNBIPt5d7bIdKROf1beyAN/BYGW5KaHbwH5Lk6rWS02FREAutp9lfx1/cH6NcjKF+
+m7ee01ZvZl4HliDtC3T7Zk6LERXpgUl+b7DUUH8i119lAg2m9IUe2K4GS0qn0jFmwvjO5QimpAKW
+RGhXxNUzzxkvFMSUHHuk2fCfDrGA4tGeEWSpiBE6doLlYsKA2KSD7ZPvfC+QsDJMlhVoSFLUmQjA
+JOgc47OlIQ6SwJAfzyBfyjs4x7dtOvPmRLgOMWuIjnDrnBdSqEGULoe256YSxXXfW8AKbnuk5F6G
++TaU33fD6Q3AOfF5u0aOq0NZJ7cguyPpVkAh7DE9ZapD8j3fcEThuk0mEDuYn/PIjhs4ViFqUZPT
+kcpG2om3PVODLAgfi49T3f+sHw==
+-----END CERTIFICATE-----
+
+Microsoft ECC Root Certificate Authority 2017
+=============================================
+-----BEGIN CERTIFICATE-----
+MIICWTCCAd+gAwIBAgIQZvI9r4fei7FK6gxXMQHC7DAKBggqhkjOPQQDAzBlMQswCQYDVQQGEwJV
+UzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTYwNAYDVQQDEy1NaWNyb3NvZnQgRUND
+IFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTcwHhcNMTkxMjE4MjMwNjQ1WhcNNDIwNzE4
+MjMxNjA0WjBlMQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTYw
+NAYDVQQDEy1NaWNyb3NvZnQgRUNDIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTcwdjAQ
+BgcqhkjOPQIBBgUrgQQAIgNiAATUvD0CQnVBEyPNgASGAlEvaqiBYgtlzPbKnR5vSmZRogPZnZH6
+thaxjG7efM3beaYvzrvOcS/lpaso7GMEZpn4+vKTEAXhgShC48Zo9OYbhGBKia/teQ87zvH2RPUB
+eMCjVDBSMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTIy5lycFIM
++Oa+sgRXKSrPQhDtNTAQBgkrBgEEAYI3FQEEAwIBADAKBggqhkjOPQQDAwNoADBlAjBY8k3qDPlf
+Xu5gKcs68tvWMoQZP3zVL8KxzJOuULsJMsbG7X7JNpQS5GiFBqIb0C8CMQCZ6Ra0DvpWSNSkMBaR
+eNtUjGUBiudQZsIxtzm6uBoiB078a1QWIP8rtedMDE2mT3M=
+-----END CERTIFICATE-----
+
+Microsoft RSA Root Certificate Authority 2017
+=============================================
+-----BEGIN CERTIFICATE-----
+MIIFqDCCA5CgAwIBAgIQHtOXCV/YtLNHcB6qvn9FszANBgkqhkiG9w0BAQwFADBlMQswCQYDVQQG
+EwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTYwNAYDVQQDEy1NaWNyb3NvZnQg
+UlNBIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTcwHhcNMTkxMjE4MjI1MTIyWhcNNDIw
+NzE4MjMwMDIzWjBlMQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9u
+MTYwNAYDVQQDEy1NaWNyb3NvZnQgUlNBIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTcw
+ggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDKW76UM4wplZEWCpW9R2LBifOZNt9GkMml
+7Xhqb0eRaPgnZ1AzHaGm++DlQ6OEAlcBXZxIQIJTELy/xztokLaCLeX0ZdDMbRnMlfl7rEqUrQ7e
+S0MdhweSE5CAg2Q1OQT85elss7YfUJQ4ZVBcF0a5toW1HLUX6NZFndiyJrDKxHBKrmCk3bPZ7Pw7
+1VdyvD/IybLeS2v4I2wDwAW9lcfNcztmgGTjGqwu+UcF8ga2m3P1eDNbx6H7JyqhtJqRjJHTOoI+
+dkC0zVJhUXAoP8XFWvLJjEm7FFtNyP9nTUwSlq31/niol4fX/V4ggNyhSyL71Imtus5Hl0dVe49F
+yGcohJUcaDDv70ngNXtk55iwlNpNhTs+VcQor1fznhPbRiefHqJeRIOkpcrVE7NLP8TjwuaGYaRS
+MLl6IE9vDzhTyzMMEyuP1pq9KsgtsRx9S1HKR9FIJ3Jdh+vVReZIZZ2vUpC6W6IYZVcSn2i51BVr
+lMRpIpj0M+Dt+VGOQVDJNE92kKz8OMHY4Xu54+OU4UZpyw4KUGsTuqwPN1q3ErWQgR5WrlcihtnJ
+0tHXUeOrO8ZV/R4O03QK0dqq6mm4lyiPSMQH+FJDOvTKVTUssKZqwJz58oHhEmrARdlns87/I6KJ
+ClTUFLkqqNfs+avNJVgyeY+QW5g5xAgGwax/Dj0ApQIDAQABo1QwUjAOBgNVHQ8BAf8EBAMCAYYw
+DwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUCctZf4aycI8awznjwNnpv7tNsiMwEAYJKwYBBAGC
+NxUBBAMCAQAwDQYJKoZIhvcNAQEMBQADggIBAKyvPl3CEZaJjqPnktaXFbgToqZCLgLNFgVZJ8og
+6Lq46BrsTaiXVq5lQ7GPAJtSzVXNUzltYkyLDVt8LkS/gxCP81OCgMNPOsduET/m4xaRhPtthH80
+dK2Jp86519efhGSSvpWhrQlTM93uCupKUY5vVau6tZRGrox/2KJQJWVggEbbMwSubLWYdFQl3JPk
++ONVFT24bcMKpBLBaYVu32TxU5nhSnUgnZUP5NbcA/FZGOhHibJXWpS2qdgXKxdJ5XbLwVaZOjex
+/2kskZGT4d9Mozd2TaGf+G0eHdP67Pv0RR0Tbc/3WeUiJ3IrhvNXuzDtJE3cfVa7o7P4NHmJweDy
+AmH3pvwPuxwXC65B2Xy9J6P9LjrRk5Sxcx0ki69bIImtt2dmefU6xqaWM/5TkshGsRGRxpl/j8nW
+ZjEgQRCHLQzWwa80mMpkg/sTV9HB8Dx6jKXB/ZUhoHHBk2dxEuqPiAppGWSZI1b7rCoucL5mxAyE
+7+WL85MB+GqQk2dLsmijtWKP6T+MejteD+eMuMZ87zf9dOLITzNy4ZQ5bb0Sr74MTnB8G2+NszKT
+c0QWbej09+CVgI+WXTik9KveCjCHk9hNAHFiRSdLOkKEW39lt2c0Ui2cFmuqqNh7o0JMcccMyj6D
+5KbvtwEwXlGjefVwaaZBRA+GsCyRxj3qrg+E
+-----END CERTIFICATE-----
+
+e-Szigno Root CA 2017
+=====================
+-----BEGIN CERTIFICATE-----
+MIICQDCCAeWgAwIBAgIMAVRI7yH9l1kN9QQKMAoGCCqGSM49BAMCMHExCzAJBgNVBAYTAkhVMREw
+DwYDVQQHDAhCdWRhcGVzdDEWMBQGA1UECgwNTWljcm9zZWMgTHRkLjEXMBUGA1UEYQwOVkFUSFUt
+MjM1ODQ0OTcxHjAcBgNVBAMMFWUtU3ppZ25vIFJvb3QgQ0EgMjAxNzAeFw0xNzA4MjIxMjA3MDZa
+Fw00MjA4MjIxMjA3MDZaMHExCzAJBgNVBAYTAkhVMREwDwYDVQQHDAhCdWRhcGVzdDEWMBQGA1UE
+CgwNTWljcm9zZWMgTHRkLjEXMBUGA1UEYQwOVkFUSFUtMjM1ODQ0OTcxHjAcBgNVBAMMFWUtU3pp
+Z25vIFJvb3QgQ0EgMjAxNzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJbcPYrYsHtvxie+RJCx
+s1YVe45DJH0ahFnuY2iyxl6H0BVIHqiQrb1TotreOpCmYF9oMrWGQd+HWyx7xf58etqjYzBhMA8G
+A1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBSHERUI0arBeAyxr87GyZDv
+vzAEwDAfBgNVHSMEGDAWgBSHERUI0arBeAyxr87GyZDvvzAEwDAKBggqhkjOPQQDAgNJADBGAiEA
+tVfd14pVCzbhhkT61NlojbjcI4qKDdQvfepz7L9NbKgCIQDLpbQS+ue16M9+k/zzNY9vTlp8tLxO
+svxyqltZ+efcMQ==
+-----END CERTIFICATE-----
+
+certSIGN Root CA G2
+===================
+-----BEGIN CERTIFICATE-----
+MIIFRzCCAy+gAwIBAgIJEQA0tk7GNi02MA0GCSqGSIb3DQEBCwUAMEExCzAJBgNVBAYTAlJPMRQw
+EgYDVQQKEwtDRVJUU0lHTiBTQTEcMBoGA1UECxMTY2VydFNJR04gUk9PVCBDQSBHMjAeFw0xNzAy
+MDYwOTI3MzVaFw00MjAyMDYwOTI3MzVaMEExCzAJBgNVBAYTAlJPMRQwEgYDVQQKEwtDRVJUU0lH
+TiBTQTEcMBoGA1UECxMTY2VydFNJR04gUk9PVCBDQSBHMjCCAiIwDQYJKoZIhvcNAQEBBQADggIP
+ADCCAgoCggIBAMDFdRmRfUR0dIf+DjuW3NgBFszuY5HnC2/OOwppGnzC46+CjobXXo9X69MhWf05
+N0IwvlDqtg+piNguLWkh59E3GE59kdUWX2tbAMI5Qw02hVK5U2UPHULlj88F0+7cDBrZuIt4Imfk
+abBoxTzkbFpG583H+u/E7Eu9aqSs/cwoUe+StCmrqzWaTOTECMYmzPhpn+Sc8CnTXPnGFiWeI8Mg
+wT0PPzhAsP6CRDiqWhqKa2NYOLQV07YRaXseVO6MGiKscpc/I1mbySKEwQdPzH/iV8oScLumZfNp
+dWO9lfsbl83kqK/20U6o2YpxJM02PbyWxPFsqa7lzw1uKA2wDrXKUXt4FMMgL3/7FFXhEZn91Qqh
+ngLjYl/rNUssuHLoPj1PrCy7Lobio3aP5ZMqz6WryFyNSwb/EkaseMsUBzXgqd+L6a8VTxaJW732
+jcZZroiFDsGJ6x9nxUWO/203Nit4ZoORUSs9/1F3dmKh7Gc+PoGD4FapUB8fepmrY7+EF3fxDTvf
+95xhszWYijqy7DwaNz9+j5LP2RIUZNoQAhVB/0/E6xyjyfqZ90bp4RjZsbgyLcsUDFDYg2WD7rlc
+z8sFWkz6GZdr1l0T08JcVLwyc6B49fFtHsufpaafItzRUZ6CeWRgKRM+o/1Pcmqr4tTluCRVLERL
+iohEnMqE0yo7AgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1Ud
+DgQWBBSCIS1mxteg4BXrzkwJd8RgnlRuAzANBgkqhkiG9w0BAQsFAAOCAgEAYN4auOfyYILVAzOB
+ywaK8SJJ6ejqkX/GM15oGQOGO0MBzwdw5AgeZYWR5hEit/UCI46uuR59H35s5r0l1ZUa8gWmr4UC
+b6741jH/JclKyMeKqdmfS0mbEVeZkkMR3rYzpMzXjWR91M08KCy0mpbqTfXERMQlqiCA2ClV9+BB
+/AYm/7k29UMUA2Z44RGx2iBfRgB4ACGlHgAoYXhvqAEBj500mv/0OJD7uNGzcgbJceaBxXntC6Z5
+8hMLnPddDnskk7RI24Zf3lCGeOdA5jGokHZwYa+cNywRtYK3qq4kNFtyDGkNzVmf9nGvnAvRCjj5
+BiKDUyUM/FHE5r7iOZULJK2v0ZXkltd0ZGtxTgI8qoXzIKNDOXZbbFD+mpwUHmUUihW9o4JFWklW
+atKcsWMy5WHgUyIOpwpJ6st+H6jiYoD2EEVSmAYY3qXNL3+q1Ok+CHLsIwMCPKaq2LxndD0UF/tU
+Sxfj03k9bWtJySgOLnRQvwzZRjoQhsmnP+mg7H/rpXdYaXHmgwo38oZJar55CJD2AhZkPuXaTH4M
+NMn5X7azKFGnpyuqSfqNZSlO42sTp5SjLVFteAxEy9/eCG/Oo2Sr05WE1LlSVHJ7liXMvGnjSG4N
+0MedJ5qq+BOS3R7fY581qRY27Iy4g/Q9iY/NtBde17MXQRBdJ3NghVdJIgc=
+-----END CERTIFICATE-----
+
+Trustwave Global Certification Authority
+========================================
+-----BEGIN CERTIFICATE-----
+MIIF2jCCA8KgAwIBAgIMBfcOhtpJ80Y1LrqyMA0GCSqGSIb3DQEBCwUAMIGIMQswCQYDVQQGEwJV
+UzERMA8GA1UECAwISWxsaW5vaXMxEDAOBgNVBAcMB0NoaWNhZ28xITAfBgNVBAoMGFRydXN0d2F2
+ZSBIb2xkaW5ncywgSW5jLjExMC8GA1UEAwwoVHJ1c3R3YXZlIEdsb2JhbCBDZXJ0aWZpY2F0aW9u
+IEF1dGhvcml0eTAeFw0xNzA4MjMxOTM0MTJaFw00MjA4MjMxOTM0MTJaMIGIMQswCQYDVQQGEwJV
+UzERMA8GA1UECAwISWxsaW5vaXMxEDAOBgNVBAcMB0NoaWNhZ28xITAfBgNVBAoMGFRydXN0d2F2
+ZSBIb2xkaW5ncywgSW5jLjExMC8GA1UEAwwoVHJ1c3R3YXZlIEdsb2JhbCBDZXJ0aWZpY2F0aW9u
+IEF1dGhvcml0eTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALldUShLPDeS0YLOvR29
+zd24q88KPuFd5dyqCblXAj7mY2Hf8g+CY66j96xz0XznswuvCAAJWX/NKSqIk4cXGIDtiLK0thAf
+LdZfVaITXdHG6wZWiYj+rDKd/VzDBcdu7oaJuogDnXIhhpCujwOl3J+IKMujkkkP7NAP4m1ET4Bq
+stTnoApTAbqOl5F2brz81Ws25kCI1nsvXwXoLG0R8+eyvpJETNKXpP7ScoFDB5zpET71ixpZfR9o
+WN0EACyW80OzfpgZdNmcc9kYvkHHNHnZ9GLCQ7mzJ7Aiy/k9UscwR7PJPrhq4ufogXBeQotPJqX+
+OsIgbrv4Fo7NDKm0G2x2EOFYeUY+VM6AqFcJNykbmROPDMjWLBz7BegIlT1lRtzuzWniTY+HKE40
+Cz7PFNm73bZQmq131BnW2hqIyE4bJ3XYsgjxroMwuREOzYfwhI0Vcnyh78zyiGG69Gm7DIwLdVcE
+uE4qFC49DxweMqZiNu5m4iK4BUBjECLzMx10coos9TkpoNPnG4CELcU9402x/RpvumUHO1jsQkUm
++9jaJXLE9gCxInm943xZYkqcBW89zubWR2OZxiRvchLIrH+QtAuRcOi35hYQcRfO3gZPSEF9NUqj
+ifLJS3tBEW1ntwiYTOURGa5CgNz7kAXU+FDKvuStx8KU1xad5hePrzb7AgMBAAGjQjBAMA8GA1Ud
+EwEB/wQFMAMBAf8wHQYDVR0OBBYEFJngGWcNYtt2s9o9uFvo/ULSMQ6HMA4GA1UdDwEB/wQEAwIB
+BjANBgkqhkiG9w0BAQsFAAOCAgEAmHNw4rDT7TnsTGDZqRKGFx6W0OhUKDtkLSGm+J1WE2pIPU/H
+PinbbViDVD2HfSMF1OQc3Og4ZYbFdada2zUFvXfeuyk3QAUHw5RSn8pk3fEbK9xGChACMf1KaA0H
+ZJDmHvUqoai7PF35owgLEQzxPy0QlG/+4jSHg9bP5Rs1bdID4bANqKCqRieCNqcVtgimQlRXtpla
+4gt5kNdXElE1GYhBaCXUNxeEFfsBctyV3lImIJgm4nb1J2/6ADtKYdkNy1GTKv0WBpanI5ojSP5R
+vbbEsLFUzt5sQa0WZ37b/TjNuThOssFgy50X31ieemKyJo90lZvkWx3SD92YHJtZuSPTMaCm/zjd
+zyBP6VhWOmfD0faZmZ26NraAL4hHT4a/RDqA5Dccprrql5gR0IRiR2Qequ5AvzSxnI9O4fKSTx+O
+856X3vOmeWqJcU9LJxdI/uz0UA9PSX3MReO9ekDFQdxhVicGaeVyQYHTtgGJoC86cnn+OjC/QezH
+Yj6RS8fZMXZC+fc8Y+wmjHMMfRod6qh8h6jCJ3zhM0EPz8/8AKAigJ5Kp28AsEFFtyLKaEjFQqKu
+3R3y4G5OBVixwJAWKqQ9EEC+j2Jjg6mcgn0tAumDMHzLJ8n9HmYAsC7TIS+OMxZsmO0QqAfWzJPP
+29FpHOTKyeC2nOnOcXHebD8WpHk=
+-----END CERTIFICATE-----
+
+Trustwave Global ECC P256 Certification Authority
+=================================================
+-----BEGIN CERTIFICATE-----
+MIICYDCCAgegAwIBAgIMDWpfCD8oXD5Rld9dMAoGCCqGSM49BAMCMIGRMQswCQYDVQQGEwJVUzER
+MA8GA1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAfBgNVBAoTGFRydXN0d2F2ZSBI
+b2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3YXZlIEdsb2JhbCBFQ0MgUDI1NiBDZXJ0aWZp
+Y2F0aW9uIEF1dGhvcml0eTAeFw0xNzA4MjMxOTM1MTBaFw00MjA4MjMxOTM1MTBaMIGRMQswCQYD
+VQQGEwJVUzERMA8GA1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAfBgNVBAoTGFRy
+dXN0d2F2ZSBIb2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3YXZlIEdsb2JhbCBFQ0MgUDI1
+NiBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABH77bOYj
+43MyCMpg5lOcunSNGLB4kFKA3TjASh3RqMyTpJcGOMoNFWLGjgEqZZ2q3zSRLoHB5DOSMcT9CTqm
+P62jQzBBMA8GA1UdEwEB/wQFMAMBAf8wDwYDVR0PAQH/BAUDAwcGADAdBgNVHQ4EFgQUo0EGrJBt
+0UrrdaVKEJmzsaGLSvcwCgYIKoZIzj0EAwIDRwAwRAIgB+ZU2g6gWrKuEZ+Hxbb/ad4lvvigtwjz
+RM4q3wghDDcCIC0mA6AFvWvR9lz4ZcyGbbOcNEhjhAnFjXca4syc4XR7
+-----END CERTIFICATE-----
+
+Trustwave Global ECC P384 Certification Authority
+=================================================
+-----BEGIN CERTIFICATE-----
+MIICnTCCAiSgAwIBAgIMCL2Fl2yZJ6SAaEc7MAoGCCqGSM49BAMDMIGRMQswCQYDVQQGEwJVUzER
+MA8GA1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAfBgNVBAoTGFRydXN0d2F2ZSBI
+b2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3YXZlIEdsb2JhbCBFQ0MgUDM4NCBDZXJ0aWZp
+Y2F0aW9uIEF1dGhvcml0eTAeFw0xNzA4MjMxOTM2NDNaFw00MjA4MjMxOTM2NDNaMIGRMQswCQYD
+VQQGEwJVUzERMA8GA1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAfBgNVBAoTGFRy
+dXN0d2F2ZSBIb2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3YXZlIEdsb2JhbCBFQ0MgUDM4
+NCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTB2MBAGByqGSM49AgEGBSuBBAAiA2IABGvaDXU1CDFH
+Ba5FmVXxERMuSvgQMSOjfoPTfygIOiYaOs+Xgh+AtycJj9GOMMQKmw6sWASr9zZ9lCOkmwqKi6vr
+/TklZvFe/oyujUF5nQlgziip04pt89ZF1PKYhDhloKNDMEEwDwYDVR0TAQH/BAUwAwEB/zAPBgNV
+HQ8BAf8EBQMDBwYAMB0GA1UdDgQWBBRVqYSJ0sEyvRjLbKYHTsjnnb6CkDAKBggqhkjOPQQDAwNn
+ADBkAjA3AZKXRRJ+oPM+rRk6ct30UJMDEr5E0k9BpIycnR+j9sKS50gU/k6bpZFXrsY3crsCMGcl
+CrEMXu6pY5Jv5ZAL/mYiykf9ijH3g/56vxC+GCsej/YpHpRZ744hN8tRmKVuSw==
+-----END CERTIFICATE-----
+
+NAVER Global Root Certification Authority
+=========================================
+-----BEGIN CERTIFICATE-----
+MIIFojCCA4qgAwIBAgIUAZQwHqIL3fXFMyqxQ0Rx+NZQTQ0wDQYJKoZIhvcNAQEMBQAwaTELMAkG
+A1UEBhMCS1IxJjAkBgNVBAoMHU5BVkVSIEJVU0lORVNTIFBMQVRGT1JNIENvcnAuMTIwMAYDVQQD
+DClOQVZFUiBHbG9iYWwgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw0xNzA4MTgwODU4
+NDJaFw0zNzA4MTgyMzU5NTlaMGkxCzAJBgNVBAYTAktSMSYwJAYDVQQKDB1OQVZFUiBCVVNJTkVT
+UyBQTEFURk9STSBDb3JwLjEyMDAGA1UEAwwpTkFWRVIgR2xvYmFsIFJvb3QgQ2VydGlmaWNhdGlv
+biBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC21PGTXLVAiQqrDZBb
+UGOukJR0F0Vy1ntlWilLp1agS7gvQnXp2XskWjFlqxcX0TM62RHcQDaH38dq6SZeWYp34+hInDEW
++j6RscrJo+KfziFTowI2MMtSAuXaMl3Dxeb57hHHi8lEHoSTGEq0n+USZGnQJoViAbbJAh2+g1G7
+XNr4rRVqmfeSVPc0W+m/6imBEtRTkZazkVrd/pBzKPswRrXKCAfHcXLJZtM0l/aM9BhK4dA9WkW2
+aacp+yPOiNgSnABIqKYPszuSjXEOdMWLyEz59JuOuDxp7W87UC9Y7cSw0BwbagzivESq2M0UXZR4
+Yb8ObtoqvC8MC3GmsxY/nOb5zJ9TNeIDoKAYv7vxvvTWjIcNQvcGufFt7QSUqP620wbGQGHfnZ3z
+VHbOUzoBppJB7ASjjw2i1QnK1sua8e9DXcCrpUHPXFNwcMmIpi3Ua2FzUCaGYQ5fG8Ir4ozVu53B
+A0K6lNpfqbDKzE0K70dpAy8i+/Eozr9dUGWokG2zdLAIx6yo0es+nPxdGoMuK8u180SdOqcXYZai
+cdNwlhVNt0xz7hlcxVs+Qf6sdWA7G2POAN3aCJBitOUt7kinaxeZVL6HSuOpXgRM6xBtVNbv8ejy
+YhbLgGvtPe31HzClrkvJE+2KAQHJuFFYwGY6sWZLxNUxAmLpdIQM201GLQIDAQABo0IwQDAdBgNV
+HQ4EFgQU0p+I36HNLL3s9TsBAZMzJ7LrYEswDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMB
+Af8wDQYJKoZIhvcNAQEMBQADggIBADLKgLOdPVQG3dLSLvCkASELZ0jKbY7gyKoNqo0hV4/GPnrK
+21HUUrPUloSlWGB/5QuOH/XcChWB5Tu2tyIvCZwTFrFsDDUIbatjcu3cvuzHV+YwIHHW1xDBE1UB
+jCpD5EHxzzp6U5LOogMFDTjfArsQLtk70pt6wKGm+LUx5vR1yblTmXVHIloUFcd4G7ad6Qz4G3bx
+hYTeodoS76TiEJd6eN4MUZeoIUCLhr0N8F5OSza7OyAfikJW4Qsav3vQIkMsRIz75Sq0bBwcupTg
+E34h5prCy8VCZLQelHsIJchxzIdFV4XTnyliIoNRlwAYl3dqmJLJfGBs32x9SuRwTMKeuB330DTH
+D8z7p/8Dvq1wkNoL3chtl1+afwkyQf3NosxabUzyqkn+Zvjp2DXrDige7kgvOtB5CTh8piKCk5XQ
+A76+AqAF3SAi428diDRgxuYKuQl1C/AH6GmWNcf7I4GOODm4RStDeKLRLBT/DShycpWbXgnbiUSY
+qqFJu3FS8r/2/yehNq+4tneI3TqkbZs0kNwUXTC/t+sX5Ie3cdCh13cV1ELX8vMxmV2b3RZtP+oG
+I/hGoiLtk/bdmuYqh7GYVPEi92tF4+KOdh2ajcQGjTa3FPOdVGm3jjzVpG2Tgbet9r1ke8LJaDmg
+kpzNNIaRkPpkUZ3+/uul9XXeifdy
+-----END CERTIFICATE-----
+
+`
+
+// CACerts builds an X.509 certificate pool containing the
+// certificate bundle from https://curl.haxx.se/ca/cacert.pem fetch on 2021-01-29 09:54:51.941105652 +0100 CET m=+1.231498959.
+// Returns nil on error along with an appropriate error code.
+func CACerts() (*x509.CertPool, error) {
+ pool := x509.NewCertPool()
+ pool.AppendCertsFromPEM([]byte(pemcerts))
+ return pool, nil
+}
diff --git a/internal/engine/netx/gocertifi/generate.go b/internal/engine/netx/gocertifi/generate.go
new file mode 100644
index 0000000..927b08b
--- /dev/null
+++ b/internal/engine/netx/gocertifi/generate.go
@@ -0,0 +1,95 @@
+// +build ignore
+
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
+//
+// Forked from github.com/certifi/gocertifi .
+//
+// This script should not be invoked directly, rather it should be
+// executed by running go generate ./... from toplevel dir.
+
+package main
+
+import (
+ "crypto/x509"
+ "io/ioutil"
+ "log"
+ "net/http"
+ "os"
+ "strings"
+ "text/template"
+ "time"
+)
+
+var tmpl = template.Must(template.New("").Parse(`// Code generated by go generate; DO NOT EDIT.
+// {{ .Timestamp }}
+// {{ .URL }}
+
+package gocertifi
+
+//go:generate go run generate.go "{{ .URL }}"
+
+import "crypto/x509"
+
+const pemcerts string = ` + "`" + `
+{{ .Bundle }}
+` + "`" + `
+
+// CACerts builds an X.509 certificate pool containing the
+// certificate bundle from {{ .URL }} fetch on {{ .Timestamp }}.
+// Returns nil on error along with an appropriate error code.
+func CACerts() (*x509.CertPool, error) {
+ pool := x509.NewCertPool()
+ pool.AppendCertsFromPEM([]byte(pemcerts))
+ return pool, nil
+}
+`))
+
+func main() {
+ if len(os.Args) != 2 || !strings.HasPrefix(os.Args[1], "https://") {
+ log.Fatal("usage: go run generate.go ")
+ }
+ url := os.Args[1]
+
+ resp, err := http.Get(url)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if resp.StatusCode != 200 {
+ log.Fatal("expected 200, got", resp.StatusCode)
+ }
+ defer resp.Body.Close()
+
+ bundle, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ pool := x509.NewCertPool()
+ if !pool.AppendCertsFromPEM(bundle) {
+ log.Fatalf("can't parse certificates from %s", url)
+ }
+
+ fp, err := os.Create("certifi.go")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ err = tmpl.Execute(fp, struct {
+ Timestamp time.Time
+ URL string
+ Bundle string
+ }{
+ Timestamp: time.Now(),
+ URL: url,
+ Bundle: string(bundle),
+ })
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ if err := fp.Close(); err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/internal/engine/netx/httptransport/bytecounter.go b/internal/engine/netx/httptransport/bytecounter.go
new file mode 100644
index 0000000..cc61559
--- /dev/null
+++ b/internal/engine/netx/httptransport/bytecounter.go
@@ -0,0 +1,73 @@
+package httptransport
+
+import (
+ "io"
+ "net/http"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/bytecounter"
+)
+
+// ByteCountingTransport is a RoundTripper that counts bytes.
+type ByteCountingTransport struct {
+ RoundTripper
+ Counter *bytecounter.Counter
+}
+
+// RoundTrip implements RoundTripper.RoundTrip
+func (txp ByteCountingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ if req.Body != nil {
+ req.Body = byteCountingBody{
+ ReadCloser: req.Body, Account: txp.Counter.CountBytesSent}
+ }
+ txp.estimateRequestMetadata(req)
+ resp, err := txp.RoundTripper.RoundTrip(req)
+ if err != nil {
+ return nil, err
+ }
+ txp.estimateResponseMetadata(resp)
+ resp.Body = byteCountingBody{
+ ReadCloser: resp.Body, Account: txp.Counter.CountBytesReceived}
+ return resp, nil
+}
+
+func (txp ByteCountingTransport) estimateRequestMetadata(req *http.Request) {
+ txp.Counter.CountBytesSent(len(req.Method))
+ txp.Counter.CountBytesSent(len(req.URL.String()))
+ for key, values := range req.Header {
+ for _, value := range values {
+ txp.Counter.CountBytesSent(len(key))
+ txp.Counter.CountBytesSent(len(": "))
+ txp.Counter.CountBytesSent(len(value))
+ txp.Counter.CountBytesSent(len("\r\n"))
+ }
+ }
+ txp.Counter.CountBytesSent(len("\r\n"))
+}
+
+func (txp ByteCountingTransport) estimateResponseMetadata(resp *http.Response) {
+ txp.Counter.CountBytesReceived(len(resp.Status))
+ for key, values := range resp.Header {
+ for _, value := range values {
+ txp.Counter.CountBytesReceived(len(key))
+ txp.Counter.CountBytesReceived(len(": "))
+ txp.Counter.CountBytesReceived(len(value))
+ txp.Counter.CountBytesReceived(len("\r\n"))
+ }
+ }
+ txp.Counter.CountBytesReceived(len("\r\n"))
+}
+
+type byteCountingBody struct {
+ io.ReadCloser
+ Account func(int)
+}
+
+func (r byteCountingBody) Read(p []byte) (int, error) {
+ count, err := r.ReadCloser.Read(p)
+ if count > 0 {
+ r.Account(count)
+ }
+ return count, err
+}
+
+var _ RoundTripper = ByteCountingTransport{}
diff --git a/internal/engine/netx/httptransport/bytecounter_test.go b/internal/engine/netx/httptransport/bytecounter_test.go
new file mode 100644
index 0000000..dd5005f
--- /dev/null
+++ b/internal/engine/netx/httptransport/bytecounter_test.go
@@ -0,0 +1,128 @@
+package httptransport_test
+
+import (
+ "errors"
+ "io"
+ "io/ioutil"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/bytecounter"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/httptransport"
+)
+
+func TestByteCounterFailure(t *testing.T) {
+ counter := bytecounter.New()
+ txp := httptransport.ByteCountingTransport{
+ Counter: counter,
+ RoundTripper: httptransport.FakeTransport{
+ Err: io.EOF,
+ },
+ }
+ client := &http.Client{Transport: txp}
+ req, err := http.NewRequest(
+ "POST", "https://www.google.com", strings.NewReader("AAAAAA"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("User-Agent", "antani-browser/1.0.0")
+ resp, err := client.Do(req)
+ if !errors.Is(err, io.EOF) {
+ t.Fatal("not the error we expected")
+ }
+ if resp != nil {
+ t.Fatal("expected nil response here")
+ }
+ if counter.Sent.Load() != 68 {
+ t.Fatal("expected around 68 bytes sent")
+ }
+ if counter.Received.Load() != 0 {
+ t.Fatal("expected zero bytes received")
+ }
+}
+
+func TestByteCounterSuccess(t *testing.T) {
+ counter := bytecounter.New()
+ txp := httptransport.ByteCountingTransport{
+ Counter: counter,
+ RoundTripper: httptransport.FakeTransport{
+ Resp: &http.Response{
+ Body: ioutil.NopCloser(strings.NewReader("1234567")),
+ Header: http.Header{
+ "Server": []string{"antani/0.1.0"},
+ },
+ Status: "200 OK",
+ StatusCode: http.StatusOK,
+ },
+ },
+ }
+ client := &http.Client{Transport: txp}
+ req, err := http.NewRequest(
+ "POST", "https://www.google.com", strings.NewReader("AAAAAA"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("User-Agent", "antani-browser/1.0.0")
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ data, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if string(data) != "1234567" {
+ t.Fatal("expected a different body here")
+ }
+ if counter.Sent.Load() != 68 {
+ t.Fatal("expected around 68 bytes sent")
+ }
+ if counter.Received.Load() != 37 {
+ t.Fatal("expected zero around 37 bytes received")
+ }
+}
+
+func TestByteCounterSuccessWithEOF(t *testing.T) {
+ counter := bytecounter.New()
+ txp := httptransport.ByteCountingTransport{
+ Counter: counter,
+ RoundTripper: httptransport.FakeTransport{
+ Resp: &http.Response{
+ Body: bodyReaderWithEOF{},
+ Header: http.Header{
+ "Server": []string{"antani/0.1.0"},
+ },
+ Status: "200 OK",
+ StatusCode: http.StatusOK,
+ },
+ },
+ }
+ client := &http.Client{Transport: txp}
+ resp, err := client.Get("https://www.google.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ data, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if string(data) != "A" {
+ t.Fatal("expected a different body here")
+ }
+}
+
+type bodyReaderWithEOF struct{}
+
+func (bodyReaderWithEOF) Read(p []byte) (int, error) {
+ if len(p) < 1 {
+ panic("should not happen")
+ }
+ p[0] = 'A'
+ return 1, io.EOF // we want code to be robust to this
+}
+func (bodyReaderWithEOF) Close() error {
+ return nil
+}
diff --git a/internal/engine/netx/httptransport/fake_test.go b/internal/engine/netx/httptransport/fake_test.go
new file mode 100644
index 0000000..4f2a25b
--- /dev/null
+++ b/internal/engine/netx/httptransport/fake_test.go
@@ -0,0 +1,56 @@
+package httptransport
+
+import (
+ "context"
+ "io/ioutil"
+ "net"
+ "net/http"
+ "time"
+)
+
+type FakeDialer struct {
+ Conn net.Conn
+ Err error
+}
+
+func (d FakeDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
+ time.Sleep(10 * time.Microsecond)
+ return d.Conn, d.Err
+}
+
+type FakeTransport struct {
+ Err error
+ Func func(*http.Request) (*http.Response, error)
+ Resp *http.Response
+}
+
+func (txp FakeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ time.Sleep(10 * time.Microsecond)
+ if txp.Func != nil {
+ return txp.Func(req)
+ }
+ if req.Body != nil {
+ ioutil.ReadAll(req.Body)
+ req.Body.Close()
+ }
+ if txp.Err != nil {
+ return nil, txp.Err
+ }
+ txp.Resp.Request = req // non thread safe but it doesn't matter
+ return txp.Resp, nil
+}
+
+func (txp FakeTransport) CloseIdleConnections() {}
+
+type FakeBody struct {
+ Err error
+}
+
+func (fb FakeBody) Read(p []byte) (int, error) {
+ time.Sleep(10 * time.Microsecond)
+ return 0, fb.Err
+}
+
+func (fb FakeBody) Close() error {
+ return nil
+}
diff --git a/internal/engine/netx/httptransport/http3transport.go b/internal/engine/netx/httptransport/http3transport.go
new file mode 100644
index 0000000..ae42740
--- /dev/null
+++ b/internal/engine/netx/httptransport/http3transport.go
@@ -0,0 +1,43 @@
+package httptransport
+
+import (
+ "context"
+ "crypto/tls"
+ "net/http"
+
+ "github.com/lucas-clemente/quic-go"
+ "github.com/lucas-clemente/quic-go/http3"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/quicdialer"
+)
+
+// QUICWrapperDialer is a QUICDialer that wraps a ContextDialer
+// This is necessary because the http3 RoundTripper does not support a DialContext method.
+type QUICWrapperDialer struct {
+ Dialer quicdialer.ContextDialer
+}
+
+// Dial implements QUICDialer.Dial
+func (d QUICWrapperDialer) Dial(network, host string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlySession, error) {
+ return d.Dialer.DialContext(context.Background(), network, host, tlsCfg, cfg)
+}
+
+// HTTP3Transport is a httptransport.RoundTripper using the http3 protocol.
+type HTTP3Transport struct {
+ http3.RoundTripper
+}
+
+// CloseIdleConnections closes all the connections opened by this transport.
+func (t *HTTP3Transport) CloseIdleConnections() {
+ t.RoundTripper.Close()
+}
+
+// NewHTTP3Transport creates a new HTTP3Transport instance.
+func NewHTTP3Transport(config Config) RoundTripper {
+ txp := &HTTP3Transport{}
+ txp.QuicConfig = &quic.Config{}
+ txp.TLSClientConfig = config.TLSConfig
+ txp.Dial = config.QUICDialer.Dial
+ return txp
+}
+
+var _ RoundTripper = &http.Transport{}
diff --git a/internal/engine/netx/httptransport/http3transport_test.go b/internal/engine/netx/httptransport/http3transport_test.go
new file mode 100644
index 0000000..8e53aa9
--- /dev/null
+++ b/internal/engine/netx/httptransport/http3transport_test.go
@@ -0,0 +1,157 @@
+package httptransport_test
+
+import (
+ "context"
+ "crypto/tls"
+ "crypto/x509"
+ "errors"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/lucas-clemente/quic-go"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/httptransport"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/selfcensor"
+)
+
+type MockQUICDialer struct{}
+
+func (d MockQUICDialer) Dial(network, host string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlySession, error) {
+ return quic.DialAddrEarly(host, tlsCfg, cfg)
+}
+
+type MockSNIQUICDialer struct {
+ namech chan string
+}
+
+func (d MockSNIQUICDialer) Dial(network, host string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlySession, error) {
+ d.namech <- tlsCfg.ServerName
+ return quic.DialAddrEarly(host, tlsCfg, cfg)
+}
+
+type MockCertQUICDialer struct {
+ certch chan *x509.CertPool
+}
+
+func (d MockCertQUICDialer) Dial(network, host string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlySession, error) {
+ d.certch <- tlsCfg.RootCAs
+ return quic.DialAddrEarly(host, tlsCfg, cfg)
+}
+
+func TestHTTP3TransportSNI(t *testing.T) {
+ namech := make(chan string, 1)
+ sni := "sni.org"
+ txp := httptransport.NewHTTP3Transport(httptransport.Config{
+ Dialer: selfcensor.SystemDialer{}, QUICDialer: MockSNIQUICDialer{namech: namech}, TLSConfig: &tls.Config{ServerName: sni}})
+ req, err := http.NewRequest("GET", "https://www.google.com", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := txp.RoundTrip(req)
+ if err == nil {
+ t.Fatal("expected error here")
+ }
+ if resp != nil {
+ t.Fatal("expected nil resp here")
+ }
+ if !strings.Contains(err.Error(), "certificate is valid for www.google.com, not "+sni) {
+ t.Fatal("unexpected error type", err)
+ }
+ servername := <-namech
+ if servername != sni {
+ t.Fatal("unexpected server name", servername)
+ }
+}
+
+func TestHTTP3TransportSNINoVerify(t *testing.T) {
+ namech := make(chan string, 1)
+ sni := "sni.org"
+ txp := httptransport.NewHTTP3Transport(httptransport.Config{
+ Dialer: selfcensor.SystemDialer{}, QUICDialer: MockSNIQUICDialer{namech: namech}, TLSConfig: &tls.Config{ServerName: sni, InsecureSkipVerify: true}})
+ req, err := http.NewRequest("GET", "https://www.google.com", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := txp.RoundTrip(req)
+ if err != nil {
+ t.Fatalf("unexpected error: %+v", err)
+ }
+ if resp == nil {
+ t.Fatal("unexpected nil resp")
+ }
+ servername := <-namech
+ if servername != sni {
+ t.Fatal("unexpected server name", servername)
+ }
+}
+
+func TestHTTP3TransportCABundle(t *testing.T) {
+ certch := make(chan *x509.CertPool, 1)
+ certpool := x509.NewCertPool()
+ txp := httptransport.NewHTTP3Transport(httptransport.Config{
+ Dialer: selfcensor.SystemDialer{}, QUICDialer: MockCertQUICDialer{certch: certch}, TLSConfig: &tls.Config{RootCAs: certpool}})
+ req, err := http.NewRequest("GET", "https://www.google.com", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := txp.RoundTrip(req)
+ if err == nil {
+ t.Fatal("expected error here")
+ }
+ if resp != nil {
+ t.Fatal("expected nil resp here")
+ }
+ // since the certificate pool is empty, the unknown authority error should be thrown
+ if !strings.Contains(err.Error(), "certificate signed by unknown authority") {
+ t.Fatal("unexpected error type")
+ }
+ certs := <-certch
+ if certs != certpool {
+ t.Fatal("not the certpool we expected")
+ }
+
+}
+
+func TestUnitHTTP3TransportSuccess(t *testing.T) {
+ txp := httptransport.NewHTTP3Transport(httptransport.Config{
+ Dialer: selfcensor.SystemDialer{}, QUICDialer: MockQUICDialer{}})
+
+ req, err := http.NewRequest("GET", "https://www.google.com", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := txp.RoundTrip(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resp == nil {
+ t.Fatal("unexpected nil response here")
+ }
+ if resp.StatusCode != 200 {
+ t.Fatal("HTTP statuscode should be 200 OK", resp.StatusCode)
+ }
+}
+
+func TestUnitHTTP3TransportFailure(t *testing.T) {
+ txp := httptransport.NewHTTP3Transport(httptransport.Config{
+ Dialer: selfcensor.SystemDialer{}, QUICDialer: MockQUICDialer{}})
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel() // so that the request immediately fails
+ req, err := http.NewRequestWithContext(ctx, "GET", "https://www.google.com", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := txp.RoundTrip(req)
+ if err == nil {
+ t.Fatal("expected error here")
+ }
+ // context.Canceled error occurs if the test host supports QUIC
+ // timeout error ("Handshake did not complete in time") occurs if the test host does not support QUIC
+ if !(errors.Is(err, context.Canceled) || strings.HasSuffix(err.Error(), "Handshake did not complete in time")) {
+ t.Fatal("not the error we expected", err)
+ }
+ if resp != nil {
+ t.Fatal("expected nil response here")
+ }
+}
diff --git a/internal/engine/netx/httptransport/httptransport.go b/internal/engine/netx/httptransport/httptransport.go
new file mode 100644
index 0000000..46b6864
--- /dev/null
+++ b/internal/engine/netx/httptransport/httptransport.go
@@ -0,0 +1,47 @@
+// Package httptransport contains HTTP transport extensions.
+package httptransport
+
+import (
+ "context"
+ "crypto/tls"
+ "net"
+ "net/http"
+
+ "github.com/lucas-clemente/quic-go"
+)
+
+// Config contains the configuration required for constructing an HTTP transport
+type Config struct {
+ Dialer Dialer
+ QUICDialer QUICDialer
+ TLSDialer TLSDialer
+ TLSConfig *tls.Config
+}
+
+// Dialer is the definition of dialer assumed by this package.
+type Dialer interface {
+ DialContext(ctx context.Context, network, address string) (net.Conn, error)
+}
+
+// TLSDialer is the definition of a TLS dialer assumed by this package.
+type TLSDialer interface {
+ DialTLSContext(ctx context.Context, network, address string) (net.Conn, error)
+}
+
+// QUICDialer is the definition of dialer for QUIC assumed by this package.
+type QUICDialer interface {
+ Dial(network, addr string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlySession, error)
+}
+
+// RoundTripper is the definition of http.RoundTripper used by this package.
+type RoundTripper interface {
+ RoundTrip(req *http.Request) (*http.Response, error)
+ CloseIdleConnections()
+}
+
+// Resolver is the interface we expect from a resolver
+type Resolver interface {
+ LookupHost(ctx context.Context, hostname string) (addrs []string, err error)
+ Network() string
+ Address() string
+}
diff --git a/internal/engine/netx/httptransport/logging.go b/internal/engine/netx/httptransport/logging.go
new file mode 100644
index 0000000..07a3bad
--- /dev/null
+++ b/internal/engine/netx/httptransport/logging.go
@@ -0,0 +1,50 @@
+package httptransport
+
+import "net/http"
+
+// Logger is the logger assumed by this package
+type Logger interface {
+ Debugf(format string, v ...interface{})
+ Debug(message string)
+}
+
+// LoggingTransport is a logging transport
+type LoggingTransport struct {
+ RoundTripper
+ Logger Logger
+}
+
+// RoundTrip implements RoundTripper.RoundTrip
+func (txp LoggingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ host := req.Host
+ if host == "" {
+ host = req.URL.Host
+ }
+ req.Header.Set("Host", host) // anticipate what Go would do
+ return txp.logTrip(req)
+}
+
+func (txp LoggingTransport) logTrip(req *http.Request) (*http.Response, error) {
+ txp.Logger.Debugf("> %s %s", req.Method, req.URL.String())
+ for key, values := range req.Header {
+ for _, value := range values {
+ txp.Logger.Debugf("> %s: %s", key, value)
+ }
+ }
+ txp.Logger.Debug(">")
+ resp, err := txp.RoundTripper.RoundTrip(req)
+ if err != nil {
+ txp.Logger.Debugf("< %s", err)
+ return nil, err
+ }
+ txp.Logger.Debugf("< %d", resp.StatusCode)
+ for key, values := range resp.Header {
+ for _, value := range values {
+ txp.Logger.Debugf("< %s: %s", key, value)
+ }
+ }
+ txp.Logger.Debug("<")
+ return resp, nil
+}
+
+var _ RoundTripper = LoggingTransport{}
diff --git a/internal/engine/netx/httptransport/logging_test.go b/internal/engine/netx/httptransport/logging_test.go
new file mode 100644
index 0000000..c9c8a16
--- /dev/null
+++ b/internal/engine/netx/httptransport/logging_test.go
@@ -0,0 +1,77 @@
+package httptransport_test
+
+import (
+ "errors"
+ "io"
+ "io/ioutil"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/apex/log"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/httptransport"
+)
+
+func TestLoggingFailure(t *testing.T) {
+ txp := httptransport.LoggingTransport{
+ Logger: log.Log,
+ RoundTripper: httptransport.FakeTransport{
+ Err: io.EOF,
+ },
+ }
+ client := &http.Client{Transport: txp}
+ resp, err := client.Get("https://www.google.com")
+ if !errors.Is(err, io.EOF) {
+ t.Fatal("not the error we expected")
+ }
+ if resp != nil {
+ t.Fatal("expected nil response here")
+ }
+}
+
+func TestLoggingFailureWithNoHostHeader(t *testing.T) {
+ txp := httptransport.LoggingTransport{
+ Logger: log.Log,
+ RoundTripper: httptransport.FakeTransport{
+ Err: io.EOF,
+ },
+ }
+ req := &http.Request{
+ Header: http.Header{},
+ URL: &url.URL{
+ Scheme: "https",
+ Host: "www.google.com",
+ Path: "/",
+ },
+ }
+ resp, err := txp.RoundTrip(req)
+ if !errors.Is(err, io.EOF) {
+ t.Fatal("not the error we expected")
+ }
+ if resp != nil {
+ t.Fatal("expected nil response here")
+ }
+}
+
+func TestLoggingSuccess(t *testing.T) {
+ txp := httptransport.LoggingTransport{
+ Logger: log.Log,
+ RoundTripper: httptransport.FakeTransport{
+ Resp: &http.Response{
+ Body: ioutil.NopCloser(strings.NewReader("")),
+ Header: http.Header{
+ "Server": []string{"antani/0.1.0"},
+ },
+ StatusCode: 200,
+ },
+ },
+ }
+ client := &http.Client{Transport: txp}
+ resp, err := client.Get("https://www.google.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ ioutil.ReadAll(resp.Body)
+ resp.Body.Close()
+}
diff --git a/internal/engine/netx/httptransport/saver.go b/internal/engine/netx/httptransport/saver.go
new file mode 100644
index 0000000..25a869d
--- /dev/null
+++ b/internal/engine/netx/httptransport/saver.go
@@ -0,0 +1,158 @@
+package httptransport
+
+import (
+ "bytes"
+ "io"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptrace"
+ "time"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/trace"
+)
+
+// SaverPerformanceHTTPTransport is a RoundTripper that saves
+// performance events occurring during the round trip
+type SaverPerformanceHTTPTransport struct {
+ RoundTripper
+ Saver *trace.Saver
+}
+
+// RoundTrip implements RoundTripper.RoundTrip
+func (txp SaverPerformanceHTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ tracep := httptrace.ContextClientTrace(req.Context())
+ if tracep == nil {
+ tracep = &httptrace.ClientTrace{
+ WroteHeaders: func() {
+ txp.Saver.Write(trace.Event{Name: "http_wrote_headers", Time: time.Now()})
+ },
+ WroteRequest: func(httptrace.WroteRequestInfo) {
+ txp.Saver.Write(trace.Event{Name: "http_wrote_request", Time: time.Now()})
+ },
+ GotFirstResponseByte: func() {
+ txp.Saver.Write(trace.Event{
+ Name: "http_first_response_byte", Time: time.Now()})
+ },
+ }
+ req = req.WithContext(httptrace.WithClientTrace(req.Context(), tracep))
+ }
+ return txp.RoundTripper.RoundTrip(req)
+}
+
+// SaverMetadataHTTPTransport is a RoundTripper that saves
+// events related to HTTP request and response metadata
+type SaverMetadataHTTPTransport struct {
+ RoundTripper
+ Saver *trace.Saver
+ Transport string
+}
+
+// RoundTrip implements RoundTripper.RoundTrip
+func (txp SaverMetadataHTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ txp.Saver.Write(trace.Event{
+ HTTPHeaders: req.Header,
+ HTTPMethod: req.Method,
+ HTTPURL: req.URL.String(),
+ Transport: txp.Transport,
+ Name: "http_request_metadata",
+ Time: time.Now(),
+ })
+ resp, err := txp.RoundTripper.RoundTrip(req)
+ if err != nil {
+ return nil, err
+ }
+ txp.Saver.Write(trace.Event{
+ HTTPHeaders: resp.Header,
+ HTTPStatusCode: resp.StatusCode,
+ Name: "http_response_metadata",
+ Time: time.Now(),
+ })
+ return resp, err
+}
+
+// SaverTransactionHTTPTransport is a RoundTripper that saves
+// events related to the HTTP transaction
+type SaverTransactionHTTPTransport struct {
+ RoundTripper
+ Saver *trace.Saver
+}
+
+// RoundTrip implements RoundTripper.RoundTrip
+func (txp SaverTransactionHTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ txp.Saver.Write(trace.Event{
+ Name: "http_transaction_start",
+ Time: time.Now(),
+ })
+ resp, err := txp.RoundTripper.RoundTrip(req)
+ txp.Saver.Write(trace.Event{
+ Err: err,
+ Name: "http_transaction_done",
+ Time: time.Now(),
+ })
+ return resp, err
+}
+
+// SaverBodyHTTPTransport is a RoundTripper that saves
+// body events occurring during the round trip
+type SaverBodyHTTPTransport struct {
+ RoundTripper
+ Saver *trace.Saver
+ SnapshotSize int
+}
+
+// RoundTrip implements RoundTripper.RoundTrip
+func (txp SaverBodyHTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ const defaultSnapSize = 1 << 17
+ snapsize := defaultSnapSize
+ if txp.SnapshotSize != 0 {
+ snapsize = txp.SnapshotSize
+ }
+ if req.Body != nil {
+ data, err := saverSnapRead(req.Body, snapsize)
+ if err != nil {
+ return nil, err
+ }
+ req.Body = saverCompose(data, req.Body)
+ txp.Saver.Write(trace.Event{
+ DataIsTruncated: len(data) >= snapsize,
+ Data: data,
+ Name: "http_request_body_snapshot",
+ Time: time.Now(),
+ })
+ }
+ resp, err := txp.RoundTripper.RoundTrip(req)
+ if err != nil {
+ return nil, err
+ }
+ data, err := saverSnapRead(resp.Body, snapsize)
+ if err != nil {
+ resp.Body.Close()
+ return nil, err
+ }
+ resp.Body = saverCompose(data, resp.Body)
+ txp.Saver.Write(trace.Event{
+ DataIsTruncated: len(data) >= snapsize,
+ Data: data,
+ Name: "http_response_body_snapshot",
+ Time: time.Now(),
+ })
+ return resp, nil
+}
+
+func saverSnapRead(r io.ReadCloser, snapsize int) ([]byte, error) {
+ return ioutil.ReadAll(io.LimitReader(r, int64(snapsize)))
+}
+
+func saverCompose(data []byte, r io.ReadCloser) io.ReadCloser {
+ return saverReadCloser{Closer: r, Reader: io.MultiReader(bytes.NewReader(data), r)}
+}
+
+type saverReadCloser struct {
+ io.Closer
+ io.Reader
+}
+
+var _ RoundTripper = SaverPerformanceHTTPTransport{}
+var _ RoundTripper = SaverMetadataHTTPTransport{}
+var _ RoundTripper = SaverBodyHTTPTransport{}
+var _ RoundTripper = SaverTransactionHTTPTransport{}
diff --git a/internal/engine/netx/httptransport/saver_test.go b/internal/engine/netx/httptransport/saver_test.go
new file mode 100644
index 0000000..8846107
--- /dev/null
+++ b/internal/engine/netx/httptransport/saver_test.go
@@ -0,0 +1,429 @@
+package httptransport_test
+
+import (
+ "errors"
+ "io/ioutil"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/httptransport"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/trace"
+)
+
+func TestSaverPerformanceNoMultipleEvents(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ saver := &trace.Saver{}
+ // register twice - do we see events twice?
+ txp := httptransport.SaverPerformanceHTTPTransport{
+ RoundTripper: http.DefaultTransport.(*http.Transport),
+ Saver: saver,
+ }
+ txp = httptransport.SaverPerformanceHTTPTransport{
+ RoundTripper: txp,
+ Saver: saver,
+ }
+ req, err := http.NewRequest("GET", "https://www.google.com", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := txp.RoundTrip(req)
+ if err != nil {
+ t.Fatal("not the error we expected")
+ }
+ if resp == nil {
+ t.Fatal("expected non nil response here")
+ }
+ ev := saver.Read()
+ // we should specifically see the events not attached to any
+ // context being submitted twice. This is fine because they are
+ // explicit, while the context is implicit and hence leads to
+ // more subtle bugs. For example, this happens when you measure
+ // every event and combine HTTP with DoH.
+ if len(ev) != 3 {
+ t.Fatal("expected three events")
+ }
+ expected := []string{
+ "http_wrote_headers", // measured with context
+ "http_wrote_request", // measured with context
+ "http_first_response_byte", // measured with context
+ }
+ for i := 0; i < len(expected); i++ {
+ if ev[i].Name != expected[i] {
+ t.Fatal("unexpected event name")
+ }
+ }
+}
+
+func TestSaverMetadataSuccess(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ saver := &trace.Saver{}
+ txp := httptransport.SaverMetadataHTTPTransport{
+ RoundTripper: http.DefaultTransport.(*http.Transport),
+ Saver: saver,
+ }
+ req, err := http.NewRequest("GET", "https://www.google.com", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Add("User-Agent", "miniooni/0.1.0-dev")
+ resp, err := txp.RoundTrip(req)
+ if err != nil {
+ t.Fatal("not the error we expected")
+ }
+ if resp == nil {
+ t.Fatal("expected non nil response here")
+ }
+ ev := saver.Read()
+ if len(ev) != 2 {
+ t.Fatal("expected two events")
+ }
+ //
+ if ev[0].HTTPMethod != "GET" {
+ t.Fatal("unexpected Method")
+ }
+ if len(ev[0].HTTPHeaders) <= 0 {
+ t.Fatal("unexpected Headers")
+ }
+ if ev[0].HTTPURL != "https://www.google.com" {
+ t.Fatal("unexpected URL")
+ }
+ if ev[0].Name != "http_request_metadata" {
+ t.Fatal("unexpected Name")
+ }
+ if !ev[0].Time.Before(time.Now()) {
+ t.Fatal("unexpected Time")
+ }
+ //
+ if ev[1].HTTPStatusCode != 200 {
+ t.Fatal("unexpected StatusCode")
+ }
+ if len(ev[1].HTTPHeaders) <= 0 {
+ t.Fatal("unexpected Headers")
+ }
+ if ev[1].Name != "http_response_metadata" {
+ t.Fatal("unexpected Name")
+ }
+ if !ev[1].Time.After(ev[0].Time) {
+ t.Fatal("unexpected Time")
+ }
+}
+
+func TestSaverMetadataFailure(t *testing.T) {
+ expected := errors.New("mocked error")
+ saver := &trace.Saver{}
+ txp := httptransport.SaverMetadataHTTPTransport{
+ RoundTripper: httptransport.FakeTransport{
+ Err: expected,
+ },
+ Saver: saver,
+ }
+ req, err := http.NewRequest("GET", "http://www.google.com", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Add("User-Agent", "miniooni/0.1.0-dev")
+ resp, err := txp.RoundTrip(req)
+ if !errors.Is(err, expected) {
+ t.Fatal("not the error we expected")
+ }
+ if resp != nil {
+ t.Fatal("expected nil response here")
+ }
+ ev := saver.Read()
+ if len(ev) != 1 {
+ t.Fatal("expected one event")
+ }
+ if ev[0].HTTPMethod != "GET" {
+ t.Fatal("unexpected Method")
+ }
+ if len(ev[0].HTTPHeaders) <= 0 {
+ t.Fatal("unexpected Headers")
+ }
+ if ev[0].HTTPURL != "http://www.google.com" {
+ t.Fatal("unexpected URL")
+ }
+ if ev[0].Name != "http_request_metadata" {
+ t.Fatal("unexpected Name")
+ }
+ if !ev[0].Time.Before(time.Now()) {
+ t.Fatal("unexpected Time")
+ }
+}
+
+func TestSaverTransactionSuccess(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ saver := &trace.Saver{}
+ txp := httptransport.SaverTransactionHTTPTransport{
+ RoundTripper: http.DefaultTransport.(*http.Transport),
+ Saver: saver,
+ }
+ req, err := http.NewRequest("GET", "https://www.google.com", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := txp.RoundTrip(req)
+ if err != nil {
+ t.Fatal("not the error we expected")
+ }
+ if resp == nil {
+ t.Fatal("expected non nil response here")
+ }
+ ev := saver.Read()
+ if len(ev) != 2 {
+ t.Fatal("expected two events")
+ }
+ //
+ if ev[0].Name != "http_transaction_start" {
+ t.Fatal("unexpected Name")
+ }
+ if !ev[0].Time.Before(time.Now()) {
+ t.Fatal("unexpected Time")
+ }
+ //
+ if ev[1].Err != nil {
+ t.Fatal("unexpected Err")
+ }
+ if ev[1].Name != "http_transaction_done" {
+ t.Fatal("unexpected Name")
+ }
+ if !ev[1].Time.After(ev[0].Time) {
+ t.Fatal("unexpected Time")
+ }
+}
+
+func TestSaverTransactionFailure(t *testing.T) {
+ expected := errors.New("mocked error")
+ saver := &trace.Saver{}
+ txp := httptransport.SaverTransactionHTTPTransport{
+ RoundTripper: httptransport.FakeTransport{
+ Err: expected,
+ },
+ Saver: saver,
+ }
+ req, err := http.NewRequest("GET", "http://www.google.com", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := txp.RoundTrip(req)
+ if !errors.Is(err, expected) {
+ t.Fatal("not the error we expected")
+ }
+ if resp != nil {
+ t.Fatal("expected nil response here")
+ }
+ ev := saver.Read()
+ if len(ev) != 2 {
+ t.Fatal("expected two events")
+ }
+ if ev[0].Name != "http_transaction_start" {
+ t.Fatal("unexpected Name")
+ }
+ if !ev[0].Time.Before(time.Now()) {
+ t.Fatal("unexpected Time")
+ }
+ if ev[1].Name != "http_transaction_done" {
+ t.Fatal("unexpected Name")
+ }
+ if !errors.Is(ev[1].Err, expected) {
+ t.Fatal("unexpected Err")
+ }
+ if !ev[1].Time.After(ev[0].Time) {
+ t.Fatal("unexpected Time")
+ }
+}
+
+func TestSaverBodySuccess(t *testing.T) {
+ saver := new(trace.Saver)
+ txp := httptransport.SaverBodyHTTPTransport{
+ RoundTripper: httptransport.FakeTransport{
+ Func: func(req *http.Request) (*http.Response, error) {
+ data, err := ioutil.ReadAll(req.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(data) != "deadbeef" {
+ t.Fatal("invalid data")
+ }
+ return &http.Response{
+ StatusCode: 501,
+ Body: ioutil.NopCloser(strings.NewReader("abad1dea")),
+ }, nil
+ },
+ },
+ SnapshotSize: 4,
+ Saver: saver,
+ }
+ body := strings.NewReader("deadbeef")
+ req, err := http.NewRequest("POST", "http://x.org/y", body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := txp.RoundTrip(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resp.StatusCode != 501 {
+ t.Fatal("unexpected status code")
+ }
+ defer resp.Body.Close()
+ data, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(data) != "abad1dea" {
+ t.Fatal("unexpected body")
+ }
+ ev := saver.Read()
+ if len(ev) != 2 {
+ t.Fatal("unexpected number of events")
+ }
+ if string(ev[0].Data) != "dead" {
+ t.Fatal("invalid Data")
+ }
+ if ev[0].DataIsTruncated != true {
+ t.Fatal("invalid DataIsTruncated")
+ }
+ if ev[0].Name != "http_request_body_snapshot" {
+ t.Fatal("invalid Name")
+ }
+ if ev[0].Time.After(time.Now()) {
+ t.Fatal("invalid Time")
+ }
+ if string(ev[1].Data) != "abad" {
+ t.Fatal("invalid Data")
+ }
+ if ev[1].DataIsTruncated != true {
+ t.Fatal("invalid DataIsTruncated")
+ }
+ if ev[1].Name != "http_response_body_snapshot" {
+ t.Fatal("invalid Name")
+ }
+ if ev[1].Time.Before(ev[0].Time) {
+ t.Fatal("invalid Time")
+ }
+}
+
+func TestSaverBodyRequestReadError(t *testing.T) {
+ saver := new(trace.Saver)
+ txp := httptransport.SaverBodyHTTPTransport{
+ RoundTripper: httptransport.FakeTransport{
+ Func: func(req *http.Request) (*http.Response, error) {
+ panic("should not be called")
+ },
+ },
+ SnapshotSize: 4,
+ Saver: saver,
+ }
+ expected := errors.New("mocked error")
+ body := httptransport.FakeBody{Err: expected}
+ req, err := http.NewRequest("POST", "http://x.org/y", body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := txp.RoundTrip(req)
+ if !errors.Is(err, expected) {
+ t.Fatal("not the error we expected")
+ }
+ if resp != nil {
+ t.Fatal("expected nil response")
+ }
+ ev := saver.Read()
+ if len(ev) != 0 {
+ t.Fatal("unexpected number of events")
+ }
+}
+
+func TestSaverBodyRoundTripError(t *testing.T) {
+ saver := new(trace.Saver)
+ expected := errors.New("mocked error")
+ txp := httptransport.SaverBodyHTTPTransport{
+ RoundTripper: httptransport.FakeTransport{
+ Err: expected,
+ },
+ SnapshotSize: 4,
+ Saver: saver,
+ }
+ body := strings.NewReader("deadbeef")
+ req, err := http.NewRequest("POST", "http://x.org/y", body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := txp.RoundTrip(req)
+ if !errors.Is(err, expected) {
+ t.Fatal("not the error we expected")
+ }
+ if resp != nil {
+ t.Fatal("expected nil response")
+ }
+ ev := saver.Read()
+ if len(ev) != 1 {
+ t.Fatal("unexpected number of events")
+ }
+ if string(ev[0].Data) != "dead" {
+ t.Fatal("invalid Data")
+ }
+ if ev[0].DataIsTruncated != true {
+ t.Fatal("invalid DataIsTruncated")
+ }
+ if ev[0].Name != "http_request_body_snapshot" {
+ t.Fatal("invalid Name")
+ }
+ if ev[0].Time.After(time.Now()) {
+ t.Fatal("invalid Time")
+ }
+}
+
+func TestSaverBodyResponseReadError(t *testing.T) {
+ saver := new(trace.Saver)
+ expected := errors.New("mocked error")
+ txp := httptransport.SaverBodyHTTPTransport{
+ RoundTripper: httptransport.FakeTransport{
+ Func: func(req *http.Request) (*http.Response, error) {
+ return &http.Response{
+ StatusCode: 200,
+ Body: httptransport.FakeBody{
+ Err: expected,
+ },
+ }, nil
+ },
+ },
+ SnapshotSize: 4,
+ Saver: saver,
+ }
+ body := strings.NewReader("deadbeef")
+ req, err := http.NewRequest("POST", "http://x.org/y", body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := txp.RoundTrip(req)
+ if !errors.Is(err, expected) {
+ t.Fatal("not the error we expected")
+ }
+ if resp != nil {
+ t.Fatal("expected nil response")
+ }
+ ev := saver.Read()
+ if len(ev) != 1 {
+ t.Fatal("unexpected number of events")
+ }
+ if string(ev[0].Data) != "dead" {
+ t.Fatal("invalid Data")
+ }
+ if ev[0].DataIsTruncated != true {
+ t.Fatal("invalid DataIsTruncated")
+ }
+ if ev[0].Name != "http_request_body_snapshot" {
+ t.Fatal("invalid Name")
+ }
+ if ev[0].Time.After(time.Now()) {
+ t.Fatal("invalid Time")
+ }
+}
diff --git a/internal/engine/netx/httptransport/system.go b/internal/engine/netx/httptransport/system.go
new file mode 100644
index 0000000..13af6fd
--- /dev/null
+++ b/internal/engine/netx/httptransport/system.go
@@ -0,0 +1,24 @@
+package httptransport
+
+import (
+ "net/http"
+)
+
+// NewSystemTransport creates a new "system" HTTP transport. That is a transport
+// using the Go standard library with custom dialer and TLS dialer.
+func NewSystemTransport(config Config) RoundTripper {
+ txp := http.DefaultTransport.(*http.Transport).Clone()
+ txp.DialContext = config.Dialer.DialContext
+ txp.DialTLSContext = config.TLSDialer.DialTLSContext
+ // Better for Cloudflare DNS and also better because we have less
+ // noisy events and we can better understand what happened.
+ txp.MaxConnsPerHost = 1
+ // The following (1) reduces the number of headers that Go will
+ // automatically send for us and (2) ensures that we always receive
+ // back the true headers, such as Content-Length. This change is
+ // functional to OONI's goal of observing the network.
+ txp.DisableCompression = true
+ return txp
+}
+
+var _ RoundTripper = &http.Transport{}
diff --git a/internal/engine/netx/httptransport/useragent.go b/internal/engine/netx/httptransport/useragent.go
new file mode 100644
index 0000000..1e9bf40
--- /dev/null
+++ b/internal/engine/netx/httptransport/useragent.go
@@ -0,0 +1,19 @@
+package httptransport
+
+import "net/http"
+
+// UserAgentTransport is a transport that ensures that we always
+// set an OONI specific default User-Agent header.
+type UserAgentTransport struct {
+ RoundTripper
+}
+
+// RoundTrip implements RoundTripper.RoundTrip
+func (txp UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ if req.Header.Get("User-Agent") == "" {
+ req.Header.Set("User-Agent", "miniooni/0.1.0-dev")
+ }
+ return txp.RoundTripper.RoundTrip(req)
+}
+
+var _ RoundTripper = UserAgentTransport{}
diff --git a/internal/engine/netx/httptransport/useragent_test.go b/internal/engine/netx/httptransport/useragent_test.go
new file mode 100644
index 0000000..c8b8e9d
--- /dev/null
+++ b/internal/engine/netx/httptransport/useragent_test.go
@@ -0,0 +1,51 @@
+package httptransport_test
+
+import (
+ "net/http"
+ "net/url"
+ "testing"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/httptransport"
+)
+
+func TestUserAgentWithDefault(t *testing.T) {
+ txp := httptransport.UserAgentTransport{
+ RoundTripper: httptransport.FakeTransport{
+ Resp: &http.Response{StatusCode: 200},
+ },
+ }
+ req := &http.Request{URL: &url.URL{
+ Scheme: "https",
+ Host: "www.google.com",
+ Path: "/",
+ }}
+ req.Header = http.Header{}
+ resp, err := txp.RoundTrip(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resp.Request.Header.Get("User-Agent") != "miniooni/0.1.0-dev" {
+ t.Fatal("not the User-Agent we expected")
+ }
+}
+
+func TestUserAgentWithExplicitValue(t *testing.T) {
+ txp := httptransport.UserAgentTransport{
+ RoundTripper: httptransport.FakeTransport{
+ Resp: &http.Response{StatusCode: 200},
+ },
+ }
+ req := &http.Request{URL: &url.URL{
+ Scheme: "https",
+ Host: "www.google.com",
+ Path: "/",
+ }}
+ req.Header = http.Header{"User-Agent": []string{"antani-client/0.1.1"}}
+ resp, err := txp.RoundTrip(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resp.Request.Header.Get("User-Agent") != "antani-client/0.1.1" {
+ t.Fatal("not the User-Agent we expected")
+ }
+}
diff --git a/internal/engine/netx/integration_test.go b/internal/engine/netx/integration_test.go
new file mode 100644
index 0000000..0b7db6d
--- /dev/null
+++ b/internal/engine/netx/integration_test.go
@@ -0,0 +1,93 @@
+package netx_test
+
+import (
+ "context"
+ "errors"
+ "io/ioutil"
+ "net/http"
+ "testing"
+
+ "github.com/apex/log"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/bytecounter"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/trace"
+)
+
+func TestSuccess(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ log.SetLevel(log.DebugLevel)
+ counter := bytecounter.New()
+ config := netx.Config{
+ BogonIsError: true,
+ ByteCounter: counter,
+ CacheResolutions: true,
+ ContextByteCounting: true,
+ DialSaver: &trace.Saver{},
+ HTTPSaver: &trace.Saver{},
+ Logger: log.Log,
+ ReadWriteSaver: &trace.Saver{},
+ ResolveSaver: &trace.Saver{},
+ TLSSaver: &trace.Saver{},
+ }
+ txp := netx.NewHTTPTransport(config)
+ client := &http.Client{Transport: txp}
+ resp, err := client.Get("https://www.google.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = ioutil.ReadAll(resp.Body); err != nil {
+ t.Fatal(err)
+ }
+ if err = resp.Body.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if counter.Sent.Load() <= 0 {
+ t.Fatal("no bytes sent?!")
+ }
+ if counter.Received.Load() <= 0 {
+ t.Fatal("no bytes received?!")
+ }
+ if ev := config.DialSaver.Read(); len(ev) <= 0 {
+ t.Fatal("no dial events?!")
+ }
+ if ev := config.HTTPSaver.Read(); len(ev) <= 0 {
+ t.Fatal("no HTTP events?!")
+ }
+ if ev := config.ReadWriteSaver.Read(); len(ev) <= 0 {
+ t.Fatal("no R/W events?!")
+ }
+ if ev := config.ResolveSaver.Read(); len(ev) <= 0 {
+ t.Fatal("no resolver events?!")
+ }
+ if ev := config.TLSSaver.Read(); len(ev) <= 0 {
+ t.Fatal("no TLS events?!")
+ }
+}
+
+func TestBogonResolutionNotBroken(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ saver := new(trace.Saver)
+ r := netx.NewResolver(netx.Config{
+ BogonIsError: true,
+ DNSCache: map[string][]string{
+ "www.google.com": {"127.0.0.1"},
+ },
+ ResolveSaver: saver,
+ Logger: log.Log,
+ })
+ addrs, err := r.LookupHost(context.Background(), "www.google.com")
+ if !errors.Is(err, errorx.ErrDNSBogon) {
+ t.Fatal("not the error we expected")
+ }
+ if err.Error() != errorx.FailureDNSBogonError {
+ t.Fatal("error not correctly wrapped")
+ }
+ if len(addrs) != 1 || addrs[0] != "127.0.0.1" {
+ t.Fatal("address was not returned")
+ }
+}
diff --git a/internal/engine/netx/netx.go b/internal/engine/netx/netx.go
new file mode 100644
index 0000000..4fb96a2
--- /dev/null
+++ b/internal/engine/netx/netx.go
@@ -0,0 +1,485 @@
+// Package netx contains code to perform network measurements.
+//
+// This library contains replacements for commonly used standard library
+// interfaces that facilitate seamless network measurements. By using
+// such replacements, as opposed to standard library interfaces, we can:
+//
+// * save the timing of HTTP events (e.g. received response headers)
+// * save the timing and result of every Connect, Read, Write, Close operation
+// * save the timing and result of the TLS handshake (including certificates)
+//
+// By default, this library uses the system resolver. In addition, it
+// is possible to configure alternative DNS transports and remote
+// servers. We support DNS over UDP, DNS over TCP, DNS over TLS (DoT),
+// and DNS over HTTPS (DoH). When using an alternative transport, we
+// are also able to intercept and save DNS messages, as well as any
+// other interaction with the remote server (e.g., the result of the
+// TLS handshake for DoT and DoH).
+//
+// We described the design and implementation of the most recent version of
+// this package at . Such
+// issue also links to a previous design document.
+package netx
+
+import (
+ "context"
+ "crypto/tls"
+ "crypto/x509"
+ "errors"
+ "net"
+ "net/http"
+ "net/url"
+
+ "github.com/lucas-clemente/quic-go"
+ "github.com/ooni/probe-cli/v3/internal/engine/internal/runtimex"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/bytecounter"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/gocertifi"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/httptransport"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/quicdialer"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/selfcensor"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/trace"
+)
+
+// Logger is the logger assumed by this package
+type Logger interface {
+ Debugf(format string, v ...interface{})
+ Debug(message string)
+}
+
+// Dialer is the definition of dialer assumed by this package.
+type Dialer interface {
+ DialContext(ctx context.Context, network, address string) (net.Conn, error)
+}
+
+// QUICDialer is the definition of a dialer for QUIC assumed by this package.
+type QUICDialer interface {
+ Dial(network, addr string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlySession, error)
+}
+
+// TLSDialer is the definition of a TLS dialer assumed by this package.
+type TLSDialer interface {
+ DialTLSContext(ctx context.Context, network, address string) (net.Conn, error)
+}
+
+// HTTPRoundTripper is the definition of http.HTTPRoundTripper used by this package.
+type HTTPRoundTripper interface {
+ RoundTrip(req *http.Request) (*http.Response, error)
+ CloseIdleConnections()
+}
+
+// Resolver is the interface we expect from a resolver
+type Resolver interface {
+ LookupHost(ctx context.Context, hostname string) (addrs []string, err error)
+ Network() string
+ Address() string
+}
+
+// Config contains configuration for creating a new transport. When any
+// field of Config is nil/empty, we will use a suitable default.
+//
+// We use different savers for different kind of events such that the
+// user of this library can choose what to save.
+type Config struct {
+ BaseResolver Resolver // default: system resolver
+ BogonIsError bool // default: bogon is not error
+ ByteCounter *bytecounter.Counter // default: no explicit byte counting
+ CacheResolutions bool // default: no caching
+ CertPool *x509.CertPool // default: use vendored gocertifi
+ ContextByteCounting bool // default: no implicit byte counting
+ DNSCache map[string][]string // default: cache is empty
+ DialSaver *trace.Saver // default: not saving dials
+ Dialer Dialer // default: dialer.DNSDialer
+ FullResolver Resolver // default: base resolver + goodies
+ QUICDialer QUICDialer // default: quicdialer.DNSDialer
+ HTTP3Enabled bool // default: disabled
+ HTTPSaver *trace.Saver // default: not saving HTTP
+ Logger Logger // default: no logging
+ NoTLSVerify bool // default: perform TLS verify
+ ProxyURL *url.URL // default: no proxy
+ ReadWriteSaver *trace.Saver // default: not saving read/write
+ ResolveSaver *trace.Saver // default: not saving resolves
+ TLSConfig *tls.Config // default: attempt using h2
+ TLSDialer TLSDialer // default: dialer.TLSDialer
+ TLSSaver *trace.Saver // default: not saving TLS
+}
+
+type tlsHandshaker interface {
+ Handshake(ctx context.Context, conn net.Conn, config *tls.Config) (
+ net.Conn, tls.ConnectionState, error)
+}
+
+// NewDefaultCertPool returns a copy of the default x509
+// certificate pool. This function panics on failure.
+func NewDefaultCertPool() *x509.CertPool {
+ pool, err := gocertifi.CACerts()
+ runtimex.PanicOnError(err, "gocertifi.CACerts() failed")
+ return pool
+}
+
+var defaultCertPool *x509.CertPool = NewDefaultCertPool()
+
+// NewResolver creates a new resolver from the specified config
+func NewResolver(config Config) Resolver {
+ if config.BaseResolver == nil {
+ config.BaseResolver = resolver.SystemResolver{}
+ }
+ var r Resolver = config.BaseResolver
+ if config.CacheResolutions {
+ r = &resolver.CacheResolver{Resolver: r}
+ }
+ if config.DNSCache != nil {
+ cache := &resolver.CacheResolver{Resolver: r, ReadOnly: true}
+ for key, values := range config.DNSCache {
+ cache.Set(key, values)
+ }
+ r = cache
+ }
+ if config.BogonIsError {
+ r = resolver.BogonResolver{Resolver: r}
+ }
+ r = resolver.ErrorWrapperResolver{Resolver: r}
+ if config.Logger != nil {
+ r = resolver.LoggingResolver{Logger: config.Logger, Resolver: r}
+ }
+ if config.ResolveSaver != nil {
+ r = resolver.SaverResolver{Resolver: r, Saver: config.ResolveSaver}
+ }
+ r = resolver.AddressResolver{Resolver: r}
+ return resolver.IDNAResolver{Resolver: r}
+}
+
+// NewDialer creates a new Dialer from the specified config
+func NewDialer(config Config) Dialer {
+ if config.FullResolver == nil {
+ config.FullResolver = NewResolver(config)
+ }
+ var d Dialer = selfcensor.SystemDialer{}
+ d = dialer.TimeoutDialer{Dialer: d}
+ d = dialer.ErrorWrapperDialer{Dialer: d}
+ if config.Logger != nil {
+ d = dialer.LoggingDialer{Dialer: d, Logger: config.Logger}
+ }
+ if config.DialSaver != nil {
+ d = dialer.SaverDialer{Dialer: d, Saver: config.DialSaver}
+ }
+ if config.ReadWriteSaver != nil {
+ d = dialer.SaverConnDialer{Dialer: d, Saver: config.ReadWriteSaver}
+ }
+ d = dialer.DNSDialer{Resolver: config.FullResolver, Dialer: d}
+ d = dialer.ProxyDialer{ProxyURL: config.ProxyURL, Dialer: d}
+ if config.ContextByteCounting {
+ d = dialer.ByteCounterDialer{Dialer: d}
+ }
+ d = dialer.ShapingDialer{Dialer: d}
+ return d
+}
+
+// NewQUICDialer creates a new DNS Dialer for QUIC, with the resolver from the specified config
+func NewQUICDialer(config Config) QUICDialer {
+ if config.FullResolver == nil {
+ config.FullResolver = NewResolver(config)
+ }
+ var d quicdialer.ContextDialer = &quicdialer.SystemDialer{Saver: config.ReadWriteSaver}
+ d = quicdialer.ErrorWrapperDialer{Dialer: d}
+ if config.TLSSaver != nil {
+ d = quicdialer.HandshakeSaver{Saver: config.TLSSaver, Dialer: d}
+ }
+ d = &quicdialer.DNSDialer{Resolver: config.FullResolver, Dialer: d}
+ var dialer QUICDialer = &httptransport.QUICWrapperDialer{Dialer: d}
+ return dialer
+}
+
+// NewTLSDialer creates a new TLSDialer from the specified config
+func NewTLSDialer(config Config) TLSDialer {
+ if config.Dialer == nil {
+ config.Dialer = NewDialer(config)
+ }
+ var h tlsHandshaker = dialer.SystemTLSHandshaker{}
+ h = dialer.TimeoutTLSHandshaker{TLSHandshaker: h}
+ h = dialer.ErrorWrapperTLSHandshaker{TLSHandshaker: h}
+ if config.Logger != nil {
+ h = dialer.LoggingTLSHandshaker{Logger: config.Logger, TLSHandshaker: h}
+ }
+ if config.TLSSaver != nil {
+ h = dialer.SaverTLSHandshaker{TLSHandshaker: h, Saver: config.TLSSaver}
+ }
+ if config.TLSConfig == nil {
+ config.TLSConfig = &tls.Config{NextProtos: []string{"h2", "http/1.1"}}
+ }
+ if config.CertPool == nil {
+ config.CertPool = defaultCertPool
+ }
+ config.TLSConfig.RootCAs = config.CertPool
+ config.TLSConfig.InsecureSkipVerify = config.NoTLSVerify
+ return dialer.TLSDialer{
+ Config: config.TLSConfig,
+ Dialer: config.Dialer,
+ TLSHandshaker: h,
+ }
+}
+
+// NewHTTPTransport creates a new HTTPRoundTripper. You can further extend the returned
+// HTTPRoundTripper before wrapping it into an http.Client.
+func NewHTTPTransport(config Config) HTTPRoundTripper {
+ if config.Dialer == nil {
+ config.Dialer = NewDialer(config)
+ }
+ if config.TLSDialer == nil {
+ config.TLSDialer = NewTLSDialer(config)
+ }
+ if config.QUICDialer == nil {
+ config.QUICDialer = NewQUICDialer(config)
+ }
+
+ tInfo := allTransportsInfo[config.HTTP3Enabled]
+ txp := tInfo.Factory(httptransport.Config{
+ Dialer: config.Dialer, QUICDialer: config.QUICDialer, TLSDialer: config.TLSDialer,
+ TLSConfig: config.TLSConfig})
+ transport := tInfo.TransportName
+
+ if config.ByteCounter != nil {
+ txp = httptransport.ByteCountingTransport{
+ Counter: config.ByteCounter, RoundTripper: txp}
+ }
+ if config.Logger != nil {
+ txp = httptransport.LoggingTransport{Logger: config.Logger, RoundTripper: txp}
+ }
+ if config.HTTPSaver != nil {
+ txp = httptransport.SaverMetadataHTTPTransport{
+ RoundTripper: txp, Saver: config.HTTPSaver, Transport: transport}
+ txp = httptransport.SaverBodyHTTPTransport{
+ RoundTripper: txp, Saver: config.HTTPSaver}
+ txp = httptransport.SaverPerformanceHTTPTransport{
+ RoundTripper: txp, Saver: config.HTTPSaver}
+ txp = httptransport.SaverTransactionHTTPTransport{
+ RoundTripper: txp, Saver: config.HTTPSaver}
+ }
+ txp = httptransport.UserAgentTransport{RoundTripper: txp}
+ return txp
+}
+
+// httpTransportInfo contains the constructing function as well as the transport name
+type httpTransportInfo struct {
+ Factory func(httptransport.Config) httptransport.RoundTripper
+ TransportName string
+}
+
+var allTransportsInfo = map[bool]httpTransportInfo{
+ false: {
+ Factory: httptransport.NewSystemTransport,
+ TransportName: "tcp",
+ },
+ true: {
+ Factory: httptransport.NewHTTP3Transport,
+ TransportName: "quic",
+ },
+}
+
+// DNSClient is a DNS client. It wraps a Resolver and it possibly
+// also wraps an HTTP client, but only when we're using DoH.
+type DNSClient struct {
+ Resolver
+ httpClient *http.Client
+}
+
+// CloseIdleConnections closes idle connections, if any.
+func (c DNSClient) CloseIdleConnections() {
+ if c.httpClient != nil {
+ c.httpClient.CloseIdleConnections()
+ }
+}
+
+// NewDNSClient creates a new DNS client. The config argument is used to
+// create the underlying Dialer and/or HTTP transport, if needed. The URL
+// argument describes the kind of client that we want to make:
+//
+// - if the URL is `doh://powerdns`, `doh://google` or `doh://cloudflare` or the URL
+// starts with `https://`, then we create a DoH client.
+//
+// - if the URL is `` or `system:///`, then we create a system client,
+// i.e. a client using the system resolver.
+//
+// - if the URL starts with `udp://`, then we create a client using
+// a resolver that uses the specified UDP endpoint.
+//
+// We return error if the URL does not parse or the URL scheme does not
+// fall into one of the cases described above.
+//
+// If config.ResolveSaver is not nil and we're creating an underlying
+// resolver where this is possible, we will also save events.
+func NewDNSClient(config Config, URL string) (DNSClient, error) {
+ return NewDNSClientWithOverrides(config, URL, "", "", "")
+}
+
+// ErrInvalidTLSVersion indicates that you passed us a string
+// that does not represent a valid TLS version.
+var ErrInvalidTLSVersion = errors.New("invalid TLS version")
+
+// ConfigureTLSVersion configures the correct TLS version into
+// the specified *tls.Config or returns an error.
+func ConfigureTLSVersion(config *tls.Config, version string) error {
+ switch version {
+ case "TLSv1.3":
+ config.MinVersion = tls.VersionTLS13
+ config.MaxVersion = tls.VersionTLS13
+ case "TLSv1.2":
+ config.MinVersion = tls.VersionTLS12
+ config.MaxVersion = tls.VersionTLS12
+ case "TLSv1.1":
+ config.MinVersion = tls.VersionTLS11
+ config.MaxVersion = tls.VersionTLS11
+ case "TLSv1.0", "TLSv1":
+ config.MinVersion = tls.VersionTLS10
+ config.MaxVersion = tls.VersionTLS10
+ case "":
+ // nothing
+ default:
+ return ErrInvalidTLSVersion
+ }
+ return nil
+}
+
+// NewDNSClientWithOverrides creates a new DNS client, similar to NewDNSClient,
+// with the option to override the default Hostname and SNI.
+func NewDNSClientWithOverrides(config Config, URL, hostOverride, SNIOverride,
+ TLSVersion string) (DNSClient, error) {
+ var c DNSClient
+ switch URL {
+ case "doh://powerdns":
+ URL = "https://doh.powerdns.org/"
+ case "doh://google":
+ URL = "https://dns.google/dns-query"
+ case "doh://cloudflare":
+ URL = "https://cloudflare-dns.com/dns-query"
+ case "":
+ URL = "system:///"
+ }
+ resolverURL, err := url.Parse(URL)
+ if err != nil {
+ return c, err
+ }
+ config.TLSConfig = &tls.Config{ServerName: SNIOverride}
+ if err := ConfigureTLSVersion(config.TLSConfig, TLSVersion); err != nil {
+ return c, err
+ }
+ switch resolverURL.Scheme {
+ case "system":
+ c.Resolver = resolver.SystemResolver{}
+ return c, nil
+ case "https":
+ config.TLSConfig.NextProtos = []string{"h2", "http/1.1"}
+ c.httpClient = &http.Client{Transport: NewHTTPTransport(config)}
+ var txp resolver.RoundTripper = resolver.NewDNSOverHTTPSWithHostOverride(
+ c.httpClient, URL, hostOverride)
+ if config.ResolveSaver != nil {
+ txp = resolver.SaverDNSTransport{
+ RoundTripper: txp,
+ Saver: config.ResolveSaver,
+ }
+ }
+ c.Resolver = resolver.NewSerialResolver(txp)
+ return c, nil
+ case "udp":
+ dialer := NewDialer(config)
+ endpoint, err := makeValidEndpoint(resolverURL)
+ if err != nil {
+ return c, err
+ }
+ var txp resolver.RoundTripper = resolver.NewDNSOverUDP(dialer, endpoint)
+ if config.ResolveSaver != nil {
+ txp = resolver.SaverDNSTransport{
+ RoundTripper: txp,
+ Saver: config.ResolveSaver,
+ }
+ }
+ c.Resolver = resolver.NewSerialResolver(txp)
+ return c, nil
+ case "dot":
+ config.TLSConfig.NextProtos = []string{"dot"}
+ tlsDialer := NewTLSDialer(config)
+ endpoint, err := makeValidEndpoint(resolverURL)
+ if err != nil {
+ return c, err
+ }
+ var txp resolver.RoundTripper = resolver.NewDNSOverTLS(
+ tlsDialer.DialTLSContext, endpoint)
+ if config.ResolveSaver != nil {
+ txp = resolver.SaverDNSTransport{
+ RoundTripper: txp,
+ Saver: config.ResolveSaver,
+ }
+ }
+ c.Resolver = resolver.NewSerialResolver(txp)
+ return c, nil
+ case "tcp":
+ dialer := NewDialer(config)
+ endpoint, err := makeValidEndpoint(resolverURL)
+ if err != nil {
+ return c, err
+ }
+ var txp resolver.RoundTripper = resolver.NewDNSOverTCP(
+ dialer.DialContext, endpoint)
+ if config.ResolveSaver != nil {
+ txp = resolver.SaverDNSTransport{
+ RoundTripper: txp,
+ Saver: config.ResolveSaver,
+ }
+ }
+ c.Resolver = resolver.NewSerialResolver(txp)
+ return c, nil
+ default:
+ return c, errors.New("unsupported resolver scheme")
+ }
+}
+
+// makeValidEndpoint makes a valid endpoint for DoT and Do53 given the
+// input URL representing such endpoint. Specifically, we are
+// concerned with the case where the port is missing. In such a
+// case, we ensure that we are using the default port 853 for DoT
+// and default port 53 for TCP and UDP.
+func makeValidEndpoint(URL *url.URL) (string, error) {
+ // Implementation note: when we're using a quoted IPv6
+ // address, URL.Host contains the quotes but instead the
+ // return value from URL.Hostname() does not.
+ //
+ // For example:
+ //
+ // - Host: [2620:fe::9]
+ // - Hostname(): 2620:fe::9
+ //
+ // We need to keep this in mind when trying to determine
+ // whether there is also a port or not.
+ //
+ // So the first step is to check whether URL.Host is already
+ // a whatever valid TCP/UDP endpoint and, if so, use it.
+ if _, _, err := net.SplitHostPort(URL.Host); err == nil {
+ return URL.Host, nil
+ }
+ // The second step is to assume that appending the default port
+ // to a host parsed by url.Parse should be giving us a valid
+ // endpoint. The possibilities in fact are:
+ //
+ // 1. domain w/o port
+ // 2. IPv4 w/o port
+ // 3. square bracket quoted IPv6 w/o port
+ // 4. other
+ //
+ // In the first three cases, appending a port leads us to a
+ // good endpoint. The fourth case does not.
+ //
+ // For this reason we check again whether we can split it using
+ // net.SplitHostPort. If we cannot, we were in case four.
+ host := URL.Host
+ if URL.Scheme == "dot" {
+ host += ":853"
+ } else {
+ host += ":53"
+ }
+ if _, _, err := net.SplitHostPort(host); err != nil {
+ return "", err
+ }
+ // Otherwise it's one of the three valid cases above.
+ return host, nil
+}
diff --git a/internal/engine/netx/netx_internal_test.go b/internal/engine/netx/netx_internal_test.go
new file mode 100644
index 0000000..9784316
--- /dev/null
+++ b/internal/engine/netx/netx_internal_test.go
@@ -0,0 +1,8 @@
+package netx
+
+import "crypto/x509"
+
+// DefaultCertPool allows tests to access the default cert pool.
+func DefaultCertPool() *x509.CertPool {
+ return defaultCertPool
+}
diff --git a/internal/engine/netx/netx_test.go b/internal/engine/netx/netx_test.go
new file mode 100644
index 0000000..00bf58e
--- /dev/null
+++ b/internal/engine/netx/netx_test.go
@@ -0,0 +1,1261 @@
+package netx_test
+
+import (
+ "crypto/tls"
+ "errors"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/apex/log"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/bytecounter"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/httptransport"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/selfcensor"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/trace"
+)
+
+func TestNewResolverVanilla(t *testing.T) {
+ r := netx.NewResolver(netx.Config{})
+ ir, ok := r.(resolver.IDNAResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ ar, ok := ir.Resolver.(resolver.AddressResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ ewr, ok := ar.Resolver.(resolver.ErrorWrapperResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ _, ok = ewr.Resolver.(resolver.SystemResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+}
+
+func TestNewResolverSpecificResolver(t *testing.T) {
+ r := netx.NewResolver(netx.Config{
+ BaseResolver: resolver.BogonResolver{
+ // not initialized because it doesn't matter in this context
+ },
+ })
+ ir, ok := r.(resolver.IDNAResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ ar, ok := ir.Resolver.(resolver.AddressResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ ewr, ok := ar.Resolver.(resolver.ErrorWrapperResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ _, ok = ewr.Resolver.(resolver.BogonResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+}
+
+func TestNewResolverWithBogonFilter(t *testing.T) {
+ r := netx.NewResolver(netx.Config{
+ BogonIsError: true,
+ })
+ ir, ok := r.(resolver.IDNAResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ ar, ok := ir.Resolver.(resolver.AddressResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ ewr, ok := ar.Resolver.(resolver.ErrorWrapperResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ br, ok := ewr.Resolver.(resolver.BogonResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ _, ok = br.Resolver.(resolver.SystemResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+}
+
+func TestNewResolverWithLogging(t *testing.T) {
+ r := netx.NewResolver(netx.Config{
+ Logger: log.Log,
+ })
+ ir, ok := r.(resolver.IDNAResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ ar, ok := ir.Resolver.(resolver.AddressResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ lr, ok := ar.Resolver.(resolver.LoggingResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ if lr.Logger != log.Log {
+ t.Fatal("not the logger we expected")
+ }
+ ewr, ok := lr.Resolver.(resolver.ErrorWrapperResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ _, ok = ewr.Resolver.(resolver.SystemResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+}
+
+func TestNewResolverWithSaver(t *testing.T) {
+ saver := new(trace.Saver)
+ r := netx.NewResolver(netx.Config{
+ ResolveSaver: saver,
+ })
+ ir, ok := r.(resolver.IDNAResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ ar, ok := ir.Resolver.(resolver.AddressResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ sr, ok := ar.Resolver.(resolver.SaverResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ if sr.Saver != saver {
+ t.Fatal("not the saver we expected")
+ }
+ ewr, ok := sr.Resolver.(resolver.ErrorWrapperResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ _, ok = ewr.Resolver.(resolver.SystemResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+}
+
+func TestNewResolverWithReadWriteCache(t *testing.T) {
+ r := netx.NewResolver(netx.Config{
+ CacheResolutions: true,
+ })
+ ir, ok := r.(resolver.IDNAResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ ar, ok := ir.Resolver.(resolver.AddressResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ ewr, ok := ar.Resolver.(resolver.ErrorWrapperResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ cr, ok := ewr.Resolver.(*resolver.CacheResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ if cr.ReadOnly != false {
+ t.Fatal("expected readwrite cache here")
+ }
+ _, ok = cr.Resolver.(resolver.SystemResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+}
+
+func TestNewResolverWithPrefilledReadonlyCache(t *testing.T) {
+ r := netx.NewResolver(netx.Config{
+ DNSCache: map[string][]string{
+ "dns.google.com": {"8.8.8.8"},
+ },
+ })
+ ir, ok := r.(resolver.IDNAResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ ar, ok := ir.Resolver.(resolver.AddressResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ ewr, ok := ar.Resolver.(resolver.ErrorWrapperResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ cr, ok := ewr.Resolver.(*resolver.CacheResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ if cr.ReadOnly != true {
+ t.Fatal("expected readonly cache here")
+ }
+ if cr.Get("dns.google.com")[0] != "8.8.8.8" {
+ t.Fatal("cache not correctly prefilled")
+ }
+ _, ok = cr.Resolver.(resolver.SystemResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+}
+
+func TestNewDialerVanilla(t *testing.T) {
+ d := netx.NewDialer(netx.Config{})
+ sd, ok := d.(dialer.ShapingDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ pd, ok := sd.Dialer.(dialer.ProxyDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ if pd.ProxyURL != nil {
+ t.Fatal("not the proxy URL we expected")
+ }
+ dnsd, ok := pd.Dialer.(dialer.DNSDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ if dnsd.Resolver == nil {
+ t.Fatal("not the resolver we expected")
+ }
+ ir, ok := dnsd.Resolver.(resolver.IDNAResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ if _, ok := ir.Resolver.(resolver.AddressResolver); !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ ewd, ok := dnsd.Dialer.(dialer.ErrorWrapperDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ td, ok := ewd.Dialer.(dialer.TimeoutDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ if _, ok := td.Dialer.(selfcensor.SystemDialer); !ok {
+ t.Fatal("not the dialer we expected")
+ }
+}
+
+func TestNewDialerWithResolver(t *testing.T) {
+ d := netx.NewDialer(netx.Config{
+ FullResolver: resolver.BogonResolver{
+ // not initialized because it doesn't matter in this context
+ },
+ })
+ sd, ok := d.(dialer.ShapingDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ pd, ok := sd.Dialer.(dialer.ProxyDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ if pd.ProxyURL != nil {
+ t.Fatal("not the proxy URL we expected")
+ }
+ dnsd, ok := pd.Dialer.(dialer.DNSDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ if dnsd.Resolver == nil {
+ t.Fatal("not the resolver we expected")
+ }
+ if _, ok := dnsd.Resolver.(resolver.BogonResolver); !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ ewd, ok := dnsd.Dialer.(dialer.ErrorWrapperDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ td, ok := ewd.Dialer.(dialer.TimeoutDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ if _, ok := td.Dialer.(selfcensor.SystemDialer); !ok {
+ t.Fatal("not the dialer we expected")
+ }
+}
+
+func TestNewDialerWithLogger(t *testing.T) {
+ d := netx.NewDialer(netx.Config{
+ Logger: log.Log,
+ })
+ sd, ok := d.(dialer.ShapingDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ pd, ok := sd.Dialer.(dialer.ProxyDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ if pd.ProxyURL != nil {
+ t.Fatal("not the proxy URL we expected")
+ }
+ dnsd, ok := pd.Dialer.(dialer.DNSDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ if dnsd.Resolver == nil {
+ t.Fatal("not the resolver we expected")
+ }
+ ir, ok := dnsd.Resolver.(resolver.IDNAResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ if _, ok := ir.Resolver.(resolver.AddressResolver); !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ ld, ok := dnsd.Dialer.(dialer.LoggingDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ if ld.Logger != log.Log {
+ t.Fatal("not the logger we expected")
+ }
+ ewd, ok := ld.Dialer.(dialer.ErrorWrapperDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ td, ok := ewd.Dialer.(dialer.TimeoutDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ if _, ok := td.Dialer.(selfcensor.SystemDialer); !ok {
+ t.Fatal("not the dialer we expected")
+ }
+}
+
+func TestNewDialerWithDialSaver(t *testing.T) {
+ saver := new(trace.Saver)
+ d := netx.NewDialer(netx.Config{
+ DialSaver: saver,
+ })
+ sd, ok := d.(dialer.ShapingDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ pd, ok := sd.Dialer.(dialer.ProxyDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ if pd.ProxyURL != nil {
+ t.Fatal("not the proxy URL we expected")
+ }
+ dnsd, ok := pd.Dialer.(dialer.DNSDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ if dnsd.Resolver == nil {
+ t.Fatal("not the resolver we expected")
+ }
+ ir, ok := dnsd.Resolver.(resolver.IDNAResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ if _, ok := ir.Resolver.(resolver.AddressResolver); !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ sad, ok := dnsd.Dialer.(dialer.SaverDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ if sad.Saver != saver {
+ t.Fatal("not the logger we expected")
+ }
+ ewd, ok := sad.Dialer.(dialer.ErrorWrapperDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ td, ok := ewd.Dialer.(dialer.TimeoutDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ if _, ok := td.Dialer.(selfcensor.SystemDialer); !ok {
+ t.Fatal("not the dialer we expected")
+ }
+}
+
+func TestNewDialerWithReadWriteSaver(t *testing.T) {
+ saver := new(trace.Saver)
+ d := netx.NewDialer(netx.Config{
+ ReadWriteSaver: saver,
+ })
+ sd, ok := d.(dialer.ShapingDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ pd, ok := sd.Dialer.(dialer.ProxyDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ if pd.ProxyURL != nil {
+ t.Fatal("not the proxy URL we expected")
+ }
+ dnsd, ok := pd.Dialer.(dialer.DNSDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ if dnsd.Resolver == nil {
+ t.Fatal("not the resolver we expected")
+ }
+ ir, ok := dnsd.Resolver.(resolver.IDNAResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ if _, ok := ir.Resolver.(resolver.AddressResolver); !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ scd, ok := dnsd.Dialer.(dialer.SaverConnDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ if scd.Saver != saver {
+ t.Fatal("not the logger we expected")
+ }
+ ewd, ok := scd.Dialer.(dialer.ErrorWrapperDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ td, ok := ewd.Dialer.(dialer.TimeoutDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ if _, ok := td.Dialer.(selfcensor.SystemDialer); !ok {
+ t.Fatal("not the dialer we expected")
+ }
+}
+
+func TestNewDialerWithContextByteCounting(t *testing.T) {
+ d := netx.NewDialer(netx.Config{
+ ContextByteCounting: true,
+ })
+ sd, ok := d.(dialer.ShapingDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ bcd, ok := sd.Dialer.(dialer.ByteCounterDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ pd, ok := bcd.Dialer.(dialer.ProxyDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ if pd.ProxyURL != nil {
+ t.Fatal("not the proxy URL we expected")
+ }
+ dnsd, ok := pd.Dialer.(dialer.DNSDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ if dnsd.Resolver == nil {
+ t.Fatal("not the resolver we expected")
+ }
+ ir, ok := dnsd.Resolver.(resolver.IDNAResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ if _, ok := ir.Resolver.(resolver.AddressResolver); !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ ewd, ok := dnsd.Dialer.(dialer.ErrorWrapperDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ td, ok := ewd.Dialer.(dialer.TimeoutDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ if _, ok := td.Dialer.(selfcensor.SystemDialer); !ok {
+ t.Fatal("not the dialer we expected")
+ }
+}
+
+func TestNewTLSDialerVanilla(t *testing.T) {
+ td := netx.NewTLSDialer(netx.Config{})
+ rtd, ok := td.(dialer.TLSDialer)
+ if !ok {
+ t.Fatal("not the TLSDialer we expected")
+ }
+ if len(rtd.Config.NextProtos) != 2 {
+ t.Fatal("invalid len(config.NextProtos)")
+ }
+ if rtd.Config.NextProtos[0] != "h2" || rtd.Config.NextProtos[1] != "http/1.1" {
+ t.Fatal("invalid Config.NextProtos")
+ }
+ if rtd.Config.RootCAs != netx.DefaultCertPool() {
+ t.Fatal("invalid Config.RootCAs")
+ }
+ if rtd.Dialer == nil {
+ t.Fatal("invalid Dialer")
+ }
+ sd, ok := rtd.Dialer.(dialer.ShapingDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ if _, ok := sd.Dialer.(dialer.ProxyDialer); !ok {
+ t.Fatal("not the Dialer we expected")
+ }
+ if rtd.TLSHandshaker == nil {
+ t.Fatal("invalid TLSHandshaker")
+ }
+ ewth, ok := rtd.TLSHandshaker.(dialer.ErrorWrapperTLSHandshaker)
+ if !ok {
+ t.Fatal("not the TLSHandshaker we expected")
+ }
+ tth, ok := ewth.TLSHandshaker.(dialer.TimeoutTLSHandshaker)
+ if !ok {
+ t.Fatal("not the TLSHandshaker we expected")
+ }
+ if _, ok := tth.TLSHandshaker.(dialer.SystemTLSHandshaker); !ok {
+ t.Fatal("not the TLSHandshaker we expected")
+ }
+}
+
+func TestNewTLSDialerWithConfig(t *testing.T) {
+ td := netx.NewTLSDialer(netx.Config{
+ TLSConfig: new(tls.Config),
+ })
+ rtd, ok := td.(dialer.TLSDialer)
+ if !ok {
+ t.Fatal("not the TLSDialer we expected")
+ }
+ if len(rtd.Config.NextProtos) != 0 {
+ t.Fatal("invalid len(config.NextProtos)")
+ }
+ if rtd.Config.RootCAs != netx.DefaultCertPool() {
+ t.Fatal("invalid Config.RootCAs")
+ }
+ if rtd.Dialer == nil {
+ t.Fatal("invalid Dialer")
+ }
+ sd, ok := rtd.Dialer.(dialer.ShapingDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ if _, ok := sd.Dialer.(dialer.ProxyDialer); !ok {
+ t.Fatal("not the Dialer we expected")
+ }
+ if rtd.TLSHandshaker == nil {
+ t.Fatal("invalid TLSHandshaker")
+ }
+ ewth, ok := rtd.TLSHandshaker.(dialer.ErrorWrapperTLSHandshaker)
+ if !ok {
+ t.Fatal("not the TLSHandshaker we expected")
+ }
+ tth, ok := ewth.TLSHandshaker.(dialer.TimeoutTLSHandshaker)
+ if !ok {
+ t.Fatal("not the TLSHandshaker we expected")
+ }
+ if _, ok := tth.TLSHandshaker.(dialer.SystemTLSHandshaker); !ok {
+ t.Fatal("not the TLSHandshaker we expected")
+ }
+}
+
+func TestNewTLSDialerWithLogging(t *testing.T) {
+ td := netx.NewTLSDialer(netx.Config{
+ Logger: log.Log,
+ })
+ rtd, ok := td.(dialer.TLSDialer)
+ if !ok {
+ t.Fatal("not the TLSDialer we expected")
+ }
+ if len(rtd.Config.NextProtos) != 2 {
+ t.Fatal("invalid len(config.NextProtos)")
+ }
+ if rtd.Config.NextProtos[0] != "h2" || rtd.Config.NextProtos[1] != "http/1.1" {
+ t.Fatal("invalid Config.NextProtos")
+ }
+ if rtd.Config.RootCAs != netx.DefaultCertPool() {
+ t.Fatal("invalid Config.RootCAs")
+ }
+ if rtd.Dialer == nil {
+ t.Fatal("invalid Dialer")
+ }
+ sd, ok := rtd.Dialer.(dialer.ShapingDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ if _, ok := sd.Dialer.(dialer.ProxyDialer); !ok {
+ t.Fatal("not the Dialer we expected")
+ }
+ if rtd.TLSHandshaker == nil {
+ t.Fatal("invalid TLSHandshaker")
+ }
+ lth, ok := rtd.TLSHandshaker.(dialer.LoggingTLSHandshaker)
+ if !ok {
+ t.Fatal("not the TLSHandshaker we expected")
+ }
+ if lth.Logger != log.Log {
+ t.Fatal("not the Logger we expected")
+ }
+ ewth, ok := lth.TLSHandshaker.(dialer.ErrorWrapperTLSHandshaker)
+ if !ok {
+ t.Fatal("not the TLSHandshaker we expected")
+ }
+ tth, ok := ewth.TLSHandshaker.(dialer.TimeoutTLSHandshaker)
+ if !ok {
+ t.Fatal("not the TLSHandshaker we expected")
+ }
+ if _, ok := tth.TLSHandshaker.(dialer.SystemTLSHandshaker); !ok {
+ t.Fatal("not the TLSHandshaker we expected")
+ }
+}
+
+func TestNewTLSDialerWithSaver(t *testing.T) {
+ saver := new(trace.Saver)
+ td := netx.NewTLSDialer(netx.Config{
+ TLSSaver: saver,
+ })
+ rtd, ok := td.(dialer.TLSDialer)
+ if !ok {
+ t.Fatal("not the TLSDialer we expected")
+ }
+ if len(rtd.Config.NextProtos) != 2 {
+ t.Fatal("invalid len(config.NextProtos)")
+ }
+ if rtd.Config.NextProtos[0] != "h2" || rtd.Config.NextProtos[1] != "http/1.1" {
+ t.Fatal("invalid Config.NextProtos")
+ }
+ if rtd.Config.RootCAs != netx.DefaultCertPool() {
+ t.Fatal("invalid Config.RootCAs")
+ }
+ if rtd.Dialer == nil {
+ t.Fatal("invalid Dialer")
+ }
+ sd, ok := rtd.Dialer.(dialer.ShapingDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ if _, ok := sd.Dialer.(dialer.ProxyDialer); !ok {
+ t.Fatal("not the Dialer we expected")
+ }
+ if rtd.TLSHandshaker == nil {
+ t.Fatal("invalid TLSHandshaker")
+ }
+ sth, ok := rtd.TLSHandshaker.(dialer.SaverTLSHandshaker)
+ if !ok {
+ t.Fatal("not the TLSHandshaker we expected")
+ }
+ if sth.Saver != saver {
+ t.Fatal("not the Logger we expected")
+ }
+ ewth, ok := sth.TLSHandshaker.(dialer.ErrorWrapperTLSHandshaker)
+ if !ok {
+ t.Fatal("not the TLSHandshaker we expected")
+ }
+ tth, ok := ewth.TLSHandshaker.(dialer.TimeoutTLSHandshaker)
+ if !ok {
+ t.Fatal("not the TLSHandshaker we expected")
+ }
+ if _, ok := tth.TLSHandshaker.(dialer.SystemTLSHandshaker); !ok {
+ t.Fatal("not the TLSHandshaker we expected")
+ }
+}
+
+func TestNewTLSDialerWithNoTLSVerifyAndConfig(t *testing.T) {
+ td := netx.NewTLSDialer(netx.Config{
+ TLSConfig: new(tls.Config),
+ NoTLSVerify: true,
+ })
+ rtd, ok := td.(dialer.TLSDialer)
+ if !ok {
+ t.Fatal("not the TLSDialer we expected")
+ }
+ if len(rtd.Config.NextProtos) != 0 {
+ t.Fatal("invalid len(config.NextProtos)")
+ }
+ if rtd.Config.InsecureSkipVerify != true {
+ t.Fatal("expected true InsecureSkipVerify")
+ }
+ if rtd.Config.RootCAs != netx.DefaultCertPool() {
+ t.Fatal("invalid Config.RootCAs")
+ }
+ if rtd.Dialer == nil {
+ t.Fatal("invalid Dialer")
+ }
+ sd, ok := rtd.Dialer.(dialer.ShapingDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ if _, ok := sd.Dialer.(dialer.ProxyDialer); !ok {
+ t.Fatal("not the Dialer we expected")
+ }
+ if rtd.TLSHandshaker == nil {
+ t.Fatal("invalid TLSHandshaker")
+ }
+ ewth, ok := rtd.TLSHandshaker.(dialer.ErrorWrapperTLSHandshaker)
+ if !ok {
+ t.Fatal("not the TLSHandshaker we expected")
+ }
+ tth, ok := ewth.TLSHandshaker.(dialer.TimeoutTLSHandshaker)
+ if !ok {
+ t.Fatal("not the TLSHandshaker we expected")
+ }
+ if _, ok := tth.TLSHandshaker.(dialer.SystemTLSHandshaker); !ok {
+ t.Fatal("not the TLSHandshaker we expected")
+ }
+}
+
+func TestNewTLSDialerWithNoTLSVerifyAndNoConfig(t *testing.T) {
+ td := netx.NewTLSDialer(netx.Config{
+ NoTLSVerify: true,
+ })
+ rtd, ok := td.(dialer.TLSDialer)
+ if !ok {
+ t.Fatal("not the TLSDialer we expected")
+ }
+ if len(rtd.Config.NextProtos) != 2 {
+ t.Fatal("invalid len(config.NextProtos)")
+ }
+ if rtd.Config.NextProtos[0] != "h2" || rtd.Config.NextProtos[1] != "http/1.1" {
+ t.Fatal("invalid Config.NextProtos")
+ }
+ if rtd.Config.InsecureSkipVerify != true {
+ t.Fatal("expected true InsecureSkipVerify")
+ }
+ if rtd.Config.RootCAs != netx.DefaultCertPool() {
+ t.Fatal("invalid Config.RootCAs")
+ }
+ if rtd.Dialer == nil {
+ t.Fatal("invalid Dialer")
+ }
+ sd, ok := rtd.Dialer.(dialer.ShapingDialer)
+ if !ok {
+ t.Fatal("not the dialer we expected")
+ }
+ if _, ok := sd.Dialer.(dialer.ProxyDialer); !ok {
+ t.Fatal("not the Dialer we expected")
+ }
+ if rtd.TLSHandshaker == nil {
+ t.Fatal("invalid TLSHandshaker")
+ }
+ ewth, ok := rtd.TLSHandshaker.(dialer.ErrorWrapperTLSHandshaker)
+ if !ok {
+ t.Fatal("not the TLSHandshaker we expected")
+ }
+ tth, ok := ewth.TLSHandshaker.(dialer.TimeoutTLSHandshaker)
+ if !ok {
+ t.Fatal("not the TLSHandshaker we expected")
+ }
+ if _, ok := tth.TLSHandshaker.(dialer.SystemTLSHandshaker); !ok {
+ t.Fatal("not the TLSHandshaker we expected")
+ }
+}
+
+func TestNewVanilla(t *testing.T) {
+ txp := netx.NewHTTPTransport(netx.Config{})
+ uatxp, ok := txp.(httptransport.UserAgentTransport)
+ if !ok {
+ t.Fatal("not the transport we expected")
+ }
+ if _, ok := uatxp.RoundTripper.(*http.Transport); !ok {
+ t.Fatal("not the transport we expected")
+ }
+}
+
+func TestNewWithDialer(t *testing.T) {
+ expected := errors.New("mocked error")
+ dialer := netx.FakeDialer{Err: expected}
+ txp := netx.NewHTTPTransport(netx.Config{
+ Dialer: dialer,
+ })
+ client := &http.Client{Transport: txp}
+ resp, err := client.Get("http://www.google.com")
+ if !errors.Is(err, expected) {
+ t.Fatal("not the error we expected")
+ }
+ if resp != nil {
+ t.Fatal("not the response we expected")
+ }
+}
+
+func TestNewWithTLSDialer(t *testing.T) {
+ expected := errors.New("mocked error")
+ tlsDialer := dialer.TLSDialer{
+ Config: new(tls.Config),
+ Dialer: netx.FakeDialer{Err: expected},
+ TLSHandshaker: dialer.SystemTLSHandshaker{},
+ }
+ txp := netx.NewHTTPTransport(netx.Config{
+ TLSDialer: tlsDialer,
+ })
+ client := &http.Client{Transport: txp}
+ resp, err := client.Get("https://www.google.com")
+ if !errors.Is(err, expected) {
+ t.Fatal("not the error we expected")
+ }
+ if resp != nil {
+ t.Fatal("not the response we expected")
+ }
+}
+
+func TestNewWithByteCounter(t *testing.T) {
+ counter := bytecounter.New()
+ txp := netx.NewHTTPTransport(netx.Config{
+ ByteCounter: counter,
+ })
+ uatxp, ok := txp.(httptransport.UserAgentTransport)
+ if !ok {
+ t.Fatal("not the transport we expected")
+ }
+ bctxp, ok := uatxp.RoundTripper.(httptransport.ByteCountingTransport)
+ if !ok {
+ t.Fatal("not the transport we expected")
+ }
+ if bctxp.Counter != counter {
+ t.Fatal("not the byte counter we expected")
+ }
+ if _, ok := bctxp.RoundTripper.(*http.Transport); !ok {
+ t.Fatal("not the transport we expected")
+ }
+}
+
+func TestNewWithLogger(t *testing.T) {
+ txp := netx.NewHTTPTransport(netx.Config{
+ Logger: log.Log,
+ })
+ uatxp, ok := txp.(httptransport.UserAgentTransport)
+ if !ok {
+ t.Fatal("not the transport we expected")
+ }
+ ltxp, ok := uatxp.RoundTripper.(httptransport.LoggingTransport)
+ if !ok {
+ t.Fatal("not the transport we expected")
+ }
+ if ltxp.Logger != log.Log {
+ t.Fatal("not the logger we expected")
+ }
+ if _, ok := ltxp.RoundTripper.(*http.Transport); !ok {
+ t.Fatal("not the transport we expected")
+ }
+}
+
+func TestNewWithSaver(t *testing.T) {
+ saver := new(trace.Saver)
+ txp := netx.NewHTTPTransport(netx.Config{
+ HTTPSaver: saver,
+ })
+ uatxp, ok := txp.(httptransport.UserAgentTransport)
+ if !ok {
+ t.Fatal("not the transport we expected")
+ }
+ stxptxp, ok := uatxp.RoundTripper.(httptransport.SaverTransactionHTTPTransport)
+ if !ok {
+ t.Fatal("not the transport we expected")
+ }
+ if stxptxp.Saver != saver {
+ t.Fatal("not the logger we expected")
+ }
+ sptxp, ok := stxptxp.RoundTripper.(httptransport.SaverPerformanceHTTPTransport)
+ if !ok {
+ t.Fatal("not the transport we expected")
+ }
+ if sptxp.Saver != saver {
+ t.Fatal("not the logger we expected")
+ }
+ sbtxp, ok := sptxp.RoundTripper.(httptransport.SaverBodyHTTPTransport)
+ if !ok {
+ t.Fatal("not the transport we expected")
+ }
+ if sbtxp.Saver != saver {
+ t.Fatal("not the logger we expected")
+ }
+ smtxp, ok := sbtxp.RoundTripper.(httptransport.SaverMetadataHTTPTransport)
+ if !ok {
+ t.Fatal("not the transport we expected")
+ }
+ if smtxp.Saver != saver {
+ t.Fatal("not the logger we expected")
+ }
+ if _, ok := smtxp.RoundTripper.(*http.Transport); !ok {
+ t.Fatal("not the transport we expected")
+ }
+}
+
+func TestNewDNSClientInvalidURL(t *testing.T) {
+ dnsclient, err := netx.NewDNSClient(netx.Config{}, "\t\t\t")
+ if err == nil || !strings.HasSuffix(err.Error(), "invalid control character in URL") {
+ t.Fatal("not the error we expected")
+ }
+ if dnsclient.Resolver != nil {
+ t.Fatal("expected nil resolver here")
+ }
+ dnsclient.CloseIdleConnections()
+}
+
+func TestNewDNSClientUnsupportedScheme(t *testing.T) {
+ dnsclient, err := netx.NewDNSClient(netx.Config{}, "antani:///")
+ if err == nil || err.Error() != "unsupported resolver scheme" {
+ t.Fatal("not the error we expected")
+ }
+ if dnsclient.Resolver != nil {
+ t.Fatal("expected nil resolver here")
+ }
+ dnsclient.CloseIdleConnections()
+}
+
+func TestNewDNSClientSystemResolver(t *testing.T) {
+ dnsclient, err := netx.NewDNSClient(
+ netx.Config{}, "system:///")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := dnsclient.Resolver.(resolver.SystemResolver); !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ dnsclient.CloseIdleConnections()
+}
+
+func TestNewDNSClientEmpty(t *testing.T) {
+ dnsclient, err := netx.NewDNSClient(
+ netx.Config{}, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := dnsclient.Resolver.(resolver.SystemResolver); !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ dnsclient.CloseIdleConnections()
+}
+
+func TestNewDNSClientPowerdnsDoH(t *testing.T) {
+ dnsclient, err := netx.NewDNSClient(
+ netx.Config{}, "doh://powerdns")
+ if err != nil {
+ t.Fatal(err)
+ }
+ r, ok := dnsclient.Resolver.(resolver.SerialResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ if _, ok := r.Transport().(resolver.DNSOverHTTPS); !ok {
+ t.Fatal("not the transport we expected")
+ }
+ dnsclient.CloseIdleConnections()
+}
+
+func TestNewDNSClientGoogleDoH(t *testing.T) {
+ dnsclient, err := netx.NewDNSClient(
+ netx.Config{}, "doh://google")
+ if err != nil {
+ t.Fatal(err)
+ }
+ r, ok := dnsclient.Resolver.(resolver.SerialResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ if _, ok := r.Transport().(resolver.DNSOverHTTPS); !ok {
+ t.Fatal("not the transport we expected")
+ }
+ dnsclient.CloseIdleConnections()
+}
+
+func TestNewDNSClientCloudflareDoH(t *testing.T) {
+ dnsclient, err := netx.NewDNSClient(
+ netx.Config{}, "doh://cloudflare")
+ if err != nil {
+ t.Fatal(err)
+ }
+ r, ok := dnsclient.Resolver.(resolver.SerialResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ if _, ok := r.Transport().(resolver.DNSOverHTTPS); !ok {
+ t.Fatal("not the transport we expected")
+ }
+ dnsclient.CloseIdleConnections()
+}
+
+func TestNewDNSClientCloudflareDoHSaver(t *testing.T) {
+ saver := new(trace.Saver)
+ dnsclient, err := netx.NewDNSClient(
+ netx.Config{ResolveSaver: saver}, "doh://cloudflare")
+ if err != nil {
+ t.Fatal(err)
+ }
+ r, ok := dnsclient.Resolver.(resolver.SerialResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ txp, ok := r.Transport().(resolver.SaverDNSTransport)
+ if !ok {
+ t.Fatal("not the transport we expected")
+ }
+ if _, ok := txp.RoundTripper.(resolver.DNSOverHTTPS); !ok {
+ t.Fatal("not the transport we expected")
+ }
+ dnsclient.CloseIdleConnections()
+}
+
+func TestNewDNSClientUDP(t *testing.T) {
+ dnsclient, err := netx.NewDNSClient(
+ netx.Config{}, "udp://8.8.8.8:53")
+ if err != nil {
+ t.Fatal(err)
+ }
+ r, ok := dnsclient.Resolver.(resolver.SerialResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ if _, ok := r.Transport().(resolver.DNSOverUDP); !ok {
+ t.Fatal("not the transport we expected")
+ }
+ dnsclient.CloseIdleConnections()
+}
+
+func TestNewDNSClientUDPDNSSaver(t *testing.T) {
+ saver := new(trace.Saver)
+ dnsclient, err := netx.NewDNSClient(
+ netx.Config{ResolveSaver: saver}, "udp://8.8.8.8:53")
+ if err != nil {
+ t.Fatal(err)
+ }
+ r, ok := dnsclient.Resolver.(resolver.SerialResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ txp, ok := r.Transport().(resolver.SaverDNSTransport)
+ if !ok {
+ t.Fatal("not the transport we expected")
+ }
+ if _, ok := txp.RoundTripper.(resolver.DNSOverUDP); !ok {
+ t.Fatal("not the transport we expected")
+ }
+ dnsclient.CloseIdleConnections()
+}
+
+func TestNewDNSClientTCP(t *testing.T) {
+ dnsclient, err := netx.NewDNSClient(
+ netx.Config{}, "tcp://8.8.8.8:53")
+ if err != nil {
+ t.Fatal(err)
+ }
+ r, ok := dnsclient.Resolver.(resolver.SerialResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ txp, ok := r.Transport().(resolver.DNSOverTCP)
+ if !ok {
+ t.Fatal("not the transport we expected")
+ }
+ if txp.Network() != "tcp" {
+ t.Fatal("not the Network we expected")
+ }
+ dnsclient.CloseIdleConnections()
+}
+
+func TestNewDNSClientTCPDNSSaver(t *testing.T) {
+ saver := new(trace.Saver)
+ dnsclient, err := netx.NewDNSClient(
+ netx.Config{ResolveSaver: saver}, "tcp://8.8.8.8:53")
+ if err != nil {
+ t.Fatal(err)
+ }
+ r, ok := dnsclient.Resolver.(resolver.SerialResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ txp, ok := r.Transport().(resolver.SaverDNSTransport)
+ if !ok {
+ t.Fatal("not the transport we expected")
+ }
+ dotcp, ok := txp.RoundTripper.(resolver.DNSOverTCP)
+ if !ok {
+ t.Fatal("not the transport we expected")
+ }
+ if dotcp.Network() != "tcp" {
+ t.Fatal("not the Network we expected")
+ }
+ dnsclient.CloseIdleConnections()
+}
+
+func TestNewDNSClientDoT(t *testing.T) {
+ dnsclient, err := netx.NewDNSClient(
+ netx.Config{}, "dot://8.8.8.8:53")
+ if err != nil {
+ t.Fatal(err)
+ }
+ r, ok := dnsclient.Resolver.(resolver.SerialResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ txp, ok := r.Transport().(resolver.DNSOverTCP)
+ if !ok {
+ t.Fatal("not the transport we expected")
+ }
+ if txp.Network() != "dot" {
+ t.Fatal("not the Network we expected")
+ }
+ dnsclient.CloseIdleConnections()
+}
+
+func TestNewDNSClientDoTDNSSaver(t *testing.T) {
+ saver := new(trace.Saver)
+ dnsclient, err := netx.NewDNSClient(
+ netx.Config{ResolveSaver: saver}, "dot://8.8.8.8:53")
+ if err != nil {
+ t.Fatal(err)
+ }
+ r, ok := dnsclient.Resolver.(resolver.SerialResolver)
+ if !ok {
+ t.Fatal("not the resolver we expected")
+ }
+ txp, ok := r.Transport().(resolver.SaverDNSTransport)
+ if !ok {
+ t.Fatal("not the transport we expected")
+ }
+ dotls, ok := txp.RoundTripper.(resolver.DNSOverTCP)
+ if !ok {
+ t.Fatal("not the transport we expected")
+ }
+ if dotls.Network() != "dot" {
+ t.Fatal("not the Network we expected")
+ }
+ dnsclient.CloseIdleConnections()
+}
+
+func TestNewDNSCLientDoTWithoutPort(t *testing.T) {
+ c, err := netx.NewDNSClientWithOverrides(
+ netx.Config{}, "dot://8.8.8.8", "", "8.8.8.8", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c.Resolver.Address() != "8.8.8.8:853" {
+ t.Fatal("expected default port to be added")
+ }
+}
+
+func TestNewDNSCLientTCPWithoutPort(t *testing.T) {
+ c, err := netx.NewDNSClientWithOverrides(
+ netx.Config{}, "tcp://8.8.8.8", "", "8.8.8.8", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c.Resolver.Address() != "8.8.8.8:53" {
+ t.Fatal("expected default port to be added")
+ }
+}
+
+func TestNewDNSCLientUDPWithoutPort(t *testing.T) {
+ c, err := netx.NewDNSClientWithOverrides(
+ netx.Config{}, "udp://8.8.8.8", "", "8.8.8.8", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c.Resolver.Address() != "8.8.8.8:53" {
+ t.Fatal("expected default port to be added")
+ }
+}
+
+func TestNewDNSClientBadDoTEndpoint(t *testing.T) {
+ _, err := netx.NewDNSClient(
+ netx.Config{}, "dot://bad:endpoint:53")
+ if err == nil || !strings.Contains(err.Error(), "too many colons in address") {
+ t.Fatal("expected error with bad endpoint")
+ }
+}
+
+func TestNewDNSClientBadTCPEndpoint(t *testing.T) {
+ _, err := netx.NewDNSClient(
+ netx.Config{}, "tcp://bad:endpoint:853")
+ if err == nil || !strings.Contains(err.Error(), "too many colons in address") {
+ t.Fatal("expected error with bad endpoint")
+ }
+}
+
+func TestNewDNSClientBadUDPEndpoint(t *testing.T) {
+ _, err := netx.NewDNSClient(
+ netx.Config{}, "udp://bad:endpoint:853")
+ if err == nil || !strings.Contains(err.Error(), "too many colons in address") {
+ t.Fatal("expected error with bad endpoint")
+ }
+}
+
+func TestNewDNSCLientWithInvalidTLSVersion(t *testing.T) {
+ _, err := netx.NewDNSClientWithOverrides(
+ netx.Config{}, "dot://8.8.8.8", "", "", "TLSv999")
+ if !errors.Is(err, netx.ErrInvalidTLSVersion) {
+ t.Fatalf("not the error we expected: %+v", err)
+ }
+}
+
+func TestConfigureTLSVersion(t *testing.T) {
+ tests := []struct {
+ name string
+ version string
+ wantErr error
+ versionMin int
+ versionMax int
+ }{{
+ name: "with TLSv1.3",
+ version: "TLSv1.3",
+ wantErr: nil,
+ versionMin: tls.VersionTLS13,
+ versionMax: tls.VersionTLS13,
+ }, {
+ name: "with TLSv1.2",
+ version: "TLSv1.2",
+ wantErr: nil,
+ versionMin: tls.VersionTLS12,
+ versionMax: tls.VersionTLS12,
+ }, {
+ name: "with TLSv1.1",
+ version: "TLSv1.1",
+ wantErr: nil,
+ versionMin: tls.VersionTLS11,
+ versionMax: tls.VersionTLS11,
+ }, {
+ name: "with TLSv1.0",
+ version: "TLSv1.0",
+ wantErr: nil,
+ versionMin: tls.VersionTLS10,
+ versionMax: tls.VersionTLS10,
+ }, {
+ name: "with TLSv1",
+ version: "TLSv1",
+ wantErr: nil,
+ versionMin: tls.VersionTLS10,
+ versionMax: tls.VersionTLS10,
+ }, {
+ name: "with default",
+ version: "",
+ wantErr: nil,
+ versionMin: 0,
+ versionMax: 0,
+ }, {
+ name: "with invalid version",
+ version: "TLSv999",
+ wantErr: netx.ErrInvalidTLSVersion,
+ versionMin: 0,
+ versionMax: 0,
+ }}
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ conf := new(tls.Config)
+ err := netx.ConfigureTLSVersion(conf, tt.version)
+ if !errors.Is(err, tt.wantErr) {
+ t.Fatalf("not the error we expected: %+v", err)
+ }
+ if conf.MinVersion != uint16(tt.versionMin) {
+ t.Fatalf("not the min version we expected: %+v", conf.MinVersion)
+ }
+ if conf.MaxVersion != uint16(tt.versionMax) {
+ t.Fatalf("not the max version we expected: %+v", conf.MaxVersion)
+ }
+ })
+ }
+}
diff --git a/internal/engine/netx/quicdialer/connectionstate_go1.14.go b/internal/engine/netx/quicdialer/connectionstate_go1.14.go
new file mode 100644
index 0000000..80d6f3f
--- /dev/null
+++ b/internal/engine/netx/quicdialer/connectionstate_go1.14.go
@@ -0,0 +1,14 @@
+// +build !go1.15
+
+package quicdialer
+
+import (
+ "crypto/tls"
+
+ "github.com/lucas-clemente/quic-go"
+)
+
+// ConnectionState returns the ConnectionState of a QUIC Session.
+func ConnectionState(sess quic.EarlySession) tls.ConnectionState {
+ return tls.ConnectionState{}
+}
diff --git a/internal/engine/netx/quicdialer/connectionstate_go1.15.go b/internal/engine/netx/quicdialer/connectionstate_go1.15.go
new file mode 100644
index 0000000..c43ca00
--- /dev/null
+++ b/internal/engine/netx/quicdialer/connectionstate_go1.15.go
@@ -0,0 +1,14 @@
+// +build go1.15
+
+package quicdialer
+
+import (
+ "crypto/tls"
+
+ "github.com/lucas-clemente/quic-go"
+)
+
+// ConnectionState returns the ConnectionState of a QUIC Session.
+func ConnectionState(sess quic.EarlySession) tls.ConnectionState {
+ return sess.ConnectionState().ConnectionState
+}
diff --git a/internal/engine/netx/quicdialer/dns.go b/internal/engine/netx/quicdialer/dns.go
new file mode 100644
index 0000000..7402ddf
--- /dev/null
+++ b/internal/engine/netx/quicdialer/dns.go
@@ -0,0 +1,59 @@
+package quicdialer
+
+import (
+ "context"
+ "crypto/tls"
+ "net"
+
+ "github.com/lucas-clemente/quic-go"
+ "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/dialid"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/dialer"
+)
+
+// DNSDialer is a dialer that uses the configured Resolver to resolve a
+// domain name to IP addresses
+type DNSDialer struct {
+ Dialer ContextDialer
+ Resolver Resolver
+}
+
+// DialContext implements ContextDialer.DialContext
+func (d DNSDialer) DialContext(
+ ctx context.Context, network, host string,
+ tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlySession, error) {
+ onlyhost, onlyport, err := net.SplitHostPort(host)
+ if err != nil {
+ return nil, err
+ }
+ // TODO(kelmenhorst): Should this be somewhere else?
+ // failure if tlsCfg is nil but that should not happen
+ if tlsCfg.ServerName == "" {
+ tlsCfg.ServerName = onlyhost
+ }
+ ctx = dialid.WithDialID(ctx)
+ var addrs []string
+ addrs, err = d.LookupHost(ctx, onlyhost)
+ if err != nil {
+ return nil, err
+ }
+ var errorslist []error
+ for _, addr := range addrs {
+ target := net.JoinHostPort(addr, onlyport)
+ sess, err := d.Dialer.DialContext(
+ ctx, network, target, tlsCfg, cfg)
+ if err == nil {
+ return sess, nil
+ }
+ errorslist = append(errorslist, err)
+ }
+ // TODO(bassosimone): maybe ReduceErrors could be in netx/internal.
+ return nil, dialer.ReduceErrors(errorslist)
+}
+
+// LookupHost implements Resolver.LookupHost
+func (d DNSDialer) LookupHost(ctx context.Context, hostname string) ([]string, error) {
+ if net.ParseIP(hostname) != nil {
+ return []string{hostname}, nil
+ }
+ return d.Resolver.LookupHost(ctx, hostname)
+}
diff --git a/internal/engine/netx/quicdialer/dns_test.go b/internal/engine/netx/quicdialer/dns_test.go
new file mode 100644
index 0000000..ad6ded2
--- /dev/null
+++ b/internal/engine/netx/quicdialer/dns_test.go
@@ -0,0 +1,142 @@
+package quicdialer_test
+
+import (
+ "context"
+ "crypto/tls"
+ "errors"
+ "net"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/lucas-clemente/quic-go"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/quicdialer"
+)
+
+type MockableResolver struct {
+ Addresses []string
+ Err error
+}
+
+func (r MockableResolver) LookupHost(ctx context.Context, host string) ([]string, error) {
+ return r.Addresses, r.Err
+}
+
+func TestDNSDialerSuccess(t *testing.T) {
+ tlsConf := &tls.Config{NextProtos: []string{"h3-29"}}
+ dialer := quicdialer.DNSDialer{
+ Resolver: new(net.Resolver), Dialer: quicdialer.SystemDialer{}}
+ sess, err := dialer.DialContext(
+ context.Background(), "udp", "www.google.com:443",
+ tlsConf, &quic.Config{})
+ if err != nil {
+ t.Fatal("unexpected error", err)
+ }
+ if sess == nil {
+ t.Fatal("non nil sess expected")
+ }
+}
+
+func TestDNSDialerNoPort(t *testing.T) {
+ tlsConf := &tls.Config{NextProtos: []string{"h3-29"}}
+ dialer := quicdialer.DNSDialer{
+ Resolver: new(net.Resolver), Dialer: quicdialer.SystemDialer{}}
+ sess, err := dialer.DialContext(
+ context.Background(), "udp", "www.google.com",
+ tlsConf, &quic.Config{})
+ if err == nil {
+ t.Fatal("expected an error here")
+ }
+ if sess != nil {
+ t.Fatal("expected a nil sess here")
+ }
+ if err.Error() != "address www.google.com: missing port in address" {
+ t.Fatal("not the error we expected")
+ }
+}
+
+func TestDNSDialerLookupHostAddress(t *testing.T) {
+ dialer := quicdialer.DNSDialer{Resolver: MockableResolver{
+ Err: errors.New("mocked error"),
+ }}
+ addrs, err := dialer.LookupHost(context.Background(), "1.1.1.1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(addrs) != 1 || addrs[0] != "1.1.1.1" {
+ t.Fatal("not the result we expected")
+ }
+}
+
+func TestDNSDialerLookupHostFailure(t *testing.T) {
+ tlsConf := &tls.Config{NextProtos: []string{"h3-29"}}
+ expected := errors.New("mocked error")
+ dialer := quicdialer.DNSDialer{Resolver: MockableResolver{
+ Err: expected,
+ }}
+ sess, err := dialer.DialContext(
+ context.Background(), "udp", "dns.google.com:853",
+ tlsConf, &quic.Config{})
+ if !errors.Is(err, expected) {
+ t.Fatal("not the error we expected")
+ }
+ if sess != nil {
+ t.Fatal("expected nil sess")
+ }
+}
+
+func TestDNSDialerInvalidPort(t *testing.T) {
+ tlsConf := &tls.Config{NextProtos: []string{"h3-29"}}
+ dialer := quicdialer.DNSDialer{
+ Resolver: new(net.Resolver), Dialer: quicdialer.SystemDialer{}}
+ sess, err := dialer.DialContext(
+ context.Background(), "udp", "www.google.com:0",
+ tlsConf, &quic.Config{})
+ if err == nil {
+ t.Fatal("expected an error here")
+ }
+ if sess != nil {
+ t.Fatal("expected nil sess")
+ }
+ if !strings.HasSuffix(err.Error(), "sendto: invalid argument") &&
+ !strings.HasSuffix(err.Error(), "sendto: can't assign requested address") {
+ t.Fatal("not the error we expected")
+ }
+}
+
+func TestDNSDialerInvalidPortSyntax(t *testing.T) {
+ tlsConf := &tls.Config{NextProtos: []string{"h3-29"}}
+ dialer := quicdialer.DNSDialer{
+ Resolver: new(net.Resolver), Dialer: quicdialer.SystemDialer{}}
+ sess, err := dialer.DialContext(
+ context.Background(), "udp", "www.google.com:port",
+ tlsConf, &quic.Config{})
+ if err == nil {
+ t.Fatal("expected an error here")
+ }
+ if sess != nil {
+ t.Fatal("expected nil sess")
+ }
+ if !errors.Is(err, strconv.ErrSyntax) {
+ t.Fatal("not the error we expected")
+ }
+}
+
+func TestDNSDialerDialEarlyFails(t *testing.T) {
+ tlsConf := &tls.Config{NextProtos: []string{"h3-29"}}
+ expected := errors.New("mocked DialEarly error")
+ dialer := quicdialer.DNSDialer{
+ Resolver: new(net.Resolver), Dialer: MockDialer{Err: expected}}
+ sess, err := dialer.DialContext(
+ context.Background(), "udp", "www.google.com:443",
+ tlsConf, &quic.Config{})
+ if err == nil {
+ t.Fatal("expected an error here")
+ }
+ if sess != nil {
+ t.Fatal("expected nil sess")
+ }
+ if !errors.Is(err, expected) {
+ t.Fatal("not the error we expected")
+ }
+}
diff --git a/internal/engine/netx/quicdialer/errorwrapper.go b/internal/engine/netx/quicdialer/errorwrapper.go
new file mode 100644
index 0000000..01978f5
--- /dev/null
+++ b/internal/engine/netx/quicdialer/errorwrapper.go
@@ -0,0 +1,34 @@
+package quicdialer
+
+import (
+ "context"
+ "crypto/tls"
+
+ "github.com/lucas-clemente/quic-go"
+ "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/dialid"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx"
+)
+
+// ErrorWrapperDialer is a dialer that performs quic err wrapping
+type ErrorWrapperDialer struct {
+ Dialer ContextDialer
+}
+
+// DialContext implements ContextDialer.DialContext
+func (d ErrorWrapperDialer) DialContext(
+ ctx context.Context, network string, host string,
+ tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlySession, error) {
+ dialID := dialid.ContextDialID(ctx)
+ sess, err := d.Dialer.DialContext(ctx, network, host, tlsCfg, cfg)
+ err = errorx.SafeErrWrapperBuilder{
+ // ConnID does not make any sense if we've failed and the error
+ // does not make any sense (and is nil) if we succeded.
+ DialID: dialID,
+ Error: err,
+ Operation: errorx.QUICHandshakeOperation,
+ }.MaybeBuild()
+ if err != nil {
+ return nil, err
+ }
+ return sess, nil
+}
diff --git a/internal/engine/netx/quicdialer/errorwrapper_test.go b/internal/engine/netx/quicdialer/errorwrapper_test.go
new file mode 100644
index 0000000..094d094
--- /dev/null
+++ b/internal/engine/netx/quicdialer/errorwrapper_test.go
@@ -0,0 +1,61 @@
+package quicdialer_test
+
+import (
+ "context"
+ "crypto/tls"
+ "errors"
+ "io"
+ "testing"
+
+ "github.com/lucas-clemente/quic-go"
+ "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/dialid"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/quicdialer"
+)
+
+func TestErrorWrapperFailure(t *testing.T) {
+ ctx := dialid.WithDialID(context.Background())
+ d := quicdialer.ErrorWrapperDialer{
+ Dialer: MockDialer{Sess: nil, Err: io.EOF}}
+ sess, err := d.DialContext(
+ ctx, "udp", "www.google.com:443", &tls.Config{}, &quic.Config{})
+ if sess != nil {
+ t.Fatal("expected a nil sess here")
+ }
+ errorWrapperCheckErr(t, err, errorx.QUICHandshakeOperation)
+}
+
+func errorWrapperCheckErr(t *testing.T, err error, op string) {
+ if !errors.Is(err, io.EOF) {
+ t.Fatal("expected another error here")
+ }
+ var errWrapper *errorx.ErrWrapper
+ if !errors.As(err, &errWrapper) {
+ t.Fatal("cannot cast to ErrWrapper")
+ }
+ if errWrapper.DialID == 0 {
+ t.Fatal("unexpected DialID")
+ }
+ if errWrapper.Operation != op {
+ t.Fatal("unexpected Operation")
+ }
+ if errWrapper.Failure != errorx.FailureEOFError {
+ t.Fatal("unexpected failure")
+ }
+}
+
+func TestErrorWrapperSuccess(t *testing.T) {
+ ctx := dialid.WithDialID(context.Background())
+ tlsConf := &tls.Config{
+ NextProtos: []string{"h3-29"},
+ ServerName: "www.google.com",
+ }
+ d := quicdialer.ErrorWrapperDialer{Dialer: quicdialer.SystemDialer{}}
+ sess, err := d.DialContext(ctx, "udp", "216.58.212.164:443", tlsConf, &quic.Config{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if sess == nil {
+ t.Fatal("expected non-nil sess here")
+ }
+}
diff --git a/internal/engine/netx/quicdialer/quicdialer.go b/internal/engine/netx/quicdialer/quicdialer.go
new file mode 100644
index 0000000..4b5aa99
--- /dev/null
+++ b/internal/engine/netx/quicdialer/quicdialer.go
@@ -0,0 +1,26 @@
+package quicdialer
+
+import (
+ "context"
+ "crypto/tls"
+
+ "github.com/lucas-clemente/quic-go"
+)
+
+// ContextDialer is a dialer for QUIC using Context.
+type ContextDialer interface {
+ // Note: assumes that tlsCfg and cfg are not nil.
+ DialContext(ctx context.Context, network, host string,
+ tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlySession, error)
+}
+
+// Dialer dials QUIC connections.
+type Dialer interface {
+ // Note: assumes that tlsCfg and cfg are not nil.
+ Dial(network, addr string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlySession, error)
+}
+
+// Resolver is the interface we expect from a resolver.
+type Resolver interface {
+ LookupHost(ctx context.Context, hostname string) (addrs []string, err error)
+}
diff --git a/internal/engine/netx/quicdialer/saver.go b/internal/engine/netx/quicdialer/saver.go
new file mode 100644
index 0000000..88fa2de
--- /dev/null
+++ b/internal/engine/netx/quicdialer/saver.go
@@ -0,0 +1,62 @@
+package quicdialer
+
+import (
+ "context"
+ "crypto/tls"
+ "time"
+
+ "github.com/lucas-clemente/quic-go"
+ "github.com/ooni/probe-cli/v3/internal/engine/internal/tlsx"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/trace"
+)
+
+// HandshakeSaver saves events occurring during the handshake
+type HandshakeSaver struct {
+ Saver *trace.Saver
+ Dialer ContextDialer
+}
+
+// DialContext implements ContextDialer.DialContext
+func (h HandshakeSaver) DialContext(ctx context.Context, network string,
+ host string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlySession, error) {
+ start := time.Now()
+ // TODO(bassosimone): in the future we probably want to also save
+ // information about what versions we're willing to accept.
+ h.Saver.Write(trace.Event{
+ Address: host,
+ Name: "quic_handshake_start",
+ NoTLSVerify: tlsCfg.InsecureSkipVerify,
+ Proto: network,
+ TLSNextProtos: tlsCfg.NextProtos,
+ TLSServerName: tlsCfg.ServerName,
+ Time: start,
+ })
+ sess, err := h.Dialer.DialContext(ctx, network, host, tlsCfg, cfg)
+ stop := time.Now()
+ if err != nil {
+ h.Saver.Write(trace.Event{
+ Duration: stop.Sub(start),
+ Err: err,
+ Name: "quic_handshake_done",
+ NoTLSVerify: tlsCfg.InsecureSkipVerify,
+ TLSNextProtos: tlsCfg.NextProtos,
+ TLSServerName: tlsCfg.ServerName,
+ Time: stop,
+ })
+ return nil, err
+ }
+ state := ConnectionState(sess)
+ h.Saver.Write(trace.Event{
+ Duration: stop.Sub(start),
+ Name: "quic_handshake_done",
+ NoTLSVerify: tlsCfg.InsecureSkipVerify,
+ TLSCipherSuite: tlsx.CipherSuiteString(state.CipherSuite),
+ TLSNegotiatedProto: state.NegotiatedProtocol,
+ TLSNextProtos: tlsCfg.NextProtos,
+ TLSPeerCerts: trace.PeerCerts(state, err),
+ TLSServerName: tlsCfg.ServerName,
+ TLSVersion: tlsx.VersionString(state.Version),
+ Time: stop,
+ })
+ return sess, nil
+}
diff --git a/internal/engine/netx/quicdialer/saver_test.go b/internal/engine/netx/quicdialer/saver_test.go
new file mode 100644
index 0000000..0e90fd3
--- /dev/null
+++ b/internal/engine/netx/quicdialer/saver_test.go
@@ -0,0 +1,118 @@
+package quicdialer_test
+
+import (
+ "context"
+ "crypto/tls"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/lucas-clemente/quic-go"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/quicdialer"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/trace"
+)
+
+type MockDialer struct {
+ Dialer quicdialer.ContextDialer
+ Sess quic.EarlySession
+ Err error
+}
+
+func (d MockDialer) DialContext(ctx context.Context, network, host string,
+ tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlySession, error) {
+ if d.Dialer != nil {
+ return d.Dialer.DialContext(ctx, network, host, tlsCfg, cfg)
+ }
+ return d.Sess, d.Err
+}
+
+func TestHandshakeSaverSuccess(t *testing.T) {
+ nextprotos := []string{"h3-29"}
+ servername := "www.google.com"
+ tlsConf := &tls.Config{
+ NextProtos: nextprotos,
+ ServerName: servername,
+ }
+ saver := &trace.Saver{}
+ dlr := quicdialer.HandshakeSaver{
+ Dialer: quicdialer.SystemDialer{},
+ Saver: saver,
+ }
+ sess, err := dlr.DialContext(context.Background(), "udp",
+ "216.58.212.164:443", tlsConf, &quic.Config{})
+ if err != nil {
+ t.Fatal("unexpected error", err)
+ }
+ if sess == nil {
+ t.Fatal("unexpected nil sess")
+ }
+ ev := saver.Read()
+ if len(ev) != 2 {
+ t.Fatal("unexpected number of events")
+ }
+ if ev[0].Name != "quic_handshake_start" {
+ t.Fatal("unexpected Name")
+ }
+ if ev[0].TLSServerName != "www.google.com" {
+ t.Fatal("unexpected TLSServerName")
+ }
+ if !reflect.DeepEqual(ev[0].TLSNextProtos, nextprotos) {
+ t.Fatal("unexpected TLSNextProtos")
+ }
+ if ev[0].Time.After(time.Now()) {
+ t.Fatal("unexpected Time")
+ }
+ if ev[1].Duration <= 0 {
+ t.Fatal("unexpected Duration")
+ }
+ if ev[1].Err != nil {
+ t.Fatal("unexpected Err", ev[1].Err)
+ }
+ if ev[1].Name != "quic_handshake_done" {
+ t.Fatal("unexpected Name")
+ }
+ if !reflect.DeepEqual(ev[1].TLSNextProtos, nextprotos) {
+ t.Fatal("unexpected TLSNextProtos")
+ }
+ if ev[1].TLSServerName != "www.google.com" {
+ t.Fatal("unexpected TLSServerName")
+ }
+ if ev[1].Time.Before(ev[0].Time) {
+ t.Fatal("unexpected Time")
+ }
+}
+
+func TestHandshakeSaverHostNameError(t *testing.T) {
+ nextprotos := []string{"h3-29"}
+ servername := "wrong.host.badssl.com"
+ tlsConf := &tls.Config{
+ NextProtos: nextprotos,
+ ServerName: servername,
+ }
+ saver := &trace.Saver{}
+ dlr := quicdialer.HandshakeSaver{
+ Dialer: quicdialer.SystemDialer{},
+ Saver: saver,
+ }
+ sess, err := dlr.DialContext(context.Background(), "udp",
+ "216.58.212.164:443", tlsConf, &quic.Config{})
+ if err == nil {
+ t.Fatal("expected an error here")
+ }
+ if sess != nil {
+ t.Fatal("expected nil sess here")
+ }
+ for _, ev := range saver.Read() {
+ if ev.Name != "quic_handshake_done" {
+ continue
+ }
+ if ev.NoTLSVerify == true {
+ t.Fatal("expected NoTLSVerify to be false")
+ }
+ if !strings.Contains(ev.Err.Error(),
+ "certificate is valid for www.google.com, not "+servername) {
+ t.Fatal("unexpected error", ev.Err)
+ }
+ }
+}
diff --git a/internal/engine/netx/quicdialer/system.go b/internal/engine/netx/quicdialer/system.go
new file mode 100644
index 0000000..eae55d4
--- /dev/null
+++ b/internal/engine/netx/quicdialer/system.go
@@ -0,0 +1,87 @@
+package quicdialer
+
+import (
+ "context"
+ "crypto/tls"
+ "errors"
+ "net"
+ "strconv"
+ "time"
+
+ "github.com/lucas-clemente/quic-go"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/trace"
+)
+
+// SystemDialer is the basic dialer for QUIC
+type SystemDialer struct {
+ // Saver saves read/write events on the underlying UDP
+ // connection. (Implementation note: we need it here since
+ // this is the only part in the codebase that is able to
+ // observe the underlying UDP connection.)
+ Saver *trace.Saver
+}
+
+// DialContext implements ContextDialer.DialContext
+func (d SystemDialer) DialContext(ctx context.Context, network string,
+ host string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlySession, error) {
+ onlyhost, onlyport, err := net.SplitHostPort(host)
+ port, err := strconv.Atoi(onlyport)
+ if err != nil {
+ return nil, err
+ }
+ ip := net.ParseIP(onlyhost)
+ if ip == nil {
+ // TODO(kelmenhorst): write test for this error condition.
+ return nil, errors.New("quicdialer: invalid IP representation")
+ }
+ udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
+ var pconn net.PacketConn = udpConn
+ if d.Saver != nil {
+ pconn = saverUDPConn{UDPConn: udpConn, saver: d.Saver}
+ }
+ udpAddr := &net.UDPAddr{IP: ip, Port: port, Zone: ""}
+ return quic.DialEarlyContext(ctx, pconn, udpAddr, host, tlsCfg, cfg)
+
+}
+
+type saverUDPConn struct {
+ *net.UDPConn
+ saver *trace.Saver
+}
+
+func (c saverUDPConn) WriteTo(p []byte, addr net.Addr) (int, error) {
+ start := time.Now()
+ count, err := c.UDPConn.WriteTo(p, addr)
+ stop := time.Now()
+ c.saver.Write(trace.Event{
+ Address: addr.String(),
+ Data: p[:count],
+ Duration: stop.Sub(start),
+ Err: err,
+ NumBytes: count,
+ Name: errorx.WriteToOperation,
+ Time: stop,
+ })
+ return count, err
+}
+
+func (c saverUDPConn) ReadMsgUDP(b, oob []byte) (int, int, int, *net.UDPAddr, error) {
+ start := time.Now()
+ n, oobn, flags, addr, err := c.UDPConn.ReadMsgUDP(b, oob)
+ stop := time.Now()
+ var data []byte
+ if n > 0 {
+ data = b[:n]
+ }
+ c.saver.Write(trace.Event{
+ Address: addr.String(),
+ Data: data,
+ Duration: stop.Sub(start),
+ Err: err,
+ NumBytes: n,
+ Name: errorx.ReadFromOperation,
+ Time: stop,
+ })
+ return n, oobn, flags, addr, err
+}
diff --git a/internal/engine/netx/quicdialer/system_test.go b/internal/engine/netx/quicdialer/system_test.go
new file mode 100644
index 0000000..24efa8c
--- /dev/null
+++ b/internal/engine/netx/quicdialer/system_test.go
@@ -0,0 +1,75 @@
+package quicdialer_test
+
+import (
+ "context"
+ "crypto/tls"
+ "testing"
+
+ "github.com/lucas-clemente/quic-go"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/quicdialer"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/trace"
+)
+
+func TestSystemDialerInvalidIPFailure(t *testing.T) {
+ tlsConf := &tls.Config{
+ NextProtos: []string{"h3-29"},
+ ServerName: "www.google.com",
+ }
+ saver := &trace.Saver{}
+ systemdialer := quicdialer.SystemDialer{
+ Saver: saver,
+ }
+ sess, err := systemdialer.DialContext(context.Background(), "udp", "a.b.c.d:0", tlsConf, &quic.Config{})
+ if err == nil {
+ t.Fatal("expected an error here")
+ }
+ if sess != nil {
+ t.Fatal("expected nil sess here")
+ }
+ if err.Error() != "quicdialer: invalid IP representation" {
+ t.Fatal("expected another error here")
+ }
+}
+
+func TestSystemDialerSuccessWithReadWrite(t *testing.T) {
+ // This is the most common use case for collecting reads, writes
+ tlsConf := &tls.Config{
+ NextProtos: []string{"h3-29"},
+ ServerName: "www.google.com",
+ }
+ saver := &trace.Saver{}
+ systemdialer := quicdialer.SystemDialer{Saver: saver}
+ _, err := systemdialer.DialContext(context.Background(), "udp",
+ "216.58.212.164:443", tlsConf, &quic.Config{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ ev := saver.Read()
+ if len(ev) < 2 {
+ t.Fatal("unexpected number of events")
+ }
+ last := len(ev) - 1
+ for idx := 1; idx < last; idx++ {
+ if ev[idx].Data == nil {
+ t.Fatal("unexpected Data")
+ }
+ if ev[idx].Duration <= 0 {
+ t.Fatal("unexpected Duration")
+ }
+ if ev[idx].Err != nil {
+ t.Fatal("unexpected Err")
+ }
+ if ev[idx].NumBytes <= 0 {
+ t.Fatal("unexpected NumBytes")
+ }
+ switch ev[idx].Name {
+ case errorx.ReadFromOperation, errorx.WriteToOperation:
+ default:
+ t.Fatal("unexpected Name")
+ }
+ if ev[idx].Time.Before(ev[idx-1].Time) {
+ t.Fatal("unexpected Time")
+ }
+ }
+}
diff --git a/internal/engine/netx/resolver/address.go b/internal/engine/netx/resolver/address.go
new file mode 100644
index 0000000..c6cb4a5
--- /dev/null
+++ b/internal/engine/netx/resolver/address.go
@@ -0,0 +1,22 @@
+package resolver
+
+import (
+ "context"
+ "net"
+)
+
+// AddressResolver is a resolver that knows how to correctly
+// resolve IP addresses to themselves.
+type AddressResolver struct {
+ Resolver
+}
+
+// LookupHost implements Resolver.LookupHost
+func (r AddressResolver) LookupHost(ctx context.Context, hostname string) ([]string, error) {
+ if net.ParseIP(hostname) != nil {
+ return []string{hostname}, nil
+ }
+ return r.Resolver.LookupHost(ctx, hostname)
+}
+
+var _ Resolver = AddressResolver{}
diff --git a/internal/engine/netx/resolver/address_test.go b/internal/engine/netx/resolver/address_test.go
new file mode 100644
index 0000000..9d3e14d
--- /dev/null
+++ b/internal/engine/netx/resolver/address_test.go
@@ -0,0 +1,36 @@
+package resolver_test
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver"
+)
+
+func TestAddressSuccess(t *testing.T) {
+ r := resolver.AddressResolver{}
+ addrs, err := r.LookupHost(context.Background(), "8.8.8.8")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(addrs) != 1 || addrs[0] != "8.8.8.8" {
+ t.Fatal("not the result we expected")
+ }
+}
+
+func TestAddressFailure(t *testing.T) {
+ expected := errors.New("mocked error")
+ r := resolver.AddressResolver{
+ Resolver: resolver.FakeResolver{
+ Err: expected,
+ },
+ }
+ addrs, err := r.LookupHost(context.Background(), "dns.google.com")
+ if !errors.Is(err, expected) {
+ t.Fatal("not the error we expected")
+ }
+ if addrs != nil {
+ t.Fatal("expected nil addrs")
+ }
+}
diff --git a/internal/engine/netx/resolver/bogon.go b/internal/engine/netx/resolver/bogon.go
new file mode 100644
index 0000000..7dddf32
--- /dev/null
+++ b/internal/engine/netx/resolver/bogon.go
@@ -0,0 +1,71 @@
+package resolver
+
+import (
+ "context"
+ "net"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/internal/runtimex"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx"
+)
+
+var privateIPBlocks []*net.IPNet
+
+func init() {
+ for _, cidr := range []string{
+ "0.0.0.0/8", // "This" network (however, Linux...)
+ "10.0.0.0/8", // RFC1918
+ "100.64.0.0/10", // Carrier grade NAT
+ "127.0.0.0/8", // IPv4 loopback
+ "169.254.0.0/16", // RFC3927 link-local
+ "172.16.0.0/12", // RFC1918
+ "192.168.0.0/16", // RFC1918
+ "224.0.0.0/4", // Multicast
+ "::1/128", // IPv6 loopback
+ "fe80::/10", // IPv6 link-local
+ "fc00::/7", // IPv6 unique local addr
+ } {
+ _, block, err := net.ParseCIDR(cidr)
+ runtimex.PanicOnError(err, "net.ParseCIDR failed")
+ privateIPBlocks = append(privateIPBlocks, block)
+ }
+}
+
+func isPrivate(ip net.IP) bool {
+ if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
+ return true
+ }
+ for _, block := range privateIPBlocks {
+ if block.Contains(ip) {
+ return true
+ }
+ }
+ return false
+}
+
+// IsBogon returns whether if an IP address is bogon. Passing to this
+// function a non-IP address causes it to return bogon.
+func IsBogon(address string) bool {
+ ip := net.ParseIP(address)
+ return ip == nil || isPrivate(ip)
+}
+
+// BogonResolver is a bogon aware resolver. When a bogon is encountered in
+// a reply, this resolver will return an error.
+type BogonResolver struct {
+ Resolver
+}
+
+// LookupHost implements Resolver.LookupHost
+func (r BogonResolver) LookupHost(ctx context.Context, hostname string) ([]string, error) {
+ addrs, err := r.Resolver.LookupHost(ctx, hostname)
+ for _, addr := range addrs {
+ if IsBogon(addr) == true {
+ // We need to return the addrs otherwise the caller cannot see/log/save
+ // the specific addresses that triggered our bogon filter
+ return addrs, errorx.ErrDNSBogon
+ }
+ }
+ return addrs, err
+}
+
+var _ Resolver = BogonResolver{}
diff --git a/internal/engine/netx/resolver/bogon_test.go b/internal/engine/netx/resolver/bogon_test.go
new file mode 100644
index 0000000..17aebfe
--- /dev/null
+++ b/internal/engine/netx/resolver/bogon_test.go
@@ -0,0 +1,52 @@
+package resolver_test
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver"
+)
+
+func TestResolverIsBogon(t *testing.T) {
+ if resolver.IsBogon("antani") != true {
+ t.Fatal("unexpected result")
+ }
+ if resolver.IsBogon("127.0.0.1") != true {
+ t.Fatal("unexpected result")
+ }
+ if resolver.IsBogon("1.1.1.1") != false {
+ t.Fatal("unexpected result")
+ }
+ if resolver.IsBogon("10.0.1.1") != true {
+ t.Fatal("unexpected result")
+ }
+}
+
+func TestBogonAwareResolverWithBogon(t *testing.T) {
+ r := resolver.BogonResolver{
+ Resolver: resolver.NewFakeResolverWithResult([]string{"127.0.0.1"}),
+ }
+ addrs, err := r.LookupHost(context.Background(), "dns.google.com")
+ if !errors.Is(err, errorx.ErrDNSBogon) {
+ t.Fatal("not the error we expected")
+ }
+ if len(addrs) != 1 || addrs[0] != "127.0.0.1" {
+ t.Fatal("expected to see address here")
+ }
+}
+
+func TestBogonAwareResolverWithoutBogon(t *testing.T) {
+ orig := []string{"8.8.8.8"}
+ r := resolver.BogonResolver{
+ Resolver: resolver.NewFakeResolverWithResult(orig),
+ }
+ addrs, err := r.LookupHost(context.Background(), "dns.google.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(addrs) != len(orig) || addrs[0] != orig[0] {
+ t.Fatal("not the error we expected")
+ }
+}
diff --git a/internal/engine/netx/resolver/cache.go b/internal/engine/netx/resolver/cache.go
new file mode 100644
index 0000000..86eae9b
--- /dev/null
+++ b/internal/engine/netx/resolver/cache.go
@@ -0,0 +1,47 @@
+package resolver
+
+import (
+ "context"
+ "sync"
+)
+
+// CacheResolver is a resolver that caches successful replies.
+type CacheResolver struct {
+ ReadOnly bool
+ Resolver
+ mu sync.Mutex
+ cache map[string][]string
+}
+
+// LookupHost implements Resolver.LookupHost
+func (r *CacheResolver) LookupHost(
+ ctx context.Context, hostname string) ([]string, error) {
+ if entry := r.Get(hostname); entry != nil {
+ return entry, nil
+ }
+ entry, err := r.Resolver.LookupHost(ctx, hostname)
+ if err != nil {
+ return nil, err
+ }
+ if r.ReadOnly == false {
+ r.Set(hostname, entry)
+ }
+ return entry, nil
+}
+
+// Get gets the currently configured entry for domain, or nil
+func (r *CacheResolver) Get(domain string) []string {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return r.cache[domain]
+}
+
+// Set allows to pre-populate the cache
+func (r *CacheResolver) Set(domain string, addresses []string) {
+ r.mu.Lock()
+ if r.cache == nil {
+ r.cache = make(map[string][]string)
+ }
+ r.cache[domain] = addresses
+ r.mu.Unlock()
+}
diff --git a/internal/engine/netx/resolver/cache_test.go b/internal/engine/netx/resolver/cache_test.go
new file mode 100644
index 0000000..05dea44
--- /dev/null
+++ b/internal/engine/netx/resolver/cache_test.go
@@ -0,0 +1,76 @@
+package resolver_test
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver"
+)
+
+func TestCacheFailure(t *testing.T) {
+ expected := errors.New("mocked error")
+ var r resolver.Resolver = resolver.FakeResolver{
+ Err: expected,
+ }
+ cache := &resolver.CacheResolver{Resolver: r}
+ addrs, err := cache.LookupHost(context.Background(), "www.google.com")
+ if !errors.Is(err, expected) {
+ t.Fatal("not the error we expected")
+ }
+ if addrs != nil {
+ t.Fatal("expected nil addrs here")
+ }
+ if cache.Get("www.google.com") != nil {
+ t.Fatal("expected empty cache here")
+ }
+}
+
+func TestCacheHitSuccess(t *testing.T) {
+ var r resolver.Resolver = resolver.FakeResolver{
+ Err: errors.New("mocked error"),
+ }
+ cache := &resolver.CacheResolver{Resolver: r}
+ cache.Set("dns.google.com", []string{"8.8.8.8"})
+ addrs, err := cache.LookupHost(context.Background(), "dns.google.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(addrs) != 1 || addrs[0] != "8.8.8.8" {
+ t.Fatal("not the result we expected")
+ }
+}
+
+func TestCacheMissSuccess(t *testing.T) {
+ var r resolver.Resolver = resolver.FakeResolver{
+ Result: []string{"8.8.8.8"},
+ }
+ cache := &resolver.CacheResolver{Resolver: r}
+ addrs, err := cache.LookupHost(context.Background(), "dns.google.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(addrs) != 1 || addrs[0] != "8.8.8.8" {
+ t.Fatal("not the result we expected")
+ }
+ if cache.Get("dns.google.com")[0] != "8.8.8.8" {
+ t.Fatal("expected full cache here")
+ }
+}
+
+func TestCacheReadonlySuccess(t *testing.T) {
+ var r resolver.Resolver = resolver.FakeResolver{
+ Result: []string{"8.8.8.8"},
+ }
+ cache := &resolver.CacheResolver{Resolver: r, ReadOnly: true}
+ addrs, err := cache.LookupHost(context.Background(), "dns.google.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(addrs) != 1 || addrs[0] != "8.8.8.8" {
+ t.Fatal("not the result we expected")
+ }
+ if cache.Get("dns.google.com") != nil {
+ t.Fatal("expected empty cache here")
+ }
+}
diff --git a/internal/engine/netx/resolver/chain.go b/internal/engine/netx/resolver/chain.go
new file mode 100644
index 0000000..def7534
--- /dev/null
+++ b/internal/engine/netx/resolver/chain.go
@@ -0,0 +1,33 @@
+package resolver
+
+import (
+ "context"
+)
+
+// ChainResolver is a chain resolver. The primary resolver is used first and, if that
+// fails, we then attempt with the secondary resolver.
+type ChainResolver struct {
+ Primary Resolver
+ Secondary Resolver
+}
+
+// LookupHost implements Resolver.LookupHost
+func (c ChainResolver) LookupHost(ctx context.Context, hostname string) ([]string, error) {
+ addrs, err := c.Primary.LookupHost(ctx, hostname)
+ if err != nil {
+ addrs, err = c.Secondary.LookupHost(ctx, hostname)
+ }
+ return addrs, err
+}
+
+// Network implements Resolver.Network
+func (c ChainResolver) Network() string {
+ return "chain"
+}
+
+// Address implements Resolver.Address
+func (c ChainResolver) Address() string {
+ return ""
+}
+
+var _ Resolver = ChainResolver{}
diff --git a/internal/engine/netx/resolver/chain_test.go b/internal/engine/netx/resolver/chain_test.go
new file mode 100644
index 0000000..e6aa1ae
--- /dev/null
+++ b/internal/engine/netx/resolver/chain_test.go
@@ -0,0 +1,28 @@
+package resolver_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver"
+)
+
+func TestChainLookupHost(t *testing.T) {
+ r := resolver.ChainResolver{
+ Primary: resolver.NewFakeResolverThatFails(),
+ Secondary: resolver.SystemResolver{},
+ }
+ if r.Address() != "" {
+ t.Fatal("invalid address")
+ }
+ if r.Network() != "chain" {
+ t.Fatal("invalid network")
+ }
+ addrs, err := r.LookupHost(context.Background(), "www.google.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if addrs == nil {
+ t.Fatal("expect non nil return value here")
+ }
+}
diff --git a/internal/engine/netx/resolver/decoder.go b/internal/engine/netx/resolver/decoder.go
new file mode 100644
index 0000000..fa101e2
--- /dev/null
+++ b/internal/engine/netx/resolver/decoder.go
@@ -0,0 +1,54 @@
+package resolver
+
+import (
+ "errors"
+
+ "github.com/miekg/dns"
+)
+
+// The Decoder decodes a DNS reply into A or AAAA entries. It will use the
+// provided qtype and only look for mathing entries. It will return error if
+// there are no entries for the requested qtype inside the reply.
+type Decoder interface {
+ Decode(qtype uint16, data []byte) ([]string, error)
+}
+
+// MiekgDecoder uses github.com/miekg/dns to implement the Decoder.
+type MiekgDecoder struct{}
+
+// Decode implements Decoder.Decode.
+func (d MiekgDecoder) Decode(qtype uint16, data []byte) ([]string, error) {
+ reply := new(dns.Msg)
+ if err := reply.Unpack(data); err != nil {
+ return nil, err
+ }
+ // TODO(bassosimone): map more errors to net.DNSError names
+ switch reply.Rcode {
+ case dns.RcodeSuccess:
+ case dns.RcodeNameError:
+ return nil, errors.New("ooniresolver: no such host")
+ default:
+ return nil, errors.New("ooniresolver: query failed")
+ }
+ var addrs []string
+ for _, answer := range reply.Answer {
+ switch qtype {
+ case dns.TypeA:
+ if rra, ok := answer.(*dns.A); ok {
+ ip := rra.A
+ addrs = append(addrs, ip.String())
+ }
+ case dns.TypeAAAA:
+ if rra, ok := answer.(*dns.AAAA); ok {
+ ip := rra.AAAA
+ addrs = append(addrs, ip.String())
+ }
+ }
+ }
+ if len(addrs) <= 0 {
+ return nil, errors.New("ooniresolver: no response returned")
+ }
+ return addrs, nil
+}
+
+var _ Decoder = MiekgDecoder{}
diff --git a/internal/engine/netx/resolver/decoder_test.go b/internal/engine/netx/resolver/decoder_test.go
new file mode 100644
index 0000000..d5982fb
--- /dev/null
+++ b/internal/engine/netx/resolver/decoder_test.go
@@ -0,0 +1,113 @@
+package resolver_test
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/miekg/dns"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver"
+)
+
+func TestDecoderUnpackError(t *testing.T) {
+ d := resolver.MiekgDecoder{}
+ data, err := d.Decode(dns.TypeA, nil)
+ if err == nil {
+ t.Fatal("expected an error here")
+ }
+ if data != nil {
+ t.Fatal("expected nil data here")
+ }
+}
+
+func TestDecoderNXDOMAIN(t *testing.T) {
+ d := resolver.MiekgDecoder{}
+ data, err := d.Decode(dns.TypeA, resolver.GenReplyError(t, dns.RcodeNameError))
+ if err == nil || !strings.HasSuffix(err.Error(), "no such host") {
+ t.Fatal("not the error we expected")
+ }
+ if data != nil {
+ t.Fatal("expected nil data here")
+ }
+}
+
+func TestDecoderOtherError(t *testing.T) {
+ d := resolver.MiekgDecoder{}
+ data, err := d.Decode(dns.TypeA, resolver.GenReplyError(t, dns.RcodeRefused))
+ if err == nil || !strings.HasSuffix(err.Error(), "query failed") {
+ t.Fatal("not the error we expected")
+ }
+ if data != nil {
+ t.Fatal("expected nil data here")
+ }
+}
+
+func TestDecoderNoAddress(t *testing.T) {
+ d := resolver.MiekgDecoder{}
+ data, err := d.Decode(dns.TypeA, resolver.GenReplySuccess(t, dns.TypeA))
+ if err == nil || !strings.HasSuffix(err.Error(), "no response returned") {
+ t.Fatal("not the error we expected")
+ }
+ if data != nil {
+ t.Fatal("expected nil data here")
+ }
+}
+
+func TestDecoderDecodeA(t *testing.T) {
+ d := resolver.MiekgDecoder{}
+ data, err := d.Decode(
+ dns.TypeA, resolver.GenReplySuccess(t, dns.TypeA, "1.1.1.1", "8.8.8.8"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(data) != 2 {
+ t.Fatal("expected two entries here")
+ }
+ if data[0] != "1.1.1.1" {
+ t.Fatal("invalid first IPv4 entry")
+ }
+ if data[1] != "8.8.8.8" {
+ t.Fatal("invalid second IPv4 entry")
+ }
+}
+
+func TestDecoderDecodeAAAA(t *testing.T) {
+ d := resolver.MiekgDecoder{}
+ data, err := d.Decode(
+ dns.TypeAAAA, resolver.GenReplySuccess(t, dns.TypeAAAA, "::1", "fe80::1"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(data) != 2 {
+ t.Fatal("expected two entries here")
+ }
+ if data[0] != "::1" {
+ t.Fatal("invalid first IPv6 entry")
+ }
+ if data[1] != "fe80::1" {
+ t.Fatal("invalid second IPv6 entry")
+ }
+}
+
+func TestDecoderUnexpectedAReply(t *testing.T) {
+ d := resolver.MiekgDecoder{}
+ data, err := d.Decode(
+ dns.TypeA, resolver.GenReplySuccess(t, dns.TypeAAAA, "::1", "fe80::1"))
+ if err == nil || !strings.HasSuffix(err.Error(), "no response returned") {
+ t.Fatal("not the error we expected")
+ }
+ if data != nil {
+ t.Fatal("expected nil data here")
+ }
+}
+
+func TestDecoderUnexpectedAAAAReply(t *testing.T) {
+ d := resolver.MiekgDecoder{}
+ data, err := d.Decode(
+ dns.TypeAAAA, resolver.GenReplySuccess(t, dns.TypeA, "1.1.1.1", "8.8.4.4."))
+ if err == nil || !strings.HasSuffix(err.Error(), "no response returned") {
+ t.Fatal("not the error we expected")
+ }
+ if data != nil {
+ t.Fatal("expected nil data here")
+ }
+}
diff --git a/internal/engine/netx/resolver/dnsoverhttps.go b/internal/engine/netx/resolver/dnsoverhttps.go
new file mode 100644
index 0000000..50f10bc
--- /dev/null
+++ b/internal/engine/netx/resolver/dnsoverhttps.go
@@ -0,0 +1,77 @@
+package resolver
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io/ioutil"
+ "net/http"
+ "time"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/internal/httpheader"
+)
+
+// DNSOverHTTPS is a DNS over HTTPS RoundTripper. Requests are submitted over
+// an HTTP/HTTPS channel provided by URL using the Do function.
+type DNSOverHTTPS struct {
+ Do func(req *http.Request) (*http.Response, error)
+ URL string
+ HostOverride string
+}
+
+// NewDNSOverHTTPS creates a new DNSOverHTTP instance from the
+// specified http.Client and URL, as a convenience.
+func NewDNSOverHTTPS(client *http.Client, URL string) DNSOverHTTPS {
+ return NewDNSOverHTTPSWithHostOverride(client, URL, "")
+}
+
+// NewDNSOverHTTPSWithHostOverride is like NewDNSOverHTTPS except that
+// it's creating a resolver where we use the specified host.
+func NewDNSOverHTTPSWithHostOverride(client *http.Client, URL, hostOverride string) DNSOverHTTPS {
+ return DNSOverHTTPS{Do: client.Do, URL: URL, HostOverride: hostOverride}
+}
+
+// RoundTrip implements RoundTripper.RoundTrip.
+func (t DNSOverHTTPS) RoundTrip(ctx context.Context, query []byte) ([]byte, error) {
+ ctx, cancel := context.WithTimeout(ctx, 45*time.Second)
+ defer cancel()
+ req, err := http.NewRequest("POST", t.URL, bytes.NewReader(query))
+ if err != nil {
+ return nil, err
+ }
+ req.Host = t.HostOverride
+ req.Header.Set("user-agent", httpheader.UserAgent())
+ req.Header.Set("content-type", "application/dns-message")
+ var resp *http.Response
+ resp, err = t.Do(req.WithContext(ctx))
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 200 {
+ // TODO(bassosimone): we should map the status code to a
+ // proper Error in the DNS context.
+ return nil, errors.New("doh: server returned error")
+ }
+ if resp.Header.Get("content-type") != "application/dns-message" {
+ return nil, errors.New("doh: invalid content-type")
+ }
+ return ioutil.ReadAll(resp.Body)
+}
+
+// RequiresPadding returns true for DoH according to RFC8467
+func (t DNSOverHTTPS) RequiresPadding() bool {
+ return true
+}
+
+// Network returns the transport network (e.g., doh, dot)
+func (t DNSOverHTTPS) Network() string {
+ return "doh"
+}
+
+// Address returns the upstream server address.
+func (t DNSOverHTTPS) Address() string {
+ return t.URL
+}
+
+var _ RoundTripper = DNSOverHTTPS{}
diff --git a/internal/engine/netx/resolver/dnsoverhttps_test.go b/internal/engine/netx/resolver/dnsoverhttps_test.go
new file mode 100644
index 0000000..f02727f
--- /dev/null
+++ b/internal/engine/netx/resolver/dnsoverhttps_test.go
@@ -0,0 +1,165 @@
+package resolver_test
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io/ioutil"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/internal/httpheader"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver"
+)
+
+func TestDNSOverHTTPSNewRequestFailure(t *testing.T) {
+ const invalidURL = "\t"
+ txp := resolver.NewDNSOverHTTPS(http.DefaultClient, invalidURL)
+ data, err := txp.RoundTrip(context.Background(), nil)
+ if err == nil || !strings.HasSuffix(err.Error(), "invalid control character in URL") {
+ t.Fatal("expected an error here")
+ }
+ if data != nil {
+ t.Fatal("expected no response here")
+ }
+}
+
+func TestDNSOverHTTPSClientDoFailure(t *testing.T) {
+ expected := errors.New("mocked error")
+ txp := resolver.DNSOverHTTPS{
+ Do: func(*http.Request) (*http.Response, error) {
+ return nil, expected
+ },
+ URL: "https://cloudflare-dns.com/dns-query",
+ }
+ data, err := txp.RoundTrip(context.Background(), nil)
+ if !errors.Is(err, expected) {
+ t.Fatal("expected an error here")
+ }
+ if data != nil {
+ t.Fatal("expected no response here")
+ }
+}
+
+func TestDNSOverHTTPSHTTPFailure(t *testing.T) {
+ txp := resolver.DNSOverHTTPS{
+ Do: func(*http.Request) (*http.Response, error) {
+ return &http.Response{
+ StatusCode: 500,
+ Body: ioutil.NopCloser(strings.NewReader("")),
+ }, nil
+ },
+ URL: "https://cloudflare-dns.com/dns-query",
+ }
+ data, err := txp.RoundTrip(context.Background(), nil)
+ if err == nil || err.Error() != "doh: server returned error" {
+ t.Fatal("expected an error here")
+ }
+ if data != nil {
+ t.Fatal("expected no response here")
+ }
+}
+
+func TestDNSOverHTTPSMissingContentType(t *testing.T) {
+ txp := resolver.DNSOverHTTPS{
+ Do: func(*http.Request) (*http.Response, error) {
+ return &http.Response{
+ StatusCode: 200,
+ Body: ioutil.NopCloser(strings.NewReader("")),
+ }, nil
+ },
+ URL: "https://cloudflare-dns.com/dns-query",
+ }
+ data, err := txp.RoundTrip(context.Background(), nil)
+ if err == nil || err.Error() != "doh: invalid content-type" {
+ t.Fatal("expected an error here")
+ }
+ if data != nil {
+ t.Fatal("expected no response here")
+ }
+}
+
+func TestDNSOverHTTPSSuccess(t *testing.T) {
+ body := []byte("AAA")
+ txp := resolver.DNSOverHTTPS{
+ Do: func(*http.Request) (*http.Response, error) {
+ return &http.Response{
+ StatusCode: 200,
+ Body: ioutil.NopCloser(bytes.NewReader(body)),
+ Header: http.Header{
+ "Content-Type": []string{"application/dns-message"},
+ },
+ }, nil
+ },
+ URL: "https://cloudflare-dns.com/dns-query",
+ }
+ data, err := txp.RoundTrip(context.Background(), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(data, body) {
+ t.Fatal("not the response we expected")
+ }
+}
+
+func TestDNSOverHTTPTransportOK(t *testing.T) {
+ const queryURL = "https://cloudflare-dns.com/dns-query"
+ txp := resolver.NewDNSOverHTTPS(http.DefaultClient, queryURL)
+ if txp.Network() != "doh" {
+ t.Fatal("invalid network")
+ }
+ if txp.RequiresPadding() != true {
+ t.Fatal("should require padding")
+ }
+ if txp.Address() != queryURL {
+ t.Fatal("invalid address")
+ }
+}
+
+func TestDNSOverHTTPSClientSetsUserAgent(t *testing.T) {
+ expected := errors.New("mocked error")
+ var correct bool
+ txp := resolver.DNSOverHTTPS{
+ Do: func(req *http.Request) (*http.Response, error) {
+ correct = req.Header.Get("User-Agent") == httpheader.UserAgent()
+ return nil, expected
+ },
+ URL: "https://cloudflare-dns.com/dns-query",
+ }
+ data, err := txp.RoundTrip(context.Background(), nil)
+ if !errors.Is(err, expected) {
+ t.Fatal("expected an error here")
+ }
+ if data != nil {
+ t.Fatal("expected no response here")
+ }
+ if !correct {
+ t.Fatal("did not see correct user agent")
+ }
+}
+
+func TestDNSOverHTTPSHostOverride(t *testing.T) {
+ var correct bool
+ expected := errors.New("mocked error")
+
+ hostOverride := "test.com"
+ txp := resolver.DNSOverHTTPS{
+ Do: func(req *http.Request) (*http.Response, error) {
+ correct = req.Host == hostOverride
+ return nil, expected
+ },
+ URL: "https://cloudflare-dns.com/dns-query",
+ HostOverride: hostOverride,
+ }
+ data, err := txp.RoundTrip(context.Background(), nil)
+ if !errors.Is(err, expected) {
+ t.Fatal("expected an error here")
+ }
+ if data != nil {
+ t.Fatal("expected no response here")
+ }
+ if !correct {
+ t.Fatal("did not see correct host override")
+ }
+}
diff --git a/internal/engine/netx/resolver/dnsovertcp.go b/internal/engine/netx/resolver/dnsovertcp.go
new file mode 100644
index 0000000..0c90f36
--- /dev/null
+++ b/internal/engine/netx/resolver/dnsovertcp.go
@@ -0,0 +1,97 @@
+package resolver
+
+import (
+ "context"
+ "errors"
+ "io"
+ "math"
+ "net"
+ "time"
+)
+
+// DialContextFunc is a generic function for dialing a connection.
+type DialContextFunc func(context.Context, string, string) (net.Conn, error)
+
+// DNSOverTCP is a DNS over TCP/TLS RoundTripper. Use NewDNSOverTCP
+// and NewDNSOverTLS to create specific instances that use plaintext
+// queries or encrypted queries over TLS.
+//
+// As a known bug, this implementation always creates a new connection
+// for each incoming query, thus increasing the response delay.
+type DNSOverTCP struct {
+ dial DialContextFunc
+ address string
+ network string
+ requiresPadding bool
+}
+
+// NewDNSOverTCP creates a new DNSOverTCP transport.
+func NewDNSOverTCP(dial DialContextFunc, address string) DNSOverTCP {
+ return DNSOverTCP{
+ dial: dial,
+ address: address,
+ network: "tcp",
+ requiresPadding: false,
+ }
+}
+
+// NewDNSOverTLS creates a new DNSOverTLS transport.
+func NewDNSOverTLS(dial DialContextFunc, address string) DNSOverTCP {
+ return DNSOverTCP{
+ dial: dial,
+ address: address,
+ network: "dot",
+ requiresPadding: true,
+ }
+}
+
+// RoundTrip implements RoundTripper.RoundTrip.
+func (t DNSOverTCP) RoundTrip(ctx context.Context, query []byte) ([]byte, error) {
+ if len(query) > math.MaxUint16 {
+ return nil, errors.New("query too long")
+ }
+ conn, err := t.dial(ctx, "tcp", t.address)
+ if err != nil {
+ return nil, err
+ }
+ defer conn.Close()
+ if err = conn.SetDeadline(time.Now().Add(10 * time.Second)); err != nil {
+ return nil, err
+ }
+ // Write request
+ buf := []byte{byte(len(query) >> 8)}
+ buf = append(buf, byte(len(query)))
+ buf = append(buf, query...)
+ if _, err = conn.Write(buf); err != nil {
+ return nil, err
+ }
+ // Read response
+ header := make([]byte, 2)
+ if _, err = io.ReadFull(conn, header); err != nil {
+ return nil, err
+ }
+ length := int(header[0])<<8 | int(header[1])
+ reply := make([]byte, length)
+ if _, err = io.ReadFull(conn, reply); err != nil {
+ return nil, err
+ }
+ return reply, nil
+}
+
+// RequiresPadding returns true for DoT and false for TCP
+// according to RFC8467.
+func (t DNSOverTCP) RequiresPadding() bool {
+ return t.requiresPadding
+}
+
+// Network returns the transport network (e.g., doh, dot)
+func (t DNSOverTCP) Network() string {
+ return t.network
+}
+
+// Address returns the upstream server address.
+func (t DNSOverTCP) Address() string {
+ return t.address
+}
+
+var _ RoundTripper = DNSOverTCP{}
diff --git a/internal/engine/netx/resolver/dnsovertcp_test.go b/internal/engine/netx/resolver/dnsovertcp_test.go
new file mode 100644
index 0000000..c3d035f
--- /dev/null
+++ b/internal/engine/netx/resolver/dnsovertcp_test.go
@@ -0,0 +1,146 @@
+package resolver_test
+
+import (
+ "context"
+ "errors"
+ "net"
+ "testing"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver"
+)
+
+func TestDNSOverTCPTransportQueryTooLarge(t *testing.T) {
+ const address = "9.9.9.9:53"
+ txp := resolver.NewDNSOverTCP(new(net.Dialer).DialContext, address)
+ reply, err := txp.RoundTrip(context.Background(), make([]byte, 1<<18))
+ if err == nil {
+ t.Fatal("expected an error here")
+ }
+ if reply != nil {
+ t.Fatal("expected nil reply here")
+ }
+}
+
+func TestDNSOverTCPTransportDialFailure(t *testing.T) {
+ const address = "9.9.9.9:53"
+ mocked := errors.New("mocked error")
+ fakedialer := resolver.FakeDialer{Err: mocked}
+ txp := resolver.NewDNSOverTCP(fakedialer.DialContext, address)
+ reply, err := txp.RoundTrip(context.Background(), make([]byte, 1<<11))
+ if !errors.Is(err, mocked) {
+ t.Fatal("not the error we expected")
+ }
+ if reply != nil {
+ t.Fatal("expected nil reply here")
+ }
+}
+
+func TestDNSOverTCPTransportSetDealineFailure(t *testing.T) {
+ const address = "9.9.9.9:53"
+ mocked := errors.New("mocked error")
+ fakedialer := resolver.FakeDialer{Conn: &resolver.FakeConn{
+ SetDeadlineError: mocked,
+ }}
+ txp := resolver.NewDNSOverTCP(fakedialer.DialContext, address)
+ reply, err := txp.RoundTrip(context.Background(), make([]byte, 1<<11))
+ if !errors.Is(err, mocked) {
+ t.Fatal("not the error we expected")
+ }
+ if reply != nil {
+ t.Fatal("expected nil reply here")
+ }
+}
+
+func TestDNSOverTCPTransportWriteFailure(t *testing.T) {
+ const address = "9.9.9.9:53"
+ mocked := errors.New("mocked error")
+ fakedialer := resolver.FakeDialer{Conn: &resolver.FakeConn{
+ WriteError: mocked,
+ }}
+ txp := resolver.NewDNSOverTCP(fakedialer.DialContext, address)
+ reply, err := txp.RoundTrip(context.Background(), make([]byte, 1<<11))
+ if !errors.Is(err, mocked) {
+ t.Fatal("not the error we expected")
+ }
+ if reply != nil {
+ t.Fatal("expected nil reply here")
+ }
+}
+
+func TestDNSOverTCPTransportReadFailure(t *testing.T) {
+ const address = "9.9.9.9:53"
+ mocked := errors.New("mocked error")
+ fakedialer := resolver.FakeDialer{Conn: &resolver.FakeConn{
+ ReadError: mocked,
+ }}
+ txp := resolver.NewDNSOverTCP(fakedialer.DialContext, address)
+ reply, err := txp.RoundTrip(context.Background(), make([]byte, 1<<11))
+ if !errors.Is(err, mocked) {
+ t.Fatal("not the error we expected")
+ }
+ if reply != nil {
+ t.Fatal("expected nil reply here")
+ }
+}
+
+func TestDNSOverTCPTransportSecondReadFailure(t *testing.T) {
+ const address = "9.9.9.9:53"
+ mocked := errors.New("mocked error")
+ fakedialer := resolver.FakeDialer{Conn: &resolver.FakeConn{
+ ReadError: mocked,
+ ReadData: []byte{byte(0), byte(2)},
+ }}
+ txp := resolver.NewDNSOverTCP(fakedialer.DialContext, address)
+ reply, err := txp.RoundTrip(context.Background(), make([]byte, 1<<11))
+ if !errors.Is(err, mocked) {
+ t.Fatal("not the error we expected")
+ }
+ if reply != nil {
+ t.Fatal("expected nil reply here")
+ }
+}
+
+func TestDNSOverTCPTransportAllGood(t *testing.T) {
+ const address = "9.9.9.9:53"
+ mocked := errors.New("mocked error")
+ fakedialer := resolver.FakeDialer{Conn: &resolver.FakeConn{
+ ReadError: mocked,
+ ReadData: []byte{byte(0), byte(1), byte(1)},
+ }}
+ txp := resolver.NewDNSOverTCP(fakedialer.DialContext, address)
+ reply, err := txp.RoundTrip(context.Background(), make([]byte, 1<<11))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(reply) != 1 || reply[0] != 1 {
+ t.Fatal("not the response we expected")
+ }
+}
+
+func TestDNSOverTCPTransportOK(t *testing.T) {
+ const address = "9.9.9.9:53"
+ txp := resolver.NewDNSOverTCP(new(net.Dialer).DialContext, address)
+ if txp.RequiresPadding() != false {
+ t.Fatal("invalid RequiresPadding")
+ }
+ if txp.Network() != "tcp" {
+ t.Fatal("invalid Network")
+ }
+ if txp.Address() != address {
+ t.Fatal("invalid Address")
+ }
+}
+
+func TestDNSOverTLSTransportOK(t *testing.T) {
+ const address = "9.9.9.9:853"
+ txp := resolver.NewDNSOverTLS(resolver.DialTLSContext, address)
+ if txp.RequiresPadding() != true {
+ t.Fatal("invalid RequiresPadding")
+ }
+ if txp.Network() != "dot" {
+ t.Fatal("invalid Network")
+ }
+ if txp.Address() != address {
+ t.Fatal("invalid Address")
+ }
+}
diff --git a/internal/engine/netx/resolver/dnsoverudp.go b/internal/engine/netx/resolver/dnsoverudp.go
new file mode 100644
index 0000000..bb4fb3d
--- /dev/null
+++ b/internal/engine/netx/resolver/dnsoverudp.go
@@ -0,0 +1,64 @@
+package resolver
+
+import (
+ "context"
+ "net"
+ "time"
+)
+
+// Dialer is the network dialer interface assumed by this package.
+type Dialer interface {
+ DialContext(ctx context.Context, network, address string) (net.Conn, error)
+}
+
+// DNSOverUDP is a DNS over UDP RoundTripper.
+type DNSOverUDP struct {
+ dialer Dialer
+ address string
+}
+
+// NewDNSOverUDP creates a DNSOverUDP instance.
+func NewDNSOverUDP(dialer Dialer, address string) DNSOverUDP {
+ return DNSOverUDP{dialer: dialer, address: address}
+}
+
+// RoundTrip implements RoundTripper.RoundTrip.
+func (t DNSOverUDP) RoundTrip(ctx context.Context, query []byte) ([]byte, error) {
+ conn, err := t.dialer.DialContext(ctx, "udp", t.address)
+ if err != nil {
+ return nil, err
+ }
+ defer conn.Close()
+ // Use five seconds timeout like Bionic does. See
+ // https://labs.ripe.net/Members/baptiste_jonglez_1/persistent-dns-connections-for-reliability-and-performance
+ if err = conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil {
+ return nil, err
+ }
+ if _, err = conn.Write(query); err != nil {
+ return nil, err
+ }
+ reply := make([]byte, 1<<17)
+ var n int
+ n, err = conn.Read(reply)
+ if err != nil {
+ return nil, err
+ }
+ return reply[:n], nil
+}
+
+// RequiresPadding returns false for UDP according to RFC8467
+func (t DNSOverUDP) RequiresPadding() bool {
+ return false
+}
+
+// Network returns the transport network (e.g., doh, dot)
+func (t DNSOverUDP) Network() string {
+ return "udp"
+}
+
+// Address returns the upstream server address.
+func (t DNSOverUDP) Address() string {
+ return t.address
+}
+
+var _ RoundTripper = DNSOverUDP{}
diff --git a/internal/engine/netx/resolver/dnsoverudp_test.go b/internal/engine/netx/resolver/dnsoverudp_test.go
new file mode 100644
index 0000000..9c9a4ce
--- /dev/null
+++ b/internal/engine/netx/resolver/dnsoverudp_test.go
@@ -0,0 +1,107 @@
+package resolver_test
+
+import (
+ "context"
+ "errors"
+ "net"
+ "testing"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver"
+)
+
+func TestDNSOverUDPDialFailure(t *testing.T) {
+ mocked := errors.New("mocked error")
+ const address = "9.9.9.9:53"
+ txp := resolver.NewDNSOverUDP(resolver.FakeDialer{Err: mocked}, address)
+ data, err := txp.RoundTrip(context.Background(), nil)
+ if !errors.Is(err, mocked) {
+ t.Fatal("not the error we expected")
+ }
+ if data != nil {
+ t.Fatal("expected no response here")
+ }
+}
+
+func TestDNSOverUDPSetDeadlineError(t *testing.T) {
+ mocked := errors.New("mocked error")
+ txp := resolver.NewDNSOverUDP(
+ resolver.FakeDialer{
+ Conn: &resolver.FakeConn{
+ SetDeadlineError: mocked,
+ },
+ }, "9.9.9.9:53",
+ )
+ data, err := txp.RoundTrip(context.Background(), nil)
+ if !errors.Is(err, mocked) {
+ t.Fatal("not the error we expected")
+ }
+ if data != nil {
+ t.Fatal("expected no response here")
+ }
+}
+
+func TestDNSOverUDPWriteFailure(t *testing.T) {
+ mocked := errors.New("mocked error")
+ txp := resolver.NewDNSOverUDP(
+ resolver.FakeDialer{
+ Conn: &resolver.FakeConn{
+ WriteError: mocked,
+ },
+ }, "9.9.9.9:53",
+ )
+ data, err := txp.RoundTrip(context.Background(), nil)
+ if !errors.Is(err, mocked) {
+ t.Fatal("not the error we expected")
+ }
+ if data != nil {
+ t.Fatal("expected no response here")
+ }
+}
+
+func TestDNSOverUDPReadFailure(t *testing.T) {
+ mocked := errors.New("mocked error")
+ txp := resolver.NewDNSOverUDP(
+ resolver.FakeDialer{
+ Conn: &resolver.FakeConn{
+ ReadError: mocked,
+ },
+ }, "9.9.9.9:53",
+ )
+ data, err := txp.RoundTrip(context.Background(), nil)
+ if !errors.Is(err, mocked) {
+ t.Fatal("not the error we expected")
+ }
+ if data != nil {
+ t.Fatal("expected no response here")
+ }
+}
+
+func TestDNSOverUDPReadSuccess(t *testing.T) {
+ const expected = 17
+ txp := resolver.NewDNSOverUDP(
+ resolver.FakeDialer{
+ Conn: &resolver.FakeConn{ReadData: make([]byte, 17)},
+ }, "9.9.9.9:53",
+ )
+ data, err := txp.RoundTrip(context.Background(), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(data) != expected {
+ t.Fatal("expected non nil data")
+ }
+}
+
+func TestDNSOverUDPTransportOK(t *testing.T) {
+ const address = "9.9.9.9:53"
+ txp := resolver.NewDNSOverUDP(&net.Dialer{}, address)
+ if txp.RequiresPadding() != false {
+ t.Fatal("invalid RequiresPadding")
+ }
+ if txp.Network() != "udp" {
+ t.Fatal("invalid Network")
+ }
+ if txp.Address() != address {
+ t.Fatal("invalid Address")
+ }
+}
diff --git a/internal/engine/netx/resolver/emitter.go b/internal/engine/netx/resolver/emitter.go
new file mode 100644
index 0000000..c4b095a
--- /dev/null
+++ b/internal/engine/netx/resolver/emitter.go
@@ -0,0 +1,89 @@
+package resolver
+
+import (
+ "context"
+ "time"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/dialid"
+ "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/modelx"
+ "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/transactionid"
+)
+
+// EmitterTransport is a RoundTripper that emits events when they occur.
+type EmitterTransport struct {
+ RoundTripper
+}
+
+// RoundTrip implements RoundTripper.RoundTrip
+func (txp EmitterTransport) RoundTrip(ctx context.Context, querydata []byte) ([]byte, error) {
+ root := modelx.ContextMeasurementRootOrDefault(ctx)
+ root.Handler.OnMeasurement(modelx.Measurement{
+ DNSQuery: &modelx.DNSQueryEvent{
+ Data: querydata,
+ DialID: dialid.ContextDialID(ctx),
+ DurationSinceBeginning: time.Now().Sub(root.Beginning),
+ },
+ })
+ replydata, err := txp.RoundTripper.RoundTrip(ctx, querydata)
+ if err != nil {
+ return nil, err
+ }
+ root.Handler.OnMeasurement(modelx.Measurement{
+ DNSReply: &modelx.DNSReplyEvent{
+ Data: replydata,
+ DialID: dialid.ContextDialID(ctx),
+ DurationSinceBeginning: time.Now().Sub(root.Beginning),
+ },
+ })
+ return replydata, nil
+}
+
+// EmitterResolver is a resolver that emits events
+type EmitterResolver struct {
+ Resolver
+}
+
+// LookupHost returns the IP addresses of a host
+func (r EmitterResolver) LookupHost(ctx context.Context, hostname string) ([]string, error) {
+ var (
+ network string
+ address string
+ )
+ type queryableResolver interface {
+ Transport() RoundTripper
+ }
+ if qr, ok := r.Resolver.(queryableResolver); ok {
+ txp := qr.Transport()
+ network, address = txp.Network(), txp.Address()
+ }
+ dialID := dialid.ContextDialID(ctx)
+ txID := transactionid.ContextTransactionID(ctx)
+ root := modelx.ContextMeasurementRootOrDefault(ctx)
+ root.Handler.OnMeasurement(modelx.Measurement{
+ ResolveStart: &modelx.ResolveStartEvent{
+ DialID: dialID,
+ DurationSinceBeginning: time.Now().Sub(root.Beginning),
+ Hostname: hostname,
+ TransactionID: txID,
+ TransportAddress: address,
+ TransportNetwork: network,
+ },
+ })
+ addrs, err := r.Resolver.LookupHost(ctx, hostname)
+ root.Handler.OnMeasurement(modelx.Measurement{
+ ResolveDone: &modelx.ResolveDoneEvent{
+ Addresses: addrs,
+ DialID: dialID,
+ DurationSinceBeginning: time.Now().Sub(root.Beginning),
+ Error: err,
+ Hostname: hostname,
+ TransactionID: txID,
+ TransportAddress: address,
+ TransportNetwork: network,
+ },
+ })
+ return addrs, err
+}
+
+var _ RoundTripper = EmitterTransport{}
+var _ Resolver = EmitterResolver{}
diff --git a/internal/engine/netx/resolver/emitter_test.go b/internal/engine/netx/resolver/emitter_test.go
new file mode 100644
index 0000000..6245d76
--- /dev/null
+++ b/internal/engine/netx/resolver/emitter_test.go
@@ -0,0 +1,220 @@
+package resolver_test
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/miekg/dns"
+ "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/dialid"
+ "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/handlers"
+ "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/modelx"
+ "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/transactionid"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver"
+)
+
+func TestEmitterTransportSuccess(t *testing.T) {
+ ctx := context.Background()
+ ctx = dialid.WithDialID(ctx)
+ handler := &handlers.SavingHandler{}
+ root := &modelx.MeasurementRoot{
+ Beginning: time.Now(),
+ Handler: handler,
+ }
+ ctx = modelx.WithMeasurementRoot(ctx, root)
+ txp := resolver.EmitterTransport{RoundTripper: resolver.FakeTransport{
+ Data: resolver.GenReplySuccess(t, dns.TypeA, "8.8.8.8"),
+ }}
+ e := resolver.MiekgEncoder{}
+ querydata, err := e.Encode("www.google.com", dns.TypeAAAA, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ replydata, err := txp.RoundTrip(ctx, querydata)
+ if err != nil {
+ t.Fatal(err)
+ }
+ events := handler.Read()
+ if len(events) != 2 {
+ t.Fatal("unexpected number of events")
+ }
+ if events[0].DNSQuery == nil {
+ t.Fatal("missing DNSQuery field")
+ }
+ if !bytes.Equal(events[0].DNSQuery.Data, querydata) {
+ t.Fatal("invalid query data")
+ }
+ if events[0].DNSQuery.DialID == 0 {
+ t.Fatal("invalid query DialID")
+ }
+ if events[0].DNSQuery.DurationSinceBeginning <= 0 {
+ t.Fatal("invalid duration since beginning")
+ }
+ if events[1].DNSReply == nil {
+ t.Fatal("missing DNSReply field")
+ }
+ if !bytes.Equal(events[1].DNSReply.Data, replydata) {
+ t.Fatal("missing reply data")
+ }
+ if events[1].DNSReply.DialID != 1 {
+ t.Fatal("invalid query DialID")
+ }
+ if events[1].DNSReply.DurationSinceBeginning <= 0 {
+ t.Fatal("invalid duration since beginning")
+ }
+}
+
+func TestEmitterTransportFailure(t *testing.T) {
+ ctx := context.Background()
+ ctx = dialid.WithDialID(ctx)
+ handler := &handlers.SavingHandler{}
+ root := &modelx.MeasurementRoot{
+ Beginning: time.Now(),
+ Handler: handler,
+ }
+ ctx = modelx.WithMeasurementRoot(ctx, root)
+ mocked := errors.New("mocked error")
+ txp := resolver.EmitterTransport{RoundTripper: resolver.FakeTransport{
+ Err: mocked,
+ }}
+ e := resolver.MiekgEncoder{}
+ querydata, err := e.Encode("www.google.com", dns.TypeAAAA, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ replydata, err := txp.RoundTrip(ctx, querydata)
+ if !errors.Is(err, mocked) {
+ t.Fatal("not the error we expected")
+ }
+ if replydata != nil {
+ t.Fatal("expected nil replydata")
+ }
+ events := handler.Read()
+ if len(events) != 1 {
+ t.Fatal("unexpected number of events")
+ }
+ if events[0].DNSQuery == nil {
+ t.Fatal("missing DNSQuery field")
+ }
+ if !bytes.Equal(events[0].DNSQuery.Data, querydata) {
+ t.Fatal("invalid query data")
+ }
+ if events[0].DNSQuery.DialID == 0 {
+ t.Fatal("invalid query DialID")
+ }
+ if events[0].DNSQuery.DurationSinceBeginning <= 0 {
+ t.Fatal("invalid duration since beginning")
+ }
+}
+
+func TestEmitterResolverFailure(t *testing.T) {
+ ctx := context.Background()
+ ctx = dialid.WithDialID(ctx)
+ ctx = transactionid.WithTransactionID(ctx)
+ handler := &handlers.SavingHandler{}
+ root := &modelx.MeasurementRoot{
+ Beginning: time.Now(),
+ Handler: handler,
+ }
+ ctx = modelx.WithMeasurementRoot(ctx, root)
+ r := resolver.EmitterResolver{Resolver: resolver.NewSerialResolver(
+ resolver.DNSOverHTTPS{
+ Do: func(req *http.Request) (*http.Response, error) {
+ return nil, io.EOF
+ },
+ URL: "https://dns.google.com/",
+ },
+ )}
+ replies, err := r.LookupHost(ctx, "www.google.com")
+ if !errors.Is(err, io.EOF) {
+ t.Fatal("not the error we expected")
+ }
+ if replies != nil {
+ t.Fatal("expected nil replies")
+ }
+ events := handler.Read()
+ if len(events) != 2 {
+ t.Fatal("unexpected number of events")
+ }
+ if events[0].ResolveStart == nil {
+ t.Fatal("missing ResolveStart field")
+ }
+ if events[0].ResolveStart.DialID == 0 {
+ t.Fatal("invalid DialID")
+ }
+ if events[0].ResolveStart.DurationSinceBeginning <= 0 {
+ t.Fatal("invalid duration since beginning")
+ }
+ if events[0].ResolveStart.Hostname != "www.google.com" {
+ t.Fatal("invalid Hostname")
+ }
+ if events[0].ResolveStart.TransactionID == 0 {
+ t.Fatal("invalid TransactionID")
+ }
+ if events[0].ResolveStart.TransportAddress != "https://dns.google.com/" {
+ t.Fatal("invalid TransportAddress")
+ }
+ if events[0].ResolveStart.TransportNetwork != "doh" {
+ t.Fatal("invalid TransportNetwork")
+ }
+ if events[1].ResolveDone == nil {
+ t.Fatal("missing ResolveDone field")
+ }
+ if events[1].ResolveDone.DialID == 0 {
+ t.Fatal("invalid DialID")
+ }
+ if events[1].ResolveDone.DurationSinceBeginning <= 0 {
+ t.Fatal("invalid duration since beginning")
+ }
+ if events[1].ResolveDone.Error != io.EOF {
+ t.Fatal("invalid Error")
+ }
+ if events[1].ResolveDone.Hostname != "www.google.com" {
+ t.Fatal("invalid Hostname")
+ }
+ if events[1].ResolveDone.TransactionID == 0 {
+ t.Fatal("invalid TransactionID")
+ }
+ if events[1].ResolveDone.TransportAddress != "https://dns.google.com/" {
+ t.Fatal("invalid TransportAddress")
+ }
+ if events[1].ResolveDone.TransportNetwork != "doh" {
+ t.Fatal("invalid TransportNetwork")
+ }
+}
+
+func TestEmitterResolverSuccess(t *testing.T) {
+ ctx := context.Background()
+ ctx = dialid.WithDialID(ctx)
+ ctx = transactionid.WithTransactionID(ctx)
+ handler := &handlers.SavingHandler{}
+ root := &modelx.MeasurementRoot{
+ Beginning: time.Now(),
+ Handler: handler,
+ }
+ ctx = modelx.WithMeasurementRoot(ctx, root)
+ r := resolver.EmitterResolver{Resolver: resolver.NewFakeResolverWithResult(
+ []string{"8.8.8.8"},
+ )}
+ replies, err := r.LookupHost(ctx, "dns.google.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(replies) != 1 {
+ t.Fatal("expected a single replies")
+ }
+ events := handler.Read()
+ if len(events) != 2 {
+ t.Fatal("unexpected number of events")
+ }
+ if events[1].ResolveDone == nil {
+ t.Fatal("missing ResolveDone field")
+ }
+ if events[1].ResolveDone.Addresses[0] != "8.8.8.8" {
+ t.Fatal("invalid Addresses")
+ }
+}
diff --git a/internal/engine/netx/resolver/encoder.go b/internal/engine/netx/resolver/encoder.go
new file mode 100644
index 0000000..aca7bc7
--- /dev/null
+++ b/internal/engine/netx/resolver/encoder.go
@@ -0,0 +1,52 @@
+package resolver
+
+import "github.com/miekg/dns"
+
+// The Encoder encodes DNS queries to bytes
+type Encoder interface {
+ Encode(domain string, qtype uint16, padding bool) ([]byte, error)
+}
+
+// MiekgEncoder uses github.com/miekg/dns to implement the Encoder.
+type MiekgEncoder struct{}
+
+const (
+ // PaddingDesiredBlockSize is the size that the padded query should be multiple of
+ PaddingDesiredBlockSize = 128
+
+ // EDNS0MaxResponseSize is the maximum response size for EDNS0
+ EDNS0MaxResponseSize = 4096
+
+ // DNSSECEnabled turns on support for DNSSEC when using EDNS0
+ DNSSECEnabled = true
+)
+
+// Encode implements Encoder.Encode
+func (e MiekgEncoder) Encode(domain string, qtype uint16, padding bool) ([]byte, error) {
+ question := dns.Question{
+ Name: dns.Fqdn(domain),
+ Qtype: qtype,
+ Qclass: dns.ClassINET,
+ }
+ query := new(dns.Msg)
+ query.Id = dns.Id()
+ query.RecursionDesired = true
+ query.Question = make([]dns.Question, 1)
+ query.Question[0] = question
+ if padding {
+ query.SetEdns0(EDNS0MaxResponseSize, DNSSECEnabled)
+ // Clients SHOULD pad queries to the closest multiple of
+ // 128 octets RFC8467#section-4.1. We inflate the query
+ // length by the size of the option (i.e. 4 octets). The
+ // cast to uint is necessary to make the modulus operation
+ // work as intended when the desiredBlockSize is smaller
+ // than (query.Len()+4) ¯\_(ツ)_/¯.
+ remainder := (PaddingDesiredBlockSize - uint(query.Len()+4)) % PaddingDesiredBlockSize
+ opt := new(dns.EDNS0_PADDING)
+ opt.Padding = make([]byte, remainder)
+ query.IsEdns0().Option = append(query.IsEdns0().Option, opt)
+ }
+ return query.Pack()
+}
+
+var _ Encoder = MiekgEncoder{}
diff --git a/internal/engine/netx/resolver/encoder_test.go b/internal/engine/netx/resolver/encoder_test.go
new file mode 100644
index 0000000..eaf8bfa
--- /dev/null
+++ b/internal/engine/netx/resolver/encoder_test.go
@@ -0,0 +1,99 @@
+package resolver_test
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/miekg/dns"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver"
+)
+
+func TestEncoderEncodeA(t *testing.T) {
+ e := resolver.MiekgEncoder{}
+ data, err := e.Encode("x.org", dns.TypeA, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ validate(t, data, byte(dns.TypeA))
+}
+
+func TestEncoderEncodeAAAA(t *testing.T) {
+ e := resolver.MiekgEncoder{}
+ data, err := e.Encode("x.org", dns.TypeAAAA, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ validate(t, data, byte(dns.TypeA))
+}
+
+func validate(t *testing.T, data []byte, qtype byte) {
+ // skipping over the query ID
+ if data[2] != 1 {
+ t.Fatal("FLAGS should only have RD set")
+ }
+ if data[3] != 0 {
+ t.Fatal("RA|Z|Rcode should be zero")
+ }
+ if data[4] != 0 || data[5] != 1 {
+ t.Fatal("QCOUNT high should be one")
+ }
+ if data[6] != 0 || data[7] != 0 {
+ t.Fatal("ANCOUNT should be zero")
+ }
+ if data[8] != 0 || data[9] != 0 {
+ t.Fatal("NSCOUNT should be zero")
+ }
+ if data[10] != 0 || data[11] != 0 {
+ t.Fatal("ARCOUNT should be zero")
+ }
+ t.Log(data[12])
+ if data[12] != 1 || data[13] != byte('x') {
+ t.Fatal("The name does not contain 1:x")
+ }
+ if data[14] != 3 || data[15] != byte('o') || data[16] != byte('r') || data[17] != byte('g') {
+ t.Fatal("The name does not containg 3:org")
+ }
+ if data[18] != 0 {
+ t.Fatal("The name does not terminate where expected")
+ }
+ if data[19] != 0 && data[20] != qtype {
+ t.Fatal("The query is not for the expected type")
+ }
+ if data[21] != 0 && data[22] != 1 {
+ t.Fatal("The query is not IN")
+ }
+}
+
+func TestEncoderPadding(t *testing.T) {
+ // The purpose of this unit test is to make sure that for a wide
+ // array of values we obtain the right query size.
+ getquerylen := func(domainlen int, padding bool) int {
+ e := resolver.MiekgEncoder{}
+ data, err := e.Encode(
+ // This is not a valid name because it ends up being way
+ // longer than 255 octets. However, the library is allowing
+ // us to generate such name and we are not going to send
+ // it on the wire. Also, we check below that the query that
+ // we generate is long enough, so we should be good.
+ dns.Fqdn(strings.Repeat("x.", domainlen)),
+ dns.TypeA, padding,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ return len(data)
+ }
+ for domainlen := 1; domainlen <= 4000; domainlen++ {
+ vanillalen := getquerylen(domainlen, false)
+ paddedlen := getquerylen(domainlen, true)
+ if vanillalen < domainlen {
+ t.Fatal("vanillalen is smaller than domainlen")
+ }
+ if (paddedlen % resolver.PaddingDesiredBlockSize) != 0 {
+ t.Fatal("paddedlen is not a multiple of PaddingDesiredBlockSize")
+ }
+ if paddedlen < vanillalen {
+ t.Fatal("paddedlen is smaller than vanillalen")
+ }
+ }
+}
diff --git a/internal/engine/netx/resolver/errorwrapper.go b/internal/engine/netx/resolver/errorwrapper.go
new file mode 100644
index 0000000..1699477
--- /dev/null
+++ b/internal/engine/netx/resolver/errorwrapper.go
@@ -0,0 +1,30 @@
+package resolver
+
+import (
+ "context"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/dialid"
+ "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/transactionid"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx"
+)
+
+// ErrorWrapperResolver is a Resolver that knows about wrapping errors.
+type ErrorWrapperResolver struct {
+ Resolver
+}
+
+// LookupHost implements Resolver.LookupHost
+func (r ErrorWrapperResolver) LookupHost(ctx context.Context, hostname string) ([]string, error) {
+ dialID := dialid.ContextDialID(ctx)
+ txID := transactionid.ContextTransactionID(ctx)
+ addrs, err := r.Resolver.LookupHost(ctx, hostname)
+ err = errorx.SafeErrWrapperBuilder{
+ DialID: dialID,
+ Error: err,
+ Operation: errorx.ResolveOperation,
+ TransactionID: txID,
+ }.MaybeBuild()
+ return addrs, err
+}
+
+var _ Resolver = ErrorWrapperResolver{}
diff --git a/internal/engine/netx/resolver/errorwrapper_test.go b/internal/engine/netx/resolver/errorwrapper_test.go
new file mode 100644
index 0000000..9212f42
--- /dev/null
+++ b/internal/engine/netx/resolver/errorwrapper_test.go
@@ -0,0 +1,58 @@
+package resolver_test
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/dialid"
+ "github.com/ooni/probe-cli/v3/internal/engine/legacy/netx/transactionid"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/errorx"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver"
+)
+
+func TestErrorWrapperSuccess(t *testing.T) {
+ orig := []string{"8.8.8.8"}
+ r := resolver.ErrorWrapperResolver{
+ Resolver: resolver.NewFakeResolverWithResult(orig),
+ }
+ addrs, err := r.LookupHost(context.Background(), "dns.google.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(addrs) != len(orig) || addrs[0] != orig[0] {
+ t.Fatal("not the result we expected")
+ }
+}
+
+func TestErrorWrapperFailure(t *testing.T) {
+ r := resolver.ErrorWrapperResolver{
+ Resolver: resolver.NewFakeResolverThatFails(),
+ }
+ ctx := context.Background()
+ ctx = dialid.WithDialID(ctx)
+ ctx = transactionid.WithTransactionID(ctx)
+ addrs, err := r.LookupHost(ctx, "dns.google.com")
+ if addrs != nil {
+ t.Fatal("expected nil addr here")
+ }
+ var errWrapper *errorx.ErrWrapper
+ if !errors.As(err, &errWrapper) {
+ t.Fatal("cannot properly cast the returned error")
+ }
+ if errWrapper.Failure != errorx.FailureDNSNXDOMAINError {
+ t.Fatal("unexpected failure")
+ }
+ if errWrapper.ConnID != 0 {
+ t.Fatal("unexpected ConnID")
+ }
+ if errWrapper.DialID == 0 {
+ t.Fatal("unexpected DialID")
+ }
+ if errWrapper.TransactionID == 0 {
+ t.Fatal("unexpected TransactionID")
+ }
+ if errWrapper.Operation != errorx.ResolveOperation {
+ t.Fatal("unexpected Operation")
+ }
+}
diff --git a/internal/engine/netx/resolver/fake_test.go b/internal/engine/netx/resolver/fake_test.go
new file mode 100644
index 0000000..e406381
--- /dev/null
+++ b/internal/engine/netx/resolver/fake_test.go
@@ -0,0 +1,142 @@
+package resolver
+
+import (
+ "context"
+ "io"
+ "net"
+ "time"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/atomicx"
+)
+
+type FakeDialer struct {
+ Conn net.Conn
+ Err error
+}
+
+func (d FakeDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
+ time.Sleep(10 * time.Microsecond)
+ return d.Conn, d.Err
+}
+
+type FakeConn struct {
+ ReadError error
+ ReadData []byte
+ SetDeadlineError error
+ SetReadDeadlineError error
+ SetWriteDeadlineError error
+ WriteError error
+}
+
+func (c *FakeConn) Read(b []byte) (int, error) {
+ if len(c.ReadData) > 0 {
+ n := copy(b, c.ReadData)
+ c.ReadData = c.ReadData[n:]
+ return n, nil
+ }
+ if c.ReadError != nil {
+ return 0, c.ReadError
+ }
+ return 0, io.EOF
+}
+
+func (c *FakeConn) Write(b []byte) (n int, err error) {
+ if c.WriteError != nil {
+ return 0, c.WriteError
+ }
+ n = len(b)
+ return
+}
+
+func (*FakeConn) Close() (err error) {
+ return
+}
+
+func (*FakeConn) LocalAddr() net.Addr {
+ return &net.TCPAddr{}
+}
+
+func (*FakeConn) RemoteAddr() net.Addr {
+ return &net.TCPAddr{}
+}
+
+func (c *FakeConn) SetDeadline(t time.Time) (err error) {
+ return c.SetDeadlineError
+}
+
+func (c *FakeConn) SetReadDeadline(t time.Time) (err error) {
+ return c.SetReadDeadlineError
+}
+
+func (c *FakeConn) SetWriteDeadline(t time.Time) (err error) {
+ return c.SetWriteDeadlineError
+}
+
+type FakeTransport struct {
+ Data []byte
+ Err error
+}
+
+func (ft FakeTransport) RoundTrip(ctx context.Context, query []byte) ([]byte, error) {
+ return ft.Data, ft.Err
+}
+
+func (ft FakeTransport) RequiresPadding() bool {
+ return false
+}
+
+func (ft FakeTransport) Address() string {
+ return ""
+}
+
+func (ft FakeTransport) Network() string {
+ return "fake"
+}
+
+type FakeEncoder struct {
+ Data []byte
+ Err error
+}
+
+func (fe FakeEncoder) Encode(domain string, qtype uint16, padding bool) ([]byte, error) {
+ return fe.Data, fe.Err
+}
+
+type FakeResolver struct {
+ NumFailures *atomicx.Int64
+ Err error
+ Result []string
+}
+
+func NewFakeResolverThatFails() FakeResolver {
+ return FakeResolver{NumFailures: atomicx.NewInt64(), Err: errNotFound}
+}
+
+func NewFakeResolverWithResult(r []string) FakeResolver {
+ return FakeResolver{NumFailures: atomicx.NewInt64(), Result: r}
+}
+
+var errNotFound = &net.DNSError{
+ Err: "no such host",
+}
+
+func (c FakeResolver) LookupHost(ctx context.Context, hostname string) ([]string, error) {
+ time.Sleep(10 * time.Microsecond)
+ if c.Err != nil {
+ if c.NumFailures != nil {
+ c.NumFailures.Add(1)
+ }
+ return nil, c.Err
+ }
+ return c.Result, nil
+}
+
+func (c FakeResolver) Network() string {
+ return "fake"
+}
+
+func (c FakeResolver) Address() string {
+ return ""
+}
+
+var _ Resolver = FakeResolver{}
diff --git a/internal/engine/netx/resolver/genreply_test.go b/internal/engine/netx/resolver/genreply_test.go
new file mode 100644
index 0000000..0901588
--- /dev/null
+++ b/internal/engine/netx/resolver/genreply_test.go
@@ -0,0 +1,76 @@
+package resolver
+
+import (
+ "net"
+ "testing"
+
+ "github.com/miekg/dns"
+)
+
+func GenReplyError(t *testing.T, code int) []byte {
+ question := dns.Question{
+ Name: dns.Fqdn("x.org"),
+ Qtype: dns.TypeA,
+ Qclass: dns.ClassINET,
+ }
+ query := new(dns.Msg)
+ query.Id = dns.Id()
+ query.RecursionDesired = true
+ query.Question = make([]dns.Question, 1)
+ query.Question[0] = question
+ reply := new(dns.Msg)
+ reply.Compress = true
+ reply.MsgHdr.RecursionAvailable = true
+ reply.SetRcode(query, code)
+ data, err := reply.Pack()
+ if err != nil {
+ t.Fatal(err)
+ }
+ return data
+}
+
+func GenReplySuccess(t *testing.T, qtype uint16, ips ...string) []byte {
+ question := dns.Question{
+ Name: dns.Fqdn("x.org"),
+ Qtype: qtype,
+ Qclass: dns.ClassINET,
+ }
+ query := new(dns.Msg)
+ query.Id = dns.Id()
+ query.RecursionDesired = true
+ query.Question = make([]dns.Question, 1)
+ query.Question[0] = question
+ reply := new(dns.Msg)
+ reply.Compress = true
+ reply.MsgHdr.RecursionAvailable = true
+ reply.SetReply(query)
+ for _, ip := range ips {
+ switch qtype {
+ case dns.TypeA:
+ reply.Answer = append(reply.Answer, &dns.A{
+ Hdr: dns.RR_Header{
+ Name: dns.Fqdn("x.org"),
+ Rrtype: qtype,
+ Class: dns.ClassINET,
+ Ttl: 0,
+ },
+ A: net.ParseIP(ip),
+ })
+ case dns.TypeAAAA:
+ reply.Answer = append(reply.Answer, &dns.AAAA{
+ Hdr: dns.RR_Header{
+ Name: dns.Fqdn("x.org"),
+ Rrtype: qtype,
+ Class: dns.ClassINET,
+ Ttl: 0,
+ },
+ AAAA: net.ParseIP(ip),
+ })
+ }
+ }
+ data, err := reply.Pack()
+ if err != nil {
+ t.Fatal(err)
+ }
+ return data
+}
diff --git a/internal/engine/netx/resolver/idna.go b/internal/engine/netx/resolver/idna.go
new file mode 100644
index 0000000..1fed5a7
--- /dev/null
+++ b/internal/engine/netx/resolver/idna.go
@@ -0,0 +1,34 @@
+package resolver
+
+import (
+ "context"
+
+ "golang.org/x/net/idna"
+)
+
+// IDNAResolver is to support resolving Internationalized Domain Names.
+// See RFC3492 for more information.
+type IDNAResolver struct {
+ Resolver
+}
+
+// LookupHost implements Resolver.LookupHost
+func (r IDNAResolver) LookupHost(ctx context.Context, hostname string) ([]string, error) {
+ host, err := idna.ToASCII(hostname)
+ if err != nil {
+ return nil, err
+ }
+ return r.Resolver.LookupHost(ctx, host)
+}
+
+// Network implements Resolver.Network.
+func (r IDNAResolver) Network() string {
+ return "idna"
+}
+
+// Address implements Resolver.Address.
+func (r IDNAResolver) Address() string {
+ return ""
+}
+
+var _ Resolver = IDNAResolver{}
diff --git a/internal/engine/netx/resolver/idna_test.go b/internal/engine/netx/resolver/idna_test.go
new file mode 100644
index 0000000..a3865ed
--- /dev/null
+++ b/internal/engine/netx/resolver/idna_test.go
@@ -0,0 +1,76 @@
+package resolver_test
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver"
+)
+
+var ErrUnexpectedPunycode = errors.New("unexpected punycode value")
+
+type CheckIDNAResolver struct {
+ Addresses []string
+ Error error
+ Expect string
+}
+
+func (resolv CheckIDNAResolver) LookupHost(ctx context.Context, hostname string) ([]string, error) {
+ if resolv.Error != nil {
+ return nil, resolv.Error
+ }
+ if hostname != resolv.Expect {
+ return nil, ErrUnexpectedPunycode
+ }
+ return resolv.Addresses, nil
+}
+
+func (r CheckIDNAResolver) Network() string {
+ return "checkidna"
+}
+
+func (r CheckIDNAResolver) Address() string {
+ return ""
+}
+
+func TestIDNAResolverSuccess(t *testing.T) {
+ expectedIPs := []string{"77.88.55.66"}
+ resolv := resolver.IDNAResolver{Resolver: CheckIDNAResolver{
+ Addresses: expectedIPs,
+ Expect: "xn--d1acpjx3f.xn--p1ai",
+ }}
+ addrs, err := resolv.LookupHost(context.Background(), "яндекс.рф")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if diff := cmp.Diff(expectedIPs, addrs); diff != "" {
+ t.Fatal(diff)
+ }
+}
+
+func TestIDNAResolverFailure(t *testing.T) {
+ resolv := resolver.IDNAResolver{Resolver: CheckIDNAResolver{
+ Error: errors.New("we should not arrive here"),
+ }}
+ // See https://www.farsightsecurity.com/blog/txt-record/punycode-20180711/
+ addrs, err := resolv.LookupHost(context.Background(), "xn--0000h")
+ if err == nil || !strings.HasPrefix(err.Error(), "idna: invalid label") {
+ t.Fatal("not the error we expected")
+ }
+ if addrs != nil {
+ t.Fatal("expected no response here")
+ }
+}
+
+func TestIDNAResolverTransportOK(t *testing.T) {
+ resolv := resolver.IDNAResolver{Resolver: CheckIDNAResolver{}}
+ if resolv.Network() != "idna" {
+ t.Fatal("invalid network")
+ }
+ if resolv.Address() != "" {
+ t.Fatal("invalid address")
+ }
+}
diff --git a/internal/engine/netx/resolver/integration_test.go b/internal/engine/netx/resolver/integration_test.go
new file mode 100644
index 0000000..72e2452
--- /dev/null
+++ b/internal/engine/netx/resolver/integration_test.go
@@ -0,0 +1,111 @@
+package resolver_test
+
+import (
+ "context"
+ "net"
+ "net/http"
+ "testing"
+
+ "github.com/apex/log"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver"
+)
+
+func init() {
+ log.SetLevel(log.DebugLevel)
+}
+
+func testresolverquick(t *testing.T, reso resolver.Resolver) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ reso = resolver.LoggingResolver{Logger: log.Log, Resolver: reso}
+ addrs, err := reso.LookupHost(context.Background(), "dns.google.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if addrs == nil {
+ t.Fatal("expected non-nil addrs here")
+ }
+ var foundquad8 bool
+ for _, addr := range addrs {
+ // See https://github.com/ooni/probe-cli/v3/internal/engine/pull/954/checks?check_run_id=1182269025
+ if addr == "8.8.8.8" || addr == "2001:4860:4860::8888" {
+ foundquad8 = true
+ }
+ }
+ if !foundquad8 {
+ t.Fatalf("did not find 8.8.8.8 in ouput; output=%+v", addrs)
+ }
+}
+
+// Ensuring we can handle Internationalized Domain Names (IDNs) without issues
+func testresolverquickidna(t *testing.T, reso resolver.Resolver) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ reso = resolver.IDNAResolver{
+ resolver.LoggingResolver{Logger: log.Log, Resolver: reso},
+ }
+ addrs, err := reso.LookupHost(context.Background(), "яндекс.рф")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if addrs == nil {
+ t.Fatal("expected non-nil addrs here")
+ }
+}
+
+func TestNewResolverSystem(t *testing.T) {
+ reso := resolver.SystemResolver{}
+ testresolverquick(t, reso)
+ testresolverquickidna(t, reso)
+}
+
+func TestNewResolverUDPAddress(t *testing.T) {
+ reso := resolver.NewSerialResolver(
+ resolver.NewDNSOverUDP(new(net.Dialer), "8.8.8.8:53"))
+ testresolverquick(t, reso)
+ testresolverquickidna(t, reso)
+}
+
+func TestNewResolverUDPDomain(t *testing.T) {
+ reso := resolver.NewSerialResolver(
+ resolver.NewDNSOverUDP(new(net.Dialer), "dns.google.com:53"))
+ testresolverquick(t, reso)
+ testresolverquickidna(t, reso)
+}
+
+func TestNewResolverTCPAddress(t *testing.T) {
+ reso := resolver.NewSerialResolver(
+ resolver.NewDNSOverTCP(new(net.Dialer).DialContext, "8.8.8.8:53"))
+ testresolverquick(t, reso)
+ testresolverquickidna(t, reso)
+}
+
+func TestNewResolverTCPDomain(t *testing.T) {
+ reso := resolver.NewSerialResolver(
+ resolver.NewDNSOverTCP(new(net.Dialer).DialContext, "dns.google.com:53"))
+ testresolverquick(t, reso)
+ testresolverquickidna(t, reso)
+}
+
+func TestNewResolverDoTAddress(t *testing.T) {
+ reso := resolver.NewSerialResolver(
+ resolver.NewDNSOverTLS(resolver.DialTLSContext, "8.8.8.8:853"))
+ testresolverquick(t, reso)
+ testresolverquickidna(t, reso)
+}
+
+func TestNewResolverDoTDomain(t *testing.T) {
+ reso := resolver.NewSerialResolver(
+ resolver.NewDNSOverTLS(resolver.DialTLSContext, "dns.google.com:853"))
+ testresolverquick(t, reso)
+ testresolverquickidna(t, reso)
+}
+
+func TestNewResolverDoH(t *testing.T) {
+ reso := resolver.NewSerialResolver(
+ resolver.NewDNSOverHTTPS(http.DefaultClient, "https://cloudflare-dns.com/dns-query"))
+ testresolverquick(t, reso)
+ testresolverquickidna(t, reso)
+}
diff --git a/internal/engine/netx/resolver/logging.go b/internal/engine/netx/resolver/logging.go
new file mode 100644
index 0000000..40e17f2
--- /dev/null
+++ b/internal/engine/netx/resolver/logging.go
@@ -0,0 +1,30 @@
+package resolver
+
+import (
+ "context"
+ "time"
+)
+
+// Logger is the logger assumed by this package
+type Logger interface {
+ Debugf(format string, v ...interface{})
+ Debug(message string)
+}
+
+// LoggingResolver is a resolver that emits events
+type LoggingResolver struct {
+ Resolver
+ Logger Logger
+}
+
+// LookupHost returns the IP addresses of a host
+func (r LoggingResolver) LookupHost(ctx context.Context, hostname string) ([]string, error) {
+ r.Logger.Debugf("resolve %s...", hostname)
+ start := time.Now()
+ addrs, err := r.Resolver.LookupHost(ctx, hostname)
+ stop := time.Now()
+ r.Logger.Debugf("resolve %s... (%+v, %+v) in %s", hostname, addrs, err, stop.Sub(start))
+ return addrs, err
+}
+
+var _ Resolver = LoggingResolver{}
diff --git a/internal/engine/netx/resolver/logging_test.go b/internal/engine/netx/resolver/logging_test.go
new file mode 100644
index 0000000..4cc18da
--- /dev/null
+++ b/internal/engine/netx/resolver/logging_test.go
@@ -0,0 +1,23 @@
+package resolver_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/apex/log"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver"
+)
+
+func TestLoggingResolver(t *testing.T) {
+ r := resolver.LoggingResolver{
+ Logger: log.Log,
+ Resolver: resolver.NewFakeResolverThatFails(),
+ }
+ addrs, err := r.LookupHost(context.Background(), "www.google.com")
+ if err == nil {
+ t.Fatal("expected an error here")
+ }
+ if addrs != nil {
+ t.Fatal("expected nil addr here")
+ }
+}
diff --git a/internal/engine/netx/resolver/resolver.go b/internal/engine/netx/resolver/resolver.go
new file mode 100644
index 0000000..96db0de
--- /dev/null
+++ b/internal/engine/netx/resolver/resolver.go
@@ -0,0 +1,18 @@
+package resolver
+
+import (
+ "context"
+)
+
+// Resolver is a DNS resolver. The *net.Resolver used by Go implements
+// this interface, but other implementations are possible.
+type Resolver interface {
+ // LookupHost resolves a hostname to a list of IP addresses.
+ LookupHost(ctx context.Context, hostname string) (addrs []string, err error)
+
+ // Network returns the network being used by the resolver
+ Network() string
+
+ // Address returns the address being used by the resolver
+ Address() string
+}
diff --git a/internal/engine/netx/resolver/saver.go b/internal/engine/netx/resolver/saver.go
new file mode 100644
index 0000000..f16f941
--- /dev/null
+++ b/internal/engine/netx/resolver/saver.go
@@ -0,0 +1,73 @@
+package resolver
+
+import (
+ "context"
+ "time"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/trace"
+)
+
+// SaverResolver is a resolver that saves events
+type SaverResolver struct {
+ Resolver
+ Saver *trace.Saver
+}
+
+// LookupHost implements Resolver.LookupHost
+func (r SaverResolver) LookupHost(ctx context.Context, hostname string) ([]string, error) {
+ start := time.Now()
+ r.Saver.Write(trace.Event{
+ Address: r.Resolver.Address(),
+ Hostname: hostname,
+ Name: "resolve_start",
+ Proto: r.Resolver.Network(),
+ Time: start,
+ })
+ addrs, err := r.Resolver.LookupHost(ctx, hostname)
+ stop := time.Now()
+ r.Saver.Write(trace.Event{
+ Addresses: addrs,
+ Address: r.Resolver.Address(),
+ Duration: stop.Sub(start),
+ Err: err,
+ Hostname: hostname,
+ Name: "resolve_done",
+ Proto: r.Resolver.Network(),
+ Time: stop,
+ })
+ return addrs, err
+}
+
+// SaverDNSTransport is a DNS transport that saves events
+type SaverDNSTransport struct {
+ RoundTripper
+ Saver *trace.Saver
+}
+
+// RoundTrip implements RoundTripper.RoundTrip
+func (txp SaverDNSTransport) RoundTrip(ctx context.Context, query []byte) ([]byte, error) {
+ start := time.Now()
+ txp.Saver.Write(trace.Event{
+ Address: txp.Address(),
+ DNSQuery: query,
+ Name: "dns_round_trip_start",
+ Proto: txp.Network(),
+ Time: start,
+ })
+ reply, err := txp.RoundTripper.RoundTrip(ctx, query)
+ stop := time.Now()
+ txp.Saver.Write(trace.Event{
+ Address: txp.Address(),
+ DNSQuery: query,
+ DNSReply: reply,
+ Duration: stop.Sub(start),
+ Err: err,
+ Name: "dns_round_trip_done",
+ Proto: txp.Network(),
+ Time: stop,
+ })
+ return reply, err
+}
+
+var _ Resolver = SaverResolver{}
+var _ RoundTripper = SaverDNSTransport{}
diff --git a/internal/engine/netx/resolver/saver_test.go b/internal/engine/netx/resolver/saver_test.go
new file mode 100644
index 0000000..ad762ff
--- /dev/null
+++ b/internal/engine/netx/resolver/saver_test.go
@@ -0,0 +1,211 @@
+package resolver_test
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "reflect"
+ "testing"
+ "time"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/trace"
+)
+
+func TestSaverResolverFailure(t *testing.T) {
+ expected := errors.New("no such host")
+ saver := &trace.Saver{}
+ reso := resolver.SaverResolver{
+ Resolver: resolver.FakeResolver{
+ Err: expected,
+ },
+ Saver: saver,
+ }
+ addrs, err := reso.LookupHost(context.Background(), "www.google.com")
+ if !errors.Is(err, expected) {
+ t.Fatal("not the error we expected")
+ }
+ if addrs != nil {
+ t.Fatal("expected nil address here")
+ }
+ ev := saver.Read()
+ if len(ev) != 2 {
+ t.Fatal("expected number of events")
+ }
+ if ev[0].Hostname != "www.google.com" {
+ t.Fatal("unexpected Hostname")
+ }
+ if ev[0].Name != "resolve_start" {
+ t.Fatal("unexpected name")
+ }
+ if !ev[0].Time.Before(time.Now()) {
+ t.Fatal("the saved time is wrong")
+ }
+ if ev[1].Addresses != nil {
+ t.Fatal("unexpected Addresses")
+ }
+ if ev[1].Duration <= 0 {
+ t.Fatal("unexpected Duration")
+ }
+ if !errors.Is(ev[1].Err, expected) {
+ t.Fatal("unexpected Err")
+ }
+ if ev[1].Hostname != "www.google.com" {
+ t.Fatal("unexpected Hostname")
+ }
+ if ev[1].Name != "resolve_done" {
+ t.Fatal("unexpected name")
+ }
+ if !ev[1].Time.After(ev[0].Time) {
+ t.Fatal("the saved time is wrong")
+ }
+}
+
+func TestSaverResolverSuccess(t *testing.T) {
+ expected := []string{"8.8.8.8", "8.8.4.4"}
+ saver := &trace.Saver{}
+ reso := resolver.SaverResolver{
+ Resolver: resolver.FakeResolver{
+ Result: expected,
+ },
+ Saver: saver,
+ }
+ addrs, err := reso.LookupHost(context.Background(), "www.google.com")
+ if err != nil {
+ t.Fatal("expected nil error here")
+ }
+ if !reflect.DeepEqual(addrs, expected) {
+ t.Fatal("not the result we expected")
+ }
+ ev := saver.Read()
+ if len(ev) != 2 {
+ t.Fatal("expected number of events")
+ }
+ if ev[0].Hostname != "www.google.com" {
+ t.Fatal("unexpected Hostname")
+ }
+ if ev[0].Name != "resolve_start" {
+ t.Fatal("unexpected name")
+ }
+ if !ev[0].Time.Before(time.Now()) {
+ t.Fatal("the saved time is wrong")
+ }
+ if !reflect.DeepEqual(ev[1].Addresses, expected) {
+ t.Fatal("unexpected Addresses")
+ }
+ if ev[1].Duration <= 0 {
+ t.Fatal("unexpected Duration")
+ }
+ if ev[1].Err != nil {
+ t.Fatal("unexpected Err")
+ }
+ if ev[1].Hostname != "www.google.com" {
+ t.Fatal("unexpected Hostname")
+ }
+ if ev[1].Name != "resolve_done" {
+ t.Fatal("unexpected name")
+ }
+ if !ev[1].Time.After(ev[0].Time) {
+ t.Fatal("the saved time is wrong")
+ }
+}
+
+func TestSaverDNSTransportFailure(t *testing.T) {
+ expected := errors.New("no such host")
+ saver := &trace.Saver{}
+ txp := resolver.SaverDNSTransport{
+ RoundTripper: resolver.FakeTransport{
+ Err: expected,
+ },
+ Saver: saver,
+ }
+ query := []byte("abc")
+ reply, err := txp.RoundTrip(context.Background(), query)
+ if !errors.Is(err, expected) {
+ t.Fatal("not the error we expected")
+ }
+ if reply != nil {
+ t.Fatal("expected nil reply here")
+ }
+ ev := saver.Read()
+ if len(ev) != 2 {
+ t.Fatal("expected number of events")
+ }
+ if !bytes.Equal(ev[0].DNSQuery, query) {
+ t.Fatal("unexpected DNSQuery")
+ }
+ if ev[0].Name != "dns_round_trip_start" {
+ t.Fatal("unexpected name")
+ }
+ if !ev[0].Time.Before(time.Now()) {
+ t.Fatal("the saved time is wrong")
+ }
+ if !bytes.Equal(ev[1].DNSQuery, query) {
+ t.Fatal("unexpected DNSQuery")
+ }
+ if ev[1].DNSReply != nil {
+ t.Fatal("unexpected DNSReply")
+ }
+ if ev[1].Duration <= 0 {
+ t.Fatal("unexpected Duration")
+ }
+ if !errors.Is(ev[1].Err, expected) {
+ t.Fatal("unexpected Err")
+ }
+ if ev[1].Name != "dns_round_trip_done" {
+ t.Fatal("unexpected name")
+ }
+ if !ev[1].Time.After(ev[0].Time) {
+ t.Fatal("the saved time is wrong")
+ }
+}
+
+func TestSaverDNSTransportSuccess(t *testing.T) {
+ expected := []byte("def")
+ saver := &trace.Saver{}
+ txp := resolver.SaverDNSTransport{
+ RoundTripper: resolver.FakeTransport{
+ Data: expected,
+ },
+ Saver: saver,
+ }
+ query := []byte("abc")
+ reply, err := txp.RoundTrip(context.Background(), query)
+ if err != nil {
+ t.Fatal("we expected nil error here")
+ }
+ if !bytes.Equal(reply, expected) {
+ t.Fatal("expected another reply here")
+ }
+ ev := saver.Read()
+ if len(ev) != 2 {
+ t.Fatal("expected number of events")
+ }
+ if !bytes.Equal(ev[0].DNSQuery, query) {
+ t.Fatal("unexpected DNSQuery")
+ }
+ if ev[0].Name != "dns_round_trip_start" {
+ t.Fatal("unexpected name")
+ }
+ if !ev[0].Time.Before(time.Now()) {
+ t.Fatal("the saved time is wrong")
+ }
+ if !bytes.Equal(ev[1].DNSQuery, query) {
+ t.Fatal("unexpected DNSQuery")
+ }
+ if !bytes.Equal(ev[1].DNSReply, expected) {
+ t.Fatal("unexpected DNSReply")
+ }
+ if ev[1].Duration <= 0 {
+ t.Fatal("unexpected Duration")
+ }
+ if ev[1].Err != nil {
+ t.Fatal("unexpected Err")
+ }
+ if ev[1].Name != "dns_round_trip_done" {
+ t.Fatal("unexpected name")
+ }
+ if !ev[1].Time.After(ev[0].Time) {
+ t.Fatal("the saved time is wrong")
+ }
+}
diff --git a/internal/engine/netx/resolver/serial.go b/internal/engine/netx/resolver/serial.go
new file mode 100644
index 0000000..6f82caa
--- /dev/null
+++ b/internal/engine/netx/resolver/serial.go
@@ -0,0 +1,113 @@
+package resolver
+
+import (
+ "context"
+ "errors"
+ "net"
+
+ "github.com/miekg/dns"
+ "github.com/ooni/probe-cli/v3/internal/engine/atomicx"
+)
+
+// RoundTripper represents an abstract DNS transport.
+type RoundTripper interface {
+ // RoundTrip sends a DNS query and receives the reply.
+ RoundTrip(ctx context.Context, query []byte) (reply []byte, err error)
+
+ // RequiresPadding return true for DoH and DoT according to RFC8467
+ RequiresPadding() bool
+
+ // Network is the network of the round tripper (e.g. "dot")
+ Network() string
+
+ // Address is the address of the round tripper (e.g. "1.1.1.1:853")
+ Address() string
+}
+
+// SerialResolver is a resolver that first issues an A query and then
+// issues an AAAA query for the requested domain.
+type SerialResolver struct {
+ Encoder Encoder
+ Decoder Decoder
+ NumTimeouts *atomicx.Int64
+ Txp RoundTripper
+}
+
+// NewSerialResolver creates a new OONI Resolver instance.
+func NewSerialResolver(t RoundTripper) SerialResolver {
+ return SerialResolver{
+ Encoder: MiekgEncoder{},
+ Decoder: MiekgDecoder{},
+ NumTimeouts: atomicx.NewInt64(),
+ Txp: t,
+ }
+}
+
+// Transport returns the transport being used.
+func (r SerialResolver) Transport() RoundTripper {
+ return r.Txp
+}
+
+// Network implements Resolver.Network
+func (r SerialResolver) Network() string {
+ return r.Txp.Network()
+}
+
+// Address implements Resolver.Address
+func (r SerialResolver) Address() string {
+ return r.Txp.Address()
+}
+
+// LookupHost implements Resolver.LookupHost.
+func (r SerialResolver) LookupHost(ctx context.Context, hostname string) ([]string, error) {
+ var addrs []string
+ addrsA, errA := r.roundTripWithRetry(ctx, hostname, dns.TypeA)
+ addrsAAAA, errAAAA := r.roundTripWithRetry(ctx, hostname, dns.TypeAAAA)
+ if errA != nil && errAAAA != nil {
+ return nil, errA
+ }
+ addrs = append(addrs, addrsA...)
+ addrs = append(addrs, addrsAAAA...)
+ return addrs, nil
+}
+
+func (r SerialResolver) roundTripWithRetry(
+ ctx context.Context, hostname string, qtype uint16) ([]string, error) {
+ var errorslist []error
+ for i := 0; i < 3; i++ {
+ replies, err := r.roundTrip(ctx, hostname, qtype)
+ if err == nil {
+ return replies, nil
+ }
+ errorslist = append(errorslist, err)
+ var operr *net.OpError
+ if errors.As(err, &operr) == false || operr.Timeout() == false {
+ // The first error is the one that is most likely to be caused
+ // by the network. Subsequent errors are more likely to be caused
+ // by context deadlines. So, the first error is attached to an
+ // operation, while subsequent errors may possibly not be. If
+ // so, the resulting failing operation is not correct.
+ break
+ }
+ r.NumTimeouts.Add(1)
+ }
+ // bugfix: we MUST return one of the errors otherwise we confuse the
+ // mechanism in errwrap that classifies the root cause operation, since
+ // it would not be able to find a child with a major operation error
+ return nil, errorslist[0]
+}
+
+func (r SerialResolver) roundTrip(
+ ctx context.Context, hostname string, qtype uint16) ([]string, error) {
+ querydata, err := r.Encoder.Encode(hostname, qtype, r.Txp.RequiresPadding())
+ if err != nil {
+ return nil, err
+ }
+ replydata, err := r.Txp.RoundTrip(ctx, querydata)
+ if err != nil {
+ return nil, err
+ }
+ return r.Decoder.Decode(qtype, replydata)
+}
+
+var _ Resolver = SerialResolver{}
diff --git a/internal/engine/netx/resolver/serial_test.go b/internal/engine/netx/resolver/serial_test.go
new file mode 100644
index 0000000..e1092e4
--- /dev/null
+++ b/internal/engine/netx/resolver/serial_test.go
@@ -0,0 +1,111 @@
+package resolver_test
+
+import (
+ "context"
+ "errors"
+ "net"
+ "strings"
+ "syscall"
+ "testing"
+
+ "github.com/miekg/dns"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver"
+)
+
+func TestOONIGettingTransport(t *testing.T) {
+ txp := resolver.NewDNSOverTLS(resolver.DialTLSContext, "8.8.8.8:853")
+ r := resolver.NewSerialResolver(txp)
+ rtx := r.Transport()
+ if rtx.Network() != "dot" || rtx.Address() != "8.8.8.8:853" {
+ t.Fatal("not the transport we expected")
+ }
+ if r.Network() != rtx.Network() {
+ t.Fatal("invalid network seen from the resolver")
+ }
+ if r.Address() != rtx.Address() {
+ t.Fatal("invalid address seen from the resolver")
+ }
+}
+
+func TestOONIEncodeError(t *testing.T) {
+ mocked := errors.New("mocked error")
+ txp := resolver.NewDNSOverTLS(resolver.DialTLSContext, "8.8.8.8:853")
+ r := resolver.SerialResolver{Encoder: resolver.FakeEncoder{Err: mocked}, Txp: txp}
+ addrs, err := r.LookupHost(context.Background(), "www.gogle.com")
+ if !errors.Is(err, mocked) {
+ t.Fatal("not the error we expected")
+ }
+ if addrs != nil {
+ t.Fatal("expected nil address here")
+ }
+}
+
+func TestOONIRoundTripError(t *testing.T) {
+ mocked := errors.New("mocked error")
+ txp := resolver.FakeTransport{Err: mocked}
+ r := resolver.NewSerialResolver(txp)
+ addrs, err := r.LookupHost(context.Background(), "www.gogle.com")
+ if !errors.Is(err, mocked) {
+ t.Fatal("not the error we expected")
+ }
+ if addrs != nil {
+ t.Fatal("expected nil address here")
+ }
+}
+
+func TestOONIWithEmptyReply(t *testing.T) {
+ txp := resolver.FakeTransport{Data: resolver.GenReplySuccess(t, dns.TypeA)}
+ r := resolver.NewSerialResolver(txp)
+ addrs, err := r.LookupHost(context.Background(), "www.gogle.com")
+ if err == nil || !strings.HasSuffix(err.Error(), "no response returned") {
+ t.Fatal("not the error we expected")
+ }
+ if addrs != nil {
+ t.Fatal("expected nil address here")
+ }
+}
+
+func TestOONIWithAReply(t *testing.T) {
+ txp := resolver.FakeTransport{
+ Data: resolver.GenReplySuccess(t, dns.TypeA, "8.8.8.8"),
+ }
+ r := resolver.NewSerialResolver(txp)
+ addrs, err := r.LookupHost(context.Background(), "www.gogle.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(addrs) != 1 || addrs[0] != "8.8.8.8" {
+ t.Fatal("not the result we expected")
+ }
+}
+
+func TestOONIWithAAAAReply(t *testing.T) {
+ txp := resolver.FakeTransport{
+ Data: resolver.GenReplySuccess(t, dns.TypeAAAA, "::1"),
+ }
+ r := resolver.NewSerialResolver(txp)
+ addrs, err := r.LookupHost(context.Background(), "www.gogle.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(addrs) != 1 || addrs[0] != "::1" {
+ t.Fatal("not the result we expected")
+ }
+}
+
+func TestOONIWithTimeout(t *testing.T) {
+ txp := resolver.FakeTransport{
+ Err: &net.OpError{Err: syscall.ETIMEDOUT, Op: "dial"},
+ }
+ r := resolver.NewSerialResolver(txp)
+ addrs, err := r.LookupHost(context.Background(), "www.gogle.com")
+ if !errors.Is(err, syscall.ETIMEDOUT) {
+ t.Fatal("not the error we expected")
+ }
+ if addrs != nil {
+ t.Fatal("expected nil address here")
+ }
+ if r.NumTimeouts.Load() <= 0 {
+ t.Fatal("we didn't actually take the timeouts")
+ }
+}
diff --git a/internal/engine/netx/resolver/system.go b/internal/engine/netx/resolver/system.go
new file mode 100644
index 0000000..784bc42
--- /dev/null
+++ b/internal/engine/netx/resolver/system.go
@@ -0,0 +1,10 @@
+package resolver
+
+import "github.com/ooni/probe-cli/v3/internal/engine/netx/selfcensor"
+
+// SystemResolver is the system resolver. It is implemented using
+// selfcensor.SystemResolver so that we can perform integration testing
+// by forcing the code to return specific responses.
+type SystemResolver = selfcensor.SystemResolver
+
+var _ Resolver = SystemResolver{}
diff --git a/internal/engine/netx/resolver/system_test.go b/internal/engine/netx/resolver/system_test.go
new file mode 100644
index 0000000..a30fb34
--- /dev/null
+++ b/internal/engine/netx/resolver/system_test.go
@@ -0,0 +1,25 @@
+package resolver_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver"
+)
+
+func TestSystemResolverLookupHost(t *testing.T) {
+ r := resolver.SystemResolver{}
+ if r.Network() != "system" {
+ t.Fatal("invalid Network")
+ }
+ if r.Address() != "" {
+ t.Fatal("invalid Address")
+ }
+ addrs, err := r.LookupHost(context.Background(), "dns.google.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if addrs == nil {
+ t.Fatal("expected non-nil result here")
+ }
+}
diff --git a/internal/engine/netx/resolver/tls_test.go b/internal/engine/netx/resolver/tls_test.go
new file mode 100644
index 0000000..3280e46
--- /dev/null
+++ b/internal/engine/netx/resolver/tls_test.go
@@ -0,0 +1,32 @@
+package resolver
+
+import (
+ "context"
+ "crypto/tls"
+ "net"
+)
+
+func DialTLSContext(ctx context.Context, network, address string) (net.Conn, error) {
+ connch := make(chan net.Conn)
+ errch := make(chan error, 1)
+ go func() {
+ conn, err := tls.Dial(network, address, new(tls.Config))
+ if err != nil {
+ errch <- err
+ return
+ }
+ select {
+ case <-ctx.Done():
+ conn.Close()
+ case connch <- conn:
+ }
+ }()
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case conn := <-connch:
+ return conn, nil
+ case err := <-errch:
+ return nil, err
+ }
+}
diff --git a/internal/engine/netx/selfcensor/selfcensor.go b/internal/engine/netx/selfcensor/selfcensor.go
new file mode 100644
index 0000000..406ded4
--- /dev/null
+++ b/internal/engine/netx/selfcensor/selfcensor.go
@@ -0,0 +1,230 @@
+// Package selfcensor contains code that triggers censorship. We use
+// this functionality to implement integration tests.
+//
+// The self censoring functionality is disabled by default. To enable it,
+// call Enable with a JSON-serialized Spec structure as its argument.
+//
+// The following example causes NXDOMAIN to be returned for `dns.google`:
+//
+// selfcensor.Enable(`{"PoisonSystemDNS":{"dns.google":["NXDOMAIN"]}}`)
+//
+// The following example blocks connecting to `8.8.8.8:443`:
+//
+// selfcensor.Enable(`{"BlockedEndpoints":{"8.8.8.8:443":"REJECT"}}`)
+//
+// The following example blocks packets containing dns.google:
+//
+// selfcensor.Enable(`{"BlockedFingerprints":{"dns.google":"RST"}}`)
+//
+// The documentation of the Spec structure contains further information on
+// how to populate the JSON. Miniooni uses the `--self-censor-spec flag` to
+// which you are supposed to pass a serialized JSON.
+package selfcensor
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "log"
+ "net"
+ "sync"
+ "time"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/atomicx"
+)
+
+// Spec indicates what self censorship techniques to implement.
+type Spec struct {
+ // PoisonSystemDNS allows you to change the behaviour of the system
+ // DNS regarding specific domains. They keys are the domains and the
+ // values are the IP addresses to return. If you set the values for
+ // a domain to `[]string{"NXDOMAIN"}`, the system resolver will return
+ // an NXDOMAIN response. If you set the values for a domain to
+ // `[]string{"TIMEOUT"}` the system resolver will return "i/o timeout".
+ PoisonSystemDNS map[string][]string
+
+ // BlockedEndpoints allows you to block specific IP endpoints. The key is
+ // `IP:port` to block. The format is the same of net.JoinHostPort. If
+ // the value is "REJECT", then the connection attempt will fail with
+ // ECONNREFUSED. If the value is "TIMEOUT", then the connector will return
+ // claiming "i/o timeout". If the value is anything else, we will
+ // perform a "REJECT".
+ BlockedEndpoints map[string]string
+
+ // BlockedFingerprints allows you to block packets whose body contains
+ // specific fingerprints. Of course, the key is the fingerprint. If
+ // the value is "RST", then the connection will be reset. If the value
+ // is "TIMEOUT", then the code will return claiming "i/o timeout". If
+ // the value is anything else, we will perform a "RST".
+ BlockedFingerprints map[string]string
+}
+
+var (
+ attempts *atomicx.Int64 = atomicx.NewInt64()
+ enabled *atomicx.Int64 = atomicx.NewInt64()
+ mu sync.Mutex
+ spec *Spec
+)
+
+// Enabled returns whether self censorship is enabled
+func Enabled() bool {
+ return enabled.Load() != 0
+}
+
+// Attempts returns the number of self censorship attempts so far. A self
+// censorship attempt is defined as the code entering into the branch that
+// _may_ perform self censorship. We expected to see this counter being
+// equal to zero when Enabled() returns false.
+func Attempts() int64 {
+ return attempts.Load()
+}
+
+// Enable turns on the self censorship engine. This function returns
+// an error if we cannot parse a Spec from the serialized JSON inside
+// data. Each time you call Enable you overwrite the previous spec.
+func Enable(data string) error {
+ mu.Lock()
+ defer mu.Unlock()
+ s := new(Spec)
+ if err := json.Unmarshal([]byte(data), s); err != nil {
+ return err
+ }
+ spec = s
+ enabled.Add(1)
+ log.Printf("selfcensor: spec %+v", *spec)
+ return nil
+}
+
+// MaybeEnable is like enable except that it does nothing in case
+// the string provided as argument is an empty string.
+func MaybeEnable(data string) (err error) {
+ if data != "" {
+ err = Enable(data)
+ }
+ return
+}
+
+// SystemResolver is a self-censoring system resolver. This resolver does
+// not censor anything unless you call selfcensor.Enable().
+type SystemResolver struct{}
+
+// errTimeout indicates that a timeout error has occurred.
+var errTimeout = errors.New("i/o timeout")
+
+// LookupHost implements Resolver.LookupHost
+func (r SystemResolver) LookupHost(ctx context.Context, hostname string) ([]string, error) {
+ if enabled.Load() != 0 { // jumps not taken by default
+ mu.Lock()
+ defer mu.Unlock()
+ attempts.Add(1)
+ if spec.PoisonSystemDNS != nil {
+ values := spec.PoisonSystemDNS[hostname]
+ if len(values) == 1 && values[0] == "NXDOMAIN" {
+ return nil, errors.New("no such host")
+ }
+ if len(values) == 1 && values[0] == "TIMEOUT" {
+ return nil, errTimeout
+ }
+ if len(values) > 0 {
+ return values, nil
+ }
+ }
+ // FALLTHROUGH
+ }
+ return net.DefaultResolver.LookupHost(ctx, hostname)
+}
+
+// Network implements Resolver.Network
+func (r SystemResolver) Network() string {
+ return "system"
+}
+
+// Address implements Resolver.Address
+func (r SystemResolver) Address() string {
+ return ""
+}
+
+// SystemDialer is a self-censoring system dialer. This dialer does
+// not censor anything unless you call selfcensor.Enable().
+type SystemDialer struct{}
+
+// defaultNetDialer is the dialer we use by default.
+var defaultNetDialer = &net.Dialer{
+ Timeout: 15 * time.Second,
+ KeepAlive: 15 * time.Second,
+}
+
+// DefaultDialer is the dialer you should use in code that wants
+// to take advantage of selfcensor capabilities.
+var DefaultDialer = SystemDialer{}
+
+// DialContext implements Dialer.DialContext
+func (d SystemDialer) DialContext(
+ ctx context.Context, network, address string) (net.Conn, error) {
+ if enabled.Load() != 0 { // jumps not taken by default
+ mu.Lock()
+ defer mu.Unlock()
+ attempts.Add(1)
+ if spec.BlockedEndpoints != nil {
+ action, ok := spec.BlockedEndpoints[address]
+ if ok && action == "TIMEOUT" {
+ return nil, errTimeout
+ }
+ if ok {
+ switch network {
+ case "tcp", "tcp4", "tcp6":
+ return nil, errors.New("connection refused")
+ default:
+ // not applicable
+ }
+ }
+ }
+ if spec.BlockedFingerprints != nil {
+ conn, err := defaultNetDialer.DialContext(ctx, network, address)
+ if err != nil {
+ return nil, err
+ }
+ return connWrapper{Conn: conn, closed: make(chan interface{}, 128),
+ fingerprints: spec.BlockedFingerprints}, nil
+ }
+ // FALLTHROUGH
+ }
+ return defaultNetDialer.DialContext(ctx, network, address)
+}
+
+type connWrapper struct {
+ net.Conn
+ closed chan interface{}
+ fingerprints map[string]string
+}
+
+func (c connWrapper) Write(p []byte) (int, error) {
+ // TODO(bassosimone): implement reassembly to workaround the
+ // splitting of the ClientHello message.
+ if _, err := c.match(p, len(p)); err != nil {
+ return 0, err
+ }
+ return c.Conn.Write(p)
+}
+
+func (c connWrapper) match(p []byte, n int) (int, error) {
+ p = p[:n] // trim
+ for key, value := range c.fingerprints {
+ if bytes.Index(p, []byte(key)) != -1 {
+ if value == "TIMEOUT" {
+ return 0, errTimeout
+ }
+ return 0, errors.New("connection reset by peer")
+ }
+ }
+ return n, nil
+}
+
+func (c connWrapper) Close() error {
+ // Implementation note: we will block here if we attempt to close
+ // too many times and noone's reading. Because we have a large buffer,
+ // and because this is integration testing code, that's fine.
+ c.closed <- true
+ return c.Conn.Close()
+}
diff --git a/internal/engine/netx/selfcensor/selfcensor_test.go b/internal/engine/netx/selfcensor/selfcensor_test.go
new file mode 100644
index 0000000..9938fda
--- /dev/null
+++ b/internal/engine/netx/selfcensor/selfcensor_test.go
@@ -0,0 +1,271 @@
+package selfcensor_test
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/netx"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/selfcensor"
+)
+
+// TestDisabled MUST be the first test in this file.
+func TestDisabled(t *testing.T) {
+ if selfcensor.Enabled() != false {
+ t.Fatal("self censorship should be disabled by default")
+ }
+ if selfcensor.Attempts() != 0 {
+ t.Fatal("we expect no self censorship attempts at the beginning")
+ }
+ t.Run("the system resolver does not trigger selfcensor events", func(t *testing.T) {
+ addrs, err := selfcensor.SystemResolver{}.LookupHost(
+ context.Background(), "dns.google",
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ if addrs == nil {
+ t.Fatal("expected non-nil addrs here")
+ }
+ if selfcensor.Attempts() != 0 {
+ t.Fatal("we expect no self censorship attempts by default")
+ }
+ })
+ t.Run("the system dialer does not trigger selfcensor events", func(t *testing.T) {
+ conn, err := selfcensor.SystemDialer{}.DialContext(
+ context.Background(), "tcp", "8.8.8.8:443",
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ if conn == nil {
+ t.Fatal("expected non-nil conn here")
+ }
+ conn.Close()
+ if selfcensor.Attempts() != 0 {
+ t.Fatal("we expect no self censorship attempts by default")
+ }
+ })
+}
+
+// TestDisabled MUST be the second test in this file.
+func TestEnableInvalidJSON(t *testing.T) {
+ if selfcensor.Enabled() != false {
+ t.Fatal("we need to start with self censorship not enabled")
+ }
+ err := selfcensor.Enable("{")
+ if err == nil || !strings.HasSuffix(err.Error(), "unexpected end of JSON input") {
+ t.Fatal("not the error we expectd")
+ }
+ if selfcensor.Enabled() != false {
+ t.Fatal("we expected self censorship to still be not enabled")
+ }
+}
+
+// TestMaybeEnableWorksAsIntended MUST be the second test in this file.
+func TestMaybeEnableWorksAsIntended(t *testing.T) {
+ if selfcensor.Enabled() != false {
+ t.Fatal("we need to start with self censorship not enabled")
+ }
+ err := selfcensor.MaybeEnable("")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if selfcensor.Enabled() != false {
+ t.Fatal("we expected self censorship to still be not enabled")
+ }
+}
+
+func TestResolveCauseNXDOMAIN(t *testing.T) {
+ err := selfcensor.MaybeEnable(`{"PoisonSystemDNS":{"dns.google":["NXDOMAIN"]}}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if selfcensor.Enabled() != true {
+ t.Fatal("we expected self censorship to be enabled now")
+ }
+ addrs, err := selfcensor.SystemResolver{}.LookupHost(
+ context.Background(), "dns.google",
+ )
+ if err == nil || !strings.HasSuffix(err.Error(), "no such host") {
+ t.Fatal("not the error we expected")
+ }
+ if addrs != nil {
+ t.Fatal("expected nil addrs here")
+ }
+}
+
+func TestResolveCauseTimeout(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
+ defer cancel()
+ err := selfcensor.MaybeEnable(`{"PoisonSystemDNS":{"dns.google":["TIMEOUT"]}}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if selfcensor.Enabled() != true {
+ t.Fatal("we expected self censorship to be enabled now")
+ }
+ addrs, err := selfcensor.SystemResolver{}.LookupHost(ctx, "dns.google")
+ if err == nil || err.Error() != "i/o timeout" {
+ t.Fatal("not the error we expected")
+ }
+ if addrs != nil {
+ t.Fatal("expected nil addrs here")
+ }
+}
+
+func TestResolveCauseBogon(t *testing.T) {
+ err := selfcensor.MaybeEnable(`{"PoisonSystemDNS":{"dns.google":["10.0.0.7"]}}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if selfcensor.Enabled() != true {
+ t.Fatal("we expected self censorship to be enabled now")
+ }
+ addrs, err := selfcensor.SystemResolver{}.LookupHost(
+ context.Background(), "dns.google")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(addrs) != 1 || addrs[0] != "10.0.0.7" {
+ t.Fatal("not the addrs we expected")
+ }
+}
+
+func TestResolveCheckNetworkAndAddress(t *testing.T) {
+ err := selfcensor.MaybeEnable(`{"PoisonSystemDNS":{"dns.google":["10.0.0.7"]}}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if selfcensor.Enabled() != true {
+ t.Fatal("we expected self censorship to be enabled now")
+ }
+ reso := selfcensor.SystemResolver{}
+ if reso.Network() != "system" {
+ t.Fatal("invalid Network")
+ }
+ if reso.Address() != "" {
+ t.Fatal("invalid Address")
+ }
+}
+
+func TestDialHandlesErrorsWithBlockedFingerprints(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
+ cancel() // so we should fail immediately!
+ err := selfcensor.MaybeEnable(`{"BlockedFingerprints":{"dns.google":"TIMEOUT"}}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if selfcensor.Enabled() != true {
+ t.Fatal("we expected self censorship to be enabled now")
+ }
+ addrs, err := selfcensor.SystemDialer{}.DialContext(ctx, "tcp", "8.8.8.8:443")
+ if err == nil || !strings.HasSuffix(err.Error(), "operation was canceled") {
+ t.Fatal("not the error we expected")
+ }
+ if addrs != nil {
+ t.Fatal("expected nil addrs here")
+ }
+}
+
+func TestDialCauseTimeout(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
+ defer cancel()
+ err := selfcensor.MaybeEnable(`{"BlockedEndpoints":{"8.8.8.8:443":"TIMEOUT"}}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if selfcensor.Enabled() != true {
+ t.Fatal("we expected self censorship to be enabled now")
+ }
+ addrs, err := selfcensor.SystemDialer{}.DialContext(ctx, "tcp", "8.8.8.8:443")
+ if err == nil || err.Error() != "i/o timeout" {
+ t.Fatal("not the error we expected")
+ }
+ if addrs != nil {
+ t.Fatal("expected nil addrs here")
+ }
+}
+
+func TestDialCauseConnectionRefused(t *testing.T) {
+ err := selfcensor.MaybeEnable(`{"BlockedEndpoints":{"8.8.8.8:443":"REJECT"}}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if selfcensor.Enabled() != true {
+ t.Fatal("we expected self censorship to be enabled now")
+ }
+ addrs, err := selfcensor.SystemDialer{}.DialContext(
+ context.Background(), "tcp", "8.8.8.8:443")
+ if err == nil || !strings.HasSuffix(err.Error(), "connection refused") {
+ t.Fatal("not the error we expected")
+ }
+ if addrs != nil {
+ t.Fatal("expected nil addrs here")
+ }
+}
+
+func TestBlockedFingerprintsTimeout(t *testing.T) {
+ err := selfcensor.MaybeEnable(`{"BlockedFingerprints":{"dns.google":"TIMEOUT"}}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if selfcensor.Enabled() != true {
+ t.Fatal("we expected self censorship to be enabled now")
+ }
+ tlsDialer := netx.NewTLSDialer(netx.Config{
+ Dialer: selfcensor.SystemDialer{},
+ })
+ conn, err := tlsDialer.DialTLSContext(
+ context.Background(), "tcp", "dns.google:443")
+ if err == nil || err.Error() != "generic_timeout_error" {
+ t.Fatal("not the error expected")
+ }
+ if conn != nil {
+ t.Fatal("expected nil conn here")
+ }
+}
+
+func TestBlockedFingerprintsNoMatch(t *testing.T) {
+ err := selfcensor.MaybeEnable(`{"BlockedFingerprints":{"ooni.io":"TIMEOUT"}}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if selfcensor.Enabled() != true {
+ t.Fatal("we expected self censorship to be enabled now")
+ }
+ tlsDialer := netx.NewTLSDialer(netx.Config{
+ Dialer: selfcensor.SystemDialer{},
+ })
+ conn, err := tlsDialer.DialTLSContext(
+ context.Background(), "tcp", "dns.google:443")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if conn == nil {
+ t.Fatal("expected non-nil conn here")
+ }
+ conn.Close()
+}
+
+func TestBlockedFingerprintsConnectionReset(t *testing.T) {
+ err := selfcensor.MaybeEnable(`{"BlockedFingerprints":{"dns.google":"RST"}}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if selfcensor.Enabled() != true {
+ t.Fatal("we expected self censorship to be enabled now")
+ }
+ tlsDialer := netx.NewTLSDialer(netx.Config{
+ Dialer: selfcensor.SystemDialer{},
+ })
+ conn, err := tlsDialer.DialTLSContext(
+ context.Background(), "tcp", "dns.google:443")
+ if err == nil || err.Error() != "connection_reset" {
+ t.Fatal("not the error we expected")
+ }
+ if conn != nil {
+ t.Fatal("expected nil conn here")
+ }
+}
diff --git a/internal/engine/netx/trace/event.go b/internal/engine/netx/trace/event.go
new file mode 100644
index 0000000..f98a84c
--- /dev/null
+++ b/internal/engine/netx/trace/event.go
@@ -0,0 +1,60 @@
+package trace
+
+import (
+ "crypto/tls"
+ "crypto/x509"
+ "errors"
+ "net/http"
+ "time"
+)
+
+// Event is one of the events within a trace
+type Event struct {
+ Addresses []string `json:",omitempty"`
+ Address string `json:",omitempty"`
+ DNSQuery []byte `json:",omitempty"`
+ DNSReply []byte `json:",omitempty"`
+ DataIsTruncated bool `json:",omitempty"`
+ Data []byte `json:",omitempty"`
+ Duration time.Duration `json:",omitempty"`
+ Err error `json:",omitempty"`
+ HTTPHeaders http.Header `json:",omitempty"`
+ HTTPMethod string `json:",omitempty"`
+ HTTPStatusCode int `json:",omitempty"`
+ HTTPURL string `json:",omitempty"`
+ Hostname string `json:",omitempty"`
+ Name string `json:",omitempty"`
+ NoTLSVerify bool `json:",omitempty"`
+ NumBytes int `json:",omitempty"`
+ Proto string `json:",omitempty"`
+ TLSServerName string `json:",omitempty"`
+ TLSCipherSuite string `json:",omitempty"`
+ TLSNegotiatedProto string `json:",omitempty"`
+ TLSNextProtos []string `json:",omitempty"`
+ TLSPeerCerts []*x509.Certificate `json:",omitempty"`
+ TLSVersion string `json:",omitempty"`
+ Time time.Time `json:",omitempty"`
+ Transport string `json:",omitempty"`
+}
+
+// PeerCerts returns the certificates presented by the peer regardless
+// of whether the TLS handshake was successful
+func PeerCerts(state tls.ConnectionState, err error) []*x509.Certificate {
+ var x509HostnameError x509.HostnameError
+ if errors.As(err, &x509HostnameError) {
+ // Test case: https://wrong.host.badssl.com/
+ return []*x509.Certificate{x509HostnameError.Certificate}
+ }
+ var x509UnknownAuthorityError x509.UnknownAuthorityError
+ if errors.As(err, &x509UnknownAuthorityError) {
+ // Test case: https://self-signed.badssl.com/. This error has
+ // never been among the ones returned by MK.
+ return []*x509.Certificate{x509UnknownAuthorityError.Cert}
+ }
+ var x509CertificateInvalidError x509.CertificateInvalidError
+ if errors.As(err, &x509CertificateInvalidError) {
+ // Test case: https://expired.badssl.com/
+ return []*x509.Certificate{x509CertificateInvalidError.Cert}
+ }
+ return state.PeerCertificates
+}
diff --git a/internal/engine/netx/trace/saver.go b/internal/engine/netx/trace/saver.go
new file mode 100644
index 0000000..c05dda9
--- /dev/null
+++ b/internal/engine/netx/trace/saver.go
@@ -0,0 +1,27 @@
+package trace
+
+import "sync"
+
+// The Saver saves a trace
+type Saver struct {
+ ops []Event
+ mu sync.Mutex
+}
+
+// Read reads and returns events inside the trace. It advances
+// the read pointer so you won't see such events again.
+func (s *Saver) Read() []Event {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ v := s.ops
+ s.ops = nil
+ return v
+}
+
+// Write adds the given event to the trace. A subsequent call
+// to Read will read this event.
+func (s *Saver) Write(ev Event) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.ops = append(s.ops, ev)
+}
diff --git a/internal/engine/netx/trace/trace_test.go b/internal/engine/netx/trace/trace_test.go
new file mode 100644
index 0000000..1cf1029
--- /dev/null
+++ b/internal/engine/netx/trace/trace_test.go
@@ -0,0 +1,26 @@
+package trace_test
+
+import (
+ "sync"
+ "testing"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/trace"
+)
+
+func TestGood(t *testing.T) {
+ saver := trace.Saver{}
+ var wg sync.WaitGroup
+ const parallel = 10
+ wg.Add(parallel)
+ for idx := 0; idx < parallel; idx++ {
+ go func() {
+ saver.Write(trace.Event{})
+ wg.Done()
+ }()
+ }
+ wg.Wait()
+ ev := saver.Read()
+ if len(ev) != parallel {
+ t.Fatal("unexpected number of events read")
+ }
+}
diff --git a/internal/engine/oonimkall/README.md b/internal/engine/oonimkall/README.md
new file mode 100644
index 0000000..5e09efb
--- /dev/null
+++ b/internal/engine/oonimkall/README.md
@@ -0,0 +1,20 @@
+# Package github.com/ooni/probe-engine/oonimkall
+
+Package oonimkall implements APIs used by OONI mobile apps. We
+expose these APIs to mobile apps using gomobile.
+
+We expose two APIs: the task API, which is derived from the
+API originally exposed by Measurement Kit, and the session API,
+which is a Go API that mobile apps can use via `gomobile`.
+
+This package is named oonimkall because it contains a partial
+reimplementation of the mkall API implemented by Measurement Kit
+in, e.g., [mkall-ios](https://github.com/measurement-kit/mkall-ios).
+
+The basic tenet of the task API is that you define an experiment
+task you wanna run using a JSON, then you start a task for it, and
+you receive events as serialized JSONs. In addition to this
+functionality, we also include extra APIs used by OONI mobile.
+
+The basic tenet of the session API is that you create an instance
+of `Session` and use it to perform the operations you need.
diff --git a/internal/engine/oonimkall/session.go b/internal/engine/oonimkall/session.go
new file mode 100644
index 0000000..01ab65e
--- /dev/null
+++ b/internal/engine/oonimkall/session.go
@@ -0,0 +1,377 @@
+package oonimkall
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "runtime"
+ "sync"
+
+ engine "github.com/ooni/probe-cli/v3/internal/engine"
+ "github.com/ooni/probe-cli/v3/internal/engine/atomicx"
+ "github.com/ooni/probe-cli/v3/internal/engine/internal/runtimex"
+ "github.com/ooni/probe-cli/v3/internal/engine/model"
+ "github.com/ooni/probe-cli/v3/internal/engine/probeservices"
+)
+
+// AtomicInt64 allows us to export atomicx.Int64 variables to
+// mobile libraries so we can use them in testing.
+type AtomicInt64 struct {
+ *atomicx.Int64
+}
+
+// The following two variables contain metrics pertaining to the number
+// of Sessions and Contexts that are currently being used.
+var (
+ ActiveSessions = &AtomicInt64{atomicx.NewInt64()}
+ ActiveContexts = &AtomicInt64{atomicx.NewInt64()}
+)
+
+// Logger is the logger used by a Session. You should implement a class
+// compatible with this interface in Java/ObjC and then save a reference
+// to this instance in the SessionConfig object. All log messages that
+// the Session will generate will be routed to this Logger.
+type Logger interface {
+ Debug(msg string)
+ Info(msg string)
+ Warn(msg string)
+}
+
+// SessionConfig contains configuration for a Session. You should
+// fill all the mandatory fields and could also optionally fill some of
+// the optional fields. Then pass this struct to NewSession.
+type SessionConfig struct {
+ // AssetsDir is the mandatory directory where to store assets
+ // required by a Session, e.g. MaxMind DB files.
+ AssetsDir string
+
+ // Logger is the optional logger that will receive all the
+ // log messages generated by a Session. If this field is nil
+ // then the session will not emit any log message.
+ Logger Logger
+
+ // ProbeServicesURL allows you to optionally force the
+ // usage of an alternative probe service instance. This setting
+ // should only be used for implementing integration tests.
+ ProbeServicesURL string
+
+ // SoftwareName is the mandatory name of the application
+ // that will be using the new Session.
+ SoftwareName string
+
+ // SoftwareVersion is the mandatory version of the application
+ // that will be using the new Session.
+ SoftwareVersion string
+
+ // StateDir is the mandatory directory where to store state
+ // information required by a Session.
+ StateDir string
+
+ // TempDir is the mandatory directory where the Session shall
+ // store temporary files. Among other tasks, Session.Close will
+ // remove any temporary file created within this Session.
+ TempDir string
+
+ // Verbose is optional. If there is a non-null Logger and this
+ // field is true, then the Logger will also receive Debug messages,
+ // otherwise it will not receive such messages.
+ Verbose bool
+}
+
+// Session contains shared state for running experiments and/or other
+// OONI related task (e.g. geolocation). Note that the Session isn't
+// mean to be a long living object. The workflow is to create a Session,
+// do the operations you need to do with it now, then make sure it is
+// not referenced by other variables, so the Go GC can finalize it.
+//
+// Future directions
+//
+// We will eventually rewrite the code for running new experiments such
+// that a Task will be created from a Session, such that experiments
+// could share the same Session and save geolookups, etc. For now, we
+// are in the suboptimal situations where Tasks create, use, and close
+// their own session, thus running more lookups than needed.
+type Session struct {
+ cl []context.CancelFunc
+ mtx sync.Mutex
+ submitter *probeservices.Submitter
+ sessp *engine.Session
+
+ // Hooks for testing (should not appear in Java/ObjC)
+ TestingCheckInBeforeNewProbeServicesClient func(ctx *Context)
+ TestingCheckInBeforeCheckIn func(ctx *Context)
+}
+
+// NewSession creates a new session. You should use a session for running
+// a set of operations in a relatively short time frame. You SHOULD NOT create
+// a single session and keep it all alive for the whole app lifecyle, since
+// the Session code is not specifically designed for this use case.
+func NewSession(config *SessionConfig) (*Session, error) {
+ kvstore, err := engine.NewFileSystemKVStore(config.StateDir)
+ if err != nil {
+ return nil, err
+ }
+ var availableps []model.Service
+ if config.ProbeServicesURL != "" {
+ availableps = append(availableps, model.Service{
+ Address: config.ProbeServicesURL,
+ Type: "https",
+ })
+ }
+ engineConfig := engine.SessionConfig{
+ AssetsDir: config.AssetsDir,
+ AvailableProbeServices: availableps,
+ KVStore: kvstore,
+ Logger: newLogger(config.Logger, config.Verbose),
+ SoftwareName: config.SoftwareName,
+ SoftwareVersion: config.SoftwareVersion,
+ TempDir: config.TempDir,
+ }
+ sessp, err := engine.NewSession(engineConfig)
+ if err != nil {
+ return nil, err
+ }
+ sess := &Session{sessp: sessp}
+ runtime.SetFinalizer(sess, sessionFinalizer)
+ ActiveSessions.Add(1)
+ return sess, nil
+}
+
+// sessionFinalizer finalizes a Session. While in general in Go code using a
+// finalizer is probably unclean, it seems that using a finalizer when binding
+// with Java/ObjC code is actually useful to simplify the apps.
+func sessionFinalizer(sess *Session) {
+ for _, fn := range sess.cl {
+ fn()
+ }
+ sess.sessp.Close() // ignore return value
+ ActiveSessions.Add(-1)
+}
+
+// Context is the context of an operation. You use this context
+// to cancel a long running operation by calling Cancel(). Because
+// you create a Context from a Session and because the Session is
+// keeping track of the Context instances it owns, you do don't
+// need to call the Cancel method when you're done.
+type Context struct {
+ cancel context.CancelFunc
+ ctx context.Context
+}
+
+// Cancel cancels pending operations using this context.
+func (ctx *Context) Cancel() {
+ ctx.cancel()
+}
+
+// NewContext creates an new interruptible Context.
+func (sess *Session) NewContext() *Context {
+ return sess.NewContextWithTimeout(-1)
+}
+
+// NewContextWithTimeout creates an new interruptible Context that will automatically
+// cancel itself after the given timeout. Setting a zero or negative timeout implies
+// there is no actual timeout configured for the Context.
+func (sess *Session) NewContextWithTimeout(timeout int64) *Context {
+ sess.mtx.Lock()
+ defer sess.mtx.Unlock()
+ ctx, origcancel := newContext(timeout)
+ ActiveContexts.Add(1)
+ var once sync.Once
+ cancel := func() {
+ once.Do(func() {
+ ActiveContexts.Add(-1)
+ origcancel()
+ })
+ }
+ sess.cl = append(sess.cl, cancel)
+ return &Context{cancel: cancel, ctx: ctx}
+}
+
+// GeolocateResults contains the GeolocateTask results.
+type GeolocateResults struct {
+ // ASN is the autonomous system number.
+ ASN string
+
+ // Country is the country code.
+ Country string
+
+ // IP is the IP address.
+ IP string
+
+ // Org is the commercial name of the ASN.
+ Org string
+}
+
+// MaybeUpdateResources ensures that resources are up to date.
+func (sess *Session) MaybeUpdateResources(ctx *Context) error {
+ sess.mtx.Lock()
+ defer sess.mtx.Unlock()
+ return sess.sessp.MaybeUpdateResources(ctx.ctx)
+}
+
+// Geolocate performs a geolocate operation and returns the results. This method
+// is (in Java terminology) synchronized with the session instance.
+func (sess *Session) Geolocate(ctx *Context) (*GeolocateResults, error) {
+ sess.mtx.Lock()
+ defer sess.mtx.Unlock()
+ info, err := sess.sessp.LookupLocationContext(ctx.ctx)
+ if err != nil {
+ return nil, err
+ }
+ return &GeolocateResults{
+ ASN: fmt.Sprintf("AS%d", info.ASN),
+ Country: info.CountryCode,
+ IP: info.ProbeIP,
+ Org: info.NetworkName,
+ }, nil
+}
+
+// SubmitMeasurementResults contains the results of a single measurement submission
+// to the OONI backends using the OONI collector API.
+type SubmitMeasurementResults struct {
+ UpdatedMeasurement string
+ UpdatedReportID string
+}
+
+// Submit submits the given measurement and returns the results. This method is (in
+// Java terminology) synchronized with the Session instance.
+func (sess *Session) Submit(ctx *Context, measurement string) (*SubmitMeasurementResults, error) {
+ sess.mtx.Lock()
+ defer sess.mtx.Unlock()
+ if sess.submitter == nil {
+ psc, err := sess.sessp.NewProbeServicesClient(ctx.ctx)
+ if err != nil {
+ return nil, err
+ }
+ sess.submitter = probeservices.NewSubmitter(psc, sess.sessp.Logger())
+ }
+ var mm model.Measurement
+ if err := json.Unmarshal([]byte(measurement), &mm); err != nil {
+ return nil, err
+ }
+ if err := sess.submitter.Submit(ctx.ctx, &mm); err != nil {
+ return nil, err
+ }
+ data, err := json.Marshal(mm)
+ runtimex.PanicOnError(err, "json.Marshal should not fail here")
+ return &SubmitMeasurementResults{
+ UpdatedMeasurement: string(data),
+ UpdatedReportID: mm.ReportID,
+ }, nil
+}
+
+// CheckInConfigWebConnectivity is the configuration for the WebConnectivity test
+type CheckInConfigWebConnectivity struct {
+ CategoryCodes []string // CategoryCodes is an array of category codes
+}
+
+// Add a category code to the array in CheckInConfigWebConnectivity
+func (ckw *CheckInConfigWebConnectivity) Add(cat string) {
+ ckw.CategoryCodes = append(ckw.CategoryCodes, cat)
+}
+
+func (ckw *CheckInConfigWebConnectivity) toModel() model.CheckInConfigWebConnectivity {
+ return model.CheckInConfigWebConnectivity{
+ CategoryCodes: ckw.CategoryCodes,
+ }
+}
+
+// CheckInConfig contains configuration for calling the checkin API.
+type CheckInConfig struct {
+ Charging bool // Charging indicate if the phone is actually charging
+ OnWiFi bool // OnWiFi indicate if the phone is actually connected to a WiFi network
+ Platform string // Platform of the probe
+ RunType string // RunType
+ SoftwareName string // SoftwareName of the probe
+ SoftwareVersion string // SoftwareVersion of the probe
+ WebConnectivity *CheckInConfigWebConnectivity // WebConnectivity class contain an array of categories
+}
+
+// CheckInInfoWebConnectivity contains the array of URLs returned by the checkin API
+type CheckInInfoWebConnectivity struct {
+ ReportID string
+ URLs []model.URLInfo
+}
+
+// URLInfo contains info on a test lists URL
+type URLInfo struct {
+ CategoryCode string
+ CountryCode string
+ URL string
+}
+
+// Size returns the number of URLs.
+func (ckw *CheckInInfoWebConnectivity) Size() int64 {
+ return int64(len(ckw.URLs))
+}
+
+// At gets the URLInfo at position idx from CheckInInfoWebConnectivity.URLs
+func (ckw *CheckInInfoWebConnectivity) At(idx int64) *URLInfo {
+ if idx < 0 || int(idx) >= len(ckw.URLs) {
+ return nil
+ }
+ w := ckw.URLs[idx]
+ return &URLInfo{
+ CategoryCode: w.CategoryCode,
+ CountryCode: w.CountryCode,
+ URL: w.URL,
+ }
+}
+
+func newCheckInInfoWebConnectivity(ckw *model.CheckInInfoWebConnectivity) *CheckInInfoWebConnectivity {
+ if ckw == nil {
+ return nil
+ }
+ out := new(CheckInInfoWebConnectivity)
+ out.ReportID = ckw.ReportID
+ out.URLs = ckw.URLs
+ return out
+}
+
+// CheckInInfo contains the return test objects from the checkin API
+type CheckInInfo struct {
+ WebConnectivity *CheckInInfoWebConnectivity
+}
+
+// CheckIn function is called by probes asking if there are tests to be run
+// The config argument contains the mandatory settings.
+// Returns the list of tests to run and the URLs, on success, or an explanatory error, in case of failure.
+func (sess *Session) CheckIn(ctx *Context, config *CheckInConfig) (*CheckInInfo, error) {
+ sess.mtx.Lock()
+ defer sess.mtx.Unlock()
+ if config.WebConnectivity == nil {
+ return nil, errors.New("oonimkall: missing webconnectivity config")
+ }
+ info, err := sess.sessp.LookupLocationContext(ctx.ctx)
+ if err != nil {
+ return nil, err
+ }
+ if sess.TestingCheckInBeforeNewProbeServicesClient != nil {
+ sess.TestingCheckInBeforeNewProbeServicesClient(ctx)
+ }
+ psc, err := sess.sessp.NewProbeServicesClient(ctx.ctx)
+ if err != nil {
+ return nil, err
+ }
+ if sess.TestingCheckInBeforeCheckIn != nil {
+ sess.TestingCheckInBeforeCheckIn(ctx)
+ }
+ cfg := model.CheckInConfig{
+ Charging: config.Charging,
+ OnWiFi: config.OnWiFi,
+ Platform: config.Platform,
+ ProbeASN: info.ASNString(),
+ ProbeCC: info.CountryCode,
+ RunType: config.RunType,
+ SoftwareVersion: config.SoftwareVersion,
+ WebConnectivity: config.WebConnectivity.toModel(),
+ }
+ result, err := psc.CheckIn(ctx.ctx, cfg)
+ if err != nil {
+ return nil, err
+ }
+ return &CheckInInfo{
+ WebConnectivity: newCheckInInfoWebConnectivity(result.WebConnectivity),
+ }, nil
+}
diff --git a/internal/engine/oonimkall/session_integration_test.go b/internal/engine/oonimkall/session_integration_test.go
new file mode 100644
index 0000000..04dc1b7
--- /dev/null
+++ b/internal/engine/oonimkall/session_integration_test.go
@@ -0,0 +1,440 @@
+package oonimkall_test
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/ioutil"
+ "os"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ engine "github.com/ooni/probe-cli/v3/internal/engine"
+ "github.com/ooni/probe-cli/v3/internal/engine/geolocate"
+ "github.com/ooni/probe-cli/v3/internal/engine/model"
+ "github.com/ooni/probe-cli/v3/internal/engine/oonimkall"
+)
+
+func NewSessionWithAssetsDir(assetsDir string) (*oonimkall.Session, error) {
+ return oonimkall.NewSession(&oonimkall.SessionConfig{
+ AssetsDir: assetsDir,
+ ProbeServicesURL: "https://ams-pg-test.ooni.org/",
+ SoftwareName: "oonimkall-test",
+ SoftwareVersion: "0.1.0",
+ StateDir: "../testdata/oonimkall/state",
+ TempDir: "../testdata/",
+ })
+}
+
+func NewSession() (*oonimkall.Session, error) {
+ return NewSessionWithAssetsDir("../testdata/oonimkall/assets")
+}
+
+func TestNewSessionWithInvalidStateDir(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ sess, err := oonimkall.NewSession(&oonimkall.SessionConfig{
+ StateDir: "",
+ })
+ if err == nil || !strings.HasSuffix(err.Error(), "no such file or directory") {
+ t.Fatal("not the error we expected")
+ }
+ if sess != nil {
+ t.Fatal("expected a nil Session here")
+ }
+}
+
+func TestNewSessionWithMissingSoftwareName(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ sess, err := oonimkall.NewSession(&oonimkall.SessionConfig{
+ StateDir: "../testdata/oonimkall/state",
+ })
+ if err == nil || err.Error() != "AssetsDir is empty" {
+ t.Fatal("not the error we expected")
+ }
+ if sess != nil {
+ t.Fatal("expected a nil Session here")
+ }
+}
+
+func TestMaybeUpdateResourcesWithCancelledContext(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ dir, err := ioutil.TempDir("", "xx")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer os.RemoveAll(dir)
+ sess, err := NewSessionWithAssetsDir(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx := sess.NewContext()
+ ctx.Cancel() // cause immediate failure
+ err = sess.MaybeUpdateResources(ctx)
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("not the error we expected: %+v", err)
+ }
+}
+
+func ReduceErrorForGeolocate(err error) error {
+ if err == nil {
+ return errors.New("we expected an error here")
+ }
+ if errors.Is(err, context.Canceled) {
+ return nil // when we have not downloaded the resources yet
+ }
+ if !errors.Is(err, geolocate.ErrAllIPLookuppersFailed) {
+ return nil // otherwise
+ }
+ return fmt.Errorf("not the error we expected: %w", err)
+}
+
+func TestGeolocateWithCancelledContext(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ sess, err := NewSession()
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx := sess.NewContext()
+ ctx.Cancel() // cause immediate failure
+ location, err := sess.Geolocate(ctx)
+ if err := ReduceErrorForGeolocate(err); err != nil {
+ t.Fatal(err)
+ }
+ if location != nil {
+ t.Fatal("expected nil location here")
+ }
+}
+
+func TestGeolocateGood(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ sess, err := NewSession()
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx := sess.NewContext()
+ location, err := sess.Geolocate(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if location.ASN == "" {
+ t.Fatal("location.ASN is empty")
+ }
+ if location.Country == "" {
+ t.Fatal("location.Country is empty")
+ }
+ if location.IP == "" {
+ t.Fatal("location.IP is empty")
+ }
+ if location.Org == "" {
+ t.Fatal("location.Org is empty")
+ }
+}
+
+func ReduceErrorForSubmitter(err error) error {
+ if err == nil {
+ return errors.New("we expected an error here")
+ }
+ if errors.Is(err, context.Canceled) {
+ return nil // when we have not downloaded the resources yet
+ }
+ if err.Error() == "all available probe services failed" {
+ return nil // otherwise
+ }
+ return fmt.Errorf("not the error we expected: %w", err)
+}
+
+func TestSubmitWithCancelledContext(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ sess, err := NewSession()
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx := sess.NewContext()
+ ctx.Cancel() // cause immediate failure
+ result, err := sess.Submit(ctx, "{}")
+ if err := ReduceErrorForSubmitter(err); err != nil {
+ t.Fatalf("not the error we expected: %+v", err)
+ }
+ if result != nil {
+ t.Fatal("expected nil result here")
+ }
+}
+
+func TestSubmitWithInvalidJSON(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ sess, err := NewSession()
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx := sess.NewContext()
+ result, err := sess.Submit(ctx, "{")
+ if err == nil || err.Error() != "unexpected end of JSON input" {
+ t.Fatalf("not the error we expected: %+v", err)
+ }
+ if result != nil {
+ t.Fatal("expected nil result here")
+ }
+}
+
+func DoSubmission(ctx *oonimkall.Context, sess *oonimkall.Session) error {
+ inputm := model.Measurement{
+ DataFormatVersion: "0.2.0",
+ MeasurementStartTime: "2019-10-28 12:51:07",
+ MeasurementRuntime: 1.71,
+ ProbeASN: "AS30722",
+ ProbeCC: "IT",
+ ProbeIP: "127.0.0.1",
+ ReportID: "",
+ ResolverIP: "172.217.33.129",
+ SoftwareName: "miniooni",
+ SoftwareVersion: "0.1.0-dev",
+ TestKeys: map[string]bool{"success": true},
+ TestName: "example",
+ TestVersion: "0.1.0",
+ }
+ inputd, err := json.Marshal(inputm)
+ if err != nil {
+ return err
+ }
+ result, err := sess.Submit(ctx, string(inputd))
+ if err != nil {
+ return fmt.Errorf("session_test.go: submit failed: %w", err)
+ }
+ if result.UpdatedMeasurement == "" {
+ return errors.New("expected non empty measurement")
+ }
+ if result.UpdatedReportID == "" {
+ return errors.New("expected non empty report ID")
+ }
+ var outputm model.Measurement
+ return json.Unmarshal([]byte(result.UpdatedMeasurement), &outputm)
+}
+
+func TestSubmitMeasurementGood(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ sess, err := NewSession()
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx := sess.NewContext()
+ if err := DoSubmission(ctx, sess); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestSubmitCancelContextAfterFirstSubmission(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ sess, err := NewSession()
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx := sess.NewContext()
+ if err := DoSubmission(ctx, sess); err != nil {
+ t.Fatal(err)
+ }
+ ctx.Cancel() // fail second submission
+ err = DoSubmission(ctx, sess)
+ if err == nil || !strings.HasPrefix(err.Error(), "session_test.go: submit failed") {
+ t.Fatalf("not the error we expected: %+v", err)
+ }
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("not the error we expected: %+v", err)
+ }
+}
+
+func TestCheckInSuccess(t *testing.T) {
+ sess, err := NewSession()
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx := sess.NewContext()
+ config := oonimkall.CheckInConfig{
+ Charging: true,
+ OnWiFi: true,
+ Platform: "android",
+ RunType: "timed",
+ SoftwareName: "ooniprobe-android",
+ SoftwareVersion: "2.7.1",
+ WebConnectivity: &oonimkall.CheckInConfigWebConnectivity{},
+ }
+ config.WebConnectivity.Add("NEWS")
+ config.WebConnectivity.Add("CULTR")
+ result, err := sess.CheckIn(ctx, &config)
+ if err != nil {
+ t.Fatalf("unexpected error: %+v", err)
+ }
+ if result == nil || result.WebConnectivity == nil {
+ t.Fatal("got nil result or WebConnectivity")
+ }
+ if len(result.WebConnectivity.URLs) < 1 {
+ t.Fatal("unexpected number of URLs")
+ }
+ if result.WebConnectivity.ReportID == "" {
+ t.Fatal("got empty report ID")
+ }
+ siz := result.WebConnectivity.Size()
+ if siz <= 0 {
+ t.Fatal("unexpected number of URLs")
+ }
+ for idx := int64(0); idx < siz; idx++ {
+ entry := result.WebConnectivity.At(idx)
+ if entry.CategoryCode != "NEWS" && entry.CategoryCode != "CULTR" {
+ t.Fatalf("unexpected category code: %+v", entry)
+ }
+ }
+ if result.WebConnectivity.At(-1) != nil {
+ t.Fatal("expected nil here")
+ }
+ if result.WebConnectivity.At(siz) != nil {
+ t.Fatal("expected nil here")
+ }
+}
+
+func TestCheckInLookupLocationFailure(t *testing.T) {
+ sess, err := NewSession()
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx := sess.NewContext()
+ config := oonimkall.CheckInConfig{
+ Charging: true,
+ OnWiFi: true,
+ Platform: "android",
+ RunType: "timed",
+ SoftwareName: "ooniprobe-android",
+ SoftwareVersion: "2.7.1",
+ WebConnectivity: &oonimkall.CheckInConfigWebConnectivity{},
+ }
+ config.WebConnectivity.Add("NEWS")
+ config.WebConnectivity.Add("CULTR")
+ ctx.Cancel() // immediate failure
+ result, err := sess.CheckIn(ctx, &config)
+ if !errors.Is(err, geolocate.ErrAllIPLookuppersFailed) {
+ t.Fatalf("not the error we expected: %+v", err)
+ }
+ if result != nil {
+ t.Fatal("expected nil result here")
+ }
+}
+
+func TestCheckInNewProbeServicesFailure(t *testing.T) {
+ sess, err := NewSession()
+ if err != nil {
+ t.Fatal(err)
+ }
+ sess.TestingCheckInBeforeNewProbeServicesClient = func(ctx *oonimkall.Context) {
+ ctx.Cancel() // cancel execution
+ }
+ ctx := sess.NewContext()
+ config := oonimkall.CheckInConfig{
+ Charging: true,
+ OnWiFi: true,
+ Platform: "android",
+ RunType: "timed",
+ SoftwareName: "ooniprobe-android",
+ SoftwareVersion: "2.7.1",
+ WebConnectivity: &oonimkall.CheckInConfigWebConnectivity{},
+ }
+ config.WebConnectivity.Add("NEWS")
+ config.WebConnectivity.Add("CULTR")
+ result, err := sess.CheckIn(ctx, &config)
+ if !errors.Is(err, engine.ErrAllProbeServicesFailed) {
+ t.Fatalf("not the error we expected: %+v", err)
+ }
+ if result != nil {
+ t.Fatal("expected nil result here")
+ }
+}
+
+func TestCheckInCheckInFailure(t *testing.T) {
+ sess, err := NewSession()
+ if err != nil {
+ t.Fatal(err)
+ }
+ sess.TestingCheckInBeforeCheckIn = func(ctx *oonimkall.Context) {
+ ctx.Cancel() // cancel execution
+ }
+ ctx := sess.NewContext()
+ config := oonimkall.CheckInConfig{
+ Charging: true,
+ OnWiFi: true,
+ Platform: "android",
+ RunType: "timed",
+ SoftwareName: "ooniprobe-android",
+ SoftwareVersion: "2.7.1",
+ WebConnectivity: &oonimkall.CheckInConfigWebConnectivity{},
+ }
+ config.WebConnectivity.Add("NEWS")
+ config.WebConnectivity.Add("CULTR")
+ result, err := sess.CheckIn(ctx, &config)
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("not the error we expected: %+v", err)
+ }
+ if result != nil {
+ t.Fatal("expected nil result here")
+ }
+}
+
+func TestCheckInNoParams(t *testing.T) {
+ sess, err := NewSession()
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx := sess.NewContext()
+ config := oonimkall.CheckInConfig{
+ Charging: true,
+ OnWiFi: true,
+ Platform: "android",
+ RunType: "timed",
+ SoftwareName: "ooniprobe-android",
+ SoftwareVersion: "2.7.1",
+ }
+ result, err := sess.CheckIn(ctx, &config)
+ if err == nil || err.Error() != "oonimkall: missing webconnectivity config" {
+ t.Fatalf("not the error we expected: %+v", err)
+ }
+ if result != nil {
+ t.Fatal("unexpected not nil result here")
+ }
+}
+
+func TestMain(m *testing.M) {
+ // Here we're basically testing whether eventually the finalizers
+ // will run and the number of active sessions and cancels will become
+ // balanced. Especially for the number of active cancels, this is an
+ // indication that we've correctly cleaned them up in the session.
+ if exitcode := m.Run(); exitcode != 0 {
+ os.Exit(exitcode)
+ }
+ for {
+ runtime.GC()
+ m, n := oonimkall.ActiveContexts.Load(), oonimkall.ActiveSessions.Load()
+ fmt.Printf("./oonimkall: ActiveContexts: %d; ActiveSessions: %d\n", m, n)
+ if m == 0 && n == 0 {
+ break
+ }
+ time.Sleep(1 * time.Second)
+ }
+ os.Exit(0)
+}
diff --git a/internal/engine/oonimkall/session_test.go b/internal/engine/oonimkall/session_test.go
new file mode 100644
index 0000000..19ca838
--- /dev/null
+++ b/internal/engine/oonimkall/session_test.go
@@ -0,0 +1,10 @@
+package oonimkall
+
+import "testing"
+
+func TestNewCheckInInfoWebConnectivityNilPointer(t *testing.T) {
+ out := newCheckInInfoWebConnectivity(nil)
+ if out != nil {
+ t.Fatal("expected nil pointer")
+ }
+}
diff --git a/internal/engine/oonimkall/sessioncontext.go b/internal/engine/oonimkall/sessioncontext.go
new file mode 100644
index 0000000..295e117
--- /dev/null
+++ b/internal/engine/oonimkall/sessioncontext.go
@@ -0,0 +1,29 @@
+package oonimkall
+
+import (
+ "context"
+ "math"
+ "time"
+)
+
+const maxTimeout = int64(time.Duration(math.MaxInt64) / time.Second)
+
+func clampTimeout(timeout, max int64) int64 {
+ if timeout > max {
+ timeout = max
+ }
+ return timeout
+}
+
+func newContext(timeout int64) (context.Context, context.CancelFunc) {
+ return newContextEx(timeout, maxTimeout)
+}
+
+func newContextEx(timeout, max int64) (context.Context, context.CancelFunc) {
+ if timeout > 0 {
+ timeout = clampTimeout(timeout, max)
+ return context.WithTimeout(
+ context.Background(), time.Duration(timeout)*time.Second)
+ }
+ return context.WithCancel(context.Background())
+}
diff --git a/internal/engine/oonimkall/sessioncontext_test.go b/internal/engine/oonimkall/sessioncontext_test.go
new file mode 100644
index 0000000..0d3a96b
--- /dev/null
+++ b/internal/engine/oonimkall/sessioncontext_test.go
@@ -0,0 +1,102 @@
+package oonimkall
+
+import (
+ "testing"
+ "time"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/atomicx"
+)
+
+func TestClampTimeout(t *testing.T) {
+ if clampTimeout(-1, maxTimeout) != -1 {
+ t.Fatal("unexpected result here")
+ }
+ if clampTimeout(0, maxTimeout) != 0 {
+ t.Fatal("unexpected result here")
+ }
+ if clampTimeout(60, maxTimeout) != 60 {
+ t.Fatal("unexpected result here")
+ }
+ if clampTimeout(maxTimeout, maxTimeout) != maxTimeout {
+ t.Fatal("unexpected result here")
+ }
+ if clampTimeout(maxTimeout+1, maxTimeout) != maxTimeout {
+ t.Fatal("unexpected result here")
+ }
+}
+
+func TestNewContextWithZeroTimeout(t *testing.T) {
+ here := atomicx.NewInt64()
+ ctx, cancel := newContext(0)
+ defer cancel()
+ go func() {
+ <-time.After(250 * time.Millisecond)
+ here.Add(1)
+ cancel()
+ }()
+ <-ctx.Done()
+ if here.Load() != 1 {
+ t.Fatal("context timeout not working as intended")
+ }
+}
+
+func TestNewContextWithNegativeTimeout(t *testing.T) {
+ here := atomicx.NewInt64()
+ ctx, cancel := newContext(-1)
+ defer cancel()
+ go func() {
+ <-time.After(250 * time.Millisecond)
+ here.Add(1)
+ cancel()
+ }()
+ <-ctx.Done()
+ if here.Load() != 1 {
+ t.Fatal("context timeout not working as intended")
+ }
+}
+
+func TestNewContextWithHugeTimeout(t *testing.T) {
+ here := atomicx.NewInt64()
+ ctx, cancel := newContext(maxTimeout + 1)
+ defer cancel()
+ go func() {
+ <-time.After(250 * time.Millisecond)
+ here.Add(1)
+ cancel()
+ }()
+ <-ctx.Done()
+ if here.Load() != 1 {
+ t.Fatal("context timeout not working as intended")
+ }
+}
+
+func TestNewContextWithReasonableTimeout(t *testing.T) {
+ here := atomicx.NewInt64()
+ ctx, cancel := newContext(1)
+ defer cancel()
+ go func() {
+ <-time.After(5 * time.Second)
+ here.Add(1)
+ cancel()
+ }()
+ <-ctx.Done()
+ if here.Load() != 0 {
+ t.Fatal("context timeout not working as intended")
+ }
+}
+
+func TestNewContextWithArtificiallyLowMaxTimeout(t *testing.T) {
+ here := atomicx.NewInt64()
+ const maxTimeout = 2
+ ctx, cancel := newContextEx(maxTimeout+1, maxTimeout)
+ defer cancel()
+ go func() {
+ <-time.After(30 * time.Second)
+ here.Add(1)
+ cancel()
+ }()
+ <-ctx.Done()
+ if here.Load() != 0 {
+ t.Fatal("context timeout not working as intended")
+ }
+}
diff --git a/internal/engine/oonimkall/sessionlogger.go b/internal/engine/oonimkall/sessionlogger.go
new file mode 100644
index 0000000..9d9ab55
--- /dev/null
+++ b/internal/engine/oonimkall/sessionlogger.go
@@ -0,0 +1,69 @@
+package oonimkall
+
+import (
+ "fmt"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/model"
+)
+
+type loggerVerbose struct {
+ Logger
+}
+
+func (slv loggerVerbose) Debugf(format string, v ...interface{}) {
+ slv.Debug(fmt.Sprintf(format, v...))
+}
+func (slv loggerVerbose) Infof(format string, v ...interface{}) {
+ slv.Info(fmt.Sprintf(format, v...))
+}
+func (slv loggerVerbose) Warnf(format string, v ...interface{}) {
+ slv.Warn(fmt.Sprintf(format, v...))
+}
+
+type loggerNormal struct {
+ Logger
+}
+
+func (sln loggerNormal) Debugf(format string, v ...interface{}) {
+ // nothing
+}
+func (sln loggerNormal) Debug(msg string) {
+ // nothing
+}
+func (sln loggerNormal) Infof(format string, v ...interface{}) {
+ sln.Info(fmt.Sprintf(format, v...))
+}
+func (sln loggerNormal) Warnf(format string, v ...interface{}) {
+ sln.Warn(fmt.Sprintf(format, v...))
+}
+
+type loggerQuiet struct{}
+
+func (loggerQuiet) Debugf(format string, v ...interface{}) {
+ // nothing
+}
+func (loggerQuiet) Debug(msg string) {
+ // nothing
+}
+func (loggerQuiet) Infof(format string, v ...interface{}) {
+ // nothing
+}
+func (loggerQuiet) Info(msg string) {
+ // nothing
+}
+func (loggerQuiet) Warnf(format string, v ...interface{}) {
+ // nothing
+}
+func (loggerQuiet) Warn(msg string) {
+ // nothing
+}
+
+func newLogger(logger Logger, verbose bool) model.Logger {
+ if logger == nil {
+ return loggerQuiet{}
+ }
+ if verbose {
+ return loggerVerbose{Logger: logger}
+ }
+ return loggerNormal{Logger: logger}
+}
diff --git a/internal/engine/oonimkall/sessionlogger_test.go b/internal/engine/oonimkall/sessionlogger_test.go
new file mode 100644
index 0000000..c0c4e08
--- /dev/null
+++ b/internal/engine/oonimkall/sessionlogger_test.go
@@ -0,0 +1,118 @@
+package oonimkall
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "sync"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/ooni/probe-cli/v3/internal/engine/model"
+)
+
+type RecordingLogger struct {
+ DebugLogs []string
+ InfoLogs []string
+ WarnLogs []string
+ mu sync.Mutex
+}
+
+func (rl *RecordingLogger) Debug(msg string) {
+ rl.mu.Lock()
+ defer rl.mu.Unlock()
+ rl.DebugLogs = append(rl.DebugLogs, msg)
+}
+
+func (rl *RecordingLogger) Info(msg string) {
+ rl.mu.Lock()
+ defer rl.mu.Unlock()
+ rl.InfoLogs = append(rl.InfoLogs, msg)
+}
+
+func (rl *RecordingLogger) Warn(msg string) {
+ rl.mu.Lock()
+ defer rl.mu.Unlock()
+ rl.WarnLogs = append(rl.WarnLogs, msg)
+}
+
+func LoggerEmitMessages(logger model.Logger) {
+ logger.Warnf("a formatted warn message: %+v", io.EOF)
+ logger.Warn("a warn string")
+ logger.Infof("a formatted info message: %+v", io.EOF)
+ logger.Info("a info string")
+ logger.Debugf("a formatted debug message: %+v", io.EOF)
+ logger.Debug("a debug string")
+}
+
+func TestNewLoggerNilLogger(t *testing.T) {
+ // The objective of this test is to make sure that, even if the
+ // Logger instance is nil, we get back something that works, that
+ // is, something that does not crash when it is used.
+ logger := newLogger(nil, true)
+ LoggerEmitMessages(logger)
+}
+
+func (rl *RecordingLogger) VerifyNumberOfEntries(debugEntries int) error {
+ if len(rl.DebugLogs) != debugEntries {
+ return errors.New("unexpected number of debug messages")
+ }
+ if len(rl.InfoLogs) != 2 {
+ return errors.New("unexpected number of info messages")
+ }
+ if len(rl.WarnLogs) != 2 {
+ return errors.New("unexpected number of warn messages")
+ }
+ return nil
+}
+
+func (rl *RecordingLogger) ExpectedEntries(level string) []string {
+ return []string{
+ fmt.Sprintf("a formatted %s message: %+v", level, io.EOF),
+ fmt.Sprintf("a %s string", level),
+ }
+}
+
+func (rl *RecordingLogger) CheckNonVerboseEntries() error {
+ if diff := cmp.Diff(rl.InfoLogs, rl.ExpectedEntries("info")); diff != "" {
+ return errors.New(diff)
+ }
+ if diff := cmp.Diff(rl.WarnLogs, rl.ExpectedEntries("warn")); diff != "" {
+ return errors.New(diff)
+ }
+ return nil
+}
+
+func (rl *RecordingLogger) CheckVerboseEntries() error {
+ if diff := cmp.Diff(rl.DebugLogs, rl.ExpectedEntries("debug")); diff != "" {
+ return errors.New(diff)
+ }
+ return nil
+}
+
+func TestNewLoggerQuietLogger(t *testing.T) {
+ handler := new(RecordingLogger)
+ logger := newLogger(handler, false)
+ LoggerEmitMessages(logger)
+ if err := handler.VerifyNumberOfEntries(0); err != nil {
+ t.Fatal(err)
+ }
+ if err := handler.CheckNonVerboseEntries(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestNewLoggerVerboseLogger(t *testing.T) {
+ handler := new(RecordingLogger)
+ logger := newLogger(handler, true)
+ LoggerEmitMessages(logger)
+ if err := handler.VerifyNumberOfEntries(2); err != nil {
+ t.Fatal(err)
+ }
+ if err := handler.CheckNonVerboseEntries(); err != nil {
+ t.Fatal(err)
+ }
+ if err := handler.CheckVerboseEntries(); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/internal/engine/oonimkall/task.go b/internal/engine/oonimkall/task.go
new file mode 100644
index 0000000..c256467
--- /dev/null
+++ b/internal/engine/oonimkall/task.go
@@ -0,0 +1,114 @@
+// Package oonimkall implements APIs used by OONI mobile apps. We
+// expose these APIs to mobile apps using gomobile.
+//
+// We expose two APIs: the task API, which is derived from the
+// API originally exposed by Measurement Kit, and the session API,
+// which is a Go API that mobile apps can use via `gomobile`.
+//
+// This package is named oonimkall because it contains a partial
+// reimplementation of the mkall API implemented by Measurement Kit
+// in, e.g., https://github.com/measurement-kit/mkall-ios.
+//
+// Task API
+//
+// The basic tenet of the task API is that you define an experiment
+// task you wanna run using a JSON, then you start a task for it, and
+// you receive events as serialized JSONs. In addition to this
+// functionality, we also include extra APIs used by OONI mobile.
+//
+// The task API was first defined in Measurement Kit v0.9.0. In this
+// context, it was called "the FFI API". The API we expose here is not
+// strictly an FFI API, but is close enough for the purpose of using
+// OONI from Android and iOS. See https://git.io/Jv4Rv
+// (measurement-kit/measurement-kit@v0.10.9) for a comprehensive
+// description of MK's FFI API.
+//
+// See also https://github.com/ooni/probe-cli/v3/internal/engine/pull/347 for the
+// design document describing the task API.
+//
+// See also https://github.com/ooni/probe-cli/v3/internal/engine/blob/master/DESIGN.md,
+// which explains why we implemented the oonimkall API.
+//
+// Session API
+//
+// The Session API is a Go API that can be exported to mobile apps
+// using the gomobile tool. The latest design document for this API is
+// at https://github.com/ooni/probe-cli/v3/internal/engine/pull/954.
+//
+// The basic tenet of the session API is that you create an instance
+// of `Session` and use it to perform the operations you need.
+package oonimkall
+
+import (
+ "context"
+ "encoding/json"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/atomicx"
+ "github.com/ooni/probe-cli/v3/internal/engine/internal/runtimex"
+ "github.com/ooni/probe-cli/v3/internal/engine/oonimkall/tasks"
+)
+
+// Task is an asynchronous task running an experiment. It mimics the
+// namesake concept initially implemented in Measurement Kit.
+//
+// Future directions
+//
+// Currently Task and Session are two unrelated APIs. As part of
+// evolving the APIs with which apps interact with the engine, we
+// will modify Task to run in the context of a Session. We will
+// do that to save extra lookups and to allow several experiments
+// running as subsequent Tasks to reuse the Session connections
+// created with the OONI probe services backends.
+type Task struct {
+ cancel context.CancelFunc
+ isdone *atomicx.Int64
+ isstopped *atomicx.Int64
+ out chan *tasks.Event
+}
+
+// StartTask starts an asynchronous task. The input argument is a
+// serialized JSON conforming to MK v0.10.9's API.
+func StartTask(input string) (*Task, error) {
+ var settings tasks.Settings
+ if err := json.Unmarshal([]byte(input), &settings); err != nil {
+ return nil, err
+ }
+ const bufsiz = 128 // common case: we don't want runner to block
+ ctx, cancel := context.WithCancel(context.Background())
+ task := &Task{
+ cancel: cancel,
+ isdone: atomicx.NewInt64(),
+ isstopped: atomicx.NewInt64(),
+ out: make(chan *tasks.Event, bufsiz),
+ }
+ go func() {
+ defer close(task.out)
+ defer task.isstopped.Add(1)
+ tasks.Run(ctx, &settings, task.out)
+ }()
+ return task, nil
+}
+
+// WaitForNextEvent blocks until the next event occurs. The returned
+// string is a serialized JSON following MK v0.10.9's API.
+func (t *Task) WaitForNextEvent() string {
+ const terminated = `{"key":"task_terminated","value":{}}` // like MK
+ evp := <-t.out
+ if evp == nil {
+ t.isdone.Add(1)
+ return terminated
+ }
+ data, err := json.Marshal(evp)
+ runtimex.PanicOnError(err, "json.Marshal failed")
+ return string(data)
+}
+
+// IsDone returns true if the task is done.
+func (t *Task) IsDone() bool {
+ return t.isdone.Load() != 0
+}
+
+// Interrupt interrupts the task.
+func (t *Task) Interrupt() {
+ t.cancel()
+}
diff --git a/internal/engine/oonimkall/task_integration_test.go b/internal/engine/oonimkall/task_integration_test.go
new file mode 100644
index 0000000..7415778
--- /dev/null
+++ b/internal/engine/oonimkall/task_integration_test.go
@@ -0,0 +1,560 @@
+package oonimkall_test
+
+import (
+ "encoding/json"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/ooni/probe-cli/v3/internal/engine/model"
+ "github.com/ooni/probe-cli/v3/internal/engine/oonimkall"
+ "github.com/ooni/probe-cli/v3/internal/engine/oonimkall/tasks"
+)
+
+type eventlike struct {
+ Key string `json:"key"`
+ Value map[string]interface{} `json:"value"`
+}
+
+func TestGood(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ task, err := oonimkall.StartTask(`{
+ "assets_dir": "../testdata/oonimkall/assets",
+ "log_level": "DEBUG",
+ "name": "Example",
+ "options": {
+ "software_name": "oonimkall-test",
+ "software_version": "0.1.0"
+ },
+ "state_dir": "../testdata/oonimkall/state",
+ "version": 1
+ }`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // interrupt the task so we also exercise this functionality
+ go func() {
+ <-time.After(time.Second)
+ task.Interrupt()
+ }()
+ for !task.IsDone() {
+ eventstr := task.WaitForNextEvent()
+ var event eventlike
+ if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
+ t.Fatal(err)
+ }
+ if event.Key == "failure.startup" {
+ t.Fatal("unexpected failure.startup event")
+ }
+ }
+ // make sure we only see task_terminated at this point
+ for {
+ eventstr := task.WaitForNextEvent()
+ var event eventlike
+ if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
+ t.Fatal(err)
+ }
+ if event.Key != "task_terminated" {
+ t.Fatalf("unexpected event.Key: %s", event.Key)
+ }
+ break
+ }
+}
+
+func TestWithMeasurementFailure(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ task, err := oonimkall.StartTask(`{
+ "assets_dir": "../testdata/oonimkall/assets",
+ "log_level": "DEBUG",
+ "name": "ExampleWithFailure",
+ "options": {
+ "no_geoip": true,
+ "no_resolver_lookup": true,
+ "software_name": "oonimkall-test",
+ "software_version": "0.1.0"
+ },
+ "state_dir": "../testdata/oonimkall/state",
+ "version": 1
+ }`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for !task.IsDone() {
+ eventstr := task.WaitForNextEvent()
+ var event eventlike
+ if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
+ t.Fatal(err)
+ }
+ if event.Key == "failure.startup" {
+ t.Fatal("unexpected failure.startup event")
+ }
+ }
+}
+
+func TestInvalidJSON(t *testing.T) {
+ task, err := oonimkall.StartTask(`{`)
+ var syntaxerr *json.SyntaxError
+ if !errors.As(err, &syntaxerr) {
+ t.Fatal("not the expected error")
+ }
+ if task != nil {
+ t.Fatal("task is not nil")
+ }
+}
+
+func TestUnsupportedSetting(t *testing.T) {
+ task, err := oonimkall.StartTask(`{
+ "assets_dir": "../testdata/oonimkall/assets",
+ "log_level": "DEBUG",
+ "name": "Example",
+ "options": {
+ "software_name": "oonimkall-test",
+ "software_version": "0.1.0"
+ },
+ "state_dir": "../testdata/oonimkall/state"
+ }`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var seen bool
+ for !task.IsDone() {
+ eventstr := task.WaitForNextEvent()
+ var event eventlike
+ if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
+ t.Fatal(err)
+ }
+ if event.Key == "failure.startup" {
+ if strings.Contains(eventstr, tasks.FailureInvalidVersion) {
+ seen = true
+ }
+ }
+ }
+ if !seen {
+ t.Fatal("did not see failure.startup with invalid version info")
+ }
+}
+
+func TestEmptyStateDir(t *testing.T) {
+ task, err := oonimkall.StartTask(`{
+ "assets_dir": "../testdata/oonimkall/assets",
+ "log_level": "DEBUG",
+ "name": "Example",
+ "options": {
+ "software_name": "oonimkall-test",
+ "software_version": "0.1.0"
+ },
+ "version": 1
+ }`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var seen bool
+ for !task.IsDone() {
+ eventstr := task.WaitForNextEvent()
+ var event eventlike
+ if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
+ t.Fatal(err)
+ }
+ if event.Key == "failure.startup" {
+ if strings.Contains(eventstr, "mkdir : no such file or directory") {
+ seen = true
+ }
+ }
+ }
+ if !seen {
+ t.Fatal("did not see failure.startup with info that state dir is empty")
+ }
+}
+
+func TestEmptyAssetsDir(t *testing.T) {
+ task, err := oonimkall.StartTask(`{
+ "log_level": "DEBUG",
+ "name": "Example",
+ "options": {
+ "software_name": "oonimkall-test",
+ "software_version": "0.1.0"
+ },
+ "state_dir": "../testdata/oonimkall/state",
+ "version": 1
+ }`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var seen bool
+ for !task.IsDone() {
+ eventstr := task.WaitForNextEvent()
+ var event eventlike
+ if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
+ t.Fatal(err)
+ }
+ if event.Key == "failure.startup" {
+ if strings.Contains(eventstr, "AssetsDir is empty") {
+ seen = true
+ }
+ }
+ }
+ if !seen {
+ t.Fatal("did not see failure.startup")
+ }
+}
+
+func TestUnknownExperiment(t *testing.T) {
+ task, err := oonimkall.StartTask(`{
+ "assets_dir": "../testdata/oonimkall/assets",
+ "log_level": "DEBUG",
+ "name": "Antani",
+ "options": {
+ "software_name": "oonimkall-test",
+ "software_version": "0.1.0"
+ },
+ "state_dir": "../testdata/oonimkall/state",
+ "version": 1
+ }`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var seen bool
+ for !task.IsDone() {
+ eventstr := task.WaitForNextEvent()
+ var event eventlike
+ if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
+ t.Fatal(err)
+ }
+ if event.Key == "failure.startup" {
+ if strings.Contains(eventstr, "no such experiment: ") {
+ seen = true
+ }
+ }
+ }
+ if !seen {
+ t.Fatal("did not see failure.startup")
+ }
+}
+
+func TestInputIsRequired(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ task, err := oonimkall.StartTask(`{
+ "assets_dir": "../testdata/oonimkall/assets",
+ "log_level": "DEBUG",
+ "name": "ExampleWithInput",
+ "options": {
+ "software_name": "oonimkall-test",
+ "software_version": "0.1.0"
+ },
+ "state_dir": "../testdata/oonimkall/state",
+ "version": 1
+ }`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var seen bool
+ for !task.IsDone() {
+ eventstr := task.WaitForNextEvent()
+ var event eventlike
+ if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
+ t.Fatal(err)
+ }
+ if event.Key == "failure.startup" {
+ if strings.Contains(eventstr, "no input provided") {
+ seen = true
+ }
+ }
+ }
+ if !seen {
+ t.Fatal("did not see failure.startup")
+ }
+}
+
+func TestMaxRuntime(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ begin := time.Now()
+ task, err := oonimkall.StartTask(`{
+ "assets_dir": "../testdata/oonimkall/assets",
+ "inputs": ["a", "b", "c"],
+ "name": "ExampleWithInput",
+ "options": {
+ "max_runtime": 1,
+ "software_name": "oonimkall-test",
+ "software_version": "0.1.0"
+ },
+ "state_dir": "../testdata/oonimkall/state",
+ "version": 1
+ }`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for !task.IsDone() {
+ eventstr := task.WaitForNextEvent()
+ var event eventlike
+ if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
+ t.Fatal(err)
+ }
+ if event.Key == "failure.startup" {
+ t.Fatal(eventstr)
+ }
+ }
+ // The runtime is long because of ancillary operations and is even more
+ // longer because of self shaping we may be performing (especially in
+ // CI builds) using `-tags shaping`). We have experimentally determined
+ // that ~10 seconds is the typical CI test run time. See:
+ //
+ // 1. https://github.com/ooni/probe-cli/v3/internal/engine/pull/588/checks?check_run_id=667263788
+ //
+ // 2. https://github.com/ooni/probe-cli/v3/internal/engine/pull/588/checks?check_run_id=667263855
+ //
+ // In case there are further timeouts, e.g. in the sessionresolver, the
+ // time used by the experiment will be much more. This is for example the
+ // case in https://github.com/ooni/probe-cli/v3/internal/engine/issues/1005.
+ if time.Now().Sub(begin) > 10*time.Second {
+ t.Fatal("expected shorter runtime")
+ }
+}
+
+func TestInterruptExampleWithInput(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ t.Skip("Skipping broken test; see https://github.com/ooni/probe-cli/v3/internal/engine/issues/992")
+ task, err := oonimkall.StartTask(`{
+ "assets_dir": "../testdata/oonimkall/assets",
+ "inputs": [
+ "http://www.kernel.org/",
+ "http://www.x.org/",
+ "http://www.microsoft.com/",
+ "http://www.slashdot.org/",
+ "http://www.repubblica.it/",
+ "http://www.google.it/",
+ "http://ooni.org/"
+ ],
+ "name": "ExampleWithInputNonInterruptible",
+ "options": {
+ "software_name": "oonimkall-test",
+ "software_version": "0.1.0"
+ },
+ "state_dir": "../testdata/oonimkall/state",
+ "version": 1
+ }`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var keys []string
+ for !task.IsDone() {
+ eventstr := task.WaitForNextEvent()
+ var event eventlike
+ if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
+ t.Fatal(err)
+ }
+ switch event.Key {
+ case "failure.startup":
+ t.Fatal(eventstr)
+ case "status.measurement_start":
+ go task.Interrupt()
+ }
+ // We compress the keys. What matters is basically that we
+ // see just one of the many possible measurements here.
+ if keys == nil || keys[len(keys)-1] != event.Key {
+ keys = append(keys, event.Key)
+ }
+ }
+ expect := []string{
+ "status.queued",
+ "status.started",
+ "status.progress",
+ "status.geoip_lookup",
+ "status.resolver_lookup",
+ "status.progress",
+ "status.report_create",
+ "status.measurement_start",
+ "log",
+ "status.progress",
+ "measurement",
+ "status.measurement_submission",
+ "status.measurement_done",
+ "status.end",
+ "task_terminated",
+ }
+ if diff := cmp.Diff(expect, keys); diff != "" {
+ t.Fatal(diff)
+ }
+}
+
+func TestInterruptNdt7(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ task, err := oonimkall.StartTask(`{
+ "assets_dir": "../testdata/oonimkall/assets",
+ "name": "Ndt7",
+ "options": {
+ "software_name": "oonimkall-test",
+ "software_version": "0.1.0"
+ },
+ "state_dir": "../testdata/oonimkall/state",
+ "version": 1
+ }`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ go func() {
+ <-time.After(11 * time.Second)
+ task.Interrupt()
+ }()
+ var keys []string
+ for !task.IsDone() {
+ eventstr := task.WaitForNextEvent()
+ var event eventlike
+ if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
+ t.Fatal(err)
+ }
+ if event.Key == "failure.startup" {
+ t.Fatal(eventstr)
+ }
+ // We compress the keys because we don't know how many
+ // status.progress we will see. What matters is that we
+ // don't see a measurement submission, since it means
+ // that we have interrupted the measurement.
+ if keys == nil || keys[len(keys)-1] != event.Key {
+ keys = append(keys, event.Key)
+ }
+ }
+ expect := []string{
+ "status.queued",
+ "status.started",
+ "status.progress",
+ "status.geoip_lookup",
+ "status.resolver_lookup",
+ "status.progress",
+ "status.report_create",
+ "status.measurement_start",
+ "status.progress",
+ "status.end",
+ "task_terminated",
+ }
+ if diff := cmp.Diff(expect, keys); diff != "" {
+ t.Fatal(diff)
+ }
+}
+
+func TestCountBytesForExample(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ task, err := oonimkall.StartTask(`{
+ "assets_dir": "../testdata/oonimkall/assets",
+ "name": "Example",
+ "options": {
+ "software_name": "oonimkall-test",
+ "software_version": "0.1.0"
+ },
+ "state_dir": "../testdata/oonimkall/state",
+ "version": 1
+ }`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var downloadKB, uploadKB float64
+ for !task.IsDone() {
+ eventstr := task.WaitForNextEvent()
+ var event eventlike
+ if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
+ t.Fatal(err)
+ }
+ switch event.Key {
+ case "failure.startup":
+ t.Fatal(eventstr)
+ case "status.end":
+ downloadKB = event.Value["downloaded_kb"].(float64)
+ uploadKB = event.Value["uploaded_kb"].(float64)
+ }
+ }
+ if downloadKB == 0 {
+ t.Fatal("downloadKB is zero")
+ }
+ if uploadKB == 0 {
+ t.Fatal("uploadKB is zero")
+ }
+}
+
+func TestPrivacyAndScrubbing(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ task, err := oonimkall.StartTask(`{
+ "assets_dir": "../testdata/oonimkall/assets",
+ "name": "Example",
+ "options": {
+ "software_name": "oonimkall-test",
+ "software_version": "0.1.0"
+ },
+ "state_dir": "../testdata/oonimkall/state",
+ "version": 1
+ }`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m *model.Measurement
+ for !task.IsDone() {
+ eventstr := task.WaitForNextEvent()
+ var event eventlike
+ if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
+ t.Fatal(err)
+ }
+ switch event.Key {
+ case "failure.startup":
+ t.Fatal(eventstr)
+ case "measurement":
+ v := []byte(event.Value["json_str"].(string))
+ m = new(model.Measurement)
+ if err := json.Unmarshal(v, &m); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ if m == nil {
+ t.Fatal("measurement is nil")
+ }
+ if m.ProbeASN == "AS0" || m.ProbeCC == "ZZ" || m.ProbeIP != "127.0.0.1" {
+ t.Fatal("unexpected result")
+ }
+}
+
+func TestNonblock(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ task, err := oonimkall.StartTask(`{
+ "assets_dir": "../testdata/oonimkall/assets",
+ "name": "Example",
+ "options": {
+ "software_name": "oonimkall-test",
+ "software_version": "0.1.0"
+ },
+ "state_dir": "../testdata/oonimkall/state",
+ "version": 1
+ }`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !task.IsRunning() {
+ t.Fatal("The runner should be running at this point")
+ }
+ // If the task blocks because it emits too much events, this test
+ // will run forever and will be killed. Because we have room for up
+ // to 128 events in the buffer, we should hopefully be fine.
+ for task.IsRunning() {
+ time.Sleep(time.Second)
+ }
+ for !task.IsDone() {
+ task.WaitForNextEvent()
+ }
+}
diff --git a/internal/engine/oonimkall/task_internal_test.go b/internal/engine/oonimkall/task_internal_test.go
new file mode 100644
index 0000000..71fcbd0
--- /dev/null
+++ b/internal/engine/oonimkall/task_internal_test.go
@@ -0,0 +1,5 @@
+package oonimkall
+
+func (t *Task) IsRunning() bool {
+ return t.isstopped.Load() == 0
+}
diff --git a/internal/engine/oonimkall/tasks/chanlogger.go b/internal/engine/oonimkall/tasks/chanlogger.go
new file mode 100644
index 0000000..8295349
--- /dev/null
+++ b/internal/engine/oonimkall/tasks/chanlogger.go
@@ -0,0 +1,87 @@
+package tasks
+
+import (
+ "fmt"
+)
+
+// ChanLogger is a logger targeting a channel
+type ChanLogger struct {
+ emitter *EventEmitter
+ hasdebug bool
+ hasinfo bool
+ haswarning bool
+ out chan<- *Event
+}
+
+// Debug implements Logger.Debug
+func (cl *ChanLogger) Debug(msg string) {
+ if cl.hasdebug {
+ cl.emitter.Emit("log", EventLog{
+ LogLevel: "DEBUG",
+ Message: msg,
+ })
+ }
+}
+
+// Debugf implements Logger.Debugf
+func (cl *ChanLogger) Debugf(format string, v ...interface{}) {
+ if cl.hasdebug {
+ cl.Debug(fmt.Sprintf(format, v...))
+ }
+}
+
+// Info implements Logger.Info
+func (cl *ChanLogger) Info(msg string) {
+ if cl.hasinfo {
+ cl.emitter.Emit("log", EventLog{
+ LogLevel: "INFO",
+ Message: msg,
+ })
+ }
+}
+
+// Infof implements Logger.Infof
+func (cl *ChanLogger) Infof(format string, v ...interface{}) {
+ if cl.hasinfo {
+ cl.Info(fmt.Sprintf(format, v...))
+ }
+}
+
+// Warn implements Logger.Warn
+func (cl *ChanLogger) Warn(msg string) {
+ if cl.haswarning {
+ cl.emitter.Emit("log", EventLog{
+ LogLevel: "WARNING",
+ Message: msg,
+ })
+ }
+}
+
+// Warnf implements Logger.Warnf
+func (cl *ChanLogger) Warnf(format string, v ...interface{}) {
+ if cl.haswarning {
+ cl.Warn(fmt.Sprintf(format, v...))
+ }
+}
+
+// NewChanLogger creates a new ChanLogger instance.
+func NewChanLogger(emitter *EventEmitter, logLevel string,
+ out chan<- *Event) *ChanLogger {
+ cl := &ChanLogger{
+ emitter: emitter,
+ out: out,
+ }
+ switch logLevel {
+ case "DEBUG", "DEBUG2":
+ cl.hasdebug = true
+ fallthrough
+ case "INFO":
+ cl.hasinfo = true
+ fallthrough
+ case "ERR", "WARNING":
+ fallthrough
+ default:
+ cl.haswarning = true
+ }
+ return cl
+}
diff --git a/internal/engine/oonimkall/tasks/event.go b/internal/engine/oonimkall/tasks/event.go
new file mode 100644
index 0000000..86c0080
--- /dev/null
+++ b/internal/engine/oonimkall/tasks/event.go
@@ -0,0 +1,57 @@
+package tasks
+
+type eventEmpty struct{}
+
+// EventFailure contains information on a failure.
+type EventFailure struct {
+ Failure string `json:"failure"`
+}
+
+// EventLog is an event containing a log message.
+type EventLog struct {
+ LogLevel string `json:"log_level"`
+ Message string `json:"message"`
+}
+
+type eventMeasurementGeneric struct {
+ Failure string `json:"failure,omitempty"`
+ Idx int64 `json:"idx"`
+ Input string `json:"input"`
+ JSONStr string `json:"json_str,omitempty"`
+}
+
+type eventStatusEnd struct {
+ DownloadedKB float64 `json:"downloaded_kb"`
+ Failure string `json:"failure"`
+ UploadedKB float64 `json:"uploaded_kb"`
+}
+
+type eventStatusGeoIPLookup struct {
+ ProbeASN string `json:"probe_asn"`
+ ProbeCC string `json:"probe_cc"`
+ ProbeIP string `json:"probe_ip"`
+ ProbeNetworkName string `json:"probe_network_name"`
+}
+
+// EventStatusProgress reports progress information.
+type EventStatusProgress struct {
+ Message string `json:"message"`
+ Percentage float64 `json:"percentage"`
+}
+
+type eventStatusReportGeneric struct {
+ ReportID string `json:"report_id"`
+}
+
+type eventStatusResolverLookup struct {
+ ResolverASN string `json:"resolver_asn"`
+ ResolverIP string `json:"resolver_ip"`
+ ResolverNetworkName string `json:"resolver_network_name"`
+}
+
+// Event is an event emitted by a task. This structure extends the event
+// described by MK v0.10.9 FFI API (https://git.io/Jv4Rv).
+type Event struct {
+ Key string `json:"key"`
+ Value interface{} `json:"value"`
+}
diff --git a/internal/engine/oonimkall/tasks/eventemitter.go b/internal/engine/oonimkall/tasks/eventemitter.go
new file mode 100644
index 0000000..5d17be7
--- /dev/null
+++ b/internal/engine/oonimkall/tasks/eventemitter.go
@@ -0,0 +1,40 @@
+package tasks
+
+// EventEmitter emits event on a channel
+type EventEmitter struct {
+ disabled map[string]bool
+ out chan<- *Event
+}
+
+// NewEventEmitter creates a new Emitter
+func NewEventEmitter(disabledEvents []string, out chan<- *Event) *EventEmitter {
+ ee := &EventEmitter{out: out}
+ ee.disabled = make(map[string]bool)
+ for _, eventname := range disabledEvents {
+ ee.disabled[eventname] = true
+ }
+ return ee
+}
+
+// EmitFailureStartup emits the failureStartup event
+func (ee *EventEmitter) EmitFailureStartup(failure string) {
+ ee.EmitFailureGeneric(failureStartup, failure)
+}
+
+// EmitFailureGeneric emits a failure event
+func (ee *EventEmitter) EmitFailureGeneric(name, failure string) {
+ ee.Emit(name, EventFailure{Failure: failure})
+}
+
+// EmitStatusProgress emits the status.Progress event
+func (ee *EventEmitter) EmitStatusProgress(percentage float64, message string) {
+ ee.Emit(statusProgress, EventStatusProgress{Message: message, Percentage: percentage})
+}
+
+// Emit emits the specified event
+func (ee *EventEmitter) Emit(key string, value interface{}) {
+ if ee.disabled[key] == true {
+ return
+ }
+ ee.out <- &Event{Key: key, Value: value}
+}
diff --git a/internal/engine/oonimkall/tasks/eventemitter_test.go b/internal/engine/oonimkall/tasks/eventemitter_test.go
new file mode 100644
index 0000000..ab24f7c
--- /dev/null
+++ b/internal/engine/oonimkall/tasks/eventemitter_test.go
@@ -0,0 +1,67 @@
+package tasks_test
+
+import (
+ "testing"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/oonimkall/tasks"
+)
+
+func TestDisabledEvents(t *testing.T) {
+ out := make(chan *tasks.Event)
+ emitter := tasks.NewEventEmitter([]string{"log"}, out)
+ go func() {
+ emitter.Emit("log", tasks.EventLog{Message: "foo"})
+ close(out)
+ }()
+ var count int64
+ for ev := range out {
+ if ev.Key == "log" {
+ count++
+ }
+ }
+ if count > 0 {
+ t.Fatal("cannot disable events")
+ }
+}
+
+func TestEmitFailureStartup(t *testing.T) {
+ out := make(chan *tasks.Event)
+ emitter := tasks.NewEventEmitter([]string{}, out)
+ go func() {
+ emitter.EmitFailureStartup("mocked error")
+ close(out)
+ }()
+ var found bool
+ for ev := range out {
+ if ev.Key == "failure.startup" {
+ evv := ev.Value.(tasks.EventFailure) // panic if not castable
+ if evv.Failure == "mocked error" {
+ found = true
+ }
+ }
+ }
+ if !found {
+ t.Fatal("did not see expected event")
+ }
+}
+
+func TestEmitStatusProgress(t *testing.T) {
+ out := make(chan *tasks.Event)
+ emitter := tasks.NewEventEmitter([]string{}, out)
+ go func() {
+ emitter.EmitStatusProgress(0.7, "foo")
+ close(out)
+ }()
+ var found bool
+ for ev := range out {
+ if ev.Key == "status.progress" {
+ evv := ev.Value.(tasks.EventStatusProgress) // panic if not castable
+ if evv.Message == "foo" && evv.Percentage == 0.7 {
+ found = true
+ }
+ }
+ }
+ if !found {
+ t.Fatal("did not see expected event")
+ }
+}
diff --git a/internal/engine/oonimkall/tasks/runner.go b/internal/engine/oonimkall/tasks/runner.go
new file mode 100644
index 0000000..ffd1ec2
--- /dev/null
+++ b/internal/engine/oonimkall/tasks/runner.go
@@ -0,0 +1,299 @@
+// Package tasks implements tasks run using the oonimkall API.
+package tasks
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "time"
+
+ engine "github.com/ooni/probe-cli/v3/internal/engine"
+ "github.com/ooni/probe-cli/v3/internal/engine/internal/runtimex"
+ "github.com/ooni/probe-cli/v3/internal/engine/model"
+)
+
+const (
+ failureIPLookup = "failure.ip_lookup"
+ failureASNLookup = "failure.asn_lookup"
+ failureCCLookup = "failure.cc_lookup"
+ failureMeasurement = "failure.measurement"
+ failureMeasurementSubmission = "failure.measurement_submission"
+ failureReportCreate = "failure.report_create"
+ failureResolverLookup = "failure.resolver_lookup"
+ failureStartup = "failure.startup"
+ measurement = "measurement"
+ statusEnd = "status.end"
+ statusGeoIPLookup = "status.geoip_lookup"
+ statusMeasurementDone = "status.measurement_done"
+ statusMeasurementStart = "status.measurement_start"
+ statusMeasurementSubmission = "status.measurement_submission"
+ statusProgress = "status.progress"
+ statusQueued = "status.queued"
+ statusReportCreate = "status.report_create"
+ statusResolverLookup = "status.resolver_lookup"
+ statusStarted = "status.started"
+)
+
+// Run runs the task specified by settings.Name until completion. This is the
+// top-level API that should be called by oonimkall.
+func Run(ctx context.Context, settings *Settings, out chan<- *Event) {
+ r := NewRunner(settings, out)
+ r.Run(ctx)
+}
+
+// Runner runs a specific task
+type Runner struct {
+ emitter *EventEmitter
+ maybeLookupLocation func(*engine.Session) error
+ out chan<- *Event
+ settings *Settings
+}
+
+// NewRunner creates a new task runner
+func NewRunner(settings *Settings, out chan<- *Event) *Runner {
+ return &Runner{
+ emitter: NewEventEmitter(settings.DisabledEvents, out),
+ out: out,
+ settings: settings,
+ }
+}
+
+// FailureInvalidVersion is the failure returned when Version is invalid
+const FailureInvalidVersion = "invalid Settings.Version number"
+
+func (r *Runner) hasUnsupportedSettings(logger *ChanLogger) bool {
+ if r.settings.Version < 1 {
+ r.emitter.EmitFailureStartup(FailureInvalidVersion)
+ return true
+ }
+ return false
+}
+
+func (r *Runner) newsession(logger *ChanLogger) (*engine.Session, error) {
+ kvstore, err := engine.NewFileSystemKVStore(r.settings.StateDir)
+ if err != nil {
+ return nil, err
+ }
+ config := engine.SessionConfig{
+ AssetsDir: r.settings.AssetsDir,
+ KVStore: kvstore,
+ Logger: logger,
+ SoftwareName: r.settings.Options.SoftwareName,
+ SoftwareVersion: r.settings.Options.SoftwareVersion,
+ TempDir: r.settings.TempDir,
+ }
+ if r.settings.Options.ProbeServicesBaseURL != "" {
+ config.AvailableProbeServices = []model.Service{{
+ Type: "https",
+ Address: r.settings.Options.ProbeServicesBaseURL,
+ }}
+ }
+ return engine.NewSession(config)
+}
+
+func (r *Runner) contextForExperiment(
+ ctx context.Context, builder *engine.ExperimentBuilder,
+) context.Context {
+ if builder.Interruptible() {
+ return ctx
+ }
+ return context.Background()
+}
+
+type runnerCallbacks struct {
+ emitter *EventEmitter
+}
+
+func (cb *runnerCallbacks) OnProgress(percentage float64, message string) {
+ cb.emitter.Emit(statusProgress, EventStatusProgress{
+ Percentage: 0.4 + (percentage * 0.6), // open report is 40%
+ Message: message,
+ })
+}
+
+// Run runs the runner until completion. The context argument controls
+// when to stop when processing multiple inputs, as well as when to stop
+// experiments explicitly marked as interruptible.
+func (r *Runner) Run(ctx context.Context) {
+ logger := NewChanLogger(r.emitter, r.settings.LogLevel, r.out)
+ r.emitter.Emit(statusQueued, eventEmpty{})
+ if r.hasUnsupportedSettings(logger) {
+ return
+ }
+ r.emitter.Emit(statusStarted, eventEmpty{})
+ sess, err := r.newsession(logger)
+ if err != nil {
+ r.emitter.EmitFailureStartup(err.Error())
+ return
+ }
+ endEvent := new(eventStatusEnd)
+ defer func() {
+ sess.Close()
+ r.emitter.Emit(statusEnd, endEvent)
+ }()
+
+ builder, err := sess.NewExperimentBuilder(r.settings.Name)
+ if err != nil {
+ r.emitter.EmitFailureStartup(err.Error())
+ return
+ }
+
+ logger.Info("Looking up OONI backends... please, be patient")
+ if err := sess.MaybeLookupBackends(); err != nil {
+ r.emitter.EmitFailureStartup(err.Error())
+ return
+ }
+ r.emitter.EmitStatusProgress(0.1, "contacted bouncer")
+
+ logger.Info("Looking up your location... please, be patient")
+ maybeLookupLocation := r.maybeLookupLocation
+ if maybeLookupLocation == nil {
+ maybeLookupLocation = func(sess *engine.Session) error {
+ return sess.MaybeLookupLocation()
+ }
+ }
+ if err := maybeLookupLocation(sess); err != nil {
+ r.emitter.EmitFailureGeneric(failureIPLookup, err.Error())
+ r.emitter.EmitFailureGeneric(failureASNLookup, err.Error())
+ r.emitter.EmitFailureGeneric(failureCCLookup, err.Error())
+ r.emitter.EmitFailureGeneric(failureResolverLookup, err.Error())
+ return
+ }
+ r.emitter.EmitStatusProgress(0.2, "geoip lookup")
+ r.emitter.EmitStatusProgress(0.3, "resolver lookup")
+ r.emitter.Emit(statusGeoIPLookup, eventStatusGeoIPLookup{
+ ProbeIP: sess.ProbeIP(),
+ ProbeASN: sess.ProbeASNString(),
+ ProbeCC: sess.ProbeCC(),
+ ProbeNetworkName: sess.ProbeNetworkName(),
+ })
+ r.emitter.Emit(statusResolverLookup, eventStatusResolverLookup{
+ ResolverASN: sess.ResolverASNString(),
+ ResolverIP: sess.ResolverIP(),
+ ResolverNetworkName: sess.ResolverNetworkName(),
+ })
+
+ builder.SetCallbacks(&runnerCallbacks{emitter: r.emitter})
+ if len(r.settings.Inputs) <= 0 {
+ switch builder.InputPolicy() {
+ case engine.InputOrQueryTestLists, engine.InputStrictlyRequired:
+ r.emitter.EmitFailureStartup("no input provided")
+ return
+ }
+ r.settings.Inputs = append(r.settings.Inputs, "")
+ }
+ experiment := builder.NewExperiment()
+ defer func() {
+ endEvent.DownloadedKB = experiment.KibiBytesReceived()
+ endEvent.UploadedKB = experiment.KibiBytesSent()
+ }()
+ if !r.settings.Options.NoCollector {
+ logger.Info("Opening report... please, be patient")
+ if err := experiment.OpenReport(); err != nil {
+ r.emitter.EmitFailureGeneric(failureReportCreate, err.Error())
+ return
+ }
+ r.emitter.EmitStatusProgress(0.4, "open report")
+ r.emitter.Emit(statusReportCreate, eventStatusReportGeneric{
+ ReportID: experiment.ReportID(),
+ })
+ }
+ // This deviates a little bit from measurement-kit, for which
+ // a zero timeout is actually valid. Since it does not make much
+ // sense, here we're changing the behaviour.
+ //
+ // See https://github.com/measurement-kit/measurement-kit/issues/1922
+ if r.settings.Options.MaxRuntime > 0 {
+ // We want to honour max_runtime only when we're running an
+ // experiment that clearly wants specific input. We could refine
+ // this policy in the future, but for now this covers in a
+ // reasonable way web connectivity, so we should be ok.
+ switch builder.InputPolicy() {
+ case engine.InputOrQueryTestLists, engine.InputStrictlyRequired:
+ var cancel context.CancelFunc
+ ctx, cancel = context.WithTimeout(
+ ctx, time.Duration(r.settings.Options.MaxRuntime)*time.Second,
+ )
+ defer cancel()
+ }
+ }
+ inputCount := len(r.settings.Inputs)
+ start := time.Now()
+ inflatedMaxRuntime := r.settings.Options.MaxRuntime + r.settings.Options.MaxRuntime/10
+ eta := start.Add(time.Duration(inflatedMaxRuntime) * time.Second)
+ for idx, input := range r.settings.Inputs {
+ if ctx.Err() != nil {
+ break
+ }
+ logger.Infof("Starting measurement with index %d", idx)
+ r.emitter.Emit(statusMeasurementStart, eventMeasurementGeneric{
+ Idx: int64(idx),
+ Input: input,
+ })
+ if input != "" && inputCount > 0 {
+ var percentage float64
+ if r.settings.Options.MaxRuntime > 0 {
+ now := time.Now()
+ percentage = (now.Sub(start).Seconds()/eta.Sub(start).Seconds())*0.6 + 0.4
+ } else {
+ percentage = (float64(idx)/float64(inputCount))*0.6 + 0.4
+ }
+ r.emitter.EmitStatusProgress(percentage, fmt.Sprintf(
+ "processing %s", input,
+ ))
+ }
+ m, err := experiment.MeasureWithContext(
+ r.contextForExperiment(ctx, builder),
+ input,
+ )
+ if builder.Interruptible() && ctx.Err() != nil {
+ // We want to stop here only if interruptible otherwise we want to
+ // submit measurement and stop at beginning of next iteration
+ break
+ }
+ m.AddAnnotations(r.settings.Annotations)
+ if err != nil {
+ r.emitter.Emit(failureMeasurement, eventMeasurementGeneric{
+ Failure: err.Error(),
+ Idx: int64(idx),
+ Input: input,
+ })
+ // fallthrough: we want to submit the report anyway
+ }
+ data, err := json.Marshal(m)
+ runtimex.PanicOnError(err, "measurement.MarshalJSON failed")
+ r.emitter.Emit(measurement, eventMeasurementGeneric{
+ Idx: int64(idx),
+ Input: input,
+ JSONStr: string(data),
+ })
+ if !r.settings.Options.NoCollector {
+ logger.Info("Submitting measurement... please, be patient")
+ err := experiment.SubmitAndUpdateMeasurement(m)
+ r.emitter.Emit(measurementSubmissionEventName(err), eventMeasurementGeneric{
+ Idx: int64(idx),
+ Input: input,
+ JSONStr: string(data),
+ Failure: measurementSubmissionFailure(err),
+ })
+ }
+ r.emitter.Emit(statusMeasurementDone, eventMeasurementGeneric{
+ Idx: int64(idx),
+ Input: input,
+ })
+ }
+}
+
+func measurementSubmissionEventName(err error) string {
+ if err != nil {
+ return failureMeasurementSubmission
+ }
+ return statusMeasurementSubmission
+}
+
+func measurementSubmissionFailure(err error) string {
+ if err != nil {
+ return err.Error()
+ }
+ return ""
+}
diff --git a/internal/engine/oonimkall/tasks/runner_integration_test.go b/internal/engine/oonimkall/tasks/runner_integration_test.go
new file mode 100644
index 0000000..31c88fa
--- /dev/null
+++ b/internal/engine/oonimkall/tasks/runner_integration_test.go
@@ -0,0 +1,404 @@
+package tasks_test
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/oonimkall/tasks"
+)
+
+func TestRunnerMaybeLookupBackendsFailure(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(500)
+ }))
+ defer server.Close()
+ out := make(chan *tasks.Event)
+ settings := &tasks.Settings{
+ AssetsDir: "../../testdata/oonimkall/assets",
+ Name: "Example",
+ Options: tasks.SettingsOptions{
+ ProbeServicesBaseURL: server.URL,
+ SoftwareName: "oonimkall-test",
+ SoftwareVersion: "0.1.0",
+ },
+ StateDir: "../../testdata/oonimkall/state",
+ Version: 1,
+ }
+ go func() {
+ tasks.Run(context.Background(), settings, out)
+ close(out)
+ }()
+ var failures []string
+ for ev := range out {
+ switch ev.Key {
+ case "failure.startup":
+ failure := ev.Value.(tasks.EventFailure).Failure
+ failures = append(failures, failure)
+ case "status.queued", "status.started", "log", "status.end":
+ default:
+ panic(fmt.Sprintf("unexpected key: %s", ev.Key))
+ }
+ }
+ if len(failures) != 1 {
+ t.Fatal("unexpected number of failures")
+ }
+ if failures[0] != "all available probe services failed" {
+ t.Fatal("invalid failure")
+ }
+}
+
+func TestRunnerOpenReportFailure(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ var (
+ nreq int64
+ mu sync.Mutex
+ )
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ defer mu.Unlock()
+ nreq++
+ if nreq == 1 {
+ w.Write([]byte(`{}`))
+ return
+ }
+ w.WriteHeader(500)
+ }))
+ defer server.Close()
+ out := make(chan *tasks.Event)
+ settings := &tasks.Settings{
+ AssetsDir: "../../testdata/oonimkall/assets",
+ Name: "Example",
+ Options: tasks.SettingsOptions{
+ ProbeServicesBaseURL: server.URL,
+ SoftwareName: "oonimkall-test",
+ SoftwareVersion: "0.1.0",
+ },
+ StateDir: "../../testdata/oonimkall/state",
+ Version: 1,
+ }
+ seench := make(chan int64)
+ go func() {
+ var seen int64
+ for ev := range out {
+ switch ev.Key {
+ case "failure.report_create":
+ seen++
+ case "status.progress":
+ evv := ev.Value.(tasks.EventStatusProgress)
+ if evv.Percentage >= 0.4 {
+ panic(fmt.Sprintf("too much progress: %+v", ev))
+ }
+ case "status.queued", "status.started", "log", "status.end",
+ "status.geoip_lookup", "status.resolver_lookup":
+ default:
+ panic(fmt.Sprintf("unexpected key: %s", ev.Key))
+ }
+ }
+ seench <- seen
+ }()
+ tasks.Run(context.Background(), settings, out)
+ close(out)
+ if n := <-seench; n != 1 {
+ t.Fatal("unexpected number of events")
+ }
+}
+
+func TestRunnerGood(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ out := make(chan *tasks.Event)
+ settings := &tasks.Settings{
+ AssetsDir: "../../testdata/oonimkall/assets",
+ LogLevel: "DEBUG",
+ Name: "Example",
+ Options: tasks.SettingsOptions{
+ SoftwareName: "oonimkall-test",
+ SoftwareVersion: "0.1.0",
+ },
+ StateDir: "../../testdata/oonimkall/state",
+ Version: 1,
+ }
+ go func() {
+ tasks.Run(context.Background(), settings, out)
+ close(out)
+ }()
+ var found bool
+ for ev := range out {
+ if ev.Key == "status.end" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatal("status.end event not found")
+ }
+}
+
+func TestRunnerWithUnsupportedSettings(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ out := make(chan *tasks.Event)
+ settings := &tasks.Settings{
+ AssetsDir: "../../testdata/oonimkall/assets",
+ LogLevel: "DEBUG",
+ Name: "Example",
+ Options: tasks.SettingsOptions{
+ SoftwareName: "oonimkall-test",
+ SoftwareVersion: "0.1.0",
+ },
+ StateDir: "../../testdata/oonimkall/state",
+ }
+ go func() {
+ tasks.Run(context.Background(), settings, out)
+ close(out)
+ }()
+ var failures []string
+ for ev := range out {
+ if ev.Key == "failure.startup" {
+ failure := ev.Value.(tasks.EventFailure).Failure
+ failures = append(failures, failure)
+ }
+ }
+ if len(failures) != 1 {
+ t.Fatal("invalid number of failures")
+ }
+ if failures[0] != tasks.FailureInvalidVersion {
+ t.Fatal("not the failure we expected")
+ }
+}
+
+func TestRunnerWithInvalidKVStorePath(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ out := make(chan *tasks.Event)
+ settings := &tasks.Settings{
+ AssetsDir: "../../testdata/oonimkall/assets",
+ LogLevel: "DEBUG",
+ Name: "Example",
+ Options: tasks.SettingsOptions{
+ SoftwareName: "oonimkall-test",
+ SoftwareVersion: "0.1.0",
+ },
+ StateDir: "", // must be empty to cause the failure below
+ Version: 1,
+ }
+ go func() {
+ tasks.Run(context.Background(), settings, out)
+ close(out)
+ }()
+ var failures []string
+ for ev := range out {
+ if ev.Key == "failure.startup" {
+ failure := ev.Value.(tasks.EventFailure).Failure
+ failures = append(failures, failure)
+ }
+ }
+ if len(failures) != 1 {
+ t.Fatal("invalid number of failures")
+ }
+ if failures[0] != "mkdir : no such file or directory" {
+ t.Fatal("not the failure we expected")
+ }
+}
+
+func TestRunnerWithInvalidExperimentName(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ out := make(chan *tasks.Event)
+ settings := &tasks.Settings{
+ AssetsDir: "../../testdata/oonimkall/assets",
+ LogLevel: "DEBUG",
+ Name: "Nonexistent",
+ Options: tasks.SettingsOptions{
+ SoftwareName: "oonimkall-test",
+ SoftwareVersion: "0.1.0",
+ },
+ StateDir: "../../testdata/oonimkall/state",
+ Version: 1,
+ }
+ go func() {
+ tasks.Run(context.Background(), settings, out)
+ close(out)
+ }()
+ var failures []string
+ for ev := range out {
+ if ev.Key == "failure.startup" {
+ failure := ev.Value.(tasks.EventFailure).Failure
+ failures = append(failures, failure)
+ }
+ }
+ if len(failures) != 1 {
+ t.Fatal("invalid number of failures")
+ }
+ if failures[0] != "no such experiment: Nonexistent" {
+ t.Fatalf("not the failure we expected: %s", failures[0])
+ }
+}
+
+func TestRunnerWithMissingInput(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ out := make(chan *tasks.Event)
+ settings := &tasks.Settings{
+ AssetsDir: "../../testdata/oonimkall/assets",
+ LogLevel: "DEBUG",
+ Name: "ExampleWithInput",
+ Options: tasks.SettingsOptions{
+ SoftwareName: "oonimkall-test",
+ SoftwareVersion: "0.1.0",
+ },
+ StateDir: "../../testdata/oonimkall/state",
+ Version: 1,
+ }
+ go func() {
+ tasks.Run(context.Background(), settings, out)
+ close(out)
+ }()
+ var failures []string
+ for ev := range out {
+ if ev.Key == "failure.startup" {
+ failure := ev.Value.(tasks.EventFailure).Failure
+ failures = append(failures, failure)
+ }
+ }
+ if len(failures) != 1 {
+ t.Fatal("invalid number of failures")
+ }
+ if failures[0] != "no input provided" {
+ t.Fatalf("not the failure we expected: %s", failures[0])
+ }
+}
+
+func TestRunnerWithMaxRuntime(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ out := make(chan *tasks.Event)
+ settings := &tasks.Settings{
+ AssetsDir: "../../testdata/oonimkall/assets",
+ Inputs: []string{"a", "b", "c", "d", "e", "f", "g", "h", "i"},
+ LogLevel: "DEBUG",
+ Name: "ExampleWithInput",
+ Options: tasks.SettingsOptions{
+ MaxRuntime: 1,
+ SoftwareName: "oonimkall-test",
+ SoftwareVersion: "0.1.0",
+ },
+ StateDir: "../../testdata/oonimkall/state",
+ Version: 1,
+ }
+ begin := time.Now()
+ go func() {
+ tasks.Run(context.Background(), settings, out)
+ close(out)
+ }()
+ var found bool
+ for ev := range out {
+ if ev.Key == "status.end" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatal("status.end event not found")
+ }
+ // The runtime is long because of ancillary operations and is even more
+ // longer because of self shaping we may be performing (especially in
+ // CI builds) using `-tags shaping`). We have experimentally determined
+ // that ~10 seconds is the typical CI test run time. See:
+ //
+ // 1. https://github.com/ooni/probe-cli/v3/internal/engine/pull/588/checks?check_run_id=667263788
+ //
+ // 2. https://github.com/ooni/probe-cli/v3/internal/engine/pull/588/checks?check_run_id=667263855
+ if time.Now().Sub(begin) > 10*time.Second {
+ t.Fatal("expected shorter runtime")
+ }
+}
+
+func TestRunnerWithMaxRuntimeNonInterruptible(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ out := make(chan *tasks.Event)
+ settings := &tasks.Settings{
+ AssetsDir: "../../testdata/oonimkall/assets",
+ Inputs: []string{"a", "b", "c", "d", "e", "f", "g", "h", "i"},
+ LogLevel: "DEBUG",
+ Name: "ExampleWithInputNonInterruptible",
+ Options: tasks.SettingsOptions{
+ MaxRuntime: 1,
+ SoftwareName: "oonimkall-test",
+ SoftwareVersion: "0.1.0",
+ },
+ StateDir: "../../testdata/oonimkall/state",
+ Version: 1,
+ }
+ begin := time.Now()
+ go func() {
+ tasks.Run(context.Background(), settings, out)
+ close(out)
+ }()
+ var found bool
+ for ev := range out {
+ if ev.Key == "status.end" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatal("status.end event not found")
+ }
+ // The runtime is long because of ancillary operations and is even more
+ // longer because of self shaping we may be performing (especially in
+ // CI builds) using `-tags shaping`). We have experimentally determined
+ // that ~10 seconds is the typical CI test run time. See:
+ //
+ // 1. https://github.com/ooni/probe-cli/v3/internal/engine/pull/588/checks?check_run_id=667263788
+ //
+ // 2. https://github.com/ooni/probe-cli/v3/internal/engine/pull/588/checks?check_run_id=667263855
+ if time.Now().Sub(begin) > 10*time.Second {
+ t.Fatal("expected shorter runtime")
+ }
+}
+
+func TestRunnerWithFailedMeasurement(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ out := make(chan *tasks.Event)
+ settings := &tasks.Settings{
+ AssetsDir: "../../testdata/oonimkall/assets",
+ Inputs: []string{"a", "b", "c", "d", "e", "f", "g", "h", "i"},
+ LogLevel: "DEBUG",
+ Name: "ExampleWithFailure",
+ Options: tasks.SettingsOptions{
+ MaxRuntime: 1,
+ SoftwareName: "oonimkall-test",
+ SoftwareVersion: "0.1.0",
+ },
+ StateDir: "../../testdata/oonimkall/state",
+ Version: 1,
+ }
+ go func() {
+ tasks.Run(context.Background(), settings, out)
+ close(out)
+ }()
+ var found bool
+ for ev := range out {
+ if ev.Key == "failure.measurement" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatal("failure.measurement event not found")
+ }
+}
diff --git a/internal/engine/oonimkall/tasks/runner_internal_test.go b/internal/engine/oonimkall/tasks/runner_internal_test.go
new file mode 100644
index 0000000..85d7cd1
--- /dev/null
+++ b/internal/engine/oonimkall/tasks/runner_internal_test.go
@@ -0,0 +1,72 @@
+package tasks
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "testing"
+
+ engine "github.com/ooni/probe-cli/v3/internal/engine"
+)
+
+func TestMeasurementSubmissionEventName(t *testing.T) {
+ if measurementSubmissionEventName(nil) != statusMeasurementSubmission {
+ t.Fatal("unexpected submission event name")
+ }
+ if measurementSubmissionEventName(errors.New("mocked error")) != failureMeasurementSubmission {
+ t.Fatal("unexpected submission event name")
+ }
+}
+
+func TestMeasurementSubmissionFailure(t *testing.T) {
+ if measurementSubmissionFailure(nil) != "" {
+ t.Fatal("unexpected submission failure")
+ }
+ if measurementSubmissionFailure(errors.New("mocked error")) != "mocked error" {
+ t.Fatal("unexpected submission failure")
+ }
+}
+
+func TestRunnerMaybeLookupLocationFailure(t *testing.T) {
+ out := make(chan *Event)
+ settings := &Settings{
+ AssetsDir: "../../testdata/oonimkall/assets",
+ Name: "Example",
+ Options: SettingsOptions{
+ SoftwareName: "oonimkall-test",
+ SoftwareVersion: "0.1.0",
+ },
+ StateDir: "../../testdata/oonimkall/state",
+ Version: 1,
+ }
+ seench := make(chan int64)
+ go func() {
+ var seen int64
+ for ev := range out {
+ switch ev.Key {
+ case "failure.ip_lookup", "failure.asn_lookup",
+ "failure.cc_lookup", "failure.resolver_lookup":
+ seen++
+ case "status.progress":
+ evv := ev.Value.(EventStatusProgress)
+ if evv.Percentage >= 0.2 {
+ panic(fmt.Sprintf("too much progress: %+v", ev))
+ }
+ case "status.queued", "status.started", "status.end":
+ default:
+ panic(fmt.Sprintf("unexpected key: %s", ev.Key))
+ }
+ }
+ seench <- seen
+ }()
+ expected := errors.New("mocked error")
+ r := NewRunner(settings, out)
+ r.maybeLookupLocation = func(*engine.Session) error {
+ return expected
+ }
+ r.Run(context.Background())
+ close(out)
+ if n := <-seench; n != 4 {
+ t.Fatal("unexpected number of events")
+ }
+}
diff --git a/internal/engine/oonimkall/tasks/settings.go b/internal/engine/oonimkall/tasks/settings.go
new file mode 100644
index 0000000..5a90553
--- /dev/null
+++ b/internal/engine/oonimkall/tasks/settings.go
@@ -0,0 +1,73 @@
+package tasks
+
+// Settings contains settings for a task. This structure derives from
+// the one described by MK v0.10.9 FFI API (https://git.io/Jv4Rv), yet
+// since 2020-12-03 we're not backwards compatible anymore.
+type Settings struct {
+ // Annotations contains the annotations to be added
+ // to every measurements performed by the task.
+ Annotations map[string]string `json:"annotations,omitempty"`
+
+ // AssetsDir is the directory where to store assets. This
+ // field is an extension of MK's specification. If
+ // this field is empty, the task won't start.
+ AssetsDir string `json:"assets_dir"`
+
+ // DisabledEvents contains disabled events. See
+ // https://git.io/Jv4Rv for the events names.
+ DisabledEvents []string `json:"disabled_events,omitempty"`
+
+ // Inputs contains the inputs. The task will fail if it
+ // requires input and you provide no input.
+ Inputs []string `json:"inputs,omitempty"`
+
+ // LogLevel contains the logs level. See https://git.io/Jv4Rv
+ // for the names of the available log levels.
+ LogLevel string `json:"log_level,omitempty"`
+
+ // Name contains the task name. By https://git.io/Jv4Rv the
+ // names are in camel case, e.g. `Ndt`.
+ Name string `json:"name"`
+
+ // Options contains the task options.
+ Options SettingsOptions `json:"options"`
+
+ // StateDir is the directory where to store persistent data. This
+ // field is an extension of MK's specification. If
+ // this field is empty, the task won't start.
+ StateDir string `json:"state_dir"`
+
+ // TempDir is the temporary directory. This field is an extension of MK's
+ // specification. If this field is empty, we will pick the tempdir that
+ // ioutil.TempDir uses by default, which may not work on mobile. According
+ // to our experiments as of 2020-06-10, leaving the TempDir empty works
+ // for iOS and does not work for Android.
+ TempDir string `json:"temp_dir"`
+
+ // Version indicates the version of this structure.
+ Version int64 `json:"version"`
+}
+
+// SettingsOptions contains the settings options
+type SettingsOptions struct {
+ // MaxRuntime is the maximum runtime expressed in seconds. A negative
+ // value for this field disables the maximum runtime. Using
+ // a zero value will also mean disabled. This is not the
+ // original behaviour of Measurement Kit, which used to run
+ // for zero time in such case.
+ MaxRuntime float64 `json:"max_runtime,omitempty"`
+
+ // NoCollector indicates whether to use a collector
+ NoCollector bool `json:"no_collector,omitempty"`
+
+ // ProbeServicesBaseURL contains the probe services base URL.
+ ProbeServicesBaseURL string `json:"probe_services_base_url,omitempty"`
+
+ // SoftwareName is the software name. If this option is not
+ // present, then the library startup will fail.
+ SoftwareName string `json:"software_name,omitempty"`
+
+ // SoftwareVersion is the software version. If this option is not
+ // present, then the library startup will fail.
+ SoftwareVersion string `json:"software_version,omitempty"`
+}
diff --git a/internal/engine/oonimkall/uuid.go b/internal/engine/oonimkall/uuid.go
new file mode 100644
index 0000000..d0c096f
--- /dev/null
+++ b/internal/engine/oonimkall/uuid.go
@@ -0,0 +1,9 @@
+package oonimkall
+
+import "github.com/google/uuid"
+
+// NewUUID4 generates a new UUID4 string. This functionality is typically
+// used by mobile apps to generate random unique identifiers.
+func NewUUID4() string {
+ return uuid.Must(uuid.NewRandom()).String()
+}
diff --git a/internal/engine/oonimkall/uuid_test.go b/internal/engine/oonimkall/uuid_test.go
new file mode 100644
index 0000000..02c8bf1
--- /dev/null
+++ b/internal/engine/oonimkall/uuid_test.go
@@ -0,0 +1,13 @@
+package oonimkall_test
+
+import (
+ "testing"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/oonimkall"
+)
+
+func TestNewUUID4(t *testing.T) {
+ if out := oonimkall.NewUUID4(); len(out) != 36 {
+ t.Fatal("not the expected output")
+ }
+}
diff --git a/internal/engine/probeservices/README.md b/internal/engine/probeservices/README.md
new file mode 100644
index 0000000..7f393f0
--- /dev/null
+++ b/internal/engine/probeservices/README.md
@@ -0,0 +1,8 @@
+# Package github.com/ooni/probe-engine/probeservices
+
+This package contains code to contact OONI probe services.
+
+The probe services are HTTPS endpoints distributed across a bunch of data
+centres implementing a bunch of OONI APIs. When started, OONI will benchmark
+the available probe services and select the fastest one. Eventually all the
+possible OONI APIs will run as probe services.
diff --git a/internal/engine/probeservices/benchselect.go b/internal/engine/probeservices/benchselect.go
new file mode 100644
index 0000000..6ff54dd
--- /dev/null
+++ b/internal/engine/probeservices/benchselect.go
@@ -0,0 +1,148 @@
+package probeservices
+
+import (
+ "context"
+ "time"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/model"
+)
+
+// Default returns the default probe services
+func Default() []model.Service {
+ return []model.Service{{
+ Address: "https://ps1.ooni.io",
+ Type: "https",
+ }, {
+ Address: "https://ps2.ooni.io",
+ Type: "https",
+ }, {
+ Front: "dkyhjv0wpi2dk.cloudfront.net",
+ Type: "cloudfront",
+ Address: "https://dkyhjv0wpi2dk.cloudfront.net",
+ }}
+}
+
+// SortEndpoints gives priority to https, then cloudfronted, then onion.
+func SortEndpoints(in []model.Service) (out []model.Service) {
+ for _, entry := range in {
+ if entry.Type == "https" {
+ out = append(out, entry)
+ }
+ }
+ for _, entry := range in {
+ if entry.Type == "cloudfront" {
+ out = append(out, entry)
+ }
+ }
+ for _, entry := range in {
+ if entry.Type == "onion" {
+ out = append(out, entry)
+ }
+ }
+ return
+}
+
+// OnlyHTTPS returns the HTTPS endpoints only.
+func OnlyHTTPS(in []model.Service) (out []model.Service) {
+ for _, entry := range in {
+ if entry.Type == "https" {
+ out = append(out, entry)
+ }
+ }
+ return
+}
+
+// OnlyFallbacks returns the fallback endpoints only.
+func OnlyFallbacks(in []model.Service) (out []model.Service) {
+ for _, entry := range SortEndpoints(in) {
+ if entry.Type != "https" {
+ out = append(out, entry)
+ }
+ }
+ return
+}
+
+// Candidate is a candidate probe service.
+type Candidate struct {
+ // Duration is the time it took to access the service.
+ Duration time.Duration
+
+ // Err indicates whether the service works.
+ Err error
+
+ // Endpoint is the service endpoint.
+ Endpoint model.Service
+
+ // TestHelpers contains the data returned by the endpoint.
+ TestHelpers map[string][]model.Service
+}
+
+func (c *Candidate) try(ctx context.Context, sess Session) {
+ client, err := NewClient(sess, c.Endpoint)
+ if err != nil {
+ c.Err = err
+ return
+ }
+ start := time.Now()
+ testhelpers, err := client.GetTestHelpers(ctx)
+ c.Duration = time.Now().Sub(start)
+ c.Err = err
+ c.TestHelpers = testhelpers
+ sess.Logger().Debugf("probe services: %+v: %+v %s", c.Endpoint, err, c.Duration)
+}
+
+func try(ctx context.Context, sess Session, svc model.Service) *Candidate {
+ candidate := &Candidate{Endpoint: svc}
+ candidate.try(ctx, sess)
+ return candidate
+}
+
+// TryAll tries all the input services using the provided context and session. It
+// returns a list containing information on each candidate that was tried. We will
+// try all the HTTPS candidates first. So, the beginning of the list will contain
+// all of them, and for each of them you will know whether it worked (by checking the
+// Err field) and how fast it was (by checking the Duration field). You should pick
+// the fastest one that worked. If none of them works, then TryAll will subsequently
+// attempt with all the available fallbacks, and return at the first success. In
+// such case, you will see a list of N failing HTTPS candidates, followed by a single
+// successful fallback candidate (e.g. cloudfronted). If all candidates fail, you
+// see in output a list containing all entries where Err is not nil.
+func TryAll(ctx context.Context, sess Session, in []model.Service) (out []*Candidate) {
+ var found bool
+ for _, svc := range OnlyHTTPS(in) {
+ candidate := try(ctx, sess, svc)
+ out = append(out, candidate)
+ if candidate.Err == nil {
+ found = true
+ }
+ }
+ if !found {
+ for _, svc := range OnlyFallbacks(in) {
+ candidate := try(ctx, sess, svc)
+ out = append(out, candidate)
+ if candidate.Err == nil {
+ return
+ }
+ }
+ }
+ return
+}
+
+// SelectBest selects the best among the candidates. If there is no
+// suitable candidate, then this function returns nil.
+func SelectBest(candidates []*Candidate) (selected *Candidate) {
+ for _, e := range candidates {
+ if e.Err != nil {
+ continue
+ }
+ if selected == nil {
+ selected = e
+ continue
+ }
+ if selected.Duration > e.Duration {
+ selected = e
+ continue
+ }
+ }
+ return
+}
diff --git a/internal/engine/probeservices/bouncer.go b/internal/engine/probeservices/bouncer.go
new file mode 100644
index 0000000..db66455
--- /dev/null
+++ b/internal/engine/probeservices/bouncer.go
@@ -0,0 +1,14 @@
+package probeservices
+
+import (
+ "context"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/model"
+)
+
+// GetTestHelpers is like GetCollectors but for test helpers.
+func (c Client) GetTestHelpers(
+ ctx context.Context) (output map[string][]model.Service, err error) {
+ err = c.Client.GetJSON(ctx, "/api/v1/test-helpers", &output)
+ return
+}
diff --git a/internal/engine/probeservices/bouncer_test.go b/internal/engine/probeservices/bouncer_test.go
new file mode 100644
index 0000000..000007b
--- /dev/null
+++ b/internal/engine/probeservices/bouncer_test.go
@@ -0,0 +1,16 @@
+package probeservices_test
+
+import (
+ "context"
+ "testing"
+)
+
+func TestGetTestHelpers(t *testing.T) {
+ testhelpers, err := newclient().GetTestHelpers(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(testhelpers) <= 1 {
+ t.Fatal("no returned test helpers?!")
+ }
+}
diff --git a/internal/engine/probeservices/checkin.go b/internal/engine/probeservices/checkin.go
new file mode 100644
index 0000000..81be9ec
--- /dev/null
+++ b/internal/engine/probeservices/checkin.go
@@ -0,0 +1,23 @@
+package probeservices
+
+import (
+ "context"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/model"
+)
+
+type checkInResult struct {
+ Tests model.CheckInInfo `json:"tests"`
+ V int `json:"v"`
+}
+
+// CheckIn function is called by probes asking if there are tests to be run
+// The config argument contains the mandatory settings.
+// Returns the list of tests to run and the URLs, on success, or an explanatory error, in case of failure.
+func (c Client) CheckIn(ctx context.Context, config model.CheckInConfig) (*model.CheckInInfo, error) {
+ var response checkInResult
+ if err := c.Client.PostJSON(ctx, "/api/v1/check-in", config, &response); err != nil {
+ return nil, err
+ }
+ return &response.Tests, nil
+}
diff --git a/internal/engine/probeservices/checkin_test.go b/internal/engine/probeservices/checkin_test.go
new file mode 100644
index 0000000..427eb4f
--- /dev/null
+++ b/internal/engine/probeservices/checkin_test.go
@@ -0,0 +1,72 @@
+package probeservices_test
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/model"
+)
+
+func TestCheckInSuccess(t *testing.T) {
+ client := newclient()
+ client.BaseURL = "https://ams-pg-test.ooni.org"
+ config := model.CheckInConfig{
+ Charging: true,
+ OnWiFi: true,
+ Platform: "android",
+ ProbeASN: "AS12353",
+ ProbeCC: "PT",
+ RunType: "timed",
+ SoftwareName: "ooniprobe-android",
+ SoftwareVersion: "2.7.1",
+ WebConnectivity: model.CheckInConfigWebConnectivity{
+ CategoryCodes: []string{"NEWS", "CULTR"},
+ },
+ }
+ ctx := context.Background()
+ result, err := client.CheckIn(ctx, config)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result == nil || result.WebConnectivity == nil {
+ t.Fatal("got nil result or WebConnectivity")
+ }
+ if result.WebConnectivity.ReportID == "" {
+ t.Fatal("ReportID is empty")
+ }
+ if len(result.WebConnectivity.URLs) < 1 {
+ t.Fatal("unexpected number of URLs")
+ }
+ for _, entry := range result.WebConnectivity.URLs {
+ if entry.CategoryCode != "NEWS" && entry.CategoryCode != "CULTR" {
+ t.Fatalf("unexpected category code: %+v", entry)
+ }
+ }
+}
+
+func TestCheckInFailure(t *testing.T) {
+ client := newclient()
+ client.BaseURL = "https://\t\t\t/" // cause test to fail
+ config := model.CheckInConfig{
+ Charging: true,
+ OnWiFi: true,
+ Platform: "android",
+ ProbeASN: "AS12353",
+ ProbeCC: "PT",
+ RunType: "timed",
+ SoftwareName: "ooniprobe-android",
+ SoftwareVersion: "2.7.1",
+ WebConnectivity: model.CheckInConfigWebConnectivity{
+ CategoryCodes: []string{"NEWS", "CULTR"},
+ },
+ }
+ ctx := context.Background()
+ result, err := client.CheckIn(ctx, config)
+ if err == nil || !strings.HasSuffix(err.Error(), "invalid control character in URL") {
+ t.Fatal("not the error we expected")
+ }
+ if result != nil {
+ t.Fatal("results?!")
+ }
+}
diff --git a/internal/engine/probeservices/checkreportid.go b/internal/engine/probeservices/checkreportid.go
new file mode 100644
index 0000000..4d53ddf
--- /dev/null
+++ b/internal/engine/probeservices/checkreportid.go
@@ -0,0 +1,29 @@
+package probeservices
+
+import (
+ "context"
+ "net/url"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/internal/httpx"
+)
+
+type checkReportIDResponse struct {
+ Found bool `json:"found"`
+}
+
+// CheckReportID checks whether the given ReportID exists.
+func (c Client) CheckReportID(ctx context.Context, reportID string) (bool, error) {
+ query := url.Values{}
+ query.Add("report_id", reportID)
+ var response checkReportIDResponse
+ err := (httpx.Client{
+ BaseURL: c.BaseURL,
+ HTTPClient: c.HTTPClient,
+ Logger: c.Logger,
+ UserAgent: c.UserAgent,
+ }).GetJSONWithQuery(ctx, "/api/_/check_report_id", query, &response)
+ if err != nil {
+ return false, err
+ }
+ return response.Found, nil
+}
diff --git a/internal/engine/probeservices/checkreportid_test.go b/internal/engine/probeservices/checkreportid_test.go
new file mode 100644
index 0000000..292e439
--- /dev/null
+++ b/internal/engine/probeservices/checkreportid_test.go
@@ -0,0 +1,61 @@
+package probeservices_test
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "testing"
+
+ "github.com/apex/log"
+ "github.com/ooni/probe-cli/v3/internal/engine/atomicx"
+ "github.com/ooni/probe-cli/v3/internal/engine/internal/httpx"
+ "github.com/ooni/probe-cli/v3/internal/engine/internal/kvstore"
+ "github.com/ooni/probe-cli/v3/internal/engine/probeservices"
+)
+
+func TestCheckReportIDWorkingAsIntended(t *testing.T) {
+ client := probeservices.Client{
+ Client: httpx.Client{
+ BaseURL: "https://ams-pg.ooni.org/",
+ HTTPClient: http.DefaultClient,
+ Logger: log.Log,
+ UserAgent: "miniooni/0.1.0-dev",
+ },
+ LoginCalls: atomicx.NewInt64(),
+ RegisterCalls: atomicx.NewInt64(),
+ StateFile: probeservices.NewStateFile(kvstore.NewMemoryKeyValueStore()),
+ }
+ reportID := `20201209T052225Z_urlgetter_IT_30722_n1_E1VUhMz08SEkgYFU`
+ ctx := context.Background()
+ found, err := client.CheckReportID(ctx, reportID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if found != true {
+ t.Fatal("unexpected found value")
+ }
+}
+
+func TestCheckReportIDWorkingWithCancelledContext(t *testing.T) {
+ client := probeservices.Client{
+ Client: httpx.Client{
+ BaseURL: "https://ams-pg.ooni.org/",
+ HTTPClient: http.DefaultClient,
+ Logger: log.Log,
+ UserAgent: "miniooni/0.1.0-dev",
+ },
+ LoginCalls: atomicx.NewInt64(),
+ RegisterCalls: atomicx.NewInt64(),
+ StateFile: probeservices.NewStateFile(kvstore.NewMemoryKeyValueStore()),
+ }
+ reportID := `20201209T052225Z_urlgetter_IT_30722_n1_E1VUhMz08SEkgYFU`
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel() // fail immediately
+ found, err := client.CheckReportID(ctx, reportID)
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("not the error we expected: %+v", err)
+ }
+ if found != false {
+ t.Fatal("unexpected found value")
+ }
+}
diff --git a/internal/engine/probeservices/collector.go b/internal/engine/probeservices/collector.go
new file mode 100644
index 0000000..35431ec
--- /dev/null
+++ b/internal/engine/probeservices/collector.go
@@ -0,0 +1,214 @@
+package probeservices
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "reflect"
+ "sync"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/model"
+)
+
+const (
+ // DefaultDataFormatVersion is the default data format version.
+ //
+ // See https://github.com/ooni/spec/tree/master/data-formats#history.
+ DefaultDataFormatVersion = "0.2.0"
+
+ // DefaultFormat is the default format
+ DefaultFormat = "json"
+)
+
+var (
+ // ErrUnsupportedDataFormatVersion indicates that the user provided
+ // in input a data format version that we do not support.
+ ErrUnsupportedDataFormatVersion = errors.New("Unsupported data format version")
+
+ // ErrUnsupportedFormat indicates that the format is not supported.
+ ErrUnsupportedFormat = errors.New("Unsupported format")
+
+ // ErrJSONFormatNotSupported indicates that the collector we're using
+ // does not support the JSON report format.
+ ErrJSONFormatNotSupported = errors.New("JSON format not supported")
+)
+
+// ReportTemplate is the template for opening a report
+type ReportTemplate struct {
+ // DataFormatVersion is unconditionally set to DefaultDataFormatVersion
+ // and you don't need to be concerned about it.
+ DataFormatVersion string `json:"data_format_version"`
+
+ // Format is unconditionally set to `json` and you don't need
+ // to be concerned about it.
+ Format string `json:"format"`
+
+ // ProbeASN is the probe's autonomous system number (e.g. `AS1234`)
+ ProbeASN string `json:"probe_asn"`
+
+ // ProbeCC is the probe's country code (e.g. `IT`)
+ ProbeCC string `json:"probe_cc"`
+
+ // SoftwareName is the app name (e.g. `measurement-kit`)
+ SoftwareName string `json:"software_name"`
+
+ // SoftwareVersion is the app version (e.g. `0.9.1`)
+ SoftwareVersion string `json:"software_version"`
+
+ // TestName is the test name (e.g. `ndt`)
+ TestName string `json:"test_name"`
+
+ // TestStartTime contains the test start time
+ TestStartTime string `json:"test_start_time"`
+
+ // TestVersion is the test version (e.g. `1.0.1`)
+ TestVersion string `json:"test_version"`
+}
+
+// NewReportTemplate creates a new ReportTemplate from a Measurement.
+func NewReportTemplate(m *model.Measurement) ReportTemplate {
+ return ReportTemplate{
+ DataFormatVersion: DefaultDataFormatVersion,
+ Format: DefaultFormat,
+ ProbeASN: m.ProbeASN,
+ ProbeCC: m.ProbeCC,
+ SoftwareName: m.SoftwareName,
+ SoftwareVersion: m.SoftwareVersion,
+ TestName: m.TestName,
+ TestStartTime: m.TestStartTime,
+ TestVersion: m.TestVersion,
+ }
+}
+
+type collectorOpenResponse struct {
+ ID string `json:"report_id"`
+ SupportedFormats []string `json:"supported_formats"`
+}
+
+type reportChan struct {
+ // ID is the report ID
+ ID string
+
+ // client is the client that was used.
+ client Client
+
+ // tmpl is the template used when opening this report.
+ tmpl ReportTemplate
+}
+
+// OpenReport opens a new report.
+func (c Client) OpenReport(ctx context.Context, rt ReportTemplate) (ReportChannel, error) {
+ if rt.DataFormatVersion != DefaultDataFormatVersion {
+ return nil, ErrUnsupportedDataFormatVersion
+ }
+ if rt.Format != DefaultFormat {
+ return nil, ErrUnsupportedFormat
+ }
+ var cor collectorOpenResponse
+ if err := c.Client.PostJSON(ctx, "/report", rt, &cor); err != nil {
+ return nil, err
+ }
+ for _, format := range cor.SupportedFormats {
+ if format == "json" {
+ return &reportChan{ID: cor.ID, client: c, tmpl: rt}, nil
+ }
+ }
+ return nil, ErrJSONFormatNotSupported
+}
+
+type collectorUpdateRequest struct {
+ // Format is the data format
+ Format string `json:"format"`
+
+ // Content is the actual report
+ Content interface{} `json:"content"`
+}
+
+type collectorUpdateResponse struct {
+ // ID is the measurement ID
+ ID string `json:"measurement_id"`
+}
+
+// CanSubmit returns true whether the provided measurement belongs to
+// this report, false otherwise. We say that a given measurement belongs
+// to this report if its report template matches the report's one.
+func (r reportChan) CanSubmit(m *model.Measurement) bool {
+ return reflect.DeepEqual(NewReportTemplate(m), r.tmpl)
+}
+
+// SubmitMeasurement submits a measurement belonging to the report
+// to the OONI collector. On success, we will modify the measurement
+// such that it contains the report ID for which it has been
+// submitted. Otherwise, we'll set the report ID to the empty
+// string, so that you know which measurements weren't submitted.
+func (r reportChan) SubmitMeasurement(ctx context.Context, m *model.Measurement) error {
+ var updateResponse collectorUpdateResponse
+ m.ReportID = r.ID
+ err := r.client.Client.PostJSON(
+ ctx, fmt.Sprintf("/report/%s", r.ID), collectorUpdateRequest{
+ Format: "json",
+ Content: m,
+ }, &updateResponse,
+ )
+ if err != nil {
+ m.ReportID = ""
+ return err
+ }
+ return nil
+}
+
+// ReportID returns the report ID.
+func (r reportChan) ReportID() string {
+ return r.ID
+}
+
+// ReportChannel is a channel through which one could submit measurements
+// belonging to the same report. The Report struct belongs to this interface.
+type ReportChannel interface {
+ CanSubmit(m *model.Measurement) bool
+ ReportID() string
+ SubmitMeasurement(ctx context.Context, m *model.Measurement) error
+}
+
+var _ ReportChannel = &reportChan{}
+
+// ReportOpener is any struct that is able to open a new ReportChannel. The
+// Client struct belongs to this interface.
+type ReportOpener interface {
+ OpenReport(ctx context.Context, rt ReportTemplate) (ReportChannel, error)
+}
+
+var _ ReportOpener = Client{}
+
+// Submitter is an abstraction allowing you to submit arbitrary measurements
+// to a given OONI backend. This implementation will take care of opening
+// reports when needed as well as of closing reports when needed. Nonetheless
+// you need to remember to call its Close method when done, because there is
+// likely an open report that has not been closed yet.
+type Submitter struct {
+ channel ReportChannel
+ logger model.Logger
+ mu sync.Mutex
+ opener ReportOpener
+}
+
+// NewSubmitter creates a new Submitter instance.
+func NewSubmitter(opener ReportOpener, logger model.Logger) *Submitter {
+ return &Submitter{opener: opener, logger: logger}
+}
+
+// Submit submits the current measurement to the OONI backend created using
+// the ReportOpener passed to the constructor.
+func (sub *Submitter) Submit(ctx context.Context, m *model.Measurement) error {
+ var err error
+ sub.mu.Lock()
+ defer sub.mu.Unlock()
+ if sub.channel == nil || !sub.channel.CanSubmit(m) {
+ sub.channel, err = sub.opener.OpenReport(ctx, NewReportTemplate(m))
+ if err != nil {
+ return err
+ }
+ sub.logger.Infof("New reportID: %s", sub.channel.ReportID())
+ }
+ return sub.channel.SubmitMeasurement(ctx, m)
+}
diff --git a/internal/engine/probeservices/collector_test.go b/internal/engine/probeservices/collector_test.go
new file mode 100644
index 0000000..c95b6dd
--- /dev/null
+++ b/internal/engine/probeservices/collector_test.go
@@ -0,0 +1,459 @@
+package probeservices_test
+
+import (
+ "context"
+ "errors"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/apex/log"
+ "github.com/google/go-cmp/cmp"
+ "github.com/ooni/probe-cli/v3/internal/engine/model"
+ "github.com/ooni/probe-cli/v3/internal/engine/probeservices"
+)
+
+type fakeTestKeys struct {
+ Failure *string `json:"failure"`
+}
+
+func makeMeasurement(rt probeservices.ReportTemplate, ID string) model.Measurement {
+ return model.Measurement{
+ DataFormatVersion: probeservices.DefaultDataFormatVersion,
+ ID: "bdd20d7a-bba5-40dd-a111-9863d7908572",
+ MeasurementRuntime: 5.0565230846405,
+ MeasurementStartTime: "2018-11-01 15:33:20",
+ ProbeIP: "1.2.3.4",
+ ProbeASN: rt.ProbeASN,
+ ProbeCC: rt.ProbeCC,
+ ReportID: ID,
+ ResolverASN: "AS15169",
+ ResolverIP: "8.8.8.8",
+ ResolverNetworkName: "Google LLC",
+ SoftwareName: rt.SoftwareName,
+ SoftwareVersion: rt.SoftwareVersion,
+ TestKeys: fakeTestKeys{Failure: nil},
+ TestName: rt.TestName,
+ TestStartTime: rt.TestStartTime,
+ TestVersion: rt.TestVersion,
+ }
+}
+
+func TestNewReportTemplate(t *testing.T) {
+ m := &model.Measurement{
+ ProbeASN: "AS117",
+ ProbeCC: "IT",
+ SoftwareName: "ooniprobe-engine",
+ SoftwareVersion: "0.1.0",
+ TestName: "dummy",
+ TestStartTime: "2019-10-28 12:51:06",
+ TestVersion: "0.1.0",
+ }
+ rt := probeservices.NewReportTemplate(m)
+ expect := probeservices.ReportTemplate{
+ DataFormatVersion: probeservices.DefaultDataFormatVersion,
+ Format: probeservices.DefaultFormat,
+ ProbeASN: "AS117",
+ ProbeCC: "IT",
+ SoftwareName: "ooniprobe-engine",
+ SoftwareVersion: "0.1.0",
+ TestName: "dummy",
+ TestStartTime: "2019-10-28 12:51:06",
+ TestVersion: "0.1.0",
+ }
+ if diff := cmp.Diff(expect, rt); diff != "" {
+ t.Fatal(diff)
+ }
+}
+
+func TestReportLifecycle(t *testing.T) {
+ ctx := context.Background()
+ template := probeservices.ReportTemplate{
+ DataFormatVersion: probeservices.DefaultDataFormatVersion,
+ Format: probeservices.DefaultFormat,
+ ProbeASN: "AS0",
+ ProbeCC: "ZZ",
+ SoftwareName: "ooniprobe-engine",
+ SoftwareVersion: "0.1.0",
+ TestName: "dummy",
+ TestStartTime: "2019-10-28 12:51:06",
+ TestVersion: "0.1.0",
+ }
+ client := newclient()
+ report, err := client.OpenReport(ctx, template)
+ if err != nil {
+ t.Fatal(err)
+ }
+ measurement := makeMeasurement(template, report.ReportID())
+ if report.CanSubmit(&measurement) != true {
+ t.Fatal("report should be able to submit this measurement")
+ }
+ if err = report.SubmitMeasurement(ctx, &measurement); err != nil {
+ t.Fatal(err)
+ }
+ if measurement.ReportID != report.ReportID() {
+ t.Fatal("report ID mismatch")
+ }
+}
+
+func TestReportLifecycleWrongExperiment(t *testing.T) {
+ ctx := context.Background()
+ template := probeservices.ReportTemplate{
+ DataFormatVersion: probeservices.DefaultDataFormatVersion,
+ Format: probeservices.DefaultFormat,
+ ProbeASN: "AS0",
+ ProbeCC: "ZZ",
+ SoftwareName: "ooniprobe-engine",
+ SoftwareVersion: "0.1.0",
+ TestName: "dummy",
+ TestStartTime: "2019-10-28 12:51:06",
+ TestVersion: "0.1.0",
+ }
+ client := newclient()
+ report, err := client.OpenReport(ctx, template)
+ if err != nil {
+ t.Fatal(err)
+ }
+ measurement := makeMeasurement(template, report.ReportID())
+ measurement.TestName = "antani"
+ if report.CanSubmit(&measurement) != false {
+ t.Fatal("report should not be able to submit this measurement")
+ }
+}
+
+func TestOpenReportInvalidDataFormatVersion(t *testing.T) {
+ ctx := context.Background()
+ template := probeservices.ReportTemplate{
+ DataFormatVersion: "0.1.0",
+ Format: probeservices.DefaultFormat,
+ ProbeASN: "AS0",
+ ProbeCC: "ZZ",
+ SoftwareName: "ooniprobe-engine",
+ SoftwareVersion: "0.1.0",
+ TestName: "dummy",
+ TestStartTime: "2019-10-28 12:51:06",
+ TestVersion: "0.1.0",
+ }
+ client := newclient()
+ report, err := client.OpenReport(ctx, template)
+ if !errors.Is(err, probeservices.ErrUnsupportedDataFormatVersion) {
+ t.Fatal("not the error we expected")
+ }
+ if report != nil {
+ t.Fatal("expected a nil report here")
+ }
+}
+
+func TestOpenReportInvalidFormat(t *testing.T) {
+ ctx := context.Background()
+ template := probeservices.ReportTemplate{
+ DataFormatVersion: probeservices.DefaultDataFormatVersion,
+ Format: "yaml",
+ ProbeASN: "AS0",
+ ProbeCC: "ZZ",
+ SoftwareName: "ooniprobe-engine",
+ SoftwareVersion: "0.1.0",
+ TestName: "dummy",
+ TestStartTime: "2019-10-28 12:51:06",
+ TestVersion: "0.1.0",
+ }
+ client := newclient()
+ report, err := client.OpenReport(ctx, template)
+ if !errors.Is(err, probeservices.ErrUnsupportedFormat) {
+ t.Fatal("not the error we expected")
+ }
+ if report != nil {
+ t.Fatal("expected a nil report here")
+ }
+}
+
+func TestJSONAPIClientCreateFailure(t *testing.T) {
+ ctx := context.Background()
+ template := probeservices.ReportTemplate{
+ DataFormatVersion: probeservices.DefaultDataFormatVersion,
+ Format: probeservices.DefaultFormat,
+ ProbeASN: "AS0",
+ ProbeCC: "ZZ",
+ SoftwareName: "ooniprobe-engine",
+ SoftwareVersion: "0.1.0",
+ TestName: "dummy",
+ TestStartTime: "2019-10-28 12:51:06",
+ TestVersion: "0.1.0",
+ }
+ client := newclient()
+ client.BaseURL = "\t" // breaks the URL parser
+ report, err := client.OpenReport(ctx, template)
+ if err == nil || !strings.HasSuffix(err.Error(), "invalid control character in URL") {
+ t.Fatal("not the error we expected")
+ }
+ if report != nil {
+ t.Fatal("expected a nil report here")
+ }
+}
+
+func TestOpenResponseNoJSONSupport(t *testing.T) {
+ server := httptest.NewServer(
+ http.HandlerFunc(func(writer http.ResponseWriter, req *http.Request) {
+ writer.Write([]byte(`{"ID":"abc","supported_formats":["yaml"]}`))
+ }),
+ )
+ defer server.Close()
+ ctx := context.Background()
+ template := probeservices.ReportTemplate{
+ DataFormatVersion: probeservices.DefaultDataFormatVersion,
+ Format: probeservices.DefaultFormat,
+ ProbeASN: "AS0",
+ ProbeCC: "ZZ",
+ SoftwareName: "ooniprobe-engine",
+ SoftwareVersion: "0.1.0",
+ TestName: "dummy",
+ TestStartTime: "2019-10-28 12:51:06",
+ TestVersion: "0.1.0",
+ }
+ client := newclient()
+ client.BaseURL = server.URL
+ report, err := client.OpenReport(ctx, template)
+ if !errors.Is(err, probeservices.ErrJSONFormatNotSupported) {
+ t.Fatal("expected an error here")
+ }
+ if report != nil {
+ t.Fatal("expected a nil report here")
+ }
+}
+
+func TestEndToEnd(t *testing.T) {
+ server := httptest.NewServer(
+ http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.RequestURI == "/report" {
+ w.Write([]byte(`{"report_id":"_id","supported_formats":["json"]}`))
+ return
+ }
+ if r.RequestURI == "/report/_id" {
+ data, err := ioutil.ReadAll(r.Body)
+ if err != nil {
+ panic(err)
+ }
+ sdata, err := ioutil.ReadFile("../testdata/collector-expected.jsonl")
+ if err != nil {
+ panic(err)
+ }
+ if diff := cmp.Diff(data, sdata); diff != "" {
+ panic(diff)
+ }
+ w.Write([]byte(`{"measurement_id":"e00c584e6e9e5326"}`))
+ return
+ }
+ if r.RequestURI == "/report/_id/close" {
+ w.Write([]byte(`{}`))
+ return
+ }
+ panic(r.RequestURI)
+ }),
+ )
+ defer server.Close()
+ ctx := context.Background()
+ template := probeservices.ReportTemplate{
+ DataFormatVersion: probeservices.DefaultDataFormatVersion,
+ Format: probeservices.DefaultFormat,
+ ProbeASN: "AS0",
+ ProbeCC: "ZZ",
+ SoftwareName: "ooniprobe-engine",
+ SoftwareVersion: "0.1.0",
+ TestName: "dummy",
+ TestStartTime: "2018-11-01 15:33:17",
+ TestVersion: "0.1.0",
+ }
+ client := newclient()
+ client.BaseURL = server.URL
+ report, err := client.OpenReport(ctx, template)
+ if err != nil {
+ t.Fatal(err)
+ }
+ measurement := makeMeasurement(template, report.ReportID())
+ if err = report.SubmitMeasurement(ctx, &measurement); err != nil {
+ t.Fatal(err)
+ }
+}
+
+type RecordingReportChannel struct {
+ tmpl probeservices.ReportTemplate
+ m []*model.Measurement
+ mu sync.Mutex
+}
+
+func (rrc *RecordingReportChannel) CanSubmit(m *model.Measurement) bool {
+ return reflect.DeepEqual(probeservices.NewReportTemplate(m), rrc.tmpl)
+}
+
+func (rrc *RecordingReportChannel) SubmitMeasurement(ctx context.Context, m *model.Measurement) error {
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+ rrc.mu.Lock()
+ defer rrc.mu.Unlock()
+ rrc.m = append(rrc.m, m)
+ return nil
+}
+
+func (rrc *RecordingReportChannel) Close(ctx context.Context) error {
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+ rrc.mu.Lock()
+ defer rrc.mu.Unlock()
+ return nil
+}
+
+func (rrc *RecordingReportChannel) ReportID() string {
+ return ""
+}
+
+type RecordingReportOpener struct {
+ channels []*RecordingReportChannel
+ mu sync.Mutex
+}
+
+func (rro *RecordingReportOpener) OpenReport(
+ ctx context.Context, rt probeservices.ReportTemplate,
+) (probeservices.ReportChannel, error) {
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
+ rrc := &RecordingReportChannel{tmpl: rt}
+ rro.mu.Lock()
+ defer rro.mu.Unlock()
+ rro.channels = append(rro.channels, rrc)
+ return rrc, nil
+}
+
+func TestOpenReportCancelledContext(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel() // immediately abort
+ template := probeservices.ReportTemplate{
+ DataFormatVersion: probeservices.DefaultDataFormatVersion,
+ Format: probeservices.DefaultFormat,
+ ProbeASN: "AS0",
+ ProbeCC: "ZZ",
+ SoftwareName: "ooniprobe-engine",
+ SoftwareVersion: "0.1.0",
+ TestName: "dummy",
+ TestStartTime: "2019-10-28 12:51:06",
+ TestVersion: "0.1.0",
+ }
+ client := newclient()
+ report, err := client.OpenReport(ctx, template)
+ if !errors.Is(err, context.Canceled) {
+ t.Fatal("not the error we expected")
+ }
+ if report != nil {
+ t.Fatal("expected nil report here")
+ }
+}
+
+func TestSubmitMeasurementCancelledContext(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ template := probeservices.ReportTemplate{
+ DataFormatVersion: probeservices.DefaultDataFormatVersion,
+ Format: probeservices.DefaultFormat,
+ ProbeASN: "AS0",
+ ProbeCC: "ZZ",
+ SoftwareName: "ooniprobe-engine",
+ SoftwareVersion: "0.1.0",
+ TestName: "dummy",
+ TestStartTime: "2019-10-28 12:51:06",
+ TestVersion: "0.1.0",
+ }
+ client := newclient()
+ report, err := client.OpenReport(ctx, template)
+ if err != nil {
+ t.Fatal(err)
+ }
+ measurement := makeMeasurement(template, report.ReportID())
+ if report.CanSubmit(&measurement) != true {
+ t.Fatal("report should be able to submit this measurement")
+ }
+ cancel() // cause submission to fail
+ err = report.SubmitMeasurement(ctx, &measurement)
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("not the error we expected: %+v", err)
+ }
+ if measurement.ReportID != "" {
+ t.Fatal("report ID should be empty here")
+ }
+}
+
+func makeMeasurementWithoutTemplate(failure, testName string) *model.Measurement {
+ return &model.Measurement{
+ DataFormatVersion: probeservices.DefaultDataFormatVersion,
+ ID: "bdd20d7a-bba5-40dd-a111-9863d7908572",
+ MeasurementRuntime: 5.0565230846405,
+ MeasurementStartTime: "2018-11-01 15:33:20",
+ ProbeIP: "1.2.3.4",
+ ProbeASN: "AS123",
+ ProbeCC: "IT",
+ ReportID: "",
+ ResolverASN: "AS15169",
+ ResolverIP: "8.8.8.8",
+ ResolverNetworkName: "Google LLC",
+ SoftwareName: "miniooni",
+ SoftwareVersion: "0.1.0-dev",
+ TestKeys: fakeTestKeys{Failure: &failure},
+ TestName: testName,
+ TestStartTime: "2018-11-01 15:33:17",
+ TestVersion: "0.1.0",
+ }
+}
+
+func TestSubmitterLifecyle(t *testing.T) {
+ rro := &RecordingReportOpener{}
+ submitter := probeservices.NewSubmitter(rro, log.Log)
+ ctx := context.Background()
+ m1 := makeMeasurementWithoutTemplate("antani", "example")
+ if err := submitter.Submit(ctx, m1); err != nil {
+ t.Fatal(err)
+ }
+ m2 := makeMeasurementWithoutTemplate("mascetti", "example")
+ if err := submitter.Submit(ctx, m2); err != nil {
+ t.Fatal(err)
+ }
+ m3 := makeMeasurementWithoutTemplate("antani", "example_extended")
+ if err := submitter.Submit(ctx, m3); err != nil {
+ t.Fatal(err)
+ }
+ if len(rro.channels) != 2 {
+ t.Fatal("unexpected number of channels")
+ }
+ if len(rro.channels[0].m) != 2 {
+ t.Fatal("unexpected number of measurements in first channel")
+ }
+ if len(rro.channels[1].m) != 1 {
+ t.Fatal("unexpected number of measurements in second channel")
+ }
+}
+
+func TestSubmitterCannotOpenNewChannel(t *testing.T) {
+ rro := &RecordingReportOpener{}
+ submitter := probeservices.NewSubmitter(rro, log.Log)
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel() // fail immediately
+ m1 := makeMeasurementWithoutTemplate("antani", "example")
+ if err := submitter.Submit(ctx, m1); !errors.Is(err, context.Canceled) {
+ t.Fatal("not the error we expected")
+ }
+ m2 := makeMeasurementWithoutTemplate("mascetti", "example")
+ if err := submitter.Submit(ctx, m2); !errors.Is(err, context.Canceled) {
+ t.Fatal(err)
+ }
+ m3 := makeMeasurementWithoutTemplate("antani", "example_extended")
+ if err := submitter.Submit(ctx, m3); !errors.Is(err, context.Canceled) {
+ t.Fatal(err)
+ }
+ if len(rro.channels) != 0 {
+ t.Fatal("unexpected number of channels")
+ }
+}
diff --git a/internal/engine/probeservices/login.go b/internal/engine/probeservices/login.go
new file mode 100644
index 0000000..4d0c2d0
--- /dev/null
+++ b/internal/engine/probeservices/login.go
@@ -0,0 +1,38 @@
+package probeservices
+
+import (
+ "context"
+ "time"
+)
+
+// LoginCredentials contains the login credentials
+type LoginCredentials struct {
+ ClientID string `json:"username"`
+ Password string `json:"password"`
+}
+
+// LoginAuth contains authentication info
+type LoginAuth struct {
+ Expire time.Time `json:"expire"`
+ Token string `json:"token"`
+}
+
+// MaybeLogin performs login if necessary
+func (c Client) MaybeLogin(ctx context.Context) error {
+ state := c.StateFile.Get()
+ if state.Auth() != nil {
+ return nil // we're already good
+ }
+ creds := state.Credentials()
+ if creds == nil {
+ return ErrNotRegistered
+ }
+ c.LoginCalls.Add(1)
+ var auth LoginAuth
+ if err := c.Client.PostJSON(ctx, "/api/v1/login", *creds, &auth); err != nil {
+ return err
+ }
+ state.Expire = auth.Expire
+ state.Token = auth.Token
+ return c.StateFile.Set(state)
+}
diff --git a/internal/engine/probeservices/login_test.go b/internal/engine/probeservices/login_test.go
new file mode 100644
index 0000000..707a9e0
--- /dev/null
+++ b/internal/engine/probeservices/login_test.go
@@ -0,0 +1,73 @@
+package probeservices_test
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/probeservices"
+ "github.com/ooni/probe-cli/v3/internal/engine/probeservices/testorchestra"
+)
+
+func TestMaybeLogin(t *testing.T) {
+ t.Run("when we already have a token", func(t *testing.T) {
+ clnt := newclient()
+ state := probeservices.State{
+ Expire: time.Now().Add(time.Hour),
+ Token: "xx-xxx-x-xxxx",
+ }
+ if err := clnt.StateFile.Set(state); err != nil {
+ t.Fatal(err)
+ }
+ ctx := context.Background()
+ if err := clnt.MaybeLogin(ctx); err != nil {
+ t.Fatal(err)
+ }
+ })
+ t.Run("when we have already registered", func(t *testing.T) {
+ clnt := newclient()
+ state := probeservices.State{
+ // Explicitly empty to clarify what this test does
+ }
+ if err := clnt.StateFile.Set(state); err != nil {
+ t.Fatal(err)
+ }
+ ctx := context.Background()
+ if err := clnt.MaybeLogin(ctx); err == nil {
+ t.Fatal("expected an error here")
+ }
+ })
+ t.Run("when the API call fails", func(t *testing.T) {
+ clnt := newclient()
+ clnt.BaseURL = "\t\t\t" // causes the code to fail
+ state := probeservices.State{
+ ClientID: "xx-xxx-x-xxxx",
+ Password: "xx",
+ }
+ if err := clnt.StateFile.Set(state); err != nil {
+ t.Fatal(err)
+ }
+ ctx := context.Background()
+ if err := clnt.MaybeLogin(ctx); err == nil {
+ t.Fatal("expected an error here")
+ }
+ })
+}
+
+func TestMaybeLoginIdempotent(t *testing.T) {
+ clnt := newclient()
+ ctx := context.Background()
+ metadata := testorchestra.MetadataFixture()
+ if err := clnt.MaybeRegister(ctx, metadata); err != nil {
+ t.Fatal(err)
+ }
+ if err := clnt.MaybeLogin(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if err := clnt.MaybeLogin(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if clnt.LoginCalls.Load() != 1 {
+ t.Fatal("called login API too many times")
+ }
+}
diff --git a/internal/engine/probeservices/measurementmeta.go b/internal/engine/probeservices/measurementmeta.go
new file mode 100644
index 0000000..de9cfbf
--- /dev/null
+++ b/internal/engine/probeservices/measurementmeta.go
@@ -0,0 +1,67 @@
+package probeservices
+
+import (
+ "context"
+ "net/url"
+ "time"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/internal/httpx"
+)
+
+// MeasurementMetaConfig contains configuration for GetMeasurementMeta.
+type MeasurementMetaConfig struct {
+ // ReportID is the mandatory report ID.
+ ReportID string
+
+ // Full indicates whether we also want the full measurement body.
+ Full bool
+
+ // Input is the optional input.
+ Input string
+}
+
+// MeasurementMeta contains measurement metadata.
+type MeasurementMeta struct {
+ // Fields returned by the API server whenever we are
+ // calling /api/v1/measurement_meta.
+ Anomaly bool `json:"anomaly"`
+ CategoryCode string `json:"category_code"`
+ Confirmed bool `json:"confirmed"`
+ Failure bool `json:"failure"`
+ Input *string `json:"input"`
+ MeasurementStartTime time.Time `json:"measurement_start_time"`
+ ProbeASN int64 `json:"probe_asn"`
+ ProbeCC string `json:"probe_cc"`
+ ReportID string `json:"report_id"`
+ Scores string `json:"scores"`
+ TestName string `json:"test_name"`
+ TestStartTime time.Time `json:"test_start_time"`
+
+ // This field is only included if the user has specified
+ // the config.Full option, otherwise it's empty.
+ RawMeasurement string `json:"raw_measurement"`
+}
+
+// GetMeasurementMeta returns meta information about a measurement.
+func (c Client) GetMeasurementMeta(
+ ctx context.Context, config MeasurementMetaConfig) (*MeasurementMeta, error) {
+ query := url.Values{}
+ query.Add("report_id", config.ReportID)
+ if config.Input != "" {
+ query.Add("input", config.Input)
+ }
+ if config.Full {
+ query.Add("full", "true")
+ }
+ var response MeasurementMeta
+ err := (httpx.Client{
+ BaseURL: c.BaseURL,
+ HTTPClient: c.HTTPClient,
+ Logger: c.Logger,
+ UserAgent: c.UserAgent,
+ }).GetJSONWithQuery(ctx, "/api/v1/measurement_meta", query, &response)
+ if err != nil {
+ return nil, err
+ }
+ return &response, nil
+}
diff --git a/internal/engine/probeservices/measurementmeta_test.go b/internal/engine/probeservices/measurementmeta_test.go
new file mode 100644
index 0000000..cd92d5a
--- /dev/null
+++ b/internal/engine/probeservices/measurementmeta_test.go
@@ -0,0 +1,111 @@
+package probeservices_test
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "testing"
+
+ "github.com/apex/log"
+ "github.com/ooni/probe-cli/v3/internal/engine/atomicx"
+ "github.com/ooni/probe-cli/v3/internal/engine/internal/httpx"
+ "github.com/ooni/probe-cli/v3/internal/engine/internal/kvstore"
+ "github.com/ooni/probe-cli/v3/internal/engine/probeservices"
+)
+
+func TestGetMeasurementMetaWorkingAsIntended(t *testing.T) {
+ client := probeservices.Client{
+ Client: httpx.Client{
+ BaseURL: "https://ams-pg.ooni.org/",
+ HTTPClient: http.DefaultClient,
+ Logger: log.Log,
+ UserAgent: "miniooni/0.1.0-dev",
+ },
+ LoginCalls: atomicx.NewInt64(),
+ RegisterCalls: atomicx.NewInt64(),
+ StateFile: probeservices.NewStateFile(kvstore.NewMemoryKeyValueStore()),
+ }
+ config := probeservices.MeasurementMetaConfig{
+ ReportID: `20201209T052225Z_urlgetter_IT_30722_n1_E1VUhMz08SEkgYFU`,
+ Full: true,
+ Input: `https://www.example.org`,
+ }
+ ctx := context.Background()
+ mmeta, err := client.GetMeasurementMeta(ctx, config)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if mmeta.Anomaly != false {
+ t.Fatal("unexpected anomaly value")
+ }
+ if mmeta.CategoryCode != "" {
+ t.Fatal("unexpected category code value")
+ }
+ if mmeta.Confirmed != false {
+ t.Fatal("unexpected confirmed value")
+ }
+ if mmeta.Failure != true {
+ // TODO(bassosimone): this field seems wrong
+ t.Fatal("unexpected failure value")
+ }
+ if mmeta.Input == nil || *mmeta.Input != config.Input {
+ t.Fatal("unexpected input value")
+ }
+ if mmeta.MeasurementStartTime.String() != "2020-12-09 05:22:25 +0000 UTC" {
+ t.Fatal("unexpected measurement start time value")
+ }
+ if mmeta.ProbeASN != 30722 {
+ t.Fatal("unexpected probe asn value")
+ }
+ if mmeta.ProbeCC != "IT" {
+ t.Fatal("unexpected probe cc value")
+ }
+ if mmeta.ReportID != config.ReportID {
+ t.Fatal("unexpected report id value")
+ }
+ // TODO(bassosimone): we could better this check
+ var scores interface{}
+ if err := json.Unmarshal([]byte(mmeta.Scores), &scores); err != nil {
+ t.Fatalf("cannot parse scores value: %+v", err)
+ }
+ if mmeta.TestName != "urlgetter" {
+ t.Fatal("unexpected test name value")
+ }
+ if mmeta.TestStartTime.String() != "2020-12-09 05:22:25 +0000 UTC" {
+ t.Fatal("unexpected test start time value")
+ }
+ // TODO(bassosimone): we could better this check
+ var rawmeas interface{}
+ if err := json.Unmarshal([]byte(mmeta.RawMeasurement), &rawmeas); err != nil {
+ t.Fatalf("cannot parse raw measurement: %+v", err)
+ }
+}
+
+func TestGetMeasurementMetaWorkingWithCancelledContext(t *testing.T) {
+ client := probeservices.Client{
+ Client: httpx.Client{
+ BaseURL: "https://ams-pg.ooni.org/",
+ HTTPClient: http.DefaultClient,
+ Logger: log.Log,
+ UserAgent: "miniooni/0.1.0-dev",
+ },
+ LoginCalls: atomicx.NewInt64(),
+ RegisterCalls: atomicx.NewInt64(),
+ StateFile: probeservices.NewStateFile(kvstore.NewMemoryKeyValueStore()),
+ }
+ config := probeservices.MeasurementMetaConfig{
+ ReportID: `20201209T052225Z_urlgetter_IT_30722_n1_E1VUhMz08SEkgYFU`,
+ Full: true,
+ Input: `https://www.example.org`,
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel() // fail immediately
+ mmeta, err := client.GetMeasurementMeta(ctx, config)
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("not the error we expected: %+v", err)
+ }
+ if mmeta != nil {
+ t.Fatal("we expected a nil mmeta here")
+ }
+}
diff --git a/internal/engine/probeservices/metadata.go b/internal/engine/probeservices/metadata.go
new file mode 100644
index 0000000..2b7b0e9
--- /dev/null
+++ b/internal/engine/probeservices/metadata.go
@@ -0,0 +1,52 @@
+package probeservices
+
+// Metadata contains metadata about a probe. This message is
+// included into a bunch of messages sent to orchestra.
+type Metadata struct {
+ AvailableBandwidth string `json:"available_bandwidth,omitempty"`
+ DeviceToken string `json:"device_token,omitempty"`
+ Language string `json:"language,omitempty"`
+ NetworkType string `json:"network_type,omitempty"`
+ Platform string `json:"platform"`
+ ProbeASN string `json:"probe_asn"`
+ ProbeCC string `json:"probe_cc"`
+ ProbeFamily string `json:"probe_family,omitempty"`
+ ProbeTimezone string `json:"probe_timezone,omitempty"`
+ SoftwareName string `json:"software_name"`
+ SoftwareVersion string `json:"software_version"`
+ SupportedTests []string `json:"supported_tests"`
+}
+
+// Valid returns true if metadata is valid, false otherwise. Metadata is
+// considered valid if all the mandatory fields are not empty. If a field
+// is marked `json:",omitempty"` in the structure definition, then it's
+// for sure mandatory. The "device_token" field is mandatory only if the
+// platform is "ios" or "android", because there's currently no device
+// token that we know of for desktop devices.
+func (m Metadata) Valid() bool {
+ if m.ProbeCC == "" {
+ return false
+ }
+ if m.ProbeASN == "" {
+ return false
+ }
+ if m.Platform == "" {
+ return false
+ }
+ if m.SoftwareName == "" {
+ return false
+ }
+ if m.SoftwareVersion == "" {
+ return false
+ }
+ if len(m.SupportedTests) < 1 {
+ return false
+ }
+ switch m.Platform {
+ case "ios", "android":
+ if m.DeviceToken == "" {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/engine/probeservices/metadata_test.go b/internal/engine/probeservices/metadata_test.go
new file mode 100644
index 0000000..840f469
--- /dev/null
+++ b/internal/engine/probeservices/metadata_test.go
@@ -0,0 +1,106 @@
+package probeservices_test
+
+import (
+ "testing"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/probeservices"
+)
+
+func TestValid(t *testing.T) {
+ t.Run("fail on probe_cc", func(t *testing.T) {
+ var m probeservices.Metadata
+ if m.Valid() != false {
+ t.Fatal("expected false here")
+ }
+ })
+ t.Run("fail on probe_asn", func(t *testing.T) {
+ m := probeservices.Metadata{
+ ProbeCC: "IT",
+ }
+ if m.Valid() != false {
+ t.Fatal("expected false here")
+ }
+ })
+ t.Run("fail on platform", func(t *testing.T) {
+ m := probeservices.Metadata{
+ ProbeCC: "IT",
+ ProbeASN: "AS1234",
+ }
+ if m.Valid() != false {
+ t.Fatal("expected false here")
+ }
+ })
+ t.Run("fail on software_name", func(t *testing.T) {
+ m := probeservices.Metadata{
+ ProbeCC: "IT",
+ ProbeASN: "AS1234",
+ Platform: "linux",
+ }
+ if m.Valid() != false {
+ t.Fatal("expected false here")
+ }
+ })
+ t.Run("fail on software_version", func(t *testing.T) {
+ m := probeservices.Metadata{
+ ProbeCC: "IT",
+ ProbeASN: "AS1234",
+ Platform: "linux",
+ SoftwareName: "miniooni",
+ }
+ if m.Valid() != false {
+ t.Fatal("expected false here")
+ }
+ })
+ t.Run("fail on supported_tests", func(t *testing.T) {
+ m := probeservices.Metadata{
+ ProbeCC: "IT",
+ ProbeASN: "AS1234",
+ Platform: "linux",
+ SoftwareName: "miniooni",
+ SoftwareVersion: "0.1.0-dev",
+ }
+ if m.Valid() != false {
+ t.Fatal("expected false here")
+ }
+ })
+ t.Run("fail on missing device_token", func(t *testing.T) {
+ m := probeservices.Metadata{
+ ProbeCC: "IT",
+ ProbeASN: "AS1234",
+ Platform: "ios",
+ SoftwareName: "miniooni",
+ SoftwareVersion: "0.1.0-dev",
+ SupportedTests: []string{"web_connectivity"},
+ }
+ if m.Valid() != false {
+ t.Fatal("expected false here")
+ }
+ })
+ t.Run("success for desktop", func(t *testing.T) {
+ m := probeservices.Metadata{
+ ProbeCC: "IT",
+ ProbeASN: "AS1234",
+ Platform: "linux",
+ SoftwareName: "miniooni",
+ SoftwareVersion: "0.1.0-dev",
+ SupportedTests: []string{"web_connectivity"},
+ }
+ if m.Valid() != true {
+ t.Fatal("expected true here")
+ }
+ })
+ t.Run("success for mobile", func(t *testing.T) {
+ m := probeservices.Metadata{
+ DeviceToken: "xx-xxx-xx-xxxx",
+ ProbeCC: "IT",
+ ProbeASN: "AS1234",
+ Platform: "android",
+ SoftwareName: "miniooni",
+ SoftwareVersion: "0.1.0-dev",
+ SupportedTests: []string{"web_connectivity"},
+ }
+ if m.Valid() != true {
+ t.Fatal("expected true here")
+ }
+ })
+}
diff --git a/internal/engine/probeservices/probeservices.go b/internal/engine/probeservices/probeservices.go
new file mode 100644
index 0000000..10a1388
--- /dev/null
+++ b/internal/engine/probeservices/probeservices.go
@@ -0,0 +1,129 @@
+// Package probeservices contains code to contact OONI probe services.
+//
+// The probe services are HTTPS endpoints distributed across a bunch of data
+// centres implementing a bunch of OONI APIs. When started, OONI will benchmark
+// the available probe services and select the fastest one. Eventually all the
+// possible OONI APIs will run as probe services.
+//
+// This package implements the following APIs:
+//
+// 1. v2.0.0 of the OONI bouncer specification defined
+// in https://github.com/ooni/spec/blob/master/backends/bk-004-bouncer;
+//
+// 2. v2.0.0 of the OONI collector specification defined
+// in https://github.com/ooni/spec/blob/master/backends/bk-003-collector.md;
+//
+// 3. most of the OONI orchestra API: login, register, fetch URLs for
+// the Web Connectivity experiment, input for Tor and Psiphon.
+//
+// Orchestra is a set of OONI APIs for probe orchestration. We currently mainly
+// using it for fetching inputs for the tor, psiphon, and web experiments.
+//
+// In addition, this package also contains code to benchmark the available
+// probe services, discard non working ones, select the fastest.
+package probeservices
+
+import (
+ "errors"
+ "net/http"
+ "net/url"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/atomicx"
+ "github.com/ooni/probe-cli/v3/internal/engine/internal/httpx"
+ "github.com/ooni/probe-cli/v3/internal/engine/model"
+)
+
+var (
+ // ErrUnsupportedEndpoint indicates that we don't support this endpoint type.
+ ErrUnsupportedEndpoint = errors.New("probe services: unsupported endpoint type")
+
+ // ErrUnsupportedCloudFrontAddress indicates that we don't support this
+ // cloudfront address (e.g. wrong scheme, presence of port).
+ ErrUnsupportedCloudFrontAddress = errors.New(
+ "probe services: unsupported cloud front address",
+ )
+
+ // ErrNotRegistered indicates that the probe is not registered
+ // with the OONI orchestra backend.
+ ErrNotRegistered = errors.New("not registered")
+
+ // ErrNotLoggedIn indicates that we are not logged in
+ ErrNotLoggedIn = errors.New("not logged in")
+
+ // ErrInvalidMetadata indicates that the metadata is not valid
+ ErrInvalidMetadata = errors.New("invalid metadata")
+)
+
+// Session is how this package sees a Session.
+type Session interface {
+ DefaultHTTPClient() *http.Client
+ KeyValueStore() model.KeyValueStore
+ Logger() model.Logger
+ ProxyURL() *url.URL
+ UserAgent() string
+}
+
+// Client is a client for the OONI probe services API.
+type Client struct {
+ httpx.Client
+ LoginCalls *atomicx.Int64
+ RegisterCalls *atomicx.Int64
+ StateFile StateFile
+}
+
+// GetCredsAndAuth is an utility function that returns the credentials with
+// which we are registered and the token with which we're logged in. If we're
+// not registered or not logged in, an error is returned instead.
+func (c Client) GetCredsAndAuth() (*LoginCredentials, *LoginAuth, error) {
+ state := c.StateFile.Get()
+ creds := state.Credentials()
+ if creds == nil {
+ return nil, nil, ErrNotRegistered
+ }
+ auth := state.Auth()
+ if auth == nil {
+ return nil, nil, ErrNotLoggedIn
+ }
+ return creds, auth, nil
+}
+
+// NewClient creates a new client for the specified probe services endpoint. This
+// function fails, e.g., we don't support the specified endpoint.
+func NewClient(sess Session, endpoint model.Service) (*Client, error) {
+ client := &Client{
+ Client: httpx.Client{
+ BaseURL: endpoint.Address,
+ HTTPClient: sess.DefaultHTTPClient(),
+ Logger: sess.Logger(),
+ ProxyURL: sess.ProxyURL(),
+ UserAgent: sess.UserAgent(),
+ },
+ LoginCalls: atomicx.NewInt64(),
+ RegisterCalls: atomicx.NewInt64(),
+ StateFile: NewStateFile(sess.KeyValueStore()),
+ }
+ switch endpoint.Type {
+ case "https":
+ return client, nil
+ case "cloudfront":
+ // Do the cloudfronting dance. The front must appear inside of the
+ // URL, so that we use it for DNS resolution and SNI. The real domain
+ // must instead appear inside of the Host header.
+ URL, err := url.Parse(client.BaseURL)
+ if err != nil {
+ return nil, err
+ }
+ if URL.Scheme != "https" || URL.Host != URL.Hostname() {
+ return nil, ErrUnsupportedCloudFrontAddress
+ }
+ client.Client.Host = URL.Hostname()
+ URL.Host = endpoint.Front
+ client.BaseURL = URL.String()
+ if _, err := url.Parse(client.BaseURL); err != nil {
+ return nil, err
+ }
+ return client, nil
+ default:
+ return nil, ErrUnsupportedEndpoint
+ }
+}
diff --git a/internal/engine/probeservices/probeservices_test.go b/internal/engine/probeservices/probeservices_test.go
new file mode 100644
index 0000000..fdb9123
--- /dev/null
+++ b/internal/engine/probeservices/probeservices_test.go
@@ -0,0 +1,627 @@
+package probeservices_test
+
+import (
+ "context"
+ "errors"
+ "io"
+ "io/ioutil"
+ "net/http"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/apex/log"
+ "github.com/google/go-cmp/cmp"
+ "github.com/ooni/probe-cli/v3/internal/engine/internal/mockable"
+ "github.com/ooni/probe-cli/v3/internal/engine/model"
+ "github.com/ooni/probe-cli/v3/internal/engine/probeservices"
+ "github.com/ooni/probe-cli/v3/internal/engine/probeservices/testorchestra"
+)
+
+func newclient() *probeservices.Client {
+ client, err := probeservices.NewClient(
+ &mockable.Session{
+ MockableHTTPClient: http.DefaultClient,
+ MockableLogger: log.Log,
+ },
+ model.Service{
+ Address: "https://ams-pg-test.ooni.org/",
+ Type: "https",
+ },
+ )
+ if err != nil {
+ panic(err) // so fail the test
+ }
+ return client
+}
+
+func TestNewClientHTTPS(t *testing.T) {
+ client, err := probeservices.NewClient(
+ &mockable.Session{}, model.Service{
+ Address: "https://x.org",
+ Type: "https",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if client.BaseURL != "https://x.org" {
+ t.Fatal("not the URL we expected")
+ }
+}
+
+func TestNewClientUnsupportedEndpoint(t *testing.T) {
+ client, err := probeservices.NewClient(
+ &mockable.Session{}, model.Service{
+ Address: "https://x.org",
+ Type: "onion",
+ })
+ if !errors.Is(err, probeservices.ErrUnsupportedEndpoint) {
+ t.Fatal("not the error we expected")
+ }
+ if client != nil {
+ t.Fatal("expected nil client here")
+ }
+}
+
+func TestNewClientCloudfrontInvalidURL(t *testing.T) {
+ client, err := probeservices.NewClient(
+ &mockable.Session{}, model.Service{
+ Address: "\t\t\t",
+ Type: "cloudfront",
+ })
+ if err == nil || !strings.HasSuffix(err.Error(), "invalid control character in URL") {
+ t.Fatal("not the error we expected")
+ }
+ if client != nil {
+ t.Fatal("expected nil client here")
+ }
+}
+
+func TestNewClientCloudfrontInvalidURLScheme(t *testing.T) {
+ client, err := probeservices.NewClient(
+ &mockable.Session{}, model.Service{
+ Address: "http://x.org",
+ Type: "cloudfront",
+ })
+ if !errors.Is(err, probeservices.ErrUnsupportedCloudFrontAddress) {
+ t.Fatal("not the error we expected")
+ }
+ if client != nil {
+ t.Fatal("expected nil client here")
+ }
+}
+
+func TestNewClientCloudfrontInvalidURLWithPort(t *testing.T) {
+ client, err := probeservices.NewClient(
+ &mockable.Session{}, model.Service{
+ Address: "https://x.org:54321",
+ Type: "cloudfront",
+ })
+ if !errors.Is(err, probeservices.ErrUnsupportedCloudFrontAddress) {
+ t.Fatal("not the error we expected")
+ }
+ if client != nil {
+ t.Fatal("expected nil client here")
+ }
+}
+
+func TestNewClientCloudfrontInvalidFront(t *testing.T) {
+ client, err := probeservices.NewClient(
+ &mockable.Session{}, model.Service{
+ Address: "https://x.org",
+ Type: "cloudfront",
+ Front: "\t\t\t",
+ })
+ if err == nil || !strings.HasSuffix(err.Error(), `invalid URL escape "%09"`) {
+ t.Fatal("not the error we expected")
+ }
+ if client != nil {
+ t.Fatal("expected nil client here")
+ }
+}
+
+func TestNewClientCloudfrontGood(t *testing.T) {
+ client, err := probeservices.NewClient(
+ &mockable.Session{}, model.Service{
+ Address: "https://x.org",
+ Type: "cloudfront",
+ Front: "google.com",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if client.BaseURL != "https://google.com" {
+ t.Fatal("not the BaseURL we expected")
+ }
+ if client.Host != "x.org" {
+ t.Fatal("not the Host we expected")
+ }
+}
+
+func TestCloudfront(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ client, err := probeservices.NewClient(
+ &mockable.Session{}, model.Service{
+ Address: "https://meek.azureedge.net",
+ Type: "cloudfront",
+ Front: "ajax.aspnetcdn.com",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ req, err := http.NewRequest("GET", client.BaseURL, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Host = client.Host
+ resp, err := http.DefaultTransport.RoundTrip(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 200 {
+ t.Fatal("unexpected status code")
+ }
+ data, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(data) != "I’m just a happy little web server.\n" {
+ t.Fatal("unexpected response body")
+ }
+}
+
+func TestDefaultProbeServicesWorkAsIntended(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ for _, e := range probeservices.Default() {
+ client, err := probeservices.NewClient(&mockable.Session{
+ MockableHTTPClient: http.DefaultClient,
+ MockableLogger: log.Log,
+ }, e)
+ if err != nil {
+ t.Fatal(err)
+ }
+ testhelpers, err := client.GetTestHelpers(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(testhelpers) < 1 {
+ t.Fatal("no test helpers?!")
+ }
+ }
+}
+
+func TestSortEndpoints(t *testing.T) {
+ in := []model.Service{{
+ Type: "onion",
+ Address: "httpo://jehhrikjjqrlpufu.onion",
+ }, {
+ Front: "dkyhjv0wpi2dk.cloudfront.net",
+ Type: "cloudfront",
+ Address: "https://dkyhjv0wpi2dk.cloudfront.net",
+ }, {
+ Type: "https",
+ Address: "https://ams-ps2.ooni.nu:443",
+ }}
+ expect := []model.Service{{
+ Type: "https",
+ Address: "https://ams-ps2.ooni.nu:443",
+ }, {
+ Front: "dkyhjv0wpi2dk.cloudfront.net",
+ Type: "cloudfront",
+ Address: "https://dkyhjv0wpi2dk.cloudfront.net",
+ }, {
+ Type: "onion",
+ Address: "httpo://jehhrikjjqrlpufu.onion",
+ }}
+ out := probeservices.SortEndpoints(in)
+ diff := cmp.Diff(out, expect)
+ if diff != "" {
+ t.Fatal(diff)
+ }
+}
+
+func TestOnlyHTTPS(t *testing.T) {
+ in := []model.Service{{
+ Type: "onion",
+ Address: "httpo://jehhrikjjqrlpufu.onion",
+ }, {
+ Type: "https",
+ Address: "https://ams-ps-nonexistent.ooni.io",
+ }, {
+ Type: "https",
+ Address: "https://hkg-ps-nonexistent.ooni.io",
+ }, {
+ Front: "dkyhjv0wpi2dk.cloudfront.net",
+ Type: "cloudfront",
+ Address: "https://dkyhjv0wpi2dk.cloudfront.net",
+ }, {
+ Type: "https",
+ Address: "https://mia-ps-nonexistent.ooni.io",
+ }}
+ expect := []model.Service{{
+ Type: "https",
+ Address: "https://ams-ps-nonexistent.ooni.io",
+ }, {
+ Type: "https",
+ Address: "https://hkg-ps-nonexistent.ooni.io",
+ }, {
+ Type: "https",
+ Address: "https://mia-ps-nonexistent.ooni.io",
+ }}
+ out := probeservices.OnlyHTTPS(in)
+ diff := cmp.Diff(out, expect)
+ if diff != "" {
+ t.Fatal(diff)
+ }
+}
+
+func TestOnlyFallbacks(t *testing.T) {
+ // put onion first so we also verify that we sort the endpoints
+ in := []model.Service{{
+ Type: "onion",
+ Address: "httpo://jehhrikjjqrlpufu.onion",
+ }, {
+ Type: "https",
+ Address: "https://ams-ps-nonexistent.ooni.io",
+ }, {
+ Type: "https",
+ Address: "https://hkg-ps-nonexistent.ooni.io",
+ }, {
+ Front: "dkyhjv0wpi2dk.cloudfront.net",
+ Type: "cloudfront",
+ Address: "https://dkyhjv0wpi2dk.cloudfront.net",
+ }, {
+ Type: "https",
+ Address: "https://mia-ps-nonexistent.ooni.io",
+ }}
+ expect := []model.Service{{
+ Front: "dkyhjv0wpi2dk.cloudfront.net",
+ Type: "cloudfront",
+ Address: "https://dkyhjv0wpi2dk.cloudfront.net",
+ }, {
+ Type: "onion",
+ Address: "httpo://jehhrikjjqrlpufu.onion",
+ }}
+ out := probeservices.OnlyFallbacks(in)
+ diff := cmp.Diff(out, expect)
+ if diff != "" {
+ t.Fatal(diff)
+ }
+}
+
+func TestTryAllCanceledContext(t *testing.T) {
+ // put onion first so we also verify that we sort the endpoints
+ in := []model.Service{{
+ Type: "onion",
+ Address: "httpo://jehhrikjjqrlpufu.onion",
+ }, {
+ Type: "https",
+ Address: "https://ams-ps-nonexistent.ooni.io",
+ }, {
+ Type: "https",
+ Address: "https://hkg-ps-nonexistent.ooni.io",
+ }, {
+ Front: "dkyhjv0wpi2dk.cloudfront.net",
+ Type: "cloudfront",
+ Address: "https://dkyhjv0wpi2dk.cloudfront.net",
+ }, {
+ Type: "https",
+ Address: "https://mia-ps-nonexistent.ooni.io",
+ }}
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel() // immediately cancel and cause every attempt to fail
+ sess := &mockable.Session{
+ MockableHTTPClient: http.DefaultClient,
+ MockableLogger: log.Log,
+ }
+ out := probeservices.TryAll(ctx, sess, in)
+ if len(out) != 5 {
+ t.Fatal("invalid number of entries")
+ }
+ //
+ if out[0].Duration <= 0 {
+ t.Fatal("invalid duration")
+ }
+ if !errors.Is(out[0].Err, context.Canceled) {
+ t.Fatal("invalid error")
+ }
+ if out[0].Endpoint.Type != "https" {
+ t.Fatal("invalid endpoint type")
+ }
+ if out[0].Endpoint.Address != "https://ams-ps-nonexistent.ooni.io" {
+ t.Fatal("invalid endpoint type")
+ }
+ //
+ if out[1].Duration <= 0 {
+ t.Fatal("invalid duration")
+ }
+ if !errors.Is(out[1].Err, context.Canceled) {
+ t.Fatal("invalid error")
+ }
+ if out[1].Endpoint.Type != "https" {
+ t.Fatal("invalid endpoint type")
+ }
+ if out[1].Endpoint.Address != "https://hkg-ps-nonexistent.ooni.io" {
+ t.Fatal("invalid endpoint type")
+ }
+ //
+ if out[2].Duration <= 0 {
+ t.Fatal("invalid duration")
+ }
+ if !errors.Is(out[2].Err, context.Canceled) {
+ t.Fatal("invalid error")
+ }
+ if out[2].Endpoint.Type != "https" {
+ t.Fatal("invalid endpoint type")
+ }
+ if out[2].Endpoint.Address != "https://mia-ps-nonexistent.ooni.io" {
+ t.Fatal("invalid endpoint type")
+ }
+ //
+ if out[3].Duration <= 0 {
+ t.Fatal("invalid duration")
+ }
+ if !errors.Is(out[3].Err, context.Canceled) {
+ t.Fatal("invalid error")
+ }
+ if out[3].Endpoint.Type != "cloudfront" {
+ t.Fatal("invalid endpoint type")
+ }
+ if out[3].Endpoint.Front != "dkyhjv0wpi2dk.cloudfront.net" {
+ t.Fatal("invalid endpoint type")
+ }
+ if out[3].Endpoint.Address != "https://dkyhjv0wpi2dk.cloudfront.net" {
+ t.Fatal("invalid endpoint type")
+ }
+ //
+ // Note: here duration may be zero because the endpoint is not supported
+ // and so we don't basically do anything. But it also may be nonzero since
+ // we also run tests in the cloud, which is slower than my desktop. So, I
+ // have not written a specific test concerning out[4].Duration.
+ if !errors.Is(out[4].Err, probeservices.ErrUnsupportedEndpoint) {
+ t.Fatal("invalid error")
+ }
+ if out[4].Endpoint.Type != "onion" {
+ t.Fatal("invalid endpoint type")
+ }
+ if out[4].Endpoint.Address != "httpo://jehhrikjjqrlpufu.onion" {
+ t.Fatal("invalid endpoint type")
+ }
+}
+
+func TestTryAllIntegrationWeRaceForFastestHTTPS(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ const pattern = "^https://ps[1-4].ooni.io$"
+ // put onion first so we also verify that we sort the endpoints
+ in := []model.Service{{
+ Type: "onion",
+ Address: "httpo://jehhrikjjqrlpufu.onion",
+ }, {
+ Type: "https",
+ Address: "https://ps1.ooni.io",
+ }, {
+ Type: "https",
+ Address: "https://ps2.ooni.io",
+ }, {
+ Front: "dkyhjv0wpi2dk.cloudfront.net",
+ Type: "cloudfront",
+ Address: "https://dkyhjv0wpi2dk.cloudfront.net",
+ }, {
+ Type: "https",
+ Address: "https://ps3.ooni.io",
+ }}
+ sess := &mockable.Session{
+ MockableHTTPClient: http.DefaultClient,
+ MockableLogger: log.Log,
+ }
+ out := probeservices.TryAll(context.Background(), sess, in)
+ if len(out) != 3 {
+ t.Fatal("invalid number of entries")
+ }
+ //
+ if out[0].Duration <= 0 {
+ t.Fatal("invalid duration")
+ }
+ if out[0].Err != nil {
+ t.Fatal("invalid error")
+ }
+ if out[0].Endpoint.Type != "https" {
+ t.Fatal("invalid endpoint type")
+ }
+ if ok, _ := regexp.MatchString(pattern, out[0].Endpoint.Address); !ok {
+ t.Fatal("invalid endpoint type")
+ }
+ //
+ if out[1].Duration <= 0 {
+ t.Fatal("invalid duration")
+ }
+ if out[1].Err != nil {
+ t.Fatal("invalid error")
+ }
+ if out[1].Endpoint.Type != "https" {
+ t.Fatal("invalid endpoint type")
+ }
+ if ok, _ := regexp.MatchString(pattern, out[1].Endpoint.Address); !ok {
+ t.Fatal("invalid endpoint type")
+ }
+ //
+ if out[2].Duration <= 0 {
+ t.Fatal("invalid duration")
+ }
+ if out[2].Err != nil {
+ t.Fatal("invalid error")
+ }
+ if out[2].Endpoint.Type != "https" {
+ t.Fatal("invalid endpoint type")
+ }
+ if ok, _ := regexp.MatchString(pattern, out[2].Endpoint.Address); !ok {
+ t.Fatal("invalid endpoint type")
+ }
+}
+
+func TestTryAllIntegrationWeFallback(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ // put onion first so we also verify that we sort the endpoints
+ in := []model.Service{{
+ Type: "onion",
+ Address: "httpo://jehhrikjjqrlpufu.onion",
+ }, {
+ Type: "https",
+ Address: "https://ps-nonexistent.ooni.io",
+ }, {
+ Type: "https",
+ Address: "https://hkg-ps-nonexistent.ooni.nu",
+ }, {
+ Front: "dkyhjv0wpi2dk.cloudfront.net",
+ Type: "cloudfront",
+ Address: "https://dkyhjv0wpi2dk.cloudfront.net",
+ }, {
+ Type: "https",
+ Address: "https://mia-ps2-nonexistent.ooni.nu",
+ }}
+ sess := &mockable.Session{
+ MockableHTTPClient: http.DefaultClient,
+ MockableLogger: log.Log,
+ }
+ out := probeservices.TryAll(context.Background(), sess, in)
+ if len(out) != 4 {
+ t.Fatal("invalid number of entries")
+ }
+ //
+ if out[0].Duration <= 0 {
+ t.Fatal("invalid duration")
+ }
+ if !strings.HasSuffix(out[0].Err.Error(), "no such host") {
+ t.Fatal("invalid error")
+ }
+ if out[0].Endpoint.Type != "https" {
+ t.Fatal("invalid endpoint type")
+ }
+ if out[0].Endpoint.Address != "https://ps-nonexistent.ooni.io" {
+ t.Fatal("invalid endpoint type")
+ }
+ //
+ if out[1].Duration <= 0 {
+ t.Fatal("invalid duration")
+ }
+ if !strings.HasSuffix(out[1].Err.Error(), "no such host") {
+ t.Fatal("invalid error")
+ }
+ if out[1].Endpoint.Type != "https" {
+ t.Fatal("invalid endpoint type")
+ }
+ if out[1].Endpoint.Address != "https://hkg-ps-nonexistent.ooni.nu" {
+ t.Fatal("invalid endpoint type")
+ }
+ //
+ if out[2].Duration <= 0 {
+ t.Fatal("invalid duration")
+ }
+ if !strings.HasSuffix(out[2].Err.Error(), "no such host") {
+ t.Fatal("invalid error")
+ }
+ if out[2].Endpoint.Type != "https" {
+ t.Fatal("invalid endpoint type")
+ }
+ if out[2].Endpoint.Address != "https://mia-ps2-nonexistent.ooni.nu" {
+ t.Fatal("invalid endpoint type")
+ }
+ //
+ if out[3].Duration <= 0 {
+ t.Fatal("invalid duration")
+ }
+ if out[3].Err != nil {
+ t.Fatal("invalid error")
+ }
+ if out[3].Endpoint.Type != "cloudfront" {
+ t.Fatal("invalid endpoint type")
+ }
+ if out[3].Endpoint.Address != "https://dkyhjv0wpi2dk.cloudfront.net" {
+ t.Fatal("invalid endpoint type")
+ }
+ if out[3].Endpoint.Front != "dkyhjv0wpi2dk.cloudfront.net" {
+ t.Fatal("invalid front")
+ }
+}
+
+func TestSelectBestEmptyInput(t *testing.T) {
+ if out := probeservices.SelectBest(nil); out != nil {
+ t.Fatal("expected nil output here")
+ }
+}
+
+func TestSelectBestOnlyFailures(t *testing.T) {
+ in := []*probeservices.Candidate{{
+ Duration: 10 * time.Millisecond,
+ Err: io.EOF,
+ }}
+ if out := probeservices.SelectBest(in); out != nil {
+ t.Fatal("expected nil output here")
+ }
+}
+
+func TestSelectBestSelectsTheFastest(t *testing.T) {
+ in := []*probeservices.Candidate{{
+ Duration: 10 * time.Millisecond,
+ Endpoint: model.Service{
+ Address: "https://ps1.ooni.io",
+ Type: "https",
+ },
+ }, {
+ Duration: 4 * time.Millisecond,
+ Endpoint: model.Service{
+ Address: "https://ps2.ooni.io",
+ Type: "https",
+ },
+ }, {
+ Duration: 7 * time.Millisecond,
+ Endpoint: model.Service{
+ Address: "https://ps3.ooni.io",
+ Type: "https",
+ },
+ }, {
+ Duration: 11 * time.Millisecond,
+ Endpoint: model.Service{
+ Address: "https://ps4.ooni.io",
+ Type: "https",
+ },
+ }}
+ expected := &probeservices.Candidate{
+ Duration: 4 * time.Millisecond,
+ Endpoint: model.Service{
+ Address: "https://ps2.ooni.io",
+ Type: "https",
+ },
+ }
+ out := probeservices.SelectBest(in)
+ if diff := cmp.Diff(out, expected); diff != "" {
+ t.Fatal(diff)
+ }
+}
+
+func TestGetCredsAndAuthNotLoggedIn(t *testing.T) {
+ clnt := newclient()
+ if err := clnt.MaybeRegister(context.Background(), testorchestra.MetadataFixture()); err != nil {
+ t.Fatal(err)
+ }
+ creds, auth, err := clnt.GetCredsAndAuth()
+ if !errors.Is(err, probeservices.ErrNotLoggedIn) {
+ t.Fatal("not the error we expected")
+ }
+ if creds != nil {
+ t.Fatal("expected nil creds here")
+ }
+ if auth != nil {
+ t.Fatal("expected nil auth here")
+ }
+}
diff --git a/internal/engine/probeservices/psiphon.go b/internal/engine/probeservices/psiphon.go
new file mode 100644
index 0000000..e208491
--- /dev/null
+++ b/internal/engine/probeservices/psiphon.go
@@ -0,0 +1,17 @@
+package probeservices
+
+import (
+ "context"
+ "fmt"
+)
+
+// FetchPsiphonConfig fetches psiphon config from authenticated OONI orchestra.
+func (c Client) FetchPsiphonConfig(ctx context.Context) ([]byte, error) {
+ _, auth, err := c.GetCredsAndAuth()
+ if err != nil {
+ return nil, err
+ }
+ client := c.Client
+ client.Authorization = fmt.Sprintf("Bearer %s", auth.Token)
+ return client.FetchResource(ctx, "/api/v1/test-list/psiphon-config")
+}
diff --git a/internal/engine/probeservices/psiphon_test.go b/internal/engine/probeservices/psiphon_test.go
new file mode 100644
index 0000000..bf194ff
--- /dev/null
+++ b/internal/engine/probeservices/psiphon_test.go
@@ -0,0 +1,46 @@
+package probeservices_test
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "testing"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/probeservices"
+ "github.com/ooni/probe-cli/v3/internal/engine/probeservices/testorchestra"
+)
+
+func TestFetchPsiphonConfig(t *testing.T) {
+ clnt := newclient()
+ if err := clnt.MaybeRegister(context.Background(), testorchestra.MetadataFixture()); err != nil {
+ t.Fatal(err)
+ }
+ if err := clnt.MaybeLogin(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ data, err := clnt.FetchPsiphonConfig(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ var config interface{}
+ if err := json.Unmarshal(data, &config); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestFetchPsiphonConfigNotRegistered(t *testing.T) {
+ clnt := newclient()
+ state := probeservices.State{
+ // Explicitly empty so the test is more clear
+ }
+ if err := clnt.StateFile.Set(state); err != nil {
+ t.Fatal(err)
+ }
+ data, err := clnt.FetchPsiphonConfig(context.Background())
+ if !errors.Is(err, probeservices.ErrNotRegistered) {
+ t.Fatal("expected an error here")
+ }
+ if data != nil {
+ t.Fatal("expected nil data here")
+ }
+}
diff --git a/internal/engine/probeservices/register.go b/internal/engine/probeservices/register.go
new file mode 100644
index 0000000..44dd16a
--- /dev/null
+++ b/internal/engine/probeservices/register.go
@@ -0,0 +1,40 @@
+package probeservices
+
+import (
+ "context"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/internal/randx"
+)
+
+type registerRequest struct {
+ Metadata
+ Password string `json:"password"`
+}
+
+type registerResult struct {
+ ClientID string `json:"client_id"`
+}
+
+// MaybeRegister registers this client if not already registered
+func (c Client) MaybeRegister(ctx context.Context, metadata Metadata) error {
+ if !metadata.Valid() {
+ return ErrInvalidMetadata
+ }
+ state := c.StateFile.Get()
+ if state.Credentials() != nil {
+ return nil // we're already good
+ }
+ c.RegisterCalls.Add(1)
+ pwd := randx.Letters(64)
+ req := ®isterRequest{
+ Metadata: metadata,
+ Password: pwd,
+ }
+ var resp registerResult
+ if err := c.Client.PostJSON(ctx, "/api/v1/register", req, &resp); err != nil {
+ return err
+ }
+ state.ClientID = resp.ClientID
+ state.Password = pwd
+ return c.StateFile.Set(state)
+}
diff --git a/internal/engine/probeservices/register_test.go b/internal/engine/probeservices/register_test.go
new file mode 100644
index 0000000..963ffd5
--- /dev/null
+++ b/internal/engine/probeservices/register_test.go
@@ -0,0 +1,63 @@
+package probeservices_test
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/probeservices"
+ "github.com/ooni/probe-cli/v3/internal/engine/probeservices/testorchestra"
+)
+
+func TestMaybeRegister(t *testing.T) {
+ t.Run("when metadata is not valid", func(t *testing.T) {
+ clnt := newclient()
+ ctx := context.Background()
+ var metadata probeservices.Metadata
+ err := clnt.MaybeRegister(ctx, metadata)
+ if !errors.Is(err, probeservices.ErrInvalidMetadata) {
+ t.Fatal("expected an error here")
+ }
+ })
+ t.Run("when we have already registered", func(t *testing.T) {
+ clnt := newclient()
+ state := probeservices.State{
+ ClientID: "xx-xxx-x-xxxx",
+ Password: "xx",
+ }
+ if err := clnt.StateFile.Set(state); err != nil {
+ t.Fatal(err)
+ }
+ ctx := context.Background()
+ metadata := testorchestra.MetadataFixture()
+ if err := clnt.MaybeRegister(ctx, metadata); err != nil {
+ t.Fatal(err)
+ }
+ })
+ t.Run("when the API call fails", func(t *testing.T) {
+ clnt := newclient()
+ clnt.BaseURL = "\t\t\t" // makes it fail
+ ctx := context.Background()
+ metadata := testorchestra.MetadataFixture()
+ err := clnt.MaybeRegister(ctx, metadata)
+ if err == nil || !strings.HasSuffix(err.Error(), "invalid control character in URL") {
+ t.Fatal("expected an error here")
+ }
+ })
+}
+
+func TestMaybeRegisterIdempotent(t *testing.T) {
+ clnt := newclient()
+ ctx := context.Background()
+ metadata := testorchestra.MetadataFixture()
+ if err := clnt.MaybeRegister(ctx, metadata); err != nil {
+ t.Fatal(err)
+ }
+ if err := clnt.MaybeRegister(ctx, metadata); err != nil {
+ t.Fatal(err)
+ }
+ if clnt.RegisterCalls.Load() != 1 {
+ t.Fatal("called register API too many times")
+ }
+}
diff --git a/internal/engine/probeservices/statefile.go b/internal/engine/probeservices/statefile.go
new file mode 100644
index 0000000..a08939b
--- /dev/null
+++ b/internal/engine/probeservices/statefile.go
@@ -0,0 +1,87 @@
+package probeservices
+
+import (
+ "encoding/json"
+ "time"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/model"
+)
+
+// State is the state stored inside the state file
+type State struct {
+ ClientID string
+ Expire time.Time
+ Password string
+ Token string
+}
+
+// Auth returns an authentication structure, if possible, otherwise
+// it returns nil, meaning that you should login again.
+func (s State) Auth() *LoginAuth {
+ if s.Token == "" {
+ return nil
+ }
+ if time.Now().Add(30 * time.Second).After(s.Expire) {
+ return nil
+ }
+ return &LoginAuth{Expire: s.Expire, Token: s.Token}
+}
+
+// Credentials returns login credentials, if possible, otherwise it
+// returns nil, meaning that you should create an account.
+func (s State) Credentials() *LoginCredentials {
+ if s.ClientID == "" {
+ return nil
+ }
+ if s.Password == "" {
+ return nil
+ }
+ return &LoginCredentials{ClientID: s.ClientID, Password: s.Password}
+}
+
+// StateFile is the orchestra state file. It is backed by
+// a generic key-value store configured by the user.
+type StateFile struct {
+ Store model.KeyValueStore
+ key string
+}
+
+// NewStateFile creates a new state file backed by a key-value store
+func NewStateFile(kvstore model.KeyValueStore) StateFile {
+ return StateFile{key: "orchestra.state", Store: kvstore}
+}
+
+// SetMockable is a mockable version of Set
+func (sf StateFile) SetMockable(s State, mf func(interface{}) ([]byte, error)) error {
+ data, err := mf(s)
+ if err != nil {
+ return err
+ }
+ return sf.Store.Set(sf.key, data)
+}
+
+// Set saves the current state on the key-value store.
+func (sf StateFile) Set(s State) error {
+ return sf.SetMockable(s, json.Marshal)
+}
+
+// GetMockable is a mockable version of Get
+func (sf StateFile) GetMockable(sfget func(string) ([]byte, error),
+ unmarshal func([]byte, interface{}) error) (State, error) {
+ value, err := sfget(sf.key)
+ if err != nil {
+ return State{}, err
+ }
+ var state State
+ if err := unmarshal(value, &state); err != nil {
+ return State{}, err
+ }
+ return state, nil
+}
+
+// Get returns the current state. In case of any error with the
+// underlying key-value store, we return an empty state.
+func (sf StateFile) Get() (state State) {
+ state, _ = sf.GetMockable(sf.Store.Get, json.Unmarshal)
+ return
+}
diff --git a/internal/engine/probeservices/statefile_test.go b/internal/engine/probeservices/statefile_test.go
new file mode 100644
index 0000000..d6bfc88
--- /dev/null
+++ b/internal/engine/probeservices/statefile_test.go
@@ -0,0 +1,153 @@
+package probeservices_test
+
+import (
+ "encoding/json"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/ooni/probe-cli/v3/internal/engine/internal/kvstore"
+ "github.com/ooni/probe-cli/v3/internal/engine/probeservices"
+)
+
+func TestStateAuth(t *testing.T) {
+ t.Run("with no Token", func(t *testing.T) {
+ state := probeservices.State{Expire: time.Now().Add(10 * time.Hour)}
+ if state.Auth() != nil {
+ t.Fatal("expected nil here")
+ }
+ })
+ t.Run("with expired Token", func(t *testing.T) {
+ state := probeservices.State{
+ Expire: time.Now().Add(-1 * time.Hour),
+ Token: "xx-x-xxx-xx",
+ }
+ if state.Auth() != nil {
+ t.Fatal("expected nil here")
+ }
+ })
+ t.Run("with good Token", func(t *testing.T) {
+ state := probeservices.State{
+ Expire: time.Now().Add(10 * time.Hour),
+ Token: "xx-x-xxx-xx",
+ }
+ if state.Auth() == nil {
+ t.Fatal("expected valid auth here")
+ }
+ })
+}
+
+func TestStateCredentials(t *testing.T) {
+ t.Run("with no ClientID", func(t *testing.T) {
+ state := probeservices.State{}
+ if state.Credentials() != nil {
+ t.Fatal("expected nil here")
+ }
+ })
+ t.Run("with no Password", func(t *testing.T) {
+ state := probeservices.State{
+ ClientID: "xx-x-xxx-xx",
+ }
+ if state.Credentials() != nil {
+ t.Fatal("expected nil here")
+ }
+ })
+ t.Run("with all good", func(t *testing.T) {
+ state := probeservices.State{
+ ClientID: "xx-x-xxx-xx",
+ Password: "xx",
+ }
+ if state.Credentials() == nil {
+ t.Fatal("expected valid auth here")
+ }
+ })
+}
+
+func TestStateFileMemoryIntegration(t *testing.T) {
+ // Does the StateFile have the property that we can write
+ // values into it and then read again the same files?
+ sf := probeservices.NewStateFile(kvstore.NewMemoryKeyValueStore())
+ s := probeservices.State{
+ Expire: time.Now(),
+ Password: "xy",
+ Token: "abc",
+ ClientID: "xx",
+ }
+ if err := sf.Set(s); err != nil {
+ t.Fatal(err)
+ }
+ os := sf.Get()
+ diff := cmp.Diff(s, os)
+ if diff != "" {
+ t.Fatal(diff)
+ }
+}
+
+func TestStateFileSetMarshalError(t *testing.T) {
+ sf := probeservices.NewStateFile(kvstore.NewMemoryKeyValueStore())
+ s := probeservices.State{
+ Expire: time.Now(),
+ Password: "xy",
+ Token: "abc",
+ ClientID: "xx",
+ }
+ expected := errors.New("mocked error")
+ failingfunc := func(v interface{}) ([]byte, error) {
+ return nil, expected
+ }
+ if err := sf.SetMockable(s, failingfunc); !errors.Is(err, expected) {
+ t.Fatal("not the error we expected")
+ }
+}
+
+func TestStateFileGetKVStoreGetError(t *testing.T) {
+ sf := probeservices.NewStateFile(kvstore.NewMemoryKeyValueStore())
+ expected := errors.New("mocked error")
+ failingfunc := func(string) ([]byte, error) {
+ return nil, expected
+ }
+ s, err := sf.GetMockable(failingfunc, json.Unmarshal)
+ if !errors.Is(err, expected) {
+ t.Fatal("not the error we expected")
+ }
+ if s.ClientID != "" {
+ t.Fatal("unexpected ClientID field")
+ }
+ if !s.Expire.IsZero() {
+ t.Fatal("unexpected Expire field")
+ }
+ if s.Password != "" {
+ t.Fatal("unexpected Password field")
+ }
+ if s.Token != "" {
+ t.Fatal("unexpected Token field")
+ }
+}
+
+func TestStateFileGetUnmarshalError(t *testing.T) {
+ sf := probeservices.NewStateFile(kvstore.NewMemoryKeyValueStore())
+ if err := sf.Set(probeservices.State{}); err != nil {
+ t.Fatal(err)
+ }
+ expected := errors.New("mocked error")
+ failingfunc := func([]byte, interface{}) error {
+ return expected
+ }
+ s, err := sf.GetMockable(sf.Store.Get, failingfunc)
+ if !errors.Is(err, expected) {
+ t.Fatal("not the error we expected")
+ }
+ if s.ClientID != "" {
+ t.Fatal("unexpected ClientID field")
+ }
+ if !s.Expire.IsZero() {
+ t.Fatal("unexpected Expire field")
+ }
+ if s.Password != "" {
+ t.Fatal("unexpected Password field")
+ }
+ if s.Token != "" {
+ t.Fatal("unexpected Token field")
+ }
+}
diff --git a/internal/engine/probeservices/testorchestra/testorchestra.go b/internal/engine/probeservices/testorchestra/testorchestra.go
new file mode 100644
index 0000000..8e5a0a9
--- /dev/null
+++ b/internal/engine/probeservices/testorchestra/testorchestra.go
@@ -0,0 +1,19 @@
+// Package testorchestra helps with testing the OONI orchestra API.
+package testorchestra
+
+import "github.com/ooni/probe-cli/v3/internal/engine/probeservices"
+
+// MetadataFixture returns a valid metadata struct. This is mostly
+// useful for testing. (We should see if we can make this private.)
+func MetadataFixture() probeservices.Metadata {
+ return probeservices.Metadata{
+ Platform: "linux",
+ ProbeASN: "AS15169",
+ ProbeCC: "US",
+ SoftwareName: "miniooni",
+ SoftwareVersion: "0.1.0-dev",
+ SupportedTests: []string{
+ "web_connectivity",
+ },
+ }
+}
diff --git a/internal/engine/probeservices/tor.go b/internal/engine/probeservices/tor.go
new file mode 100644
index 0000000..3b480b2
--- /dev/null
+++ b/internal/engine/probeservices/tor.go
@@ -0,0 +1,24 @@
+package probeservices
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/model"
+)
+
+// FetchTorTargets returns the targets for the tor experiment.
+func (c Client) FetchTorTargets(ctx context.Context, cc string) (result map[string]model.TorTarget, err error) {
+ _, auth, err := c.GetCredsAndAuth()
+ if err != nil {
+ return nil, err
+ }
+ client := c.Client
+ client.Authorization = fmt.Sprintf("Bearer %s", auth.Token)
+ query := url.Values{}
+ query.Add("country_code", cc)
+ err = client.GetJSONWithQuery(
+ ctx, "/api/v1/test-list/tor-targets", query, &result)
+ return
+}
diff --git a/internal/engine/probeservices/tor_test.go b/internal/engine/probeservices/tor_test.go
new file mode 100644
index 0000000..127e87c
--- /dev/null
+++ b/internal/engine/probeservices/tor_test.go
@@ -0,0 +1,82 @@
+package probeservices_test
+
+import (
+ "context"
+ "net/http"
+ "testing"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/probeservices"
+ "github.com/ooni/probe-cli/v3/internal/engine/probeservices/testorchestra"
+)
+
+func TestFetchTorTargets(t *testing.T) {
+ clnt := newclient()
+ if err := clnt.MaybeRegister(context.Background(), testorchestra.MetadataFixture()); err != nil {
+ t.Fatal(err)
+ }
+ if err := clnt.MaybeLogin(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ data, err := clnt.FetchTorTargets(context.Background(), "ZZ")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if data == nil || len(data) <= 0 {
+ t.Fatal("invalid data")
+ }
+}
+
+func TestFetchTorTargetsNotRegistered(t *testing.T) {
+ clnt := newclient()
+ state := probeservices.State{
+ // Explicitly empty so the test is more clear
+ }
+ if err := clnt.StateFile.Set(state); err != nil {
+ t.Fatal(err)
+ }
+ data, err := clnt.FetchTorTargets(context.Background(), "ZZ")
+ if err == nil {
+ t.Fatal("expected an error here")
+ }
+ if data != nil {
+ t.Fatal("expected nil data here")
+ }
+}
+
+type FetchTorTargetsHTTPTransport struct {
+ Response *http.Response
+}
+
+func (clnt *FetchTorTargetsHTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ resp, err := http.DefaultTransport.RoundTrip(req)
+ if err != nil {
+ return nil, err
+ }
+ if req.URL.Path == "/api/v1/test-list/tor-targets" {
+ clnt.Response = resp
+ }
+ return resp, err
+}
+
+func TestFetchTorTargetsSetsQueryString(t *testing.T) {
+ clnt := newclient()
+ txp := new(FetchTorTargetsHTTPTransport)
+ clnt.HTTPClient.Transport = txp
+ if err := clnt.MaybeRegister(context.Background(), testorchestra.MetadataFixture()); err != nil {
+ t.Fatal(err)
+ }
+ if err := clnt.MaybeLogin(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ data, err := clnt.FetchTorTargets(context.Background(), "ZZ")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if data == nil || len(data) <= 0 {
+ t.Fatal("invalid data")
+ }
+ requestURL := txp.Response.Request.URL
+ if requestURL.Query().Get("country_code") != "ZZ" {
+ t.Fatal("invalid country code")
+ }
+}
diff --git a/internal/engine/probeservices/urls.go b/internal/engine/probeservices/urls.go
new file mode 100644
index 0000000..7066b88
--- /dev/null
+++ b/internal/engine/probeservices/urls.go
@@ -0,0 +1,36 @@
+package probeservices
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+ "strings"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/model"
+)
+
+type urlListResult struct {
+ Results []model.URLInfo `json:"results"`
+}
+
+// FetchURLList fetches the list of URLs used by WebConnectivity. The config
+// argument contains the optional settings. Returns the list of URLs, on success,
+// or an explanatory error, in case of failure.
+func (c Client) FetchURLList(ctx context.Context, config model.URLListConfig) ([]model.URLInfo, error) {
+ query := url.Values{}
+ if config.CountryCode != "" {
+ query.Set("country_code", config.CountryCode)
+ }
+ if config.Limit > 0 {
+ query.Set("limit", fmt.Sprintf("%d", config.Limit))
+ }
+ if len(config.Categories) > 0 {
+ query.Set("category_codes", strings.Join(config.Categories, ","))
+ }
+ var response urlListResult
+ err := c.Client.GetJSONWithQuery(ctx, "/api/v1/test-list/urls", query, &response)
+ if err != nil {
+ return nil, err
+ }
+ return response.Results, nil
+}
diff --git a/internal/engine/probeservices/urls_test.go b/internal/engine/probeservices/urls_test.go
new file mode 100644
index 0000000..5cd7843
--- /dev/null
+++ b/internal/engine/probeservices/urls_test.go
@@ -0,0 +1,50 @@
+package probeservices_test
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/model"
+)
+
+func TestFetchURLListSuccess(t *testing.T) {
+ client := newclient()
+ client.BaseURL = "https://ams-pg-test.ooni.org"
+ config := model.URLListConfig{
+ Categories: []string{"NEWS", "CULTR"},
+ CountryCode: "IT",
+ Limit: 17,
+ }
+ ctx := context.Background()
+ result, err := client.FetchURLList(ctx, config)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(result) != 17 {
+ t.Fatal("unexpected number of results")
+ }
+ for _, entry := range result {
+ if entry.CategoryCode != "NEWS" && entry.CategoryCode != "CULTR" {
+ t.Fatalf("unexpected category code: %+v", entry)
+ }
+ }
+}
+
+func TestFetchURLListFailure(t *testing.T) {
+ client := newclient()
+ client.BaseURL = "https://\t\t\t/" // cause test to fail
+ config := model.URLListConfig{
+ Categories: []string{"NEWS", "CULTR"},
+ CountryCode: "IT",
+ Limit: 17,
+ }
+ ctx := context.Background()
+ result, err := client.FetchURLList(ctx, config)
+ if err == nil || !strings.HasSuffix(err.Error(), "invalid control character in URL") {
+ t.Fatal("not the error we expected")
+ }
+ if len(result) != 0 {
+ t.Fatal("results?!")
+ }
+}
diff --git a/internal/engine/publish-android.bash b/internal/engine/publish-android.bash
new file mode 100755
index 0000000..c2090e8
--- /dev/null
+++ b/internal/engine/publish-android.bash
@@ -0,0 +1,28 @@
+#!/bin/bash
+set -e
+pkgname=oonimkall
+version=$(date -u +%Y.%m.%d-%H%M%S)
+baseurl=https://api.bintray.com/content/ooni/android/$pkgname/$version/org/ooni/$pkgname/$version
+aarfile=./MOBILE/android/$pkgname.aar
+aarfile_version=./MOBILE/android/$pkgname-$version.aar
+ln $aarfile $aarfile_version
+sourcesfile=./MOBILE/android/$pkgname-sources.jar
+sourcesfile_version=./MOBILE/android/$pkgname-$version-sources.jar
+ln $sourcesfile $sourcesfile_version
+pomfile=./MOBILE/android/$pkgname-$version.pom
+pomtemplate=./MOBILE/template.pom
+user=bassosimone
+cat $pomtemplate|sed "s/@VERSION@/$version/g" > $pomfile
+if [ -z $BINTRAY_API_KEY ]; then
+ echo "FATAL: missing BINTRAY_API_KEY variable" 1>&2
+ exit 1
+fi
+# We currently publish the mobile-staging branch. To cleanup we can fetch all the versions using
+# the
+# query, which returns a list of versions. From such list, we can delete the versions we
+# don't need using .
+for filename in $aarfile_version $sourcesfile_version $pomfile; do
+ basefilename=$(basename $filename)
+ curl -sT $filename -u $user:$BINTRAY_API_KEY $baseurl/$basefilename?publish=1 >/dev/null
+done
+echo "implementation 'org.ooni:oonimkall:$version'"
diff --git a/internal/engine/publish-ios.bash b/internal/engine/publish-ios.bash
new file mode 100755
index 0000000..8b5a8b3
--- /dev/null
+++ b/internal/engine/publish-ios.bash
@@ -0,0 +1,23 @@
+#!/bin/bash
+set -e
+pkgname=oonimkall
+version=$(date -u +%Y.%m.%d-%H%M%S)
+baseurl=https://api.bintray.com/content/ooni/ios/$pkgname/$version
+framework=./MOBILE/ios/$pkgname.framework
+frameworkzip=./MOBILE/ios/$pkgname.framework.zip
+podspecfile=./MOBILE/ios/$pkgname.podspec
+podspectemplate=./MOBILE/template.podspec
+user=bassosimone
+(cd ./MOBILE/ios && rm -f $pkgname.framework.zip && zip -yr $pkgname.framework.zip $pkgname.framework)
+cat $podspectemplate|sed "s/@VERSION@/$version/g" > $podspecfile
+if [ -z $BINTRAY_API_KEY ]; then
+ echo "FATAL: missing BINTRAY_API_KEY variable" 1>&2
+ exit 1
+fi
+# We currently publish the mobile-staging branch. To cleanup we can fetch all the versions using
+# the
+# query, which returns a list of versions. From such list, we can delete the versions we
+# don't need using .
+curl -sT $frameworkzip -u $user:$BINTRAY_API_KEY $baseurl/$pkgname-$version.framework.zip?publish=1 >/dev/null
+curl -sT $podspecfile -u $user:$BINTRAY_API_KEY $baseurl/$pkgname-$version.podspec?publish=1 >/dev/null
+echo "pod 'oonimkall', :podspec => 'https://dl.bintray.com/ooni/ios/$pkgname-$version.podspec'"
diff --git a/internal/engine/resources/README.md b/internal/engine/resources/README.md
new file mode 100644
index 0000000..f1e9428
--- /dev/null
+++ b/internal/engine/resources/README.md
@@ -0,0 +1,3 @@
+# Package github.com/ooni/probe-engine/resources
+
+This package contains code to download OONI resources.
diff --git a/internal/engine/resources/assets.go b/internal/engine/resources/assets.go
new file mode 100644
index 0000000..0209750
--- /dev/null
+++ b/internal/engine/resources/assets.go
@@ -0,0 +1,42 @@
+package resources
+
+const (
+ // Version contains the assets version.
+ Version = 20210129095811
+
+ // ASNDatabaseName is the ASN-DB file name
+ ASNDatabaseName = "asn.mmdb"
+
+ // CountryDatabaseName is country-DB file name
+ CountryDatabaseName = "country.mmdb"
+
+ // BaseURL is the asset's repository base URL
+ BaseURL = "https://github.com/"
+)
+
+// ResourceInfo contains information on a resource.
+type ResourceInfo struct {
+ // URLPath is the resource's URL path.
+ URLPath string
+
+ // GzSHA256 is used to validate the downloaded file.
+ GzSHA256 string
+
+ // SHA256 is used to check whether the assets file
+ // stored locally is still up-to-date.
+ SHA256 string
+}
+
+// All contains info on all known assets.
+var All = map[string]ResourceInfo{
+ "asn.mmdb": {
+ URLPath: "/ooni/probe-assets/releases/download/20210129095811/asn.mmdb.gz",
+ GzSHA256: "ef1759bf8b77128723436c4ec5a3d7f2e695fb5a959e741ba39012ced325132c",
+ SHA256: "0afa5afc48ba913933f17b11213c3044499c8338cf63b8f9af2778faa5875474",
+ },
+ "country.mmdb": {
+ URLPath: "/ooni/probe-assets/releases/download/20210129095811/country.mmdb.gz",
+ GzSHA256: "5d465224ab02242a8a79652161d2768e64dd91fc1ed840ca3d0746f4cd29a914",
+ SHA256: "b4aa1292d072d9b2631711e6d3ac69c1e89687b4d513d43a1c330a92b7345e4d",
+ },
+}
diff --git a/internal/engine/resources/resources.go b/internal/engine/resources/resources.go
new file mode 100644
index 0000000..d41ba8e
--- /dev/null
+++ b/internal/engine/resources/resources.go
@@ -0,0 +1,104 @@
+// Package resources contains code to download resources.
+package resources
+
+import (
+ "bytes"
+ "compress/gzip"
+ "context"
+ "crypto/sha256"
+ "fmt"
+ "io"
+ "io/ioutil"
+ "net/http"
+ "os"
+ "path/filepath"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/internal/httpx"
+ "github.com/ooni/probe-cli/v3/internal/engine/model"
+)
+
+// Client is a client for fetching resources.
+type Client struct {
+ // HTTPClient is the HTTP client to use.
+ HTTPClient *http.Client
+
+ // Logger is the logger to use.
+ Logger model.Logger
+
+ // OSMkdirAll allows testing os.MkdirAll failures.
+ OSMkdirAll func(path string, perm os.FileMode) error
+
+ // UserAgent is the user agent to use.
+ UserAgent string
+
+ // WorkDir is the directory where to save resources.
+ WorkDir string
+}
+
+// Ensure ensures that resources are downloaded and current.
+func (c *Client) Ensure(ctx context.Context) error {
+ mkdirall := c.OSMkdirAll
+ if mkdirall == nil {
+ mkdirall = os.MkdirAll
+ }
+ if err := mkdirall(c.WorkDir, 0700); err != nil {
+ return err
+ }
+ for name, resource := range All {
+ if err := c.EnsureForSingleResource(
+ ctx, name, resource, func(real, expected string) bool {
+ return real == expected
+ },
+ gzip.NewReader, ioutil.ReadAll,
+ ); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// EnsureForSingleResource ensures that a single resource
+// is downloaded and is current.
+func (c *Client) EnsureForSingleResource(
+ ctx context.Context, name string, resource ResourceInfo,
+ equal func(real, expected string) bool,
+ gzipNewReader func(r io.Reader) (*gzip.Reader, error),
+ ioutilReadAll func(r io.Reader) ([]byte, error),
+) error {
+ fullpath := filepath.Join(c.WorkDir, name)
+ data, err := ioutil.ReadFile(fullpath)
+ if err == nil {
+ sha256sum := fmt.Sprintf("%x", sha256.Sum256(data))
+ if equal(sha256sum, resource.SHA256) {
+ return nil
+ }
+ c.Logger.Debugf("resources: %s is outdated", fullpath)
+ } else {
+ c.Logger.Debugf("resources: can't read %s: %s", fullpath, err.Error())
+ }
+ data, err = (httpx.Client{
+ BaseURL: BaseURL,
+ HTTPClient: c.HTTPClient,
+ Logger: c.Logger,
+ UserAgent: c.UserAgent,
+ }).FetchResourceAndVerify(ctx, resource.URLPath, resource.GzSHA256)
+ if err != nil {
+ return err
+ }
+ c.Logger.Debugf("resources: uncompress %s", fullpath)
+ gzreader, err := gzipNewReader(bytes.NewReader(data))
+ if err != nil {
+ return err
+ }
+ defer gzreader.Close() // we already have a sha256 for it
+ data, err = ioutilReadAll(gzreader) // small file
+ if err != nil {
+ return err
+ }
+ sha256sum := fmt.Sprintf("%x", sha256.Sum256(data))
+ if equal(sha256sum, resource.SHA256) == false {
+ return fmt.Errorf("resources: %s sha256 mismatch", fullpath)
+ }
+ c.Logger.Debugf("resources: overwrite %s", fullpath)
+ return ioutil.WriteFile(fullpath, data, 0600)
+}
diff --git a/internal/engine/resources/resources_test.go b/internal/engine/resources/resources_test.go
new file mode 100644
index 0000000..025df30
--- /dev/null
+++ b/internal/engine/resources/resources_test.go
@@ -0,0 +1,180 @@
+package resources_test
+
+import (
+ "compress/gzip"
+ "context"
+ "errors"
+ "io"
+ "io/ioutil"
+ "net/http"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/apex/log"
+ "github.com/ooni/probe-cli/v3/internal/engine/resources"
+)
+
+func TestEnsureMkdirAllFailure(t *testing.T) {
+ log.SetLevel(log.DebugLevel)
+ expected := errors.New("mocked error")
+ client := resources.Client{
+ HTTPClient: http.DefaultClient,
+ Logger: log.Log,
+ OSMkdirAll: func(string, os.FileMode) error {
+ return expected
+ },
+ UserAgent: "ooniprobe-engine/0.1.0",
+ WorkDir: "/foobar",
+ }
+ err := client.Ensure(context.Background())
+ if !errors.Is(err, expected) {
+ t.Fatal("not the error we expected")
+ }
+}
+
+func TestEnsure(t *testing.T) {
+ tempdir, err := ioutil.TempDir("", "ooniprobe-engine-resources-test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ client := resources.Client{
+ HTTPClient: http.DefaultClient,
+ Logger: log.Log,
+ UserAgent: "ooniprobe-engine/0.1.0",
+ WorkDir: tempdir,
+ }
+ err = client.Ensure(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ // the second round should be idempotent
+ err = client.Ensure(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestEnsureFailure(t *testing.T) {
+ log.SetLevel(log.DebugLevel)
+ tempdir, err := ioutil.TempDir("", "ooniprobe-engine-resources-test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ client := resources.Client{
+ HTTPClient: http.DefaultClient,
+ Logger: log.Log,
+ UserAgent: "ooniprobe-engine/0.1.0",
+ WorkDir: tempdir,
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ err = client.Ensure(ctx)
+ if !errors.Is(err, context.Canceled) {
+ t.Fatal("not the error we expected")
+ }
+}
+
+func TestEnsureFailAllComparisons(t *testing.T) {
+ log.SetLevel(log.DebugLevel)
+ tempdir, err := ioutil.TempDir("", "ooniprobe-engine-resources-test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ client := resources.Client{
+ HTTPClient: http.DefaultClient,
+ Logger: log.Log,
+ UserAgent: "ooniprobe-engine/0.1.0",
+ WorkDir: tempdir,
+ }
+ // run once to download the resource once
+ err = client.EnsureForSingleResource(
+ context.Background(), "ca-bundle.pem", resources.ResourceInfo{
+ URLPath: "/ooni/probe-assets/releases/download/20190822135402/ca-bundle.pem.gz",
+ GzSHA256: "d5a6aa2290ee18b09cc4fb479e2577ed5ae66c253870ba09776803a5396ea3ab",
+ SHA256: "cb2eca3fbfa232c9e3874e3852d43b33589f27face98eef10242a853d83a437a",
+ }, func(left, right string) bool {
+ return left == right
+ },
+ gzip.NewReader, ioutil.ReadAll,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ // re-run with broken comparison operator so that we should
+ // first redownload and then fail for invalid SHA256.
+ err = client.EnsureForSingleResource(
+ context.Background(), "ca-bundle.pem", resources.ResourceInfo{
+ URLPath: "/ooni/probe-assets/releases/download/20190822135402/ca-bundle.pem.gz",
+ GzSHA256: "d5a6aa2290ee18b09cc4fb479e2577ed5ae66c253870ba09776803a5396ea3ab",
+ SHA256: "cb2eca3fbfa232c9e3874e3852d43b33589f27face98eef10242a853d83a437a",
+ }, func(left, right string) bool {
+ return false // comparison for equality always fails
+ },
+ gzip.NewReader, ioutil.ReadAll,
+ )
+ if err == nil || !strings.HasSuffix(err.Error(), "sha256 mismatch") {
+ t.Fatal("not the error we expected")
+ }
+}
+
+func TestEnsureFailGzipNewReader(t *testing.T) {
+ log.SetLevel(log.DebugLevel)
+ tempdir, err := ioutil.TempDir("", "ooniprobe-engine-resources-test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ client := resources.Client{
+ HTTPClient: http.DefaultClient,
+ Logger: log.Log,
+ UserAgent: "ooniprobe-engine/0.1.0",
+ WorkDir: tempdir,
+ }
+ expected := errors.New("mocked error")
+ err = client.EnsureForSingleResource(
+ context.Background(), "ca-bundle.pem", resources.ResourceInfo{
+ URLPath: "/ooni/probe-assets/releases/download/20190822135402/ca-bundle.pem.gz",
+ GzSHA256: "d5a6aa2290ee18b09cc4fb479e2577ed5ae66c253870ba09776803a5396ea3ab",
+ SHA256: "cb2eca3fbfa232c9e3874e3852d43b33589f27face98eef10242a853d83a437a",
+ }, func(left, right string) bool {
+ return left == right
+ },
+ func(r io.Reader) (*gzip.Reader, error) {
+ return nil, expected
+ },
+ ioutil.ReadAll,
+ )
+ if !errors.Is(err, expected) {
+ t.Fatal("not the error we expected")
+ }
+}
+
+func TestEnsureFailIoUtilReadAll(t *testing.T) {
+ log.SetLevel(log.DebugLevel)
+ tempdir, err := ioutil.TempDir("", "ooniprobe-engine-resources-test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ client := resources.Client{
+ HTTPClient: http.DefaultClient,
+ Logger: log.Log,
+ UserAgent: "ooniprobe-engine/0.1.0",
+ WorkDir: tempdir,
+ }
+ expected := errors.New("mocked error")
+ err = client.EnsureForSingleResource(
+ context.Background(), "ca-bundle.pem", resources.ResourceInfo{
+ URLPath: "/ooni/probe-assets/releases/download/20190822135402/ca-bundle.pem.gz",
+ GzSHA256: "d5a6aa2290ee18b09cc4fb479e2577ed5ae66c253870ba09776803a5396ea3ab",
+ SHA256: "cb2eca3fbfa232c9e3874e3852d43b33589f27face98eef10242a853d83a437a",
+ }, func(left, right string) bool {
+ return left == right
+ },
+ gzip.NewReader, func(r io.Reader) ([]byte, error) {
+ return nil, expected
+ },
+ )
+ if !errors.Is(err, expected) {
+ t.Fatal("not the error we expected")
+ }
+}
diff --git a/internal/engine/saver.go b/internal/engine/saver.go
new file mode 100644
index 0000000..b939482
--- /dev/null
+++ b/internal/engine/saver.go
@@ -0,0 +1,69 @@
+package engine
+
+import (
+ "errors"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/model"
+)
+
+// Saver saves a measurement on some persistent storage.
+type Saver interface {
+ SaveMeasurement(m *model.Measurement) error
+}
+
+// SaverConfig is the configuration for creating a new Saver.
+type SaverConfig struct {
+ // Enabled is true if saving is enabled.
+ Enabled bool
+
+ // Experiment is the experiment we're currently running.
+ Experiment SaverExperiment
+
+ // FilePath is the filepath where to append the measurement as a
+ // serialized JSON followed by a newline character.
+ FilePath string
+
+ // Logger is the logger used by the saver.
+ Logger model.Logger
+}
+
+// SaverExperiment is an experiment according to the Saver.
+type SaverExperiment interface {
+ SaveMeasurement(m *model.Measurement, filepath string) error
+}
+
+// NewSaver creates a new instance of Saver.
+func NewSaver(config SaverConfig) (Saver, error) {
+ if !config.Enabled {
+ return fakeSaver{}, nil
+ }
+ if config.FilePath == "" {
+ return nil, errors.New("saver: passed an empty filepath")
+ }
+ return realSaver{
+ Experiment: config.Experiment,
+ FilePath: config.FilePath,
+ Logger: config.Logger,
+ }, nil
+}
+
+type fakeSaver struct{}
+
+func (fs fakeSaver) SaveMeasurement(m *model.Measurement) error {
+ return nil
+}
+
+var _ Saver = fakeSaver{}
+
+type realSaver struct {
+ Experiment SaverExperiment
+ FilePath string
+ Logger model.Logger
+}
+
+func (rs realSaver) SaveMeasurement(m *model.Measurement) error {
+ rs.Logger.Info("saving measurement to disk")
+ return rs.Experiment.SaveMeasurement(m, rs.FilePath)
+}
+
+var _ Saver = realSaver{}
diff --git a/internal/engine/saver_test.go b/internal/engine/saver_test.go
new file mode 100644
index 0000000..95f9307
--- /dev/null
+++ b/internal/engine/saver_test.go
@@ -0,0 +1,80 @@
+package engine
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/apex/log"
+ "github.com/google/go-cmp/cmp"
+ "github.com/ooni/probe-cli/v3/internal/engine/model"
+)
+
+func TestNewSaverDisabled(t *testing.T) {
+ saver, err := NewSaver(SaverConfig{
+ Enabled: false,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := saver.(fakeSaver); !ok {
+ t.Fatal("not the type of Saver we expected")
+ }
+ m := new(model.Measurement)
+ if err := saver.SaveMeasurement(m); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestNewSaverWithEmptyFilePath(t *testing.T) {
+ saver, err := NewSaver(SaverConfig{
+ Enabled: true,
+ FilePath: "",
+ })
+ if err == nil || err.Error() != "saver: passed an empty filepath" {
+ t.Fatalf("not the error we expected: %+v", err)
+ }
+ if saver != nil {
+ t.Fatal("saver should be nil here")
+ }
+}
+
+type FakeSaverExperiment struct {
+ M *model.Measurement
+ Error error
+ FilePath string
+}
+
+func (fse *FakeSaverExperiment) SaveMeasurement(m *model.Measurement, filepath string) error {
+ fse.M = m
+ fse.FilePath = filepath
+ return fse.Error
+}
+
+var _ SaverExperiment = &FakeSaverExperiment{}
+
+func TestNewSaverWithFailureWhenSaving(t *testing.T) {
+ expected := errors.New("mocked error")
+ fse := &FakeSaverExperiment{Error: expected}
+ saver, err := NewSaver(SaverConfig{
+ Enabled: true,
+ FilePath: "report.jsonl",
+ Experiment: fse,
+ Logger: log.Log,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := saver.(realSaver); !ok {
+ t.Fatal("not the type of saver we expected")
+ }
+ m := &model.Measurement{Input: "www.kernel.org"}
+ if err := saver.SaveMeasurement(m); !errors.Is(err, expected) {
+ t.Fatalf("not the error we expected: %+v", err)
+ }
+ if diff := cmp.Diff(fse.M, m); diff != "" {
+ t.Fatal(diff)
+ }
+ if fse.FilePath != "report.jsonl" {
+ t.Fatal("passed invalid filepath")
+ }
+}
diff --git a/internal/engine/session.go b/internal/engine/session.go
new file mode 100644
index 0000000..1fda2d2
--- /dev/null
+++ b/internal/engine/session.go
@@ -0,0 +1,514 @@
+package engine
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "sync"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/atomicx"
+ "github.com/ooni/probe-cli/v3/internal/engine/geolocate"
+ "github.com/ooni/probe-cli/v3/internal/engine/internal/kvstore"
+ "github.com/ooni/probe-cli/v3/internal/engine/internal/platform"
+ "github.com/ooni/probe-cli/v3/internal/engine/internal/sessionresolver"
+ "github.com/ooni/probe-cli/v3/internal/engine/internal/tunnel"
+ "github.com/ooni/probe-cli/v3/internal/engine/model"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx/bytecounter"
+ "github.com/ooni/probe-cli/v3/internal/engine/probeservices"
+ "github.com/ooni/probe-cli/v3/internal/engine/resources"
+ "github.com/ooni/probe-cli/v3/internal/engine/version"
+)
+
+// SessionConfig contains the Session config
+type SessionConfig struct {
+ AssetsDir string
+ AvailableProbeServices []model.Service
+ KVStore KVStore
+ Logger model.Logger
+ ProxyURL *url.URL
+ SoftwareName string
+ SoftwareVersion string
+ TempDir string
+ TorArgs []string
+ TorBinary string
+}
+
+// Session is a measurement session
+type Session struct {
+ assetsDir string
+ availableProbeServices []model.Service
+ availableTestHelpers map[string][]model.Service
+ byteCounter *bytecounter.Counter
+ httpDefaultTransport netx.HTTPRoundTripper
+ kvStore model.KeyValueStore
+ location *geolocate.Results
+ logger model.Logger
+ proxyURL *url.URL
+ queryProbeServicesCount *atomicx.Int64
+ resolver *sessionresolver.Resolver
+ selectedProbeServiceHook func(*model.Service)
+ selectedProbeService *model.Service
+ softwareName string
+ softwareVersion string
+ tempDir string
+ torArgs []string
+ torBinary string
+ tunnelMu sync.Mutex
+ tunnelName string
+ tunnel tunnel.Tunnel
+}
+
+// NewSession creates a new session or returns an error
+func NewSession(config SessionConfig) (*Session, error) {
+ if config.AssetsDir == "" {
+ return nil, errors.New("AssetsDir is empty")
+ }
+ if config.Logger == nil {
+ return nil, errors.New("Logger is empty")
+ }
+ if config.SoftwareName == "" {
+ return nil, errors.New("SoftwareName is empty")
+ }
+ if config.SoftwareVersion == "" {
+ return nil, errors.New("SoftwareVersion is empty")
+ }
+ if config.KVStore == nil {
+ config.KVStore = kvstore.NewMemoryKeyValueStore()
+ }
+ // Implementation note: if config.TempDir is empty, then Go will
+ // 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")
+ if err != nil {
+ return nil, err
+ }
+ sess := &Session{
+ assetsDir: config.AssetsDir,
+ availableProbeServices: config.AvailableProbeServices,
+ byteCounter: bytecounter.New(),
+ kvStore: config.KVStore,
+ logger: config.Logger,
+ proxyURL: config.ProxyURL,
+ queryProbeServicesCount: atomicx.NewInt64(),
+ softwareName: config.SoftwareName,
+ softwareVersion: config.SoftwareVersion,
+ tempDir: tempDir,
+ torArgs: config.TorArgs,
+ torBinary: config.TorBinary,
+ }
+ httpConfig := netx.Config{
+ ByteCounter: sess.byteCounter,
+ BogonIsError: true,
+ Logger: sess.logger,
+ }
+ sess.resolver = sessionresolver.New(httpConfig)
+ httpConfig.FullResolver = sess.resolver
+ httpConfig.ProxyURL = config.ProxyURL // no need to proxy the resolver
+ sess.httpDefaultTransport = netx.NewHTTPTransport(httpConfig)
+ return sess, nil
+}
+
+// ASNDatabasePath returns the path where the ASN database path should
+// be if you have called s.FetchResourcesIdempotent.
+func (s *Session) ASNDatabasePath() string {
+ return filepath.Join(s.assetsDir, resources.ASNDatabaseName)
+}
+
+// KibiBytesReceived accounts for the KibiBytes received by the HTTP clients
+// managed by this session so far, including experiments.
+func (s *Session) KibiBytesReceived() float64 {
+ return s.byteCounter.KibiBytesReceived()
+}
+
+// KibiBytesSent is like KibiBytesReceived but for the bytes sent.
+func (s *Session) KibiBytesSent() float64 {
+ return s.byteCounter.KibiBytesSent()
+}
+
+// Close ensures that we close all the idle connections that the HTTP clients
+// we are currently using may have created. It will also remove the temp dir
+// that contains data from this session. Not calling this function may likely
+// cause memory leaks in your application because of open idle connections,
+// as well as excessive usage of disk space.
+func (s *Session) Close() error {
+ s.httpDefaultTransport.CloseIdleConnections()
+ s.resolver.CloseIdleConnections()
+ s.logger.Infof("%s", s.resolver.Stats())
+ if s.tunnel != nil {
+ s.tunnel.Stop()
+ }
+ return os.RemoveAll(s.tempDir)
+}
+
+// CountryDatabasePath is like ASNDatabasePath but for the country DB path.
+func (s *Session) CountryDatabasePath() string {
+ return filepath.Join(s.assetsDir, resources.CountryDatabaseName)
+}
+
+// GetTestHelpersByName returns the available test helpers that
+// use the specified name, or false if there's none.
+func (s *Session) GetTestHelpersByName(name string) ([]model.Service, bool) {
+ services, ok := s.availableTestHelpers[name]
+ return services, ok
+}
+
+// DefaultHTTPClient returns the session's default HTTP client.
+func (s *Session) DefaultHTTPClient() *http.Client {
+ return &http.Client{Transport: s.httpDefaultTransport}
+}
+
+// KeyValueStore returns the configured key-value store.
+func (s *Session) KeyValueStore() model.KeyValueStore {
+ return s.kvStore
+}
+
+// Logger returns the logger used by the session.
+func (s *Session) Logger() model.Logger {
+ return s.logger
+}
+
+// MaybeLookupLocation is a caching location lookup call.
+func (s *Session) MaybeLookupLocation() error {
+ return s.MaybeLookupLocationContext(context.Background())
+}
+
+// MaybeLookupBackends is a caching OONI backends lookup call.
+func (s *Session) MaybeLookupBackends() error {
+ return s.maybeLookupBackends(context.Background())
+}
+
+// MaybeLookupBackendsContext is like MaybeLookupBackends but with context.
+func (s *Session) MaybeLookupBackendsContext(ctx context.Context) (err error) {
+ return s.maybeLookupBackends(ctx)
+}
+
+// ErrAlreadyUsingProxy indicates that we cannot create a tunnel with
+// a specific name because we already configured a proxy.
+var ErrAlreadyUsingProxy = errors.New(
+ "session: cannot create a new tunnel of this kind: we are already using a proxy",
+)
+
+// MaybeStartTunnel starts the requested tunnel.
+//
+// This function silently succeeds if we're already using a tunnel with
+// the same name or if the requested tunnel name is the empty string. This
+// function fails, tho, when we already have a proxy or a tunnel with
+// another name and we try to open a tunnel. This function of course also
+// fails if we cannot start the requested tunnel. All in all, if you request
+// for a tunnel name that is not the empty string and you get a nil error,
+// you can be confident that session.ProxyURL() gives you the tunnel URL.
+//
+// The tunnel will be closed by session.Close().
+func (s *Session) MaybeStartTunnel(ctx context.Context, name string) error {
+ s.tunnelMu.Lock()
+ defer s.tunnelMu.Unlock()
+ if s.tunnel != nil && s.tunnelName == name {
+ // We've been asked more than once to start the same tunnel.
+ return nil
+ }
+ if s.proxyURL != nil && name == "" {
+ // The user configured a proxy and here we're not actually trying
+ // to start any tunnel since `name` is empty.
+ return nil
+ }
+ if s.proxyURL != nil || s.tunnel != nil {
+ // We already have a proxy or we have a different tunnel. Because a tunnel
+ // sets a proxy, the second check for s.tunnel is for robustness.
+ return ErrAlreadyUsingProxy
+ }
+ tunnel, err := tunnel.Start(ctx, tunnel.Config{
+ Name: name,
+ Session: s,
+ })
+ if err != nil {
+ s.logger.Warnf("cannot start tunnel: %+v", err)
+ return err
+ }
+ // Implementation note: tunnel _may_ be NIL here if name is ""
+ if tunnel == nil {
+ return nil
+ }
+ s.tunnelName = name
+ s.tunnel = tunnel
+ s.proxyURL = tunnel.SOCKS5ProxyURL()
+ return nil
+}
+
+// NewExperimentBuilder returns a new experiment builder
+// for the experiment with the given name, or an error if
+// there's no such experiment with the given name
+func (s *Session) NewExperimentBuilder(name string) (*ExperimentBuilder, error) {
+ return newExperimentBuilder(s, name)
+}
+
+// NewProbeServicesClient creates a new client for talking with the
+// OONI probe services. This function will benchmark the available
+// probe services, and select the fastest. In case all probe services
+// seem to be down, we try again applying circumvention tactics.
+func (s *Session) NewProbeServicesClient(ctx context.Context) (*probeservices.Client, error) {
+ if err := s.maybeLookupBackends(ctx); err != nil {
+ return nil, err
+ }
+ if err := s.MaybeLookupLocationContext(ctx); err != nil {
+ return nil, err
+ }
+ if s.selectedProbeServiceHook != nil {
+ s.selectedProbeServiceHook(s.selectedProbeService)
+ }
+ return probeservices.NewClient(s, *s.selectedProbeService)
+}
+
+// NewSubmitter creates a new submitter instance.
+func (s *Session) NewSubmitter(ctx context.Context) (Submitter, error) {
+ psc, err := s.NewProbeServicesClient(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return probeservices.NewSubmitter(psc, s.Logger()), nil
+}
+
+// NewOrchestraClient creates a new orchestra client. This client is registered
+// and logged in with the OONI orchestra. An error is returned on failure.
+func (s *Session) NewOrchestraClient(ctx context.Context) (model.ExperimentOrchestraClient, error) {
+ clnt, err := s.NewProbeServicesClient(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return s.initOrchestraClient(ctx, clnt, clnt.MaybeLogin)
+}
+
+// Platform returns the current platform. The platform is one of:
+//
+// - android
+// - ios
+// - linux
+// - macos
+// - windows
+// - unknown
+//
+// When running on the iOS simulator, the returned platform is
+// macos rather than ios if CGO is disabled. This is a known issue,
+// that however should have a very limited impact.
+func (s *Session) Platform() string {
+ return platform.Name()
+}
+
+// ProbeASNString returns the probe ASN as a string.
+func (s *Session) ProbeASNString() string {
+ return fmt.Sprintf("AS%d", s.ProbeASN())
+}
+
+// ProbeASN returns the probe ASN as an integer.
+func (s *Session) ProbeASN() uint {
+ asn := geolocate.DefaultProbeASN
+ if s.location != nil {
+ asn = s.location.ASN
+ }
+ return asn
+}
+
+// ProbeCC returns the probe CC.
+func (s *Session) ProbeCC() string {
+ cc := geolocate.DefaultProbeCC
+ if s.location != nil {
+ cc = s.location.CountryCode
+ }
+ return cc
+}
+
+// ProbeNetworkName returns the probe network name.
+func (s *Session) ProbeNetworkName() string {
+ nn := geolocate.DefaultProbeNetworkName
+ if s.location != nil {
+ nn = s.location.NetworkName
+ }
+ return nn
+}
+
+// ProbeIP returns the probe IP.
+func (s *Session) ProbeIP() string {
+ ip := geolocate.DefaultProbeIP
+ if s.location != nil {
+ ip = s.location.ProbeIP
+ }
+ return ip
+}
+
+// ProxyURL returns the Proxy URL, or nil if not set
+func (s *Session) ProxyURL() *url.URL {
+ return s.proxyURL
+}
+
+// ResolverASNString returns the resolver ASN as a string
+func (s *Session) ResolverASNString() string {
+ return fmt.Sprintf("AS%d", s.ResolverASN())
+}
+
+// ResolverASN returns the resolver ASN
+func (s *Session) ResolverASN() uint {
+ asn := geolocate.DefaultResolverASN
+ if s.location != nil {
+ asn = s.location.ResolverASN
+ }
+ return asn
+}
+
+// ResolverIP returns the resolver IP
+func (s *Session) ResolverIP() string {
+ ip := geolocate.DefaultResolverIP
+ if s.location != nil {
+ ip = s.location.ResolverIP
+ }
+ return ip
+}
+
+// ResolverNetworkName returns the resolver network name.
+func (s *Session) ResolverNetworkName() string {
+ nn := geolocate.DefaultResolverNetworkName
+ if s.location != nil {
+ nn = s.location.ResolverNetworkName
+ }
+ return nn
+}
+
+// SoftwareName returns the application name.
+func (s *Session) SoftwareName() string {
+ return s.softwareName
+}
+
+// SoftwareVersion returns the application version.
+func (s *Session) SoftwareVersion() string {
+ return s.softwareVersion
+}
+
+// TempDir returns the temporary directory.
+func (s *Session) TempDir() string {
+ return s.tempDir
+}
+
+// TorArgs returns the configured extra args for the tor binary. If not set
+// we will not pass in any extra arg. Applies to `-OTunnel=tor` mainly.
+func (s *Session) TorArgs() []string {
+ return s.torArgs
+}
+
+// TorBinary returns the configured path to the tor binary. If not set
+// we will attempt to use "tor". Applies to `-OTunnel=tor` mainly.
+func (s *Session) TorBinary() string {
+ return s.torBinary
+}
+
+// UserAgent constructs the user agent to be used in this session.
+func (s *Session) UserAgent() (useragent string) {
+ useragent += s.softwareName + "/" + s.softwareVersion
+ useragent += " ooniprobe-engine/" + version.Version
+ return
+}
+
+// MaybeUpdateResources updates the resources if needed.
+func (s *Session) MaybeUpdateResources(ctx context.Context) error {
+ return (&resources.Client{
+ HTTPClient: s.DefaultHTTPClient(),
+ Logger: s.logger,
+ UserAgent: s.UserAgent(),
+ WorkDir: s.assetsDir,
+ }).Ensure(ctx)
+}
+
+func (s *Session) getAvailableProbeServices() []model.Service {
+ if len(s.availableProbeServices) > 0 {
+ return s.availableProbeServices
+ }
+ return probeservices.Default()
+}
+
+func (s *Session) initOrchestraClient(
+ ctx context.Context, clnt *probeservices.Client,
+ maybeLogin func(ctx context.Context) error,
+) (*probeservices.Client, error) {
+ // The original implementation has as its only use case that we
+ // were registering and logging in for sending an update regarding
+ // the probe whereabouts. Yet here in probe-engine, the orchestra
+ // is currently only used to fetch inputs. For this purpose, we don't
+ // need to communicate any specific information. The code that will
+ // perform an update used to be responsible of doing that. Now, we
+ // are not using orchestra for this purpose anymore.
+ meta := probeservices.Metadata{
+ Platform: "miniooni",
+ ProbeASN: "AS0",
+ ProbeCC: "ZZ",
+ SoftwareName: "miniooni",
+ SoftwareVersion: "0.1.0-dev",
+ SupportedTests: []string{"web_connectivity"},
+ }
+ if err := clnt.MaybeRegister(ctx, meta); err != nil {
+ return nil, err
+ }
+ if err := maybeLogin(ctx); err != nil {
+ return nil, err
+ }
+ return clnt, nil
+}
+
+// LookupASN maps an IP address to its ASN and network name. This method implements
+// LocationLookupASNLookupper.LookupASN.
+func (s *Session) LookupASN(dbPath, ip string) (uint, string, error) {
+ return geolocate.LookupASN(dbPath, ip)
+}
+
+// ErrAllProbeServicesFailed indicates all probe services failed.
+var ErrAllProbeServicesFailed = errors.New("all available probe services failed")
+
+func (s *Session) maybeLookupBackends(ctx context.Context) error {
+ // TODO(bassosimone): do we need a mutex here?
+ if s.selectedProbeService != nil {
+ return nil
+ }
+ s.queryProbeServicesCount.Add(1)
+ candidates := probeservices.TryAll(ctx, s, s.getAvailableProbeServices())
+ selected := probeservices.SelectBest(candidates)
+ if selected == nil {
+ return ErrAllProbeServicesFailed
+ }
+ s.logger.Infof("session: using probe services: %+v", selected.Endpoint)
+ s.selectedProbeService = &selected.Endpoint
+ s.availableTestHelpers = selected.TestHelpers
+ return nil
+}
+
+// LookupLocationContext performs a location lookup. If you want memoisation
+// of the results, you should use MaybeLookupLocationContext.
+func (s *Session) LookupLocationContext(ctx context.Context) (*geolocate.Results, error) {
+ // Implementation note: we don't perform the lookup of the resolver IP
+ // when we are using a proxy because that might leak information.
+ task := geolocate.Must(geolocate.NewTask(geolocate.Config{
+ EnableResolverLookup: s.proxyURL == nil,
+ HTTPClient: s.DefaultHTTPClient(),
+ Logger: s.Logger(),
+ ResourcesManager: s,
+ UserAgent: s.UserAgent(),
+ }))
+ return task.Run(ctx)
+}
+
+// MaybeLookupLocationContext is like MaybeLookupLocation but with a context
+// that can be used to interrupt this long running operation.
+func (s *Session) MaybeLookupLocationContext(ctx context.Context) error {
+ if s.location == nil {
+ location, err := s.LookupLocationContext(ctx)
+ if err != nil {
+ return err
+ }
+ s.location = location
+ }
+ return nil
+}
+
+var _ model.ExperimentSession = &Session{}
diff --git a/internal/engine/session_integration_test.go b/internal/engine/session_integration_test.go
new file mode 100644
index 0000000..18f240d
--- /dev/null
+++ b/internal/engine/session_integration_test.go
@@ -0,0 +1,659 @@
+package engine
+
+import (
+ "context"
+ "errors"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "syscall"
+ "testing"
+ "time"
+
+ "github.com/apex/log"
+ "github.com/google/go-cmp/cmp"
+ "github.com/ooni/probe-cli/v3/internal/engine/geolocate"
+ "github.com/ooni/probe-cli/v3/internal/engine/model"
+ "github.com/ooni/probe-cli/v3/internal/engine/netx"
+ "github.com/ooni/probe-cli/v3/internal/engine/probeservices"
+ "github.com/ooni/probe-cli/v3/internal/engine/version"
+)
+
+func TestNewSessionBuilderChecks(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ t.Run("with no settings", func(t *testing.T) {
+ newSessionMustFail(t, SessionConfig{})
+ })
+ t.Run("with only assets dir", func(t *testing.T) {
+ newSessionMustFail(t, SessionConfig{
+ AssetsDir: "testdata",
+ })
+ })
+ t.Run("with also logger", func(t *testing.T) {
+ newSessionMustFail(t, SessionConfig{
+ AssetsDir: "testdata",
+ Logger: model.DiscardLogger,
+ })
+ })
+ t.Run("with also software name", func(t *testing.T) {
+ newSessionMustFail(t, SessionConfig{
+ AssetsDir: "testdata",
+ Logger: model.DiscardLogger,
+ SoftwareName: "ooniprobe-engine",
+ })
+ })
+ t.Run("with software version and wrong tempdir", func(t *testing.T) {
+ newSessionMustFail(t, SessionConfig{
+ AssetsDir: "testdata",
+ Logger: model.DiscardLogger,
+ SoftwareName: "ooniprobe-engine",
+ SoftwareVersion: "0.0.1",
+ TempDir: "./nonexistent",
+ })
+ })
+}
+
+func TestNewSessionBuilderGood(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ newSessionForTesting(t)
+}
+
+func newSessionMustFail(t *testing.T, config SessionConfig) {
+ sess, err := NewSession(config)
+ if err == nil {
+ t.Fatal("expected an error here")
+ }
+ if sess != nil {
+ t.Fatal("expected nil session here")
+ }
+}
+
+func TestSessionTorArgsTorBinary(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ sess, err := NewSession(SessionConfig{
+ AssetsDir: "testdata",
+ AvailableProbeServices: []model.Service{{
+ Address: "https://ams-pg-test.ooni.org",
+ Type: "https",
+ }},
+ Logger: model.DiscardLogger,
+ SoftwareName: "ooniprobe-engine",
+ SoftwareVersion: "0.0.1",
+ TorArgs: []string{"antani1", "antani2", "antani3"},
+ TorBinary: "mascetti",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if sess.TorBinary() != "mascetti" {
+ t.Fatal("not the TorBinary we expected")
+ }
+ if len(sess.TorArgs()) != 3 {
+ t.Fatal("not the TorArgs length we expected")
+ }
+ if sess.TorArgs()[0] != "antani1" {
+ t.Fatal("not the TorArgs[0] we expected")
+ }
+ if sess.TorArgs()[1] != "antani2" {
+ t.Fatal("not the TorArgs[1] we expected")
+ }
+ if sess.TorArgs()[2] != "antani3" {
+ t.Fatal("not the TorArgs[2] we expected")
+ }
+}
+
+func newSessionForTestingNoLookupsWithProxyURL(t *testing.T, URL *url.URL) *Session {
+ sess, err := NewSession(SessionConfig{
+ AssetsDir: "testdata",
+ AvailableProbeServices: []model.Service{{
+ Address: "https://ams-pg-test.ooni.org",
+ Type: "https",
+ }},
+ Logger: model.DiscardLogger,
+ ProxyURL: URL,
+ SoftwareName: "ooniprobe-engine",
+ SoftwareVersion: "0.0.1",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return sess
+}
+
+func newSessionForTestingNoLookups(t *testing.T) *Session {
+ return newSessionForTestingNoLookupsWithProxyURL(t, nil)
+}
+
+func newSessionForTestingNoBackendsLookup(t *testing.T) *Session {
+ sess := newSessionForTestingNoLookups(t)
+ if err := sess.MaybeLookupLocation(); err != nil {
+ t.Fatal(err)
+ }
+ log.Infof("Platform: %s", sess.Platform())
+ log.Infof("ProbeASN: %d", sess.ProbeASN())
+ log.Infof("ProbeASNString: %s", sess.ProbeASNString())
+ log.Infof("ProbeCC: %s", sess.ProbeCC())
+ log.Infof("ProbeIP: %s", sess.ProbeIP())
+ log.Infof("ProbeNetworkName: %s", sess.ProbeNetworkName())
+ log.Infof("ResolverASN: %d", sess.ResolverASN())
+ log.Infof("ResolverASNString: %s", sess.ResolverASNString())
+ log.Infof("ResolverIP: %s", sess.ResolverIP())
+ log.Infof("ResolverNetworkName: %s", sess.ResolverNetworkName())
+ return sess
+}
+
+func newSessionForTesting(t *testing.T) *Session {
+ sess := newSessionForTestingNoBackendsLookup(t)
+ if err := sess.MaybeLookupBackends(); err != nil {
+ t.Fatal(err)
+ }
+ return sess
+}
+
+func TestNewOrchestraClient(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ sess := newSessionForTestingNoLookups(t)
+ defer sess.Close()
+ clnt, err := sess.NewOrchestraClient(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if clnt == nil {
+ t.Fatal("expected non nil client here")
+ }
+}
+
+func TestInitOrchestraClientMaybeRegisterError(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel() // so we fail immediately
+ sess := newSessionForTestingNoLookups(t)
+ defer sess.Close()
+ clnt, err := probeservices.NewClient(sess, model.Service{
+ Address: "https://ams-pg-test.ooni.org/",
+ Type: "https",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ outclnt, err := sess.initOrchestraClient(
+ ctx, clnt, clnt.MaybeLogin,
+ )
+ if !errors.Is(err, context.Canceled) {
+ t.Fatal("not the error we expected")
+ }
+ if outclnt != nil {
+ t.Fatal("expected a nil client here")
+ }
+}
+
+func TestInitOrchestraClientMaybeLoginError(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ ctx := context.Background()
+ sess := newSessionForTestingNoLookups(t)
+ defer sess.Close()
+ clnt, err := probeservices.NewClient(sess, model.Service{
+ Address: "https://ams-pg-test.ooni.org/",
+ Type: "https",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ expected := errors.New("mocked error")
+ outclnt, err := sess.initOrchestraClient(
+ ctx, clnt, func(context.Context) error {
+ return expected
+ },
+ )
+ if !errors.Is(err, expected) {
+ t.Fatal("not the error we expected")
+ }
+ if outclnt != nil {
+ t.Fatal("expected a nil client here")
+ }
+}
+
+func TestBouncerError(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ // Combine proxy testing with a broken proxy with errors
+ // in reaching out to the bouncer.
+ server := httptest.NewServer(http.HandlerFunc(
+ func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(500)
+ },
+ ))
+ defer server.Close()
+ URL, err := url.Parse(server.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sess := newSessionForTestingNoLookupsWithProxyURL(t, URL)
+ defer sess.Close()
+ if sess.ProxyURL() == nil {
+ t.Fatal("expected to see explicit proxy here")
+ }
+ if err := sess.MaybeLookupBackends(); err == nil {
+ t.Fatal("expected an error here")
+ }
+}
+
+func TestMaybeLookupBackendsNewClientError(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ sess := newSessionForTestingNoLookups(t)
+ sess.availableProbeServices = []model.Service{{
+ Type: "onion",
+ Address: "httpo://jehhrikjjqrlpufu.onion",
+ }}
+ defer sess.Close()
+ err := sess.MaybeLookupBackends()
+ if !errors.Is(err, ErrAllProbeServicesFailed) {
+ t.Fatal("not the error we expected")
+ }
+}
+
+func TestSessionLocationLookup(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ sess := newSessionForTestingNoLookups(t)
+ defer sess.Close()
+ if err := sess.MaybeLookupLocation(); err != nil {
+ t.Fatal(err)
+ }
+ if sess.ProbeASNString() == geolocate.DefaultProbeASNString {
+ t.Fatal("unexpected ProbeASNString")
+ }
+ if sess.ProbeASN() == geolocate.DefaultProbeASN {
+ t.Fatal("unexpected ProbeASN")
+ }
+ if sess.ProbeCC() == geolocate.DefaultProbeCC {
+ t.Fatal("unexpected ProbeCC")
+ }
+ if sess.ProbeIP() == geolocate.DefaultProbeIP {
+ t.Fatal("unexpected ProbeIP")
+ }
+ if sess.ProbeNetworkName() == geolocate.DefaultProbeNetworkName {
+ t.Fatal("unexpected ProbeNetworkName")
+ }
+ if sess.ResolverASN() == geolocate.DefaultResolverASN {
+ t.Fatal("unexpected ResolverASN")
+ }
+ if sess.ResolverASNString() == geolocate.DefaultResolverASNString {
+ t.Fatal("unexpected ResolverASNString")
+ }
+ if sess.ResolverIP() == geolocate.DefaultResolverIP {
+ t.Fatal("unexpected ResolverIP")
+ }
+ if sess.ResolverNetworkName() == geolocate.DefaultResolverNetworkName {
+ t.Fatal("unexpected ResolverNetworkName")
+ }
+ if sess.KibiBytesSent() <= 0 {
+ t.Fatal("unexpected KibiBytesSent")
+ }
+ if sess.KibiBytesReceived() <= 0 {
+ t.Fatal("unexpected KibiBytesReceived")
+ }
+}
+
+func TestSessionCloseCancelsTempDir(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ sess := newSessionForTestingNoLookups(t)
+ tempDir := sess.TempDir()
+ if _, err := os.Stat(tempDir); err != nil {
+ t.Fatal(err)
+ }
+ if err := sess.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(tempDir); !errors.Is(err, syscall.ENOENT) {
+ t.Fatal("not the error we expected")
+ }
+}
+
+func TestSessionDownloadResources(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ tmpdir, err := ioutil.TempDir("", "test-download-resources-idempotent")
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx := context.Background()
+ sess := newSessionForTestingNoLookups(t)
+ defer sess.Close()
+ sess.SetAssetsDir(tmpdir)
+ err = sess.MaybeUpdateResources(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ readfile := func(path string) (err error) {
+ _, err = ioutil.ReadFile(path)
+ return
+ }
+ if err := readfile(sess.ASNDatabasePath()); err != nil {
+ t.Fatal(err)
+ }
+ if err := readfile(sess.CountryDatabasePath()); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestGetAvailableProbeServices(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ sess, err := NewSession(SessionConfig{
+ AssetsDir: "testdata",
+ Logger: model.DiscardLogger,
+ SoftwareName: "ooniprobe-engine",
+ SoftwareVersion: "0.0.1",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer sess.Close()
+ all := sess.GetAvailableProbeServices()
+ diff := cmp.Diff(all, probeservices.Default())
+ if diff != "" {
+ t.Fatal(diff)
+ }
+}
+
+func TestMaybeLookupBackendsFailure(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ sess, err := NewSession(SessionConfig{
+ AssetsDir: "testdata",
+ Logger: model.DiscardLogger,
+ SoftwareName: "ooniprobe-engine",
+ SoftwareVersion: "0.0.1",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer sess.Close()
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel() // so we fail immediately
+ err = sess.MaybeLookupBackendsContext(ctx)
+ if !errors.Is(err, ErrAllProbeServicesFailed) {
+ t.Fatal("unexpected error")
+ }
+}
+
+func TestMaybeLookupTestHelpersIdempotent(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ sess, err := NewSession(SessionConfig{
+ AssetsDir: "testdata",
+ Logger: model.DiscardLogger,
+ SoftwareName: "ooniprobe-engine",
+ SoftwareVersion: "0.0.1",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer sess.Close()
+ ctx := context.Background()
+ if err = sess.MaybeLookupBackendsContext(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if err = sess.MaybeLookupBackendsContext(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if sess.QueryProbeServicesCount() != 1 {
+ t.Fatal("unexpected number of queries sent to the bouncer")
+ }
+}
+
+func TestAllProbeServicesUnsupported(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ sess, err := NewSession(SessionConfig{
+ AssetsDir: "testdata",
+ Logger: model.DiscardLogger,
+ SoftwareName: "ooniprobe-engine",
+ SoftwareVersion: "0.0.1",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer sess.Close()
+ sess.AppendAvailableProbeService(model.Service{
+ Address: "mascetti",
+ Type: "antani",
+ })
+ err = sess.MaybeLookupBackends()
+ if !errors.Is(err, ErrAllProbeServicesFailed) {
+ t.Fatal("unexpected error")
+ }
+}
+
+func TestStartTunnelGood(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ sess := newSessionForTestingNoLookups(t)
+ defer sess.Close()
+ ctx := context.Background()
+ if err := sess.MaybeStartTunnel(ctx, "psiphon"); err != nil {
+ t.Fatal(err)
+ }
+ if err := sess.MaybeStartTunnel(ctx, "psiphon"); err != nil {
+ t.Fatal(err) // check twice, must be idempotent
+ }
+ if sess.ProxyURL() == nil {
+ t.Fatal("expected non-nil ProxyURL")
+ }
+}
+
+func TestStartTunnelNonexistent(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ sess := newSessionForTestingNoLookups(t)
+ defer sess.Close()
+ ctx := context.Background()
+ if err := sess.MaybeStartTunnel(ctx, "antani"); err.Error() != "unsupported tunnel" {
+ t.Fatal("not the error we expected")
+ }
+ if sess.ProxyURL() != nil {
+ t.Fatal("expected nil ProxyURL")
+ }
+}
+
+func TestStartTunnelEmptyString(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ sess := newSessionForTestingNoLookups(t)
+ defer sess.Close()
+ ctx := context.Background()
+ if sess.MaybeStartTunnel(ctx, "") != nil {
+ t.Fatal("expected no error here")
+ }
+ if sess.ProxyURL() != nil {
+ t.Fatal("expected nil ProxyURL")
+ }
+}
+
+func TestStartTunnelEmptyStringWithProxy(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ proxyURL := &url.URL{Scheme: "socks5", Host: "127.0.0.1:9050"}
+ sess := newSessionForTestingNoLookups(t)
+ sess.proxyURL = proxyURL
+ defer sess.Close()
+ ctx := context.Background()
+ if sess.MaybeStartTunnel(ctx, "") != nil {
+ t.Fatal("expected no error here")
+ }
+ diff := cmp.Diff(proxyURL, sess.ProxyURL())
+ if diff != "" {
+ t.Fatal(diff)
+ }
+}
+
+func TestStartTunnelWithAlreadyExistingTunnel(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ sess := newSessionForTestingNoLookups(t)
+ defer sess.Close()
+ ctx := context.Background()
+ if sess.MaybeStartTunnel(ctx, "psiphon") != nil {
+ t.Fatal("expected no error here")
+ }
+ prev := sess.ProxyURL()
+ err := sess.MaybeStartTunnel(ctx, "tor")
+ if !errors.Is(err, ErrAlreadyUsingProxy) {
+ t.Fatal("expected another error here")
+ }
+ cur := sess.ProxyURL()
+ diff := cmp.Diff(prev, cur)
+ if diff != "" {
+ t.Fatal(diff)
+ }
+}
+
+func TestStartTunnelWithAlreadyExistingProxy(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ sess := newSessionForTestingNoLookups(t)
+ defer sess.Close()
+ ctx := context.Background()
+ orig := &url.URL{Scheme: "socks5", Host: "[::1]:9050"}
+ sess.proxyURL = orig
+ err := sess.MaybeStartTunnel(ctx, "psiphon")
+ if !errors.Is(err, ErrAlreadyUsingProxy) {
+ t.Fatal("expected another error here")
+ }
+ cur := sess.ProxyURL()
+ diff := cmp.Diff(orig, cur)
+ if diff != "" {
+ t.Fatal(diff)
+ }
+}
+
+func TestStartTunnelCanceledContext(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ sess := newSessionForTestingNoLookups(t)
+ defer sess.Close()
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel() // immediately cancel
+ err := sess.MaybeStartTunnel(ctx, "psiphon")
+ if !errors.Is(err, context.Canceled) {
+ t.Fatal("not the error we expected")
+ }
+}
+
+func TestUserAgentNoProxy(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ expect := "ooniprobe-engine/0.0.1 ooniprobe-engine/" + version.Version
+ sess := newSessionForTestingNoLookups(t)
+ ua := sess.UserAgent()
+ diff := cmp.Diff(expect, ua)
+ if diff != "" {
+ t.Fatal(diff)
+ }
+}
+
+func TestNewOrchestraClientMaybeLookupBackendsFailure(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ sess := newSessionForTestingNoLookups(t)
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel() // fail immediately
+ client, err := sess.NewOrchestraClient(ctx)
+ if !errors.Is(err, ErrAllProbeServicesFailed) {
+ t.Fatal("not the error we expected")
+ }
+ if client != nil {
+ t.Fatal("expected nil client here")
+ }
+}
+
+type httpTransportThatSleeps struct {
+ txp netx.HTTPRoundTripper
+ st time.Duration
+}
+
+func (txp httpTransportThatSleeps) RoundTrip(req *http.Request) (*http.Response, error) {
+ resp, err := txp.txp.RoundTrip(req)
+ time.Sleep(txp.st)
+ return resp, err
+}
+
+func (txp httpTransportThatSleeps) CloseIdleConnections() {
+ txp.txp.CloseIdleConnections()
+}
+
+func TestNewOrchestraClientMaybeLookupLocationFailure(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ sess := newSessionForTestingNoLookups(t)
+ sess.httpDefaultTransport = httpTransportThatSleeps{
+ txp: sess.httpDefaultTransport,
+ st: 5 * time.Second,
+ }
+ // The transport sleeps for five seconds, so the context should be expired by
+ // the time in which we attempt at looking up the location. Because the
+ // implementation performs the round-trip and _then_ sleeps, it means we'll
+ // see the context expired error when performing the location lookup.
+ ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
+ defer cancel()
+ client, err := sess.NewOrchestraClient(ctx)
+ if !errors.Is(err, geolocate.ErrAllIPLookuppersFailed) {
+ t.Fatalf("not the error we expected: %+v", err)
+ }
+ if client != nil {
+ t.Fatal("expected nil client here")
+ }
+}
+
+func TestNewOrchestraClientProbeServicesNewClientFailure(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skip test in short mode")
+ }
+ sess := newSessionForTestingNoLookups(t)
+ sess.selectedProbeServiceHook = func(svc *model.Service) {
+ svc.Type = "antani" // should really not be supported for a long time
+ }
+ client, err := sess.NewOrchestraClient(context.Background())
+ if !errors.Is(err, probeservices.ErrUnsupportedEndpoint) {
+ t.Fatal("not the error we expected")
+ }
+ if client != nil {
+ t.Fatal("expected nil client here")
+ }
+}
diff --git a/internal/engine/session_internal_test.go b/internal/engine/session_internal_test.go
new file mode 100644
index 0000000..416b3b1
--- /dev/null
+++ b/internal/engine/session_internal_test.go
@@ -0,0 +1,21 @@
+package engine
+
+import (
+ "github.com/ooni/probe-cli/v3/internal/engine/model"
+)
+
+func (s *Session) SetAssetsDir(assetsDir string) {
+ s.assetsDir = assetsDir
+}
+
+func (s *Session) GetAvailableProbeServices() []model.Service {
+ return s.getAvailableProbeServices()
+}
+
+func (s *Session) AppendAvailableProbeService(svc model.Service) {
+ s.availableProbeServices = append(s.availableProbeServices, svc)
+}
+
+func (s *Session) QueryProbeServicesCount() int64 {
+ return s.queryProbeServicesCount.Load()
+}
diff --git a/internal/engine/submitter.go b/internal/engine/submitter.go
new file mode 100644
index 0000000..f5ee834
--- /dev/null
+++ b/internal/engine/submitter.go
@@ -0,0 +1,67 @@
+package engine
+
+import (
+ "context"
+
+ "github.com/ooni/probe-cli/v3/internal/engine/model"
+)
+
+// TODO(bassosimone): maybe keep track of which measurements
+// could not be submitted by a specific submitter?
+
+// Submitter submits a measurement to the OONI collector.
+type Submitter interface {
+ // Submit submits the measurement and updates its
+ // report ID field in case of success.
+ Submit(ctx context.Context, m *model.Measurement) error
+}
+
+// SubmitterSession is the Submitter's view of the Session.
+type SubmitterSession interface {
+ // NewSubmitter creates a new probeservices Submitter.
+ NewSubmitter(ctx context.Context) (Submitter, error)
+}
+
+// SubmitterConfig contains settings for NewSubmitter.
+type SubmitterConfig struct {
+ // Enabled is true if measurement submission is enabled.
+ Enabled bool
+
+ // Session is the current session.
+ Session SubmitterSession
+
+ // Logger is the logger to be used.
+ Logger model.Logger
+}
+
+// NewSubmitter creates a new submitter instance. Depending on
+// whether submission is enabled or not, the returned submitter
+// instance migh just be a stub implementation.
+func NewSubmitter(ctx context.Context, config SubmitterConfig) (Submitter, error) {
+ if !config.Enabled {
+ return stubSubmitter{}, nil
+ }
+ subm, err := config.Session.NewSubmitter(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return realSubmitter{subm: subm, logger: config.Logger}, nil
+}
+
+type stubSubmitter struct{}
+
+func (stubSubmitter) Submit(ctx context.Context, m *model.Measurement) error {
+ return nil
+}
+
+var _ Submitter = stubSubmitter{}
+
+type realSubmitter struct {
+ subm Submitter
+ logger model.Logger
+}
+
+func (rs realSubmitter) Submit(ctx context.Context, m *model.Measurement) error {
+ rs.logger.Info("submitting measurement to OONI collector; please be patient...")
+ return rs.subm.Submit(ctx, m)
+}
diff --git a/internal/engine/submitter_test.go b/internal/engine/submitter_test.go
new file mode 100644
index 0000000..d436856
--- /dev/null
+++ b/internal/engine/submitter_test.go
@@ -0,0 +1,88 @@
+package engine
+
+import (
+ "context"
+ "errors"
+ "sync/atomic"
+ "testing"
+
+ "github.com/apex/log"
+ "github.com/ooni/probe-cli/v3/internal/engine/model"
+)
+
+func TestSubmitterNotEnabled(t *testing.T) {
+ ctx := context.Background()
+ submitter, err := NewSubmitter(ctx, SubmitterConfig{
+ Enabled: false,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := submitter.(stubSubmitter); !ok {
+ t.Fatal("we did not get a stubSubmitter instance")
+ }
+ m := new(model.Measurement)
+ if err := submitter.Submit(ctx, m); err != nil {
+ t.Fatal(err)
+ }
+}
+
+type FakeSubmitter struct {
+ Calls uint32
+ Error error
+}
+
+func (fs *FakeSubmitter) Submit(ctx context.Context, m *model.Measurement) error {
+ atomic.AddUint32(&fs.Calls, 1)
+ return fs.Error
+}
+
+var _ Submitter = &FakeSubmitter{}
+
+type FakeSubmitterSession struct {
+ Error error
+ Submitter Submitter
+}
+
+func (fse FakeSubmitterSession) NewSubmitter(ctx context.Context) (Submitter, error) {
+ return fse.Submitter, fse.Error
+}
+
+var _ SubmitterSession = FakeSubmitterSession{}
+
+func TestNewSubmitterFails(t *testing.T) {
+ expected := errors.New("mocked error")
+ ctx := context.Background()
+ submitter, err := NewSubmitter(ctx, SubmitterConfig{
+ Enabled: true,
+ Session: FakeSubmitterSession{Error: expected},
+ })
+ if !errors.Is(err, expected) {
+ t.Fatalf("not the error we expected: %+v", err)
+ }
+ if submitter != nil {
+ t.Fatal("expected nil submitter here")
+ }
+}
+
+func TestNewSubmitterWithFailedSubmission(t *testing.T) {
+ expected := errors.New("mocked error")
+ ctx := context.Background()
+ fakeSubmitter := &FakeSubmitter{Error: expected}
+ submitter, err := NewSubmitter(ctx, SubmitterConfig{
+ Enabled: true,
+ Logger: log.Log,
+ Session: FakeSubmitterSession{Submitter: fakeSubmitter},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ m := new(model.Measurement)
+ err = submitter.Submit(context.Background(), m)
+ if !errors.Is(err, expected) {
+ t.Fatalf("not the error we expected: %+v", err)
+ }
+ if fakeSubmitter.Calls != 1 {
+ t.Fatal("unexpected number of calls")
+ }
+}
diff --git a/internal/engine/testdata/.gitignore b/internal/engine/testdata/.gitignore
new file mode 100644
index 0000000..c2dab52
--- /dev/null
+++ b/internal/engine/testdata/.gitignore
@@ -0,0 +1,10 @@
+/asn.mmdb
+/ca-bundle.pem
+/country.mmdb
+/enginetests*/
+/kvstore2/
+/oonimkall
+/oonipsiphon
+/psiphon_config.json
+/psiphon_unit_tests/
+/test-download-resources-idempotent*/
diff --git a/internal/engine/testdata/inputloader1.txt b/internal/engine/testdata/inputloader1.txt
new file mode 100644
index 0000000..b2a875b
--- /dev/null
+++ b/internal/engine/testdata/inputloader1.txt
@@ -0,0 +1,3 @@
+https://www.x.org/
+https://www.slashdot.org/
+https://abc.xyz/
diff --git a/internal/engine/testdata/inputloader2.txt b/internal/engine/testdata/inputloader2.txt
new file mode 100644
index 0000000..c2b713e
--- /dev/null
+++ b/internal/engine/testdata/inputloader2.txt
@@ -0,0 +1 @@
+https://run.ooni.io/
diff --git a/internal/engine/testdata/inputloader3.txt b/internal/engine/testdata/inputloader3.txt
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/internal/engine/testdata/inputloader3.txt
@@ -0,0 +1 @@
+
diff --git a/internal/engine/testjafar.bash b/internal/engine/testjafar.bash
new file mode 100755
index 0000000..0c415dc
--- /dev/null
+++ b/internal/engine/testjafar.bash
@@ -0,0 +1,129 @@
+#!/bin/bash
+
+#
+# This script uses cURL to verify that Jafar is able to produce a
+# bunch of censorship conditions. It should be noted that this script
+# only works on Linux and will never work on other systems.
+#
+
+set -e
+
+function execute() {
+ echo "+ $@" 1>&2
+ "$@"
+}
+
+function expectexitcode() {
+ local expect
+ local exitcode
+ expect=$1
+ shift
+ set +e
+ "$@"
+ exitcode=$?
+ set -e
+ echo "expected exitcode $expect, found $exitcode" 1>&2
+ if [ $exitcode != $expect ]; then
+ exit 1
+ fi
+}
+
+function runtest() {
+ echo "=== BEGIN $1 ==="
+ "$1"
+ echo "=== END $1 ==="
+}
+
+function http_got_nothing() {
+ expectexitcode 52 execute ./jafar -iptables-hijack-http-to 127.0.0.1:7117 \
+ -main-command 'curl -sm5 --connect-to ::example.com: http://ooni.io'
+}
+
+function http_recv_error() {
+ expectexitcode 56 execute ./jafar -iptables-reset-keyword ooni \
+ -main-command 'curl -sm5 --connect-to ::example.com: http://ooni.io'
+}
+
+function http_operation_timedout() {
+ expectexitcode 28 execute ./jafar -iptables-drop-keyword ooni \
+ -main-command 'curl -sm5 --connect-to ::example.com: http://ooni.io'
+}
+
+function http_couldnt_connect() {
+ local ip
+ ip=$(host -tA example.com|cut -f4 -d' ')
+ expectexitcode 7 execute ./jafar -iptables-reset-ip $ip \
+ -main-command 'curl -sm5 --connect-to ::example.com: http://ooni.io'
+}
+
+function http_blockpage() {
+ outfile=$(mktemp)
+ chown nobody $outfile # curl runs as user nobody
+ expectexitcode 0 execute ./jafar -http-proxy-block ooni \
+ -iptables-hijack-http-to 127.0.0.1:80 \
+ -main-command "curl -so $outfile --connect-to ::example.com: http://ooni.io"
+ if ! grep -q '451 Unavailable For Legal Reasons' $outfile; then
+ echo "fatal: the blockpage does not contain the expected pattern" 1>&2
+ exit 1
+ fi
+}
+
+function dns_injection() {
+ output=$(expectexitcode 0 execute ./jafar \
+ -iptables-hijack-dns-to 127.0.0.1:53 \
+ -dns-proxy-hijack ooni \
+ -main-command 'dig +time=2 +short @example.com ooni.io')
+ if [ "$output" != "127.0.0.1" ]; then
+ echo "fatal: the resulting IP is not the expected one" 1>&2
+ exit 1
+ fi
+}
+
+function dns_timeout() {
+ expectexitcode 9 execute ./jafar \
+ -iptables-hijack-dns-to 127.0.0.1:53 \
+ -dns-proxy-ignore ooni \
+ -main-command 'dig +time=2 +short @example.com ooni.io'
+}
+
+function dns_nxdomain() {
+ output=$(expectexitcode 0 execute ./jafar \
+ -iptables-hijack-dns-to 127.0.0.1:53 \
+ -dns-proxy-block ooni \
+ -main-command 'dig +time=2 +short @example.com ooni.io')
+ if [ "$output" != "" ]; then
+ echo "fatal: expected no output here" 1>&2
+ exit 1
+ fi
+}
+
+function sni_man_in_the_middle() {
+ expectexitcode 60 execute ./jafar -iptables-hijack-https-to 127.0.0.1:4114 \
+ -main-command 'curl -sm5 --connect-to ::example.com: https://ooni.io'
+}
+
+function sni_got_nothing() {
+ expectexitcode 52 execute ./jafar -iptables-hijack-https-to 127.0.0.1:4114 \
+ -main-command 'curl -sm5 --cacert badproxy.pem --connect-to ::example.com: https://ooni.io'
+}
+
+function sni_connect_error() {
+ expectexitcode 35 execute ./jafar -iptables-reset-keyword ooni \
+ -main-command 'curl -sm5 --connect-to ::example.com: https://ooni.io'
+}
+
+function main() {
+ runtest http_got_nothing
+ runtest http_recv_error
+ runtest http_operation_timedout
+ runtest http_couldnt_connect
+ runtest http_blockpage
+ runtest dns_injection
+ runtest dns_timeout
+ runtest dns_nxdomain
+ runtest sni_man_in_the_middle
+ runtest sni_got_nothing
+ runtest sni_connect_error
+}
+
+main "$@"
diff --git a/internal/engine/testusing.bash b/internal/engine/testusing.bash
new file mode 100755
index 0000000..d0e5f92
--- /dev/null
+++ b/internal/engine/testusing.bash
@@ -0,0 +1,23 @@
+#!/bin/bash
+
+#
+# This script simulates a user creating a new project that depends
+# on github.com/ooni/probe-engine@GITHUB_SHA.
+#
+
+set -ex
+mkdir -p /tmp/example.org/x
+cd /tmp/example.org/x
+go mod init example.org/x
+cat > main.go << EOF
+package main
+
+import "github.com/ooni/probe-engine/libminiooni"
+
+func main() {
+ libminiooni.Main()
+}
+EOF
+go get -v github.com/ooni/probe-engine@$GITHUB_SHA
+go build -v .
+./x --yes -OTunnel=psiphon -ni https://www.example.com urlgetter
diff --git a/internal/engine/version/version.go b/internal/engine/version/version.go
new file mode 100644
index 0000000..7f384e6
--- /dev/null
+++ b/internal/engine/version/version.go
@@ -0,0 +1,5 @@
+// Package version contains the probe-engine version.
+package version
+
+// Version is the version of the engine
+const Version = "0.23.0"