chore: merge probe-engine into probe-cli (#201)

This is how I did it:

1. `git clone https://github.com/ooni/probe-engine internal/engine`

2. ```
(cd internal/engine && git describe --tags)
v0.23.0
```

3. `nvim go.mod` (merging `go.mod` with `internal/engine/go.mod`

4. `rm -rf internal/.git internal/engine/go.{mod,sum}`

5. `git add internal/engine`

6. `find . -type f -name \*.go -exec sed -i 's@/ooni/probe-engine@/ooni/probe-cli/v3/internal/engine@g' {} \;`

7. `go build ./...` (passes)

8. `go test -race ./...` (temporary failure on RiseupVPN)

9. `go mod tidy`

10. this commit message

Once this piece of work is done, we can build a new version of `ooniprobe` that
is using `internal/engine` directly. We need to do more work to ensure all the
other functionality in `probe-engine` (e.g. making mobile packages) are still WAI.

Part of https://github.com/ooni/probe/issues/1335
This commit is contained in:
Simone Basso 2021-02-02 12:05:47 +01:00 committed by GitHub
commit d57c78bc71
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
535 changed files with 66182 additions and 23 deletions

View file

@ -0,0 +1,51 @@
// Package fsx contains file system extension
package fsx
import (
"fmt"
"os"
"syscall"
)
// File is a generic file. This interface is taken from the draft
// iofs golang design. We'll use fs.File when available.
type File interface {
Stat() (os.FileInfo, error)
Read([]byte) (int, error)
Close() error
}
// FS is a generic file system. Like File, it's adapted from
// the draft iofs golang design document.
type FS interface {
Open(name string) (File, error)
}
// Open is a wrapper for os.Open that ensures that we're opening a file.
func Open(pathname string) (File, error) {
return OpenWithFS(filesystem{}, pathname)
}
// OpenWithFS is like Open but with explicit file system argument.
func OpenWithFS(fs FS, pathname string) (File, error) {
file, err := fs.Open(pathname)
if err != nil {
return nil, err
}
info, err := file.Stat()
if err != nil {
file.Close()
return nil, err
}
if info.IsDir() {
file.Close()
return nil, fmt.Errorf("input path points to a directory: %w", syscall.EISDIR)
}
return file, nil
}
type filesystem struct{}
func (filesystem) Open(pathname string) (File, error) {
return os.Open(pathname)
}

View file

@ -0,0 +1,75 @@
package fsx_test
import (
"errors"
"os"
"sync/atomic"
"syscall"
"testing"
"github.com/ooni/probe-cli/v3/internal/engine/internal/fsx"
)
var StateBaseDir = "./testdata/"
type FailingStatFS struct {
CloseCount *int32
}
type FailingStatFile struct {
CloseCount *int32
}
var errStatFailed = errors.New("stat failed")
func (FailingStatFile) Stat() (os.FileInfo, error) {
return nil, errStatFailed
}
func (fs FailingStatFS) Open(pathname string) (fsx.File, error) {
return FailingStatFile{CloseCount: fs.CloseCount}, nil
}
func (fs FailingStatFile) Close() error {
if fs.CloseCount != nil {
atomic.AddInt32(fs.CloseCount, 1)
}
return nil
}
func (FailingStatFile) Read([]byte) (int, error) {
return 0, nil
}
func TestOpenWithFailingStat(t *testing.T) {
var count int32
_, err := fsx.OpenWithFS(FailingStatFS{CloseCount: &count}, StateBaseDir+"testfile.txt")
if !errors.Is(err, errStatFailed) {
t.Errorf("expected error with invalid FS: %+v", err)
}
if count != 1 {
t.Error("expected counter to be equal to 1")
}
}
func TestOpenNonexistentFile(t *testing.T) {
_, err := fsx.Open(StateBaseDir + "invalidtestfile.txt")
if !errors.Is(err, syscall.ENOENT) {
t.Errorf("not the error we expected")
}
}
func TestOpenDirectoryShouldFail(t *testing.T) {
_, err := fsx.Open(StateBaseDir)
if !errors.Is(err, syscall.EISDIR) {
t.Fatalf("not the error we expected: %+v", err)
}
}
func TestOpeningExistingFileShouldWork(t *testing.T) {
file, err := fsx.Open(StateBaseDir + "testfile.txt")
if err != nil {
t.Fatal(err)
}
defer file.Close()
}

View file

@ -0,0 +1,15 @@
// Package httpfailure groups a bunch of extra HTTP failures.
//
// These failures only matter in the context of processing the results
// of specific experiments, e.g., whatsapp, telegram.
package httpfailure
var (
// UnexpectedStatusCode indicates that we re not getting
// the expected (range of) HTTP status code(s).
UnexpectedStatusCode = "http_unexpected_status_code"
// UnexpectedRedirectURL indicates that the redirect URL
// returned by the server is not the expected one.
UnexpectedRedirectURL = "http_unexpected_redirect_url"
)

View file

@ -0,0 +1,6 @@
package httpheader
// Accept returns the Accept header used for measuring.
func Accept() string {
return "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
}

View file

@ -0,0 +1,6 @@
package httpheader
// AcceptLanguage returns the Accept-Language header used for measuring.
func AcceptLanguage() string {
return "en-US;q=0.8,en;q=0.5"
}

View file

@ -0,0 +1,16 @@
// Package httpheader contains code to set common HTTP headers.
package httpheader
// UserAgent returns the User-Agent header used for measuring.
func UserAgent() string {
// 12.0% as of Jan 29, 2021 according to https://techblog.willshouse.com/2012/01/03/most-common-user-agents/
const ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36"
return ua
}
// CLIUserAgent returns the User-Agent used when we want to
// pretent to be a command line HTTP client.
func CLIUserAgent() string {
// here we always put the latest version of cURL.
return "curl/7.73.0"
}

View file

@ -0,0 +1,44 @@
package httpx
import (
"io/ioutil"
"net/http"
"time"
)
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
}

View file

@ -0,0 +1,31 @@
package httpx
import (
"context"
"crypto/sha256"
"fmt"
)
// FetchResource fetches the specified resource and returns it.
func (c Client) FetchResource(ctx context.Context, URLPath string) ([]byte, error) {
request, err := c.NewRequest(ctx, "GET", URLPath, nil, nil)
if err != nil {
return nil, err
}
return c.Do(request)
}
// FetchResourceAndVerify fetches and verifies a specific resource.
func (c Client) FetchResourceAndVerify(ctx context.Context, URL, SHA256Sum string) ([]byte, error) {
c.Logger.Debugf("httpx: expected SHA256: %s", SHA256Sum)
data, err := c.FetchResource(ctx, URL)
if err != nil {
return nil, err
}
s := fmt.Sprintf("%x", sha256.Sum256(data))
c.Logger.Debugf("httpx: real SHA256: %s", s)
if SHA256Sum != s {
return nil, fmt.Errorf("httpx: SHA256 mismatch: got %s and expected %s", s, SHA256Sum)
}
return data, nil
}

View file

@ -0,0 +1,154 @@
package httpx_test
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/internal/engine/internal/httpx"
)
func TestFetchResourceIntegration(t *testing.T) {
log.SetLevel(log.DebugLevel)
ctx := context.Background()
data, err := (httpx.Client{
BaseURL: "http://facebook.com/",
HTTPClient: http.DefaultClient,
Logger: log.Log,
UserAgent: "ooniprobe-engine/0.1.0",
}).FetchResource(ctx, "/robots.txt")
if err != nil {
t.Fatal(err)
}
if len(data) <= 0 {
t.Fatal("Did not expect an empty resource")
}
}
func TestFetchResourceExpiredContext(t *testing.T) {
log.SetLevel(log.DebugLevel)
ctx, cancel := context.WithCancel(context.Background())
cancel()
data, err := (httpx.Client{
BaseURL: "http://facebook.com/",
HTTPClient: http.DefaultClient,
Logger: log.Log,
UserAgent: "ooniprobe-engine/0.1.0",
}).FetchResource(ctx, "/robots.txt")
if !errors.Is(err, context.Canceled) {
t.Fatal("not the error we expected")
}
if len(data) != 0 {
t.Fatal("expected an empty resource")
}
}
func TestFetchResourceAndVerifyIntegration(t *testing.T) {
log.SetLevel(log.DebugLevel)
ctx := context.Background()
data, err := (httpx.Client{
BaseURL: "https://github.com/",
HTTPClient: http.DefaultClient,
Logger: log.Log,
UserAgent: "ooniprobe-engine/0.1.0",
}).FetchResourceAndVerify(
ctx,
"/measurement-kit/generic-assets/releases/download/20190426155936/generic-assets-20190426155936.tar.gz",
"34d8a9c8ab30c242469482dc280be832d8a06b4400f8927604dd361bf979b795",
)
if err != nil {
t.Fatal(err)
}
if len(data) <= 0 {
t.Fatal("Did not expect an empty resource")
}
}
func TestFetchResourceInvalidURL(t *testing.T) {
log.SetLevel(log.DebugLevel)
ctx := context.Background()
data, err := (httpx.Client{
BaseURL: "http://\t/",
HTTPClient: http.DefaultClient,
Logger: log.Log,
UserAgent: "ooniprobe-engine/0.1.0",
}).FetchResource(ctx, "/robots.txt")
if err == nil || !strings.HasSuffix(err.Error(), "invalid control character in URL") {
t.Fatal("not the error we expected")
}
if len(data) != 0 {
t.Fatal("expected an empty resource")
}
}
func TestFetchResource400(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(400)
},
))
defer server.Close()
log.SetLevel(log.DebugLevel)
ctx := context.Background()
data, err := (httpx.Client{
Authorization: "foobar",
BaseURL: server.URL,
HTTPClient: http.DefaultClient,
Logger: log.Log,
UserAgent: "ooniprobe-engine/0.1.0",
}).FetchResource(ctx, "")
if err == nil || !strings.HasSuffix(err.Error(), "400 Bad Request") {
t.Fatal("not the error we expected")
}
if len(data) != 0 {
t.Fatal("expected an empty resource")
}
}
func TestFetchResourceAndVerify400(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(400)
},
))
defer server.Close()
log.SetLevel(log.DebugLevel)
ctx := context.Background()
data, err := (httpx.Client{
BaseURL: server.URL,
HTTPClient: http.DefaultClient,
Logger: log.Log,
UserAgent: "ooniprobe-engine/0.1.0",
}).FetchResourceAndVerify(ctx, "", "abcde")
if err == nil || !strings.HasSuffix(err.Error(), "400 Bad Request") {
t.Fatal("not the error we expected")
}
if len(data) != 0 {
t.Fatal("expected an empty resource")
}
}
func TestFetchResourceAndVerifyInvalidSHA256(t *testing.T) {
log.SetLevel(log.DebugLevel)
ctx := context.Background()
data, err := (httpx.Client{
BaseURL: "https://github.com/",
HTTPClient: http.DefaultClient,
Logger: log.Log,
UserAgent: "ooniprobe-engine/0.1.0",
}).FetchResourceAndVerify(
ctx,
"/measurement-kit/generic-assets/releases/download/20190426155936/generic-assets-20190426155936.tar.gz",
"34d8a9ceeb30c242469482dc280be832d8a06b4400f8927604dd361bf979b795",
)
if err == nil || !strings.HasPrefix(err.Error(), "httpx: SHA256 mismatch:") {
t.Fatal("not the error we expected")
}
if len(data) != 0 {
t.Fatal("expected an empty resource")
}
}

View file

@ -0,0 +1,168 @@
// Package httpx contains http extensions.
package httpx
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/url"
"github.com/ooni/probe-cli/v3/internal/engine/netx/dialer"
)
// Logger is the definition of Logger used by this package.
type Logger interface {
Debugf(format string, v ...interface{})
}
// Client is an extended client.
type Client struct {
// Accept contains the accept header.
Accept string
// Authorization contains the authorization header.
Authorization string
// BaseURL is the base URL of the API.
BaseURL string
// HTTPClient is the real http client to use.
HTTPClient *http.Client
// Host allows to set a specific host header. This is useful
// to implement, e.g., cloudfronting.
Host string
// Logger is the logger to use.
Logger Logger
// ProxyURL allows to force a proxy URL to fallback to a
// tunnel, e.g., Psiphon.
ProxyURL *url.URL
// UserAgent is the user agent to use.
UserAgent string
}
// NewRequestWithJSONBody creates a new request with a JSON body
func (c Client) NewRequestWithJSONBody(
ctx context.Context, method, resourcePath string,
query url.Values, body interface{}) (*http.Request, error) {
data, err := json.Marshal(body)
if err != nil {
return nil, err
}
c.Logger.Debugf("httpx: request body: %d bytes", len(data))
request, err := c.NewRequest(
ctx, method, resourcePath, query, bytes.NewReader(data))
if err != nil {
return nil, err
}
if body != nil {
request.Header.Set("Content-Type", "application/json")
}
return request, nil
}
// NewRequest creates a new request.
func (c Client) NewRequest(ctx context.Context, method, resourcePath string,
query url.Values, body io.Reader) (*http.Request, error) {
URL, err := url.Parse(c.BaseURL)
if err != nil {
return nil, err
}
URL.Path = resourcePath
if query != nil {
URL.RawQuery = query.Encode()
}
c.Logger.Debugf("httpx: method: %s", method)
c.Logger.Debugf("httpx: URL: %s", URL.String())
request, err := http.NewRequest(method, URL.String(), body)
if err != nil {
return nil, err
}
request.Host = c.Host // allow cloudfronting
if c.Authorization != "" {
request.Header.Set("Authorization", c.Authorization)
}
if c.Accept != "" {
request.Header.Set("Accept", c.Accept)
}
request.Header.Set("User-Agent", c.UserAgent)
// Implementation note: the following allows tunneling if c.ProxyURL
// is not nil. Because the proxy URL is set as part of each request
// generated using this function, every request that eventually needs
// to reconnect will always do so using the proxy.
ctx = dialer.WithProxyURL(ctx, c.ProxyURL)
return request.WithContext(ctx), nil
}
// Do performs the provided request and returns the response body or an error.
func (c Client) Do(request *http.Request) ([]byte, error) {
response, err := c.HTTPClient.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return nil, fmt.Errorf("httpx: request failed: %s", response.Status)
}
return ioutil.ReadAll(response.Body)
}
// DoJSON performs the provided request and unmarshals the JSON response body
// into the provided output variable.
func (c Client) DoJSON(request *http.Request, output interface{}) error {
data, err := c.Do(request)
if err != nil {
return err
}
c.Logger.Debugf("httpx: response body: %d bytes", len(data))
return json.Unmarshal(data, output)
}
// GetJSON reads the JSON resource at resourcePath and unmarshals the
// results into output. The request is bounded by the lifetime of the
// context passed as argument. Returns the error that occurred.
func (c Client) GetJSON(ctx context.Context, resourcePath string, output interface{}) error {
return c.GetJSONWithQuery(ctx, resourcePath, nil, output)
}
// GetJSONWithQuery is like GetJSON but also has a query.
func (c Client) GetJSONWithQuery(
ctx context.Context, resourcePath string,
query url.Values, output interface{}) error {
request, err := c.NewRequest(ctx, "GET", resourcePath, query, nil)
if err != nil {
return err
}
return c.DoJSON(request, output)
}
// PostJSON creates a JSON subresource of the resource at resourcePath
// using the JSON document at input and returning the result into the
// JSON document at output. The request is bounded by the context's
// lifetime. Returns the error that occurred.
func (c Client) PostJSON(
ctx context.Context, resourcePath string, input, output interface{}) error {
request, err := c.NewRequestWithJSONBody(ctx, "POST", resourcePath, nil, input)
if err != nil {
return err
}
return c.DoJSON(request, output)
}
// PutJSON updates a JSON resource at a specific path and returns
// the error that occurred and possibly an output document
func (c Client) PutJSON(
ctx context.Context, resourcePath string, input, output interface{}) error {
request, err := c.NewRequestWithJSONBody(ctx, "PUT", resourcePath, nil, input)
if err != nil {
return err
}
return c.DoJSON(request, output)
}

View file

@ -0,0 +1,316 @@
package httpx_test
import (
"context"
"errors"
"io"
"net/http"
"net/url"
"strings"
"testing"
"github.com/apex/log"
"github.com/google/go-cmp/cmp"
"github.com/ooni/probe-cli/v3/internal/engine/internal/httpx"
"github.com/ooni/probe-cli/v3/internal/engine/netx/dialer"
)
const userAgent = "miniooni/0.1.0-dev"
func newClient() httpx.Client {
return httpx.Client{
BaseURL: "https://httpbin.org",
HTTPClient: http.DefaultClient,
Logger: log.Log,
UserAgent: userAgent,
}
}
func TestNewRequestWithJSONBodyJSONMarshalFailure(t *testing.T) {
client := newClient()
req, err := client.NewRequestWithJSONBody(
context.Background(), "GET", "/", nil, make(chan interface{}),
)
if err == nil || !strings.HasPrefix(err.Error(), "json: unsupported type") {
t.Fatal("not the error we expected")
}
if req != nil {
t.Fatal("expected nil request here")
}
}
func TestNewRequestWithJSONBodyNewRequestFailure(t *testing.T) {
client := newClient()
client.BaseURL = "\t\t\t" // cause URL parse error
req, err := client.NewRequestWithJSONBody(
context.Background(), "GET", "/", nil, nil,
)
if err == nil || !strings.HasSuffix(err.Error(), "invalid control character in URL") {
t.Fatal("not the error we expected")
}
if req != nil {
t.Fatal("expected nil request here")
}
}
func TestNewRequestWithQuery(t *testing.T) {
client := newClient()
q := url.Values{}
q.Add("antani", "mascetti")
q.Add("melandri", "conte")
req, err := client.NewRequest(
context.Background(), "GET", "/", q, nil,
)
if err != nil {
t.Fatal(err)
}
if req.URL.Query().Get("antani") != "mascetti" {
t.Fatal("expected different query string here")
}
if req.URL.Query().Get("melandri") != "conte" {
t.Fatal("expected different query string here")
}
}
func TestNewRequestNewRequestFailure(t *testing.T) {
client := newClient()
req, err := client.NewRequest(
context.Background(), "\t\t\t", "/", nil, nil,
)
if err == nil || !strings.HasPrefix(err.Error(), "net/http: invalid method") {
t.Fatal("not the error we expected")
}
if req != nil {
t.Fatal("expected nil request here")
}
}
func TestNewRequestCloudfronting(t *testing.T) {
client := newClient()
client.Host = "www.x.org"
req, err := client.NewRequest(
context.Background(), "GET", "/", nil, nil,
)
if err != nil {
t.Fatal(err)
}
if req.Host != client.Host {
t.Fatal("expected different req.Host here")
}
}
func TestNewRequestAcceptIsSet(t *testing.T) {
client := newClient()
client.Accept = "application/xml"
req, err := client.NewRequestWithJSONBody(
context.Background(), "GET", "/", nil, []string{},
)
if err != nil {
t.Fatal(err)
}
if req.Header.Get("Accept") != "application/xml" {
t.Fatal("expected different Accept here")
}
}
func TestNewRequestContentTypeIsSet(t *testing.T) {
client := newClient()
req, err := client.NewRequestWithJSONBody(
context.Background(), "GET", "/", nil, []string{},
)
if err != nil {
t.Fatal(err)
}
if req.Header.Get("Content-Type") != "application/json" {
t.Fatal("expected different Content-Type here")
}
}
func TestNewRequestAuthorizationHeader(t *testing.T) {
client := newClient()
client.Authorization = "deadbeef"
req, err := client.NewRequest(
context.Background(), "GET", "/", nil, nil,
)
if err != nil {
t.Fatal(err)
}
if req.Header.Get("Authorization") != client.Authorization {
t.Fatal("expected different Authorization here")
}
}
func TestNewRequestUserAgentIsSet(t *testing.T) {
client := newClient()
req, err := client.NewRequest(
context.Background(), "GET", "/", nil, nil,
)
if err != nil {
t.Fatal(err)
}
if req.Header.Get("User-Agent") != userAgent {
t.Fatal("expected different User-Agent here")
}
}
func TestNewRequestTunnelingIsPossible(t *testing.T) {
client := newClient()
client.ProxyURL = &url.URL{Scheme: "socks5", Host: "[::1]:54321"}
req, err := client.NewRequest(
context.Background(), "GET", "/", nil, nil,
)
if err != nil {
t.Fatal(err)
}
cmp := cmp.Diff(dialer.ContextProxyURL(req.Context()), client.ProxyURL)
if cmp != "" {
t.Fatal(cmp)
}
}
func TestClientDoJSONClientDoFailure(t *testing.T) {
expected := errors.New("mocked error")
client := newClient()
client.HTTPClient = &http.Client{Transport: httpx.FakeTransport{
Err: expected,
}}
err := client.DoJSON(&http.Request{URL: &url.URL{Scheme: "https", Host: "x.org"}}, nil)
if !errors.Is(err, expected) {
t.Fatal("not the error we expected")
}
}
func TestClientDoJSONResponseNotSuccessful(t *testing.T) {
client := newClient()
client.HTTPClient = &http.Client{Transport: httpx.FakeTransport{
Resp: &http.Response{
StatusCode: 401,
Body: httpx.FakeBody{},
},
}}
err := client.DoJSON(&http.Request{URL: &url.URL{Scheme: "https", Host: "x.org"}}, nil)
if err == nil || !strings.HasPrefix(err.Error(), "httpx: request failed") {
t.Fatal("not the error we expected")
}
}
func TestClientDoJSONResponseReadingBodyError(t *testing.T) {
expected := errors.New("mocked error")
client := newClient()
client.HTTPClient = &http.Client{Transport: httpx.FakeTransport{
Resp: &http.Response{
StatusCode: 200,
Body: httpx.FakeBody{
Err: expected,
},
},
}}
err := client.DoJSON(&http.Request{URL: &url.URL{Scheme: "https", Host: "x.org"}}, nil)
if !errors.Is(err, expected) {
t.Fatal("not the error we expected")
}
}
func TestClientDoJSONResponseIsNotJSON(t *testing.T) {
client := newClient()
client.HTTPClient = &http.Client{Transport: httpx.FakeTransport{
Resp: &http.Response{
StatusCode: 200,
Body: httpx.FakeBody{
Err: io.EOF,
},
},
}}
err := client.DoJSON(&http.Request{URL: &url.URL{Scheme: "https", Host: "x.org"}}, nil)
if err == nil || err.Error() != "unexpected end of JSON input" {
t.Fatal("not the error we expected")
}
}
type httpbinheaders struct {
Headers map[string]string `json:"headers"`
}
func TestReadJSONSuccess(t *testing.T) {
var headers httpbinheaders
err := newClient().GetJSON(context.Background(), "/headers", &headers)
if err != nil {
t.Fatal(err)
}
if headers.Headers["Host"] != "httpbin.org" {
t.Fatal("unexpected Host header")
}
if headers.Headers["User-Agent"] != "miniooni/0.1.0-dev" {
t.Fatal("unexpected Host header")
}
}
type httpbinpost struct {
Data string `json:"data"`
}
func TestCreateJSONSuccess(t *testing.T) {
headers := httpbinheaders{
Headers: map[string]string{
"Foo": "bar",
},
}
var response httpbinpost
err := newClient().PostJSON(context.Background(), "/post", &headers, &response)
if err != nil {
t.Fatal(err)
}
if response.Data != `{"headers":{"Foo":"bar"}}` {
t.Fatal(response.Data)
}
}
type httpbinput struct {
Data string `json:"data"`
}
func TestUpdateJSONSuccess(t *testing.T) {
headers := httpbinheaders{
Headers: map[string]string{
"Foo": "bar",
},
}
var response httpbinpost
err := newClient().PutJSON(context.Background(), "/put", &headers, &response)
if err != nil {
t.Fatal(err)
}
if response.Data != `{"headers":{"Foo":"bar"}}` {
t.Fatal(response.Data)
}
}
func TestReadJSONFailure(t *testing.T) {
var headers httpbinheaders
client := newClient()
client.BaseURL = "\t\t\t\t"
err := client.GetJSON(context.Background(), "/headers", &headers)
if err == nil || !strings.HasSuffix(err.Error(), "invalid control character in URL") {
t.Fatal("not the error we expected")
}
}
func TestCreateJSONFailure(t *testing.T) {
var headers httpbinheaders
client := newClient()
client.BaseURL = "\t\t\t\t"
err := client.PostJSON(context.Background(), "/headers", &headers, &headers)
if err == nil || !strings.HasSuffix(err.Error(), "invalid control character in URL") {
t.Fatal("not the error we expected")
}
}
func TestUpdateJSONFailure(t *testing.T) {
var headers httpbinheaders
client := newClient()
client.BaseURL = "\t\t\t\t"
err := client.PutJSON(context.Background(), "/headers", &headers, &headers)
if err == nil || !strings.HasSuffix(err.Error(), "invalid control character in URL") {
t.Fatal("not the error we expected")
}
}

View file

@ -0,0 +1,26 @@
// Package humanizex is like dustin/go-humanize
package humanizex
import "fmt"
// SI is like dustin/go-humanize.SI
func SI(value float64, unit string) string {
value, prefix := reduce(value)
return fmt.Sprintf("%3.0f %s%s", value, prefix, unit)
}
func reduce(value float64) (float64, string) {
if value < 1e03 {
return value, " "
}
value /= 1e03
if value < 1e03 {
return value, "k"
}
value /= 1e03
if value < 1e03 {
return value, "M"
}
value /= 1e03
return value, "G"
}

View file

@ -0,0 +1,34 @@
package humanizex_test
import (
"testing"
"github.com/ooni/probe-cli/v3/internal/engine/internal/humanizex"
)
func TestGood(t *testing.T) {
if humanizex.SI(128, "bit/s") != "128 bit/s" {
t.Fatal("unexpected result")
}
if humanizex.SI(1280, "bit/s") != " 1 kbit/s" {
t.Fatal("unexpected result")
}
if humanizex.SI(12800, "bit/s") != " 13 kbit/s" {
t.Fatal("unexpected result")
}
if humanizex.SI(128000, "bit/s") != "128 kbit/s" {
t.Fatal("unexpected result")
}
if humanizex.SI(1280000, "bit/s") != " 1 Mbit/s" {
t.Fatal("unexpected result")
}
if humanizex.SI(12800000, "bit/s") != " 13 Mbit/s" {
t.Fatal("unexpected result")
}
if humanizex.SI(128000000, "bit/s") != "128 Mbit/s" {
t.Fatal("unexpected result")
}
if humanizex.SI(1280000000, "bit/s") != " 1 Gbit/s" {
t.Fatal("unexpected result")
}
}

View file

@ -0,0 +1,44 @@
// Package kvstore contains key-value stores
package kvstore
import (
"errors"
"sync"
)
// MemoryKeyValueStore is an in-memory key-value store
type MemoryKeyValueStore struct {
m map[string][]byte
mu sync.Mutex
}
// NewMemoryKeyValueStore creates a new in-memory key-value store
func NewMemoryKeyValueStore() *MemoryKeyValueStore {
return &MemoryKeyValueStore{
m: make(map[string][]byte),
}
}
// Get returns a key from the key value store
func (kvs *MemoryKeyValueStore) Get(key string) ([]byte, error) {
var (
err error
ok bool
value []byte
)
kvs.mu.Lock()
defer kvs.mu.Unlock()
value, ok = kvs.m[key]
if !ok {
err = errors.New("no such key")
}
return value, err
}
// Set sets a key into the key value store
func (kvs *MemoryKeyValueStore) Set(key string, value []byte) error {
kvs.mu.Lock()
defer kvs.mu.Unlock()
kvs.m[key] = value
return nil
}

View file

@ -0,0 +1,28 @@
package kvstore
import "testing"
func TestNoSuchKey(t *testing.T) {
kvs := NewMemoryKeyValueStore()
value, err := kvs.Get("nonexistent")
if err == nil {
t.Fatal("expected an error here")
}
if value != nil {
t.Fatal("expected empty string here")
}
}
func TestExistingKey(t *testing.T) {
kvs := NewMemoryKeyValueStore()
if err := kvs.Set("antani", []byte("mascetti")); err != nil {
t.Fatal(err)
}
value, err := kvs.Get("antani")
if err != nil {
t.Fatal(err)
}
if string(value) != "mascetti" {
t.Fatal("not the result we expected")
}
}

View file

@ -0,0 +1,79 @@
// Package mlablocate contains a locate.measurementlab.net client.
package mlablocate
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"github.com/ooni/probe-cli/v3/internal/engine/model"
)
// Client is a locate.measurementlab.net client.
type Client struct {
HTTPClient *http.Client
Hostname string
Logger model.Logger
Scheme string
UserAgent string
}
// NewClient creates a new locate.measurementlab.net client.
func NewClient(httpClient *http.Client, logger model.Logger, userAgent string) *Client {
return &Client{
HTTPClient: httpClient,
Hostname: "locate.measurementlab.net",
Logger: logger,
Scheme: "https",
UserAgent: userAgent,
}
}
// Result is a result of a query to locate.measurementlab.net.
type Result struct {
City string `json:"city"`
Country string `json:"country"`
IP []string `json:"ip"`
FQDN string `json:"fqdn"`
Site string `json:"site"`
}
// Query performs a locate.measurementlab.net query.
func (c *Client) Query(ctx context.Context, tool string) (Result, error) {
URL := &url.URL{
Scheme: c.Scheme,
Host: c.Hostname,
Path: tool,
}
req, err := http.NewRequestWithContext(ctx, "GET", URL.String(), nil)
if err != nil {
return Result{}, err
}
req.Header.Add("User-Agent", c.UserAgent)
c.Logger.Debugf("mlablocate: GET %s", URL.String())
resp, err := c.HTTPClient.Do(req)
if err != nil {
return Result{}, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return Result{}, fmt.Errorf("mlablocate: non-200 status code: %d", resp.StatusCode)
}
data, err := ioutil.ReadAll(resp.Body)
if err != nil {
return Result{}, err
}
c.Logger.Debugf("mlablocate: %s", string(data))
var result Result
if err := json.Unmarshal(data, &result); err != nil {
return Result{}, err
}
if result.FQDN == "" {
return Result{}, errors.New("mlablocate: returned empty FQDN")
}
return result, nil
}

View file

@ -0,0 +1,206 @@
package mlablocate_test
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/internal/engine/internal/mlablocate"
)
func TestWithoutProxy(t *testing.T) {
client := mlablocate.NewClient(
http.DefaultClient,
log.Log,
"miniooni/0.1.0-dev",
)
result, err := client.Query(context.Background(), "ndt7")
if err != nil {
t.Fatal(err)
}
if result.FQDN == "" {
t.Fatal("unexpected empty fqdn")
}
}
func Test404Response(t *testing.T) {
client := mlablocate.NewClient(
http.DefaultClient,
log.Log,
"miniooni/0.1.0-dev",
)
result, err := client.Query(context.Background(), "nonexistent")
if err == nil || !strings.Contains(err.Error(), "mlablocate: non-200 status code") {
t.Fatal("not the error we expected")
}
if result.FQDN != "" {
t.Fatal("expected empty fqdn")
}
}
func TestNewRequestFailure(t *testing.T) {
client := mlablocate.NewClient(
http.DefaultClient,
log.Log,
"miniooni/0.1.0-dev",
)
client.Hostname = "\t"
result, err := client.Query(context.Background(), "nonexistent")
if err == nil || !strings.Contains(err.Error(), "invalid URL escape") {
t.Fatal("not the error we expected")
}
if result.FQDN != "" {
t.Fatal("expected empty fqdn")
}
}
func TestHTTPClientDoFailure(t *testing.T) {
client := mlablocate.NewClient(
http.DefaultClient,
log.Log,
"miniooni/0.1.0-dev",
)
expected := errors.New("mocked error")
client.HTTPClient = &http.Client{
Transport: &roundTripFails{Error: expected},
}
result, err := client.Query(context.Background(), "nonexistent")
if !errors.Is(err, expected) {
t.Fatal("not the error we expected")
}
if result.FQDN != "" {
t.Fatal("expected empty fqdn")
}
}
type roundTripFails struct {
Error error
}
func (txp *roundTripFails) RoundTrip(*http.Request) (*http.Response, error) {
return nil, txp.Error
}
func TestCannotReadBody(t *testing.T) {
client := mlablocate.NewClient(
http.DefaultClient,
log.Log,
"miniooni/0.1.0-dev",
)
expected := errors.New("mocked error")
client.HTTPClient = &http.Client{
Transport: &readingBodyFails{Error: expected},
}
result, err := client.Query(context.Background(), "nonexistent")
if !errors.Is(err, expected) {
t.Fatal("not the error we expected")
}
if result.FQDN != "" {
t.Fatal("expected empty fqdn")
}
}
type readingBodyFails struct {
Error error
}
func (txp *readingBodyFails) RoundTrip(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
Body: &readingBodyFailsBody{Error: txp.Error},
}, nil
}
type readingBodyFailsBody struct {
Error error
}
func (b *readingBodyFailsBody) Read(p []byte) (int, error) {
return 0, b.Error
}
func (b *readingBodyFailsBody) Close() error {
return nil
}
func TestInvalidJSON(t *testing.T) {
client := mlablocate.NewClient(
http.DefaultClient,
log.Log,
"miniooni/0.1.0-dev",
)
client.HTTPClient = &http.Client{
Transport: &invalidJSON{},
}
result, err := client.Query(context.Background(), "nonexistent")
if err == nil || !strings.Contains(err.Error(), "unexpected end of JSON input") {
t.Fatal("not the error we expected")
}
if result.FQDN != "" {
t.Fatal("expected empty fqdn")
}
}
type invalidJSON struct{}
func (txp *invalidJSON) RoundTrip(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
Body: &invalidJSONBody{},
}, nil
}
type invalidJSONBody struct{}
func (b *invalidJSONBody) Read(p []byte) (int, error) {
if len(p) < 1 {
return 0, errors.New("slice too short")
}
p[0] = '{'
return 1, io.EOF
}
func (b *invalidJSONBody) Close() error {
return nil
}
func TestEmptyFQDN(t *testing.T) {
client := mlablocate.NewClient(
http.DefaultClient,
log.Log,
"miniooni/0.1.0-dev",
)
client.HTTPClient = &http.Client{
Transport: &emptyFQDN{},
}
result, err := client.Query(context.Background(), "nonexistent")
if err == nil || !strings.HasSuffix(err.Error(), "returned empty FQDN") {
t.Fatal("not the error we expected")
}
if result.FQDN != "" {
t.Fatal("expected empty fqdn")
}
}
type emptyFQDN struct{}
func (txp *emptyFQDN) RoundTrip(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
Body: &emptyFQDNBody{},
}, nil
}
type emptyFQDNBody struct{}
func (b *emptyFQDNBody) Read(p []byte) (int, error) {
return copy(p, []byte(`{"fqdn":""}`)), io.EOF
}
func (b *emptyFQDNBody) Close() error {
return nil
}

View file

@ -0,0 +1,45 @@
package mlablocatev2
import (
"io/ioutil"
"net/http"
"time"
)
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 {
Data []byte
Err error
}
func (fb FakeBody) Read(p []byte) (int, error) {
time.Sleep(10 * time.Microsecond)
return copy(p, fb.Data), fb.Err // simplifed but OK
}
func (fb FakeBody) Close() error {
return nil
}

View file

@ -0,0 +1,152 @@
// Package mlablocatev2 use m-lab locate services API v2.
package mlablocatev2
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"regexp"
"github.com/ooni/probe-cli/v3/internal/engine/model"
)
const (
// ndt7URLPath is the URL path to be used for ndt
ndt7URLPath = "v2/nearest/ndt/ndt7"
)
var (
// ErrRequestFailed indicates that the response is not "200 Ok"
ErrRequestFailed = errors.New("mlablocatev2: request failed")
// ErrEmptyResponse indicates that no hosts were returned
ErrEmptyResponse = errors.New("mlablocatev2: empty response")
)
// Client is a client for v2 of the locate services.
type Client struct {
HTTPClient *http.Client
Hostname string
Logger model.Logger
Scheme string
UserAgent string
}
// NewClient creates a client for v2 of the locate services.
func NewClient(httpClient *http.Client, logger model.Logger, userAgent string) Client {
return Client{
HTTPClient: httpClient,
Hostname: "locate.measurementlab.net",
Logger: logger,
Scheme: "https",
UserAgent: userAgent,
}
}
// entryRecord describes one of the boxes returned by v2 of
// the locate service. It gives you the FQDN of the specific
// box along with URLs for each experiment phase. Use the
// URLs directly because they contain access tokens.
type entryRecord struct {
Machine string `json:"machine"`
URLs map[string]string `json:"urls"`
}
var (
// siteRegexp is the regexp to extract the site from the
// machine name when the domain is a v2 domain.
//
// Example: mlab3-mil04.mlab-oti.measurement-lab.org.
siteRegexp = regexp.MustCompile(
`^(mlab[1-4]d?)-([a-z]{3}[0-9tc]{2})\.([a-z0-9-]{1,16})\.(measurement-lab\.org)$`)
)
// Site returns the site name. If it is not possible to determine
// the site name, we return the empty string.
func (er entryRecord) Site() string {
m := siteRegexp.FindAllStringSubmatch(er.Machine, -1)
if len(m) != 1 || len(m[0]) != 5 {
return ""
}
return m[0][2]
}
// resultRecord is a result of a query to locate.measurementlab.net.
type resultRecord struct {
Results []entryRecord `json:"results"`
}
// query performs a locate.measurementlab.net query
// using v2 of the locate protocol.
func (c Client) query(ctx context.Context, path string) (resultRecord, error) {
URL := &url.URL{
Scheme: c.Scheme,
Host: c.Hostname,
Path: path,
}
req, err := http.NewRequestWithContext(ctx, "GET", URL.String(), nil)
if err != nil {
return resultRecord{}, err
}
req.Header.Add("User-Agent", c.UserAgent)
c.Logger.Debugf("mlablocatev2: GET %s", URL.String())
resp, err := c.HTTPClient.Do(req)
if err != nil {
return resultRecord{}, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return resultRecord{}, fmt.Errorf("%w: %d", ErrRequestFailed, resp.StatusCode)
}
data, err := ioutil.ReadAll(resp.Body)
if err != nil {
return resultRecord{}, err
}
c.Logger.Debugf("mlablocatev2: %s", string(data))
var result resultRecord
if err := json.Unmarshal(data, &result); err != nil {
return resultRecord{}, err
}
return result, nil
}
// NDT7Result is the result of a v2 locate services query for ndt7.
type NDT7Result struct {
Hostname string
Site string
WSSDownloadURL string
WSSUploadURL string
}
// QueryNDT7 performs a v2 locate services query for ndt7.
func (c Client) QueryNDT7(ctx context.Context) ([]NDT7Result, error) {
out, err := c.query(ctx, ndt7URLPath)
if err != nil {
return nil, err
}
var result []NDT7Result
for _, entry := range out.Results {
r := NDT7Result{
WSSDownloadURL: entry.URLs["wss:///ndt/v7/download"],
WSSUploadURL: entry.URLs["wss:///ndt/v7/upload"],
}
if r.WSSDownloadURL == "" || r.WSSUploadURL == "" {
continue
}
url, err := url.Parse(r.WSSDownloadURL)
if err != nil {
continue
}
r.Site = entry.Site()
r.Hostname = url.Hostname()
result = append(result, r)
}
if len(result) <= 0 {
return nil, ErrEmptyResponse
}
return result, nil
}

View file

@ -0,0 +1,16 @@
package mlablocatev2
import "context"
type ResultRecord resultRecord
func (c Client) Query(ctx context.Context, path string) (ResultRecord, error) {
out, err := c.query(ctx, path)
if err != nil {
return ResultRecord{}, err
}
return ResultRecord(out), nil
}
type EntryRecord = entryRecord

View file

@ -0,0 +1,232 @@
package mlablocatev2_test
import (
"context"
"errors"
"io"
"net/http"
"net/url"
"strings"
"testing"
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/internal/engine/internal/mlablocatev2"
)
func TestSuccess(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
client := mlablocatev2.NewClient(http.DefaultClient, log.Log, "miniooni/0.1.0-dev")
result, err := client.QueryNDT7(context.Background())
if err != nil {
t.Fatal(err)
}
if len(result) <= 0 {
t.Fatal("unexpected empty result")
}
for _, entry := range result {
if entry.Hostname == "" {
t.Fatal("expected non empty Machine here")
}
if entry.Site == "" {
t.Fatal("expected non=-empty Site here")
}
if entry.WSSDownloadURL == "" {
t.Fatal("expected non-empty WSSDownloadURL here")
}
if _, err := url.Parse(entry.WSSDownloadURL); err != nil {
t.Fatal(err)
}
if entry.WSSUploadURL == "" {
t.Fatal("expected non-empty WSSUploadURL here")
}
if _, err := url.Parse(entry.WSSUploadURL); err != nil {
t.Fatal(err)
}
}
}
func Test404Response(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
client := mlablocatev2.NewClient(http.DefaultClient, log.Log, "miniooni/0.1.0-dev")
result, err := client.Query(context.Background(), "nonexistent")
if !errors.Is(err, mlablocatev2.ErrRequestFailed) {
t.Fatal("not the error we expected")
}
if result.Results != nil {
t.Fatal("expected empty results")
}
}
func TestNewRequestFailure(t *testing.T) {
client := mlablocatev2.NewClient(http.DefaultClient, log.Log, "miniooni/0.1.0-dev")
client.Hostname = "\t"
result, err := client.Query(context.Background(), "nonexistent")
if err == nil || !strings.Contains(err.Error(), "invalid URL escape") {
t.Fatal("not the error we expected")
}
if result.Results != nil {
t.Fatal("expected empty fqdn")
}
}
func TestHTTPClientDoFailure(t *testing.T) {
client := mlablocatev2.NewClient(http.DefaultClient, log.Log, "miniooni/0.1.0-dev")
expected := errors.New("mocked error")
client.HTTPClient = &http.Client{
Transport: mlablocatev2.FakeTransport{Err: expected},
}
result, err := client.Query(context.Background(), "nonexistent")
if !errors.Is(err, expected) {
t.Fatal("not the error we expected")
}
if result.Results != nil {
t.Fatal("expected empty fqdn")
}
}
func TestCannotReadBody(t *testing.T) {
client := mlablocatev2.NewClient(http.DefaultClient, log.Log, "miniooni/0.1.0-dev")
expected := errors.New("mocked error")
client.HTTPClient = &http.Client{
Transport: mlablocatev2.FakeTransport{
Resp: &http.Response{
StatusCode: 200,
Body: mlablocatev2.FakeBody{
Err: expected,
},
},
},
}
result, err := client.Query(context.Background(), "nonexistent")
if !errors.Is(err, expected) {
t.Fatal("not the error we expected")
}
if result.Results != nil {
t.Fatal("expected empty fqdn")
}
}
func TestInvalidJSON(t *testing.T) {
client := mlablocatev2.NewClient(http.DefaultClient, log.Log, "miniooni/0.1.0-dev")
client.HTTPClient = &http.Client{
Transport: mlablocatev2.FakeTransport{
Resp: &http.Response{
StatusCode: 200,
Body: mlablocatev2.FakeBody{
Err: io.EOF,
Data: []byte(`{`),
},
},
},
}
result, err := client.Query(context.Background(), "nonexistent")
if err == nil || !strings.Contains(err.Error(), "unexpected end of JSON input") {
t.Fatal("not the error we expected")
}
if result.Results != nil {
t.Fatal("expected empty fqdn")
}
}
func TestEmptyResponse(t *testing.T) {
client := mlablocatev2.NewClient(http.DefaultClient, log.Log, "miniooni/0.1.0-dev")
client.HTTPClient = &http.Client{
Transport: mlablocatev2.FakeTransport{
Resp: &http.Response{
StatusCode: 200,
Body: mlablocatev2.FakeBody{
Err: io.EOF,
Data: []byte(`{}`),
},
},
},
}
result, err := client.QueryNDT7(context.Background())
if !errors.Is(err, mlablocatev2.ErrEmptyResponse) {
t.Fatal("not the error we expected")
}
if result != nil {
t.Fatal("expected empty fqdn")
}
}
func TestNDT7QueryFails(t *testing.T) {
client := mlablocatev2.NewClient(http.DefaultClient, log.Log, "miniooni/0.1.0-dev")
client.HTTPClient = &http.Client{
Transport: mlablocatev2.FakeTransport{
Resp: &http.Response{
StatusCode: 404,
Body: mlablocatev2.FakeBody{Err: io.EOF},
},
},
}
result, err := client.QueryNDT7(context.Background())
if !errors.Is(err, mlablocatev2.ErrRequestFailed) {
t.Fatal("not the error we expected")
}
if result != nil {
t.Fatal("expected empty fqdn")
}
}
func TestNDT7InvalidURLs(t *testing.T) {
client := mlablocatev2.NewClient(http.DefaultClient, log.Log, "miniooni/0.1.0-dev")
client.HTTPClient = &http.Client{
Transport: mlablocatev2.FakeTransport{
Resp: &http.Response{
StatusCode: 200,
Body: mlablocatev2.FakeBody{
Data: []byte(
`{"results":[{"machine":"mlab3-mil04.mlab-oti.measurement-lab.org","urls":{"wss:///ndt/v7/download":":","wss:///ndt/v7/upload":":"}}]}`),
Err: io.EOF,
},
},
},
}
result, err := client.QueryNDT7(context.Background())
if !errors.Is(err, mlablocatev2.ErrEmptyResponse) {
t.Fatal("not the error we expected")
}
if result != nil {
t.Fatal("expected empty fqdn")
}
}
func TestEntryRecordSite(t *testing.T) {
type fields struct {
Machine string
URLs map[string]string
}
tests := []struct {
name string
fields fields
want string
}{{
name: "with invalid machine name",
fields: fields{
Machine: "ndt-iupui-mlab3-mil02.mlab-oti.measurement-lab.org",
},
want: "",
}, {
name: "with valid machine name",
fields: fields{
Machine: "mlab3-mil04.mlab-oti.measurement-lab.org",
},
want: "mil04",
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
er := mlablocatev2.EntryRecord{
Machine: tt.fields.Machine,
URLs: tt.fields.URLs,
}
if got := er.Site(); got != tt.want {
t.Errorf("entryRecord.Site() = %v, want %v", got, tt.want)
}
})
}
}

View file

@ -0,0 +1,191 @@
// Package mockable contains mockable objects
package mockable
import (
"context"
"net/http"
"net/url"
"github.com/ooni/probe-cli/v3/internal/engine/internal/kvstore"
"github.com/ooni/probe-cli/v3/internal/engine/internal/psiphonx"
"github.com/ooni/probe-cli/v3/internal/engine/internal/runtimex"
"github.com/ooni/probe-cli/v3/internal/engine/internal/torx"
"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/probeservices"
"github.com/ooni/probe-cli/v3/internal/engine/probeservices/testorchestra"
)
// Session allows to mock sessions.
type Session struct {
MockableASNDatabasePath string
MockableTestHelpers map[string][]model.Service
MockableHTTPClient *http.Client
MockableLogger model.Logger
MockableMaybeResolverIP string
MockableOrchestraClient model.ExperimentOrchestraClient
MockableOrchestraClientError error
MockableProbeASNString string
MockableProbeCC string
MockableProbeIP string
MockableProbeNetworkName string
MockableProxyURL *url.URL
MockableResolverIP string
MockableSoftwareName string
MockableSoftwareVersion string
MockableTempDir string
MockableTorArgs []string
MockableTorBinary string
MockableUserAgent string
}
// ASNDatabasePath implements ExperimentSession.ASNDatabasePath
func (sess *Session) ASNDatabasePath() string {
return sess.MockableASNDatabasePath
}
// GetTestHelpersByName implements ExperimentSession.GetTestHelpersByName
func (sess *Session) GetTestHelpersByName(name string) ([]model.Service, bool) {
services, okay := sess.MockableTestHelpers[name]
return services, okay
}
// DefaultHTTPClient implements ExperimentSession.DefaultHTTPClient
func (sess *Session) DefaultHTTPClient() *http.Client {
return sess.MockableHTTPClient
}
// KeyValueStore returns the configured key-value store.
func (sess *Session) KeyValueStore() model.KeyValueStore {
return kvstore.NewMemoryKeyValueStore()
}
// Logger implements ExperimentSession.Logger
func (sess *Session) Logger() model.Logger {
return sess.MockableLogger
}
// MaybeResolverIP implements ExperimentSession.MaybeResolverIP.
func (sess *Session) MaybeResolverIP() string {
return sess.MockableMaybeResolverIP
}
// NewOrchestraClient implements ExperimentSession.NewOrchestraClient
func (sess *Session) NewOrchestraClient(ctx context.Context) (model.ExperimentOrchestraClient, error) {
if sess.MockableOrchestraClient != nil {
return sess.MockableOrchestraClient, nil
}
if sess.MockableOrchestraClientError != nil {
return nil, sess.MockableOrchestraClientError
}
clnt, err := probeservices.NewClient(sess, model.Service{
Address: "https://ams-pg-test.ooni.org/",
Type: "https",
})
runtimex.PanicOnError(err, "orchestra.NewClient should not fail here")
meta := testorchestra.MetadataFixture()
if err := clnt.MaybeRegister(ctx, meta); err != nil {
return nil, err
}
if err := clnt.MaybeLogin(ctx); err != nil {
return nil, err
}
return clnt, nil
}
// ProbeASNString implements ExperimentSession.ProbeASNString
func (sess *Session) ProbeASNString() string {
return sess.MockableProbeASNString
}
// ProbeCC implements ExperimentSession.ProbeCC
func (sess *Session) ProbeCC() string {
return sess.MockableProbeCC
}
// ProbeIP implements ExperimentSession.ProbeIP
func (sess *Session) ProbeIP() string {
return sess.MockableProbeIP
}
// ProbeNetworkName implements ExperimentSession.ProbeNetworkName
func (sess *Session) ProbeNetworkName() string {
return sess.MockableProbeNetworkName
}
// ProxyURL implements ExperimentSession.ProxyURL
func (sess *Session) ProxyURL() *url.URL {
return sess.MockableProxyURL
}
// ResolverIP implements ExperimentSession.ResolverIP
func (sess *Session) ResolverIP() string {
return sess.MockableResolverIP
}
// SoftwareName implements ExperimentSession.SoftwareName
func (sess *Session) SoftwareName() string {
return sess.MockableSoftwareName
}
// SoftwareVersion implements ExperimentSession.SoftwareVersion
func (sess *Session) SoftwareVersion() string {
return sess.MockableSoftwareVersion
}
// TempDir implements ExperimentSession.TempDir
func (sess *Session) TempDir() string {
return sess.MockableTempDir
}
// TorArgs implements ExperimentSession.TorArgs.
func (sess *Session) TorArgs() []string {
return sess.MockableTorArgs
}
// TorBinary implements ExperimentSession.TorBinary.
func (sess *Session) TorBinary() string {
return sess.MockableTorBinary
}
// UserAgent implements ExperimentSession.UserAgent
func (sess *Session) UserAgent() string {
return sess.MockableUserAgent
}
var _ model.ExperimentSession = &Session{}
var _ probeservices.Session = &Session{}
var _ psiphonx.Session = &Session{}
var _ tunnel.Session = &Session{}
var _ torx.Session = &Session{}
// ExperimentOrchestraClient is the experiment's view of
// a client for querying the OONI orchestra.
type ExperimentOrchestraClient struct {
MockableFetchPsiphonConfigResult []byte
MockableFetchPsiphonConfigErr error
MockableFetchTorTargetsResult map[string]model.TorTarget
MockableFetchTorTargetsErr error
MockableFetchURLListResult []model.URLInfo
MockableFetchURLListErr error
}
// FetchPsiphonConfig implements ExperimentOrchestraClient.FetchPsiphonConfig
func (c ExperimentOrchestraClient) FetchPsiphonConfig(
ctx context.Context) ([]byte, error) {
return c.MockableFetchPsiphonConfigResult, c.MockableFetchPsiphonConfigErr
}
// FetchTorTargets implements ExperimentOrchestraClient.TorTargets
func (c ExperimentOrchestraClient) FetchTorTargets(
ctx context.Context, cc string) (map[string]model.TorTarget, error) {
return c.MockableFetchTorTargetsResult, c.MockableFetchTorTargetsErr
}
// FetchURLList implements ExperimentOrchestraClient.FetchURLList.
func (c ExperimentOrchestraClient) FetchURLList(
ctx context.Context, config model.URLListConfig) ([]model.URLInfo, error) {
return c.MockableFetchURLListResult, c.MockableFetchURLListErr
}
var _ model.ExperimentOrchestraClient = ExperimentOrchestraClient{}

View file

@ -0,0 +1,66 @@
// Package multierror contains code to manage multiple errors.
package multierror
import (
"errors"
"fmt"
"strings"
)
// Union is the logical union of several errors. The Union will
// appear to be the Root error, except that it will actually
// be possible to look deeper and see specific sub errors that
// occurred using errors.As and errors.Is.
type Union struct {
Children []error
Root error
}
// New creates a new Union error instance.
func New(root error) *Union {
return &Union{Root: root}
}
// Unwrap returns the Root error of the Union error.
func (err Union) Unwrap() error {
return err.Root
}
// Add adds the specified child error to the Union error.
func (err *Union) Add(child error) {
err.Children = append(err.Children, child)
}
// AddWithPrefix adds the specified child error to the Union error
// with the specified prefix before the child error.
func (err *Union) AddWithPrefix(prefix string, child error) {
err.Add(fmt.Errorf("%s: %w", prefix, child))
}
// Is returns whether the Union error contains at least one child
// error that is exactly the specified target error.
func (err Union) Is(target error) bool {
if errors.Is(err.Root, target) {
return true
}
for _, c := range err.Children {
if errors.Is(c, target) {
return true
}
}
return false
}
// Error returns a string representation of the Union error.
func (err Union) Error() string {
var sb strings.Builder
sb.WriteString(err.Root.Error())
sb.WriteString(": [")
for _, c := range err.Children {
sb.WriteString(" ")
sb.WriteString(c.Error())
sb.WriteString(";")
}
sb.WriteString("]")
return sb.String()
}

View file

@ -0,0 +1,85 @@
package multierror_test
import (
"context"
"errors"
"fmt"
"io"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/ooni/probe-cli/v3/internal/engine/internal/multierror"
)
func TestEmpty(t *testing.T) {
root := errors.New("antani")
var err error = multierror.New(root)
if err.Error() != "antani: []" {
t.Fatal("unexpected Error value")
}
if !errors.Is(err, root) {
t.Fatal("error should be root")
}
if !errors.Is(errors.Unwrap(err), root) {
t.Fatal("unwrapping did not return root")
}
if errors.Is(err, io.EOF) {
t.Fatal("error should not be EOF")
}
}
func TestNonEmpty(t *testing.T) {
root := errors.New("antani")
container := multierror.New(root)
container.AddWithPrefix("first operation failed", io.EOF)
container.AddWithPrefix("second operation failed", context.Canceled)
var err error = container
expect := "antani: [ first operation failed: EOF; second operation failed: context canceled;]"
if diff := cmp.Diff(err.Error(), expect); diff != "" {
t.Fatal(diff)
}
if !errors.Is(err, root) {
t.Fatal("error should be root")
}
if !errors.Is(errors.Unwrap(err), root) {
t.Fatal("unwrapping did not return root")
}
if !errors.Is(err, io.EOF) {
t.Fatal("error should be EOF")
}
if !errors.Is(err, context.Canceled) {
t.Fatal("error should be context.Canceled")
}
var as *multierror.Union
if !errors.As(err, &as) {
t.Fatal("cannot cast error to multierror.Union")
}
if !errors.Is(as.Root, root) {
t.Fatal("unexpected root")
}
if len(as.Children) != 2 {
t.Fatal("unexpected number of children")
}
}
type SpecificRootError struct {
Value int
}
func (sre SpecificRootError) Error() string {
return fmt.Sprintf("%d", sre.Value)
}
func TestAsWorksForRoot(t *testing.T) {
const expected = 144
var (
err error = multierror.New(&SpecificRootError{Value: expected})
sre *SpecificRootError
)
if !errors.As(err, &sre) {
t.Fatal("cannot cast error to original type")
}
if sre.Value != expected {
t.Fatal("unexpected sre.Value")
}
}

View file

@ -0,0 +1,46 @@
// Package platform returns the platform name. The name returned here
// is compatible with the names returned by Measurement Kit.
package platform
import "runtime"
// Name returns the platform name. The returned value is one of:
//
// 1. "android"
// 2. "ios"
// 3. "linux"
// 5. "macos"
// 4. "windows"
// 5. "unknown"
//
// The android, ios, linux, macos, windows, and unknown strings are
// also returned by Measurement Kit. As a known bug, the detection of
// darwin-based systems relies on the architecture, when CGO support
// has been disabled. In such case, the code will return "ios" when
// using arm{,64} and "macos" when using x86{,_64}.
func Name() string {
if name := cgoname(); name != "unknown" {
return name
}
return puregoname(runtime.GOOS, runtime.GOARCH)
}
func puregoname(goos, goarch string) string {
switch goos {
case "android", "linux", "windows":
return goos
case "darwin":
return detectDarwin(goarch)
}
return "unknown"
}
func detectDarwin(goarch string) string {
switch goarch {
case "386", "amd64":
return "macos"
case "arm", "arm64":
return "ios"
}
return "unknown"
}

View file

@ -0,0 +1,31 @@
// +build cgo
package platform
//
// /* Guess the platform in which we are.
//
// See: <https://sourceforge.net/p/predef/wiki/OperatingSystems/>
// <http://stackoverflow.com/a/18729350> */
//
//#if defined __ANDROID__
//# define OONI_PLATFORM "android"
//#elif defined __linux__
//# define OONI_PLATFORM "linux"
//#elif defined _WIN32
//# define OONI_PLATFORM "windows"
//#elif defined __APPLE__
//# include <TargetConditionals.h>
//# if TARGET_OS_IPHONE
//# define OONI_PLATFORM "ios"
//# else
//# define OONI_PLATFORM "macos"
//# endif
//#else
//# define OONI_PLATFORM "unknown"
//#endif
import "C"
func cgoname() string {
return C.OONI_PLATFORM
}

View file

@ -0,0 +1,7 @@
// +build !cgo
package platform
func cgoname() string {
return "unknown"
}

View file

@ -0,0 +1,68 @@
package platform
import (
"fmt"
"testing"
)
func TestGood(t *testing.T) {
var expected bool
switch Name() {
case "android", "ios", "linux", "macos", "windows":
expected = true
}
if !expected {
t.Fatal("unexpected platform name")
}
}
func TestPuregoname(t *testing.T) {
var runtimevariables = []struct {
expected string
goarch string
goos string
}{{
expected: "android",
goarch: "*",
goos: "android",
}, {
expected: "ios",
goarch: "arm64",
goos: "darwin",
}, {
expected: "ios",
goarch: "arm",
goos: "darwin",
}, {
expected: "linux",
goarch: "*",
goos: "linux",
}, {
expected: "macos",
goarch: "amd64",
goos: "darwin",
}, {
expected: "macos",
goarch: "386",
goos: "darwin",
}, {
expected: "unknown",
goarch: "*",
goos: "solaris",
}, {
expected: "unknown",
goarch: "mips",
goos: "darwin",
}, {
expected: "windows",
goarch: "*",
goos: "windows",
}}
for _, v := range runtimevariables {
t.Run(fmt.Sprintf("with %s/%s", v.goos, v.goarch), func(t *testing.T) {
if puregoname(v.goos, v.goarch) != v.expected {
t.Fatal("unexpected results")
}
})
}
}

View file

@ -0,0 +1,136 @@
// Package psiphonx is a wrapper around the psiphon-tunnel-core.
package psiphonx
import (
"context"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"time"
"github.com/ooni/probe-cli/v3/internal/engine/model"
"github.com/ooni/psiphon/oopsi/github.com/Psiphon-Labs/psiphon-tunnel-core/ClientLibrary/clientlib"
)
// Session is the way in which this package sees a Session.
type Session interface {
NewOrchestraClient(ctx context.Context) (model.ExperimentOrchestraClient, error)
TempDir() string
}
// Dependencies contains dependencies for Start
type Dependencies interface {
MkdirAll(path string, perm os.FileMode) error
RemoveAll(path string) error
Start(ctx context.Context, config []byte,
workdir string) (*clientlib.PsiphonTunnel, error)
}
type defaultDependencies struct{}
func (defaultDependencies) MkdirAll(path string, perm os.FileMode) error {
return os.MkdirAll(path, perm)
}
func (defaultDependencies) RemoveAll(path string) error {
return os.RemoveAll(path)
}
func (defaultDependencies) Start(
ctx context.Context, config []byte, workdir string) (*clientlib.PsiphonTunnel, error) {
return clientlib.StartTunnel(ctx, config, "", clientlib.Parameters{
DataRootDirectory: &workdir}, nil, nil)
}
// Config contains the settings for Start. The empty config object implies
// that we will be using default settings for starting the tunnel.
type Config struct {
// Dependencies contains dependencies for Start.
Dependencies Dependencies
// WorkDir is the directory where Psiphon should store
// its configuration database.
WorkDir string
}
// Tunnel is a psiphon tunnel
type Tunnel struct {
tunnel *clientlib.PsiphonTunnel
duration time.Duration
}
func makeworkingdir(config Config) (string, error) {
const testdirname = "oonipsiphon"
workdir := filepath.Join(config.WorkDir, testdirname)
if err := config.Dependencies.RemoveAll(workdir); err != nil {
return "", err
}
if err := config.Dependencies.MkdirAll(workdir, 0700); err != nil {
return "", err
}
return workdir, nil
}
// Start starts the psiphon tunnel.
func Start(
ctx context.Context, sess Session, config Config) (*Tunnel, error) {
select {
case <-ctx.Done():
return nil, ctx.Err() // simplifies unit testing this code
default:
}
if config.Dependencies == nil {
config.Dependencies = defaultDependencies{}
}
if config.WorkDir == "" {
config.WorkDir = sess.TempDir()
}
clnt, err := sess.NewOrchestraClient(ctx)
if err != nil {
return nil, err
}
configJSON, err := clnt.FetchPsiphonConfig(ctx)
if err != nil {
return nil, err
}
workdir, err := makeworkingdir(config)
if err != nil {
return nil, err
}
start := time.Now()
tunnel, err := config.Dependencies.Start(ctx, configJSON, workdir)
if err != nil {
return nil, err
}
stop := time.Now()
return &Tunnel{tunnel: tunnel, duration: stop.Sub(start)}, nil
}
// Stop is an idempotent method that shuts down the tunnel
func (t *Tunnel) Stop() {
if t != nil {
t.tunnel.Stop()
}
}
// SOCKS5ProxyURL returns the SOCKS5 proxy URL.
func (t *Tunnel) SOCKS5ProxyURL() (proxyURL *url.URL) {
if t != nil {
proxyURL = &url.URL{
Scheme: "socks5",
Host: net.JoinHostPort(
"127.0.0.1", fmt.Sprintf("%d", t.tunnel.SOCKSProxyPort)),
}
}
return
}
// BootstrapTime returns the bootstrap time
func (t *Tunnel) BootstrapTime() (duration time.Duration) {
if t != nil {
duration = t.duration
}
return
}

View file

@ -0,0 +1,188 @@
package psiphonx_test
import (
"context"
"errors"
"os"
"testing"
"github.com/ooni/psiphon/oopsi/github.com/Psiphon-Labs/psiphon-tunnel-core/ClientLibrary/clientlib"
"github.com/apex/log"
engine "github.com/ooni/probe-cli/v3/internal/engine"
"github.com/ooni/probe-cli/v3/internal/engine/internal/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/internal/psiphonx"
)
func TestStartWithCancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
sess, err := engine.NewSession(engine.SessionConfig{
AssetsDir: "../../testdata",
Logger: log.Log,
SoftwareName: "ooniprobe-engine",
SoftwareVersion: "0.0.1",
})
if err != nil {
t.Fatal(err)
}
tunnel, err := psiphonx.Start(ctx, sess, psiphonx.Config{})
if !errors.Is(err, context.Canceled) {
t.Fatal("not the error we expected")
}
if tunnel != nil {
t.Fatal("expected nil tunnel here")
}
}
func TestStartStop(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
sess, err := engine.NewSession(engine.SessionConfig{
AssetsDir: "../../testdata",
Logger: log.Log,
SoftwareName: "ooniprobe-engine",
SoftwareVersion: "0.0.1",
})
if err != nil {
t.Fatal(err)
}
tunnel, err := psiphonx.Start(context.Background(), sess, psiphonx.Config{})
if err != nil {
t.Fatal(err)
}
if tunnel.SOCKS5ProxyURL() == nil {
t.Fatal("expected non nil URL here")
}
if tunnel.BootstrapTime() <= 0 {
t.Fatal("expected positive bootstrap time here")
}
tunnel.Stop()
}
func TestNewOrchestraClientFailure(t *testing.T) {
expected := errors.New("mocked error")
sess := &mockable.Session{
MockableOrchestraClientError: expected,
}
tunnel, err := psiphonx.Start(context.Background(), sess, psiphonx.Config{})
if !errors.Is(err, expected) {
t.Fatal("not the error we expected")
}
if tunnel != nil {
t.Fatal("expected nil tunnel here")
}
}
func TestFetchPsiphonConfigFailure(t *testing.T) {
expected := errors.New("mocked error")
clnt := mockable.ExperimentOrchestraClient{
MockableFetchPsiphonConfigErr: expected,
}
sess := &mockable.Session{
MockableOrchestraClient: clnt,
}
tunnel, err := psiphonx.Start(context.Background(), sess, psiphonx.Config{})
if !errors.Is(err, expected) {
t.Fatal("not the error we expected")
}
if tunnel != nil {
t.Fatal("expected nil tunnel here")
}
}
func TestMakeMkdirAllFailure(t *testing.T) {
expected := errors.New("mocked error")
dependencies := FakeDependencies{
MkdirAllErr: expected,
}
clnt := mockable.ExperimentOrchestraClient{
MockableFetchPsiphonConfigResult: []byte(`{}`),
}
sess := &mockable.Session{
MockableOrchestraClient: clnt,
}
tunnel, err := psiphonx.Start(context.Background(), sess, psiphonx.Config{
Dependencies: dependencies,
})
if !errors.Is(err, expected) {
t.Fatal("not the error we expected")
}
if tunnel != nil {
t.Fatal("expected nil tunnel here")
}
}
func TestMakeRemoveAllFailure(t *testing.T) {
expected := errors.New("mocked error")
dependencies := FakeDependencies{
RemoveAllErr: expected,
}
clnt := mockable.ExperimentOrchestraClient{
MockableFetchPsiphonConfigResult: []byte(`{}`),
}
sess := &mockable.Session{
MockableOrchestraClient: clnt,
}
tunnel, err := psiphonx.Start(context.Background(), sess, psiphonx.Config{
Dependencies: dependencies,
})
if !errors.Is(err, expected) {
t.Fatal("not the error we expected")
}
if tunnel != nil {
t.Fatal("expected nil tunnel here")
}
}
func TestMakeStartFailure(t *testing.T) {
expected := errors.New("mocked error")
dependencies := FakeDependencies{
StartErr: expected,
}
clnt := mockable.ExperimentOrchestraClient{
MockableFetchPsiphonConfigResult: []byte(`{}`),
}
sess := &mockable.Session{
MockableOrchestraClient: clnt,
}
tunnel, err := psiphonx.Start(context.Background(), sess, psiphonx.Config{
Dependencies: dependencies,
})
if !errors.Is(err, expected) {
t.Fatal("not the error we expected")
}
if tunnel != nil {
t.Fatal("expected nil tunnel here")
}
}
func TestNilTunnel(t *testing.T) {
var tunnel *psiphonx.Tunnel
if tunnel.BootstrapTime() != 0 {
t.Fatal("expected zero bootstrap time")
}
if tunnel.SOCKS5ProxyURL() != nil {
t.Fatal("expected nil SOCKS Proxy URL")
}
tunnel.Stop() // must not crash
}
type FakeDependencies struct {
MkdirAllErr error
RemoveAllErr error
StartErr error
}
func (fd FakeDependencies) MkdirAll(path string, perm os.FileMode) error {
return fd.MkdirAllErr
}
func (fd FakeDependencies) RemoveAll(path string) error {
return fd.RemoveAllErr
}
func (fd FakeDependencies) Start(
ctx context.Context, config []byte, workdir string) (*clientlib.PsiphonTunnel, error) {
return nil, fd.StartErr
}

View file

@ -0,0 +1,50 @@
// Package randx contains math/rand extensions
package randx
import (
"math/rand"
"time"
"unicode"
)
const (
uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
lowercase = "abcdefghijklmnopqrstuvwxyz"
letters = uppercase + lowercase
)
func lettersWithString(n int, letterBytes string) string {
// See https://stackoverflow.com/questions/22892120
rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
b := make([]byte, n)
for i := range b {
b[i] = letterBytes[rnd.Intn(len(letterBytes))]
}
return string(b)
}
// Letters return a string composed of random letters
func Letters(n int) string {
return lettersWithString(n, letters)
}
// LettersUppercase return a string composed of random uppercase letters
func LettersUppercase(n int) string {
return lettersWithString(n, uppercase)
}
// ChangeCapitalization returns a new string where the capitalization
// of each character is changed at random.
func ChangeCapitalization(source string) (dest string) {
rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
for _, chr := range source {
if unicode.IsLower(chr) && rnd.Float64() <= 0.5 {
dest += string(unicode.ToUpper(chr))
} else if unicode.IsUpper(chr) && rnd.Float64() <= 0.5 {
dest += string(unicode.ToLower(chr))
} else {
dest += string(chr)
}
}
return
}

View file

@ -0,0 +1,34 @@
package randx_test
import (
"testing"
"github.com/ooni/probe-cli/v3/internal/engine/internal/randx"
)
func TestLetters(t *testing.T) {
str := randx.Letters(1024)
for _, chr := range str {
if (chr >= 'A' && chr <= 'Z') || (chr >= 'a' && chr <= 'z') {
continue
}
t.Fatal("invalid input char")
}
}
func TestLettersUppercase(t *testing.T) {
str := randx.LettersUppercase(1024)
for _, chr := range str {
if chr >= 'A' && chr <= 'Z' {
continue
}
t.Fatal("invalid input char")
}
}
func TestChangeCapitalization(t *testing.T) {
str := randx.Letters(2048)
if randx.ChangeCapitalization(str) == str {
t.Fatal("capitalization not changed")
}
}

View file

@ -0,0 +1,12 @@
// Package runtimex contains runtime extensions. This package is inspired to the excellent
// github.com/m-lab/rtx package, except that it's simpler.
package runtimex
import "fmt"
// PanicOnError panics if err is not nil.
func PanicOnError(err error, message string) {
if err != nil {
panic(fmt.Errorf("%s: %w", message, err))
}
}

View file

@ -0,0 +1,27 @@
package runtimex_test
import (
"errors"
"testing"
"github.com/ooni/probe-cli/v3/internal/engine/internal/runtimex"
)
func TestGood(t *testing.T) {
runtimex.PanicOnError(nil, "antani failed")
}
func TestBad(t *testing.T) {
expected := errors.New("mocked error")
if !errors.Is(badfunc(expected), expected) {
t.Fatal("not the error we expected")
}
}
func badfunc(in error) (out error) {
defer func() {
out = recover().(error)
}()
runtimex.PanicOnError(in, "antani failed")
return
}

View file

@ -0,0 +1,85 @@
// Package sessionresolver contains the resolver used by the session. This
// resolver uses Powerdns DoH by default and falls back on the system
// provided resolver if Powerdns DoH is not working.
package sessionresolver
import (
"context"
"fmt"
"time"
"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/netx"
)
// Resolver is the session resolver.
type Resolver struct {
Primary netx.DNSClient
PrimaryFailure *atomicx.Int64
PrimaryQuery *atomicx.Int64
Fallback netx.DNSClient
FallbackFailure *atomicx.Int64
FallbackQuery *atomicx.Int64
}
// New creates a new session resolver.
func New(config netx.Config) *Resolver {
primary, err := netx.NewDNSClientWithOverrides(config,
"https://cloudflare.com/dns-query", "dns.cloudflare.com", "", "")
runtimex.PanicOnError(err, "cannot create dns over https resolver")
fallback, err := netx.NewDNSClient(config, "system:///")
runtimex.PanicOnError(err, "cannot create system resolver")
return &Resolver{
Primary: primary,
PrimaryFailure: atomicx.NewInt64(),
PrimaryQuery: atomicx.NewInt64(),
Fallback: fallback,
FallbackFailure: atomicx.NewInt64(),
FallbackQuery: atomicx.NewInt64(),
}
}
// CloseIdleConnections closes the idle connections, if any
func (r *Resolver) CloseIdleConnections() {
r.Primary.CloseIdleConnections()
r.Fallback.CloseIdleConnections()
}
// Stats returns stats about the session resolver.
func (r *Resolver) Stats() string {
return fmt.Sprintf("sessionresolver: failure rate: primary: %d/%d; fallback: %d/%d",
r.PrimaryFailure.Load(), r.PrimaryQuery.Load(),
r.FallbackFailure.Load(), r.FallbackQuery.Load())
}
// LookupHost implements Resolver.LookupHost
func (r *Resolver) LookupHost(ctx context.Context, hostname string) ([]string, error) {
// Algorithm similar to Firefox TRR2 mode. See:
// https://wiki.mozilla.org/Trusted_Recursive_Resolver#DNS-over-HTTPS_Prefs_in_Firefox
// We use a higher timeout than Firefox's timeout (1.5s) to be on the safe side
// and therefore see to use DoH more often.
r.PrimaryQuery.Add(1)
trr2, cancel := context.WithTimeout(ctx, 4*time.Second)
defer cancel()
addrs, err := r.Primary.LookupHost(trr2, hostname)
if err != nil {
r.PrimaryFailure.Add(1)
r.FallbackQuery.Add(1)
addrs, err = r.Fallback.LookupHost(ctx, hostname)
if err != nil {
r.FallbackFailure.Add(1)
}
}
return addrs, err
}
// Network implements Resolver.Network
func (r *Resolver) Network() string {
return "sessionresolver"
}
// Address implements Resolver.Address
func (r *Resolver) Address() string {
return ""
}

View file

@ -0,0 +1,31 @@
package sessionresolver_test
import (
"context"
"strings"
"testing"
"github.com/ooni/probe-cli/v3/internal/engine/internal/sessionresolver"
"github.com/ooni/probe-cli/v3/internal/engine/netx"
)
func TestFallbackWorks(t *testing.T) {
reso := sessionresolver.New(netx.Config{})
defer reso.CloseIdleConnections()
if reso.Network() != "sessionresolver" {
t.Fatal("unexpected Network")
}
if reso.Address() != "" {
t.Fatal("unexpected Address")
}
addrs, err := reso.LookupHost(context.Background(), "antani.ooni.nu")
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")
}
if reso.PrimaryFailure.Load() != 1 || reso.FallbackFailure.Load() != 1 {
t.Fatal("not the counters we expected to see here")
}
}

View file

@ -0,0 +1,63 @@
// Package tlsx contains TLS extensions
package tlsx
import (
"crypto/tls"
"fmt"
)
var (
tlsVersionString = map[uint16]string{
tls.VersionSSL30: "SSLv3",
tls.VersionTLS10: "TLSv1",
tls.VersionTLS11: "TLSv1.1",
tls.VersionTLS12: "TLSv1.2",
tls.VersionTLS13: "TLSv1.3",
0: "", // guarantee correct behaviour
}
tlsCipherSuiteString = map[uint16]string{
tls.TLS_RSA_WITH_RC4_128_SHA: "TLS_RSA_WITH_RC4_128_SHA",
tls.TLS_RSA_WITH_3DES_EDE_CBC_SHA: "TLS_RSA_WITH_3DES_EDE_CBC_SHA",
tls.TLS_RSA_WITH_AES_128_CBC_SHA: "TLS_RSA_WITH_AES_128_CBC_SHA",
tls.TLS_RSA_WITH_AES_256_CBC_SHA: "TLS_RSA_WITH_AES_256_CBC_SHA",
tls.TLS_RSA_WITH_AES_128_CBC_SHA256: "TLS_RSA_WITH_AES_128_CBC_SHA256",
tls.TLS_RSA_WITH_AES_128_GCM_SHA256: "TLS_RSA_WITH_AES_128_GCM_SHA256",
tls.TLS_RSA_WITH_AES_256_GCM_SHA384: "TLS_RSA_WITH_AES_256_GCM_SHA384",
tls.TLS_ECDHE_ECDSA_WITH_RC4_128_SHA: "TLS_ECDHE_ECDSA_WITH_RC4_128_SHA",
tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA: "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA",
tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA: "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA",
tls.TLS_ECDHE_RSA_WITH_RC4_128_SHA: "TLS_ECDHE_RSA_WITH_RC4_128_SHA",
tls.TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA: "TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA",
tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA: "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA",
tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA: "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA",
tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256: "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256",
tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256: "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256",
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256: "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256: "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384: "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384: "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305: "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305",
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305: "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305",
tls.TLS_AES_128_GCM_SHA256: "TLS_AES_128_GCM_SHA256",
tls.TLS_AES_256_GCM_SHA384: "TLS_AES_256_GCM_SHA384",
tls.TLS_CHACHA20_POLY1305_SHA256: "TLS_CHACHA20_POLY1305_SHA256",
0: "", // guarantee correct behaviour
}
)
// VersionString returns a TLS version string.
func VersionString(value uint16) string {
if str, found := tlsVersionString[value]; found {
return str
}
return fmt.Sprintf("TLS_VERSION_UNKNOWN_%d", value)
}
// CipherSuiteString returns the TLS cipher suite as a string.
func CipherSuiteString(value uint16) string {
if str, found := tlsCipherSuiteString[value]; found {
return str
}
return fmt.Sprintf("TLS_CIPHER_SUITE_UNKNOWN_%d", value)
}

View file

@ -0,0 +1,30 @@
package tlsx
import (
"crypto/tls"
"testing"
)
func TestVersionString(t *testing.T) {
if VersionString(tls.VersionTLS13) != "TLSv1.3" {
t.Fatal("not working for existing version")
}
if VersionString(1) != "TLS_VERSION_UNKNOWN_1" {
t.Fatal("not working for nonexisting version")
}
if VersionString(0) != "" {
t.Fatal("not working for zero version")
}
}
func TestCipherSuite(t *testing.T) {
if CipherSuiteString(tls.TLS_AES_128_GCM_SHA256) != "TLS_AES_128_GCM_SHA256" {
t.Fatal("not working for existing cipher suite")
}
if CipherSuiteString(1) != "TLS_CIPHER_SUITE_UNKNOWN_1" {
t.Fatal("not working for nonexisting cipher suite")
}
if CipherSuiteString(0) != "" {
t.Fatal("not working for zero cipher suite")
}
}

View file

@ -0,0 +1,137 @@
// Package torx contains code to control tor.
package torx
import (
"context"
"fmt"
"net/url"
"path"
"strings"
"time"
"github.com/cretz/bine/control"
"github.com/cretz/bine/tor"
)
// Session is the way in which this package sees a Session.
type Session interface {
TempDir() string
TorArgs() []string
TorBinary() string
}
// TorProcess is a running tor process
type TorProcess interface {
Close() error
}
// Tunnel is the Tor tunnel
type Tunnel struct {
bootstrapTime time.Duration
instance TorProcess
proxy *url.URL
}
// BootstrapTime is the bootstrsap time
func (tt *Tunnel) BootstrapTime() (duration time.Duration) {
if tt != nil {
duration = tt.bootstrapTime
}
return
}
// SOCKS5ProxyURL returns the URL of the SOCKS5 proxy
func (tt *Tunnel) SOCKS5ProxyURL() (url *url.URL) {
if tt != nil {
url = tt.proxy
}
return
}
// Stop stops the Tor tunnel
func (tt *Tunnel) Stop() {
if tt != nil {
tt.instance.Close()
}
}
// StartConfig contains the configuration for StartWithConfig
type StartConfig struct {
Sess Session
Start func(ctx context.Context, conf *tor.StartConf) (*tor.Tor, error)
EnableNetwork func(ctx context.Context, tor *tor.Tor, wait bool) error
GetInfo func(ctrl *control.Conn, keys ...string) ([]*control.KeyVal, error)
}
// Start starts the tor tunnel
func Start(ctx context.Context, sess Session) (*Tunnel, error) {
return StartWithConfig(ctx, StartConfig{
Sess: sess,
Start: func(ctx context.Context, conf *tor.StartConf) (*tor.Tor, error) {
return tor.Start(ctx, conf)
},
EnableNetwork: func(ctx context.Context, tor *tor.Tor, wait bool) error {
return tor.EnableNetwork(ctx, wait)
},
GetInfo: func(ctrl *control.Conn, keys ...string) ([]*control.KeyVal, error) {
return ctrl.GetInfo(keys...)
},
})
}
// StartWithConfig is a configurable Start for testing
func StartWithConfig(ctx context.Context, config StartConfig) (*Tunnel, error) {
select {
case <-ctx.Done():
return nil, ctx.Err() // allows to write unit tests using this code
default:
}
logfile := LogFile(config.Sess)
extraArgs := append([]string{}, config.Sess.TorArgs()...)
extraArgs = append(extraArgs, "Log")
extraArgs = append(extraArgs, "notice stderr")
extraArgs = append(extraArgs, "Log")
extraArgs = append(extraArgs, fmt.Sprintf(`notice file %s`, logfile))
instance, err := config.Start(ctx, &tor.StartConf{
DataDir: path.Join(config.Sess.TempDir(), "tor"),
ExtraArgs: extraArgs,
ExePath: config.Sess.TorBinary(),
NoHush: true,
})
if err != nil {
return nil, err
}
instance.StopProcessOnClose = true
start := time.Now()
if err := config.EnableNetwork(ctx, instance, true); err != nil {
instance.Close()
return nil, err
}
stop := time.Now()
// Adapted from <https://git.io/Jfc7N>
info, err := config.GetInfo(instance.Control, "net/listeners/socks")
if err != nil {
instance.Close()
return nil, err
}
if len(info) != 1 || info[0].Key != "net/listeners/socks" {
instance.Close()
return nil, fmt.Errorf("unable to get socks proxy address")
}
proxyAddress := info[0].Val
if strings.HasPrefix(proxyAddress, "unix:") {
instance.Close()
return nil, fmt.Errorf("tor returned unsupported proxy")
}
return &Tunnel{
bootstrapTime: stop.Sub(start),
instance: instance,
proxy: &url.URL{Scheme: "socks5", Host: proxyAddress},
}, nil
}
// LogFile returns the name of tor logs given a specific session. The file
// is always located somewhere inside the sess.TempDir() directory.
func LogFile(sess Session) string {
return path.Join(sess.TempDir(), "tor.log")
}

View file

@ -0,0 +1,14 @@
package torx
import (
"net/url"
"time"
)
func NewTunnel(bootstrapTime time.Duration, instance TorProcess, proxy *url.URL) *Tunnel {
return &Tunnel{
bootstrapTime: bootstrapTime,
instance: instance,
proxy: proxy,
}
}

View file

@ -0,0 +1,209 @@
package torx_test
import (
"context"
"errors"
"net/url"
"testing"
"github.com/cretz/bine/control"
"github.com/cretz/bine/tor"
"github.com/ooni/probe-cli/v3/internal/engine/internal/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/internal/torx"
)
type Closer struct {
counter int
}
func (c *Closer) Close() error {
c.counter++
return errors.New("mocked mocked mocked")
}
func TestTunnelNonNil(t *testing.T) {
closer := new(Closer)
proxy := &url.URL{Scheme: "x", Host: "10.0.0.1:443"}
tun := torx.NewTunnel(128, closer, proxy)
if tun.BootstrapTime() != 128 {
t.Fatal("not the bootstrap time we expected")
}
if tun.SOCKS5ProxyURL() != proxy {
t.Fatal("not the url we expected")
}
tun.Stop()
if closer.counter != 1 {
t.Fatal("something went wrong while stopping the tunnel")
}
}
func TestTunnelNil(t *testing.T) {
var tun *torx.Tunnel
if tun.BootstrapTime() != 0 {
t.Fatal("not the bootstrap time we expected")
}
if tun.SOCKS5ProxyURL() != nil {
t.Fatal("not the url we expected")
}
tun.Stop() // ensure we don't crash
}
func TestStartWithCancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
tun, err := torx.Start(ctx, &mockable.Session{})
if !errors.Is(err, context.Canceled) {
t.Fatal("not the error we expected")
}
if tun != nil {
t.Fatal("expected nil tunnel here")
}
}
func TestStartWithConfigStartFailure(t *testing.T) {
expected := errors.New("mocked error")
ctx := context.Background()
tun, err := torx.StartWithConfig(ctx, torx.StartConfig{
Sess: &mockable.Session{},
Start: func(ctx context.Context, conf *tor.StartConf) (*tor.Tor, error) {
return nil, expected
},
})
if !errors.Is(err, expected) {
t.Fatal("not the error we expected")
}
if tun != nil {
t.Fatal("expected nil tunnel here")
}
}
func TestStartWithConfigEnableNetworkFailure(t *testing.T) {
expected := errors.New("mocked error")
ctx := context.Background()
tun, err := torx.StartWithConfig(ctx, torx.StartConfig{
Sess: &mockable.Session{},
Start: func(ctx context.Context, conf *tor.StartConf) (*tor.Tor, error) {
return &tor.Tor{}, nil
},
EnableNetwork: func(ctx context.Context, tor *tor.Tor, wait bool) error {
return expected
},
})
if !errors.Is(err, expected) {
t.Fatal("not the error we expected")
}
if tun != nil {
t.Fatal("expected nil tunnel here")
}
}
func TestStartWithConfigGetInfoFailure(t *testing.T) {
expected := errors.New("mocked error")
ctx := context.Background()
tun, err := torx.StartWithConfig(ctx, torx.StartConfig{
Sess: &mockable.Session{},
Start: func(ctx context.Context, conf *tor.StartConf) (*tor.Tor, error) {
return &tor.Tor{}, nil
},
EnableNetwork: func(ctx context.Context, tor *tor.Tor, wait bool) error {
return nil
},
GetInfo: func(ctrl *control.Conn, keys ...string) ([]*control.KeyVal, error) {
return nil, expected
},
})
if !errors.Is(err, expected) {
t.Fatal("not the error we expected")
}
if tun != nil {
t.Fatal("expected nil tunnel here")
}
}
func TestStartWithConfigGetInfoInvalidNumberOfKeys(t *testing.T) {
ctx := context.Background()
tun, err := torx.StartWithConfig(ctx, torx.StartConfig{
Sess: &mockable.Session{},
Start: func(ctx context.Context, conf *tor.StartConf) (*tor.Tor, error) {
return &tor.Tor{}, nil
},
EnableNetwork: func(ctx context.Context, tor *tor.Tor, wait bool) error {
return nil
},
GetInfo: func(ctrl *control.Conn, keys ...string) ([]*control.KeyVal, error) {
return nil, nil
},
})
if err.Error() != "unable to get socks proxy address" {
t.Fatal("not the error we expected")
}
if tun != nil {
t.Fatal("expected nil tunnel here")
}
}
func TestStartWithConfigGetInfoInvalidKey(t *testing.T) {
ctx := context.Background()
tun, err := torx.StartWithConfig(ctx, torx.StartConfig{
Sess: &mockable.Session{},
Start: func(ctx context.Context, conf *tor.StartConf) (*tor.Tor, error) {
return &tor.Tor{}, nil
},
EnableNetwork: func(ctx context.Context, tor *tor.Tor, wait bool) error {
return nil
},
GetInfo: func(ctrl *control.Conn, keys ...string) ([]*control.KeyVal, error) {
return []*control.KeyVal{{}}, nil
},
})
if err.Error() != "unable to get socks proxy address" {
t.Fatal("not the error we expected")
}
if tun != nil {
t.Fatal("expected nil tunnel here")
}
}
func TestStartWithConfigGetInfoInvalidProxyType(t *testing.T) {
ctx := context.Background()
tun, err := torx.StartWithConfig(ctx, torx.StartConfig{
Sess: &mockable.Session{},
Start: func(ctx context.Context, conf *tor.StartConf) (*tor.Tor, error) {
return &tor.Tor{}, nil
},
EnableNetwork: func(ctx context.Context, tor *tor.Tor, wait bool) error {
return nil
},
GetInfo: func(ctrl *control.Conn, keys ...string) ([]*control.KeyVal, error) {
return []*control.KeyVal{{Key: "net/listeners/socks", Val: "127.0.0.1:9050"}}, nil
},
})
if err != nil {
t.Fatal(err)
}
if tun == nil {
t.Fatal("expected non-nil tunnel here")
}
}
func TestStartWithConfigSuccess(t *testing.T) {
ctx := context.Background()
tun, err := torx.StartWithConfig(ctx, torx.StartConfig{
Sess: &mockable.Session{},
Start: func(ctx context.Context, conf *tor.StartConf) (*tor.Tor, error) {
return &tor.Tor{}, nil
},
EnableNetwork: func(ctx context.Context, tor *tor.Tor, wait bool) error {
return nil
},
GetInfo: func(ctrl *control.Conn, keys ...string) ([]*control.KeyVal, error) {
return []*control.KeyVal{{Key: "net/listeners/socks", Val: "unix:/foo/bar"}}, nil
},
})
if err.Error() != "tor returned unsupported proxy" {
t.Fatal("not the error we expected")
}
if tun != nil {
t.Fatal("expected nil tunnel here")
}
}

View file

@ -0,0 +1,64 @@
// Package tunnel contains code to create a psiphon or tor tunnel.
package tunnel
import (
"context"
"errors"
"net/url"
"time"
"github.com/ooni/probe-cli/v3/internal/engine/internal/psiphonx"
"github.com/ooni/probe-cli/v3/internal/engine/internal/torx"
"github.com/ooni/probe-cli/v3/internal/engine/model"
)
// Session is the way in which this package sees a Session.
type Session interface {
psiphonx.Session
torx.Session
Logger() model.Logger
}
// Tunnel is a tunnel used by the session
type Tunnel interface {
BootstrapTime() time.Duration
SOCKS5ProxyURL() *url.URL
Stop()
}
// Config contains config for the session tunnel.
type Config struct {
Name string
Session Session
WorkDir string
}
// Start starts a new tunnel by name or returns an error. Note that if you
// pass to this function the "" tunnel, you get back nil, nil.
func Start(ctx context.Context, config Config) (Tunnel, error) {
logger := config.Session.Logger()
switch config.Name {
case "":
logger.Debugf("no tunnel has been requested")
return enforceNilContract(nil, nil)
case "psiphon":
logger.Infof("starting %s tunnel; please be patient...", config.Name)
tun, err := psiphonx.Start(ctx, config.Session, psiphonx.Config{
WorkDir: config.WorkDir,
})
return enforceNilContract(tun, err)
case "tor":
logger.Infof("starting %s tunnel; please be patient...", config.Name)
tun, err := torx.Start(ctx, config.Session)
return enforceNilContract(tun, err)
default:
return nil, errors.New("unsupported tunnel")
}
}
func enforceNilContract(tun Tunnel, err error) (Tunnel, error) {
if err != nil {
return nil, err
}
return tun, nil
}

View file

@ -0,0 +1,80 @@
package tunnel_test
import (
"context"
"errors"
"testing"
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/internal/engine/internal/mockable"
"github.com/ooni/probe-cli/v3/internal/engine/internal/tunnel"
)
func TestNoTunnel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
tunnel, err := tunnel.Start(ctx, tunnel.Config{
Name: "",
Session: &mockable.Session{
MockableLogger: log.Log,
},
})
if err != nil {
t.Fatal(err)
}
if tunnel != nil {
t.Fatal("expected nil tunnel here")
}
}
func TestPsiphonTunnel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
tunnel, err := tunnel.Start(ctx, tunnel.Config{
Name: "psiphon",
Session: &mockable.Session{
MockableLogger: log.Log,
},
})
if !errors.Is(err, context.Canceled) {
t.Fatal("not the error we expected")
}
if tunnel != nil {
t.Fatal("expected nil tunnel here")
}
}
func TestTorTunnel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
tunnel, err := tunnel.Start(ctx, tunnel.Config{
Name: "tor",
Session: &mockable.Session{
MockableLogger: log.Log,
},
})
if !errors.Is(err, context.Canceled) {
t.Fatal("not the error we expected")
}
if tunnel != nil {
t.Fatal("expected nil tunnel here")
}
}
func TestInvalidTunnel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
tunnel, err := tunnel.Start(ctx, tunnel.Config{
Name: "antani",
Session: &mockable.Session{
MockableLogger: log.Log,
},
})
if err == nil || err.Error() != "unsupported tunnel" {
t.Fatal("not the error we expected")
}
t.Log(tunnel)
if tunnel != nil {
t.Fatal("expected nil tunnel here")
}
}