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:
parent
b1ce300c8d
commit
d57c78bc71
535 changed files with 66182 additions and 23 deletions
51
internal/engine/internal/fsx/fsx.go
Normal file
51
internal/engine/internal/fsx/fsx.go
Normal 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)
|
||||
}
|
||||
75
internal/engine/internal/fsx/fsx_test.go
Normal file
75
internal/engine/internal/fsx/fsx_test.go
Normal 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()
|
||||
}
|
||||
15
internal/engine/internal/httpfailure/httpfailure.go
Normal file
15
internal/engine/internal/httpfailure/httpfailure.go
Normal 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"
|
||||
)
|
||||
6
internal/engine/internal/httpheader/accept.go
Normal file
6
internal/engine/internal/httpheader/accept.go
Normal 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"
|
||||
}
|
||||
6
internal/engine/internal/httpheader/acceptlanguage.go
Normal file
6
internal/engine/internal/httpheader/acceptlanguage.go
Normal 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"
|
||||
}
|
||||
16
internal/engine/internal/httpheader/useragent.go
Normal file
16
internal/engine/internal/httpheader/useragent.go
Normal 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"
|
||||
}
|
||||
44
internal/engine/internal/httpx/fake_test.go
Normal file
44
internal/engine/internal/httpx/fake_test.go
Normal 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
|
||||
}
|
||||
31
internal/engine/internal/httpx/fetch.go
Normal file
31
internal/engine/internal/httpx/fetch.go
Normal 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
|
||||
}
|
||||
154
internal/engine/internal/httpx/fetch_test.go
Normal file
154
internal/engine/internal/httpx/fetch_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
168
internal/engine/internal/httpx/jsonapi.go
Normal file
168
internal/engine/internal/httpx/jsonapi.go
Normal 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)
|
||||
}
|
||||
316
internal/engine/internal/httpx/jsonapi_test.go
Normal file
316
internal/engine/internal/httpx/jsonapi_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
26
internal/engine/internal/humanizex/humanizex.go
Normal file
26
internal/engine/internal/humanizex/humanizex.go
Normal 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"
|
||||
}
|
||||
34
internal/engine/internal/humanizex/humanizex_test.go
Normal file
34
internal/engine/internal/humanizex/humanizex_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
44
internal/engine/internal/kvstore/kvstore.go
Normal file
44
internal/engine/internal/kvstore/kvstore.go
Normal 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
|
||||
}
|
||||
28
internal/engine/internal/kvstore/kvstore_test.go
Normal file
28
internal/engine/internal/kvstore/kvstore_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
79
internal/engine/internal/mlablocate/mlablocate.go
Normal file
79
internal/engine/internal/mlablocate/mlablocate.go
Normal 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
|
||||
}
|
||||
206
internal/engine/internal/mlablocate/mlablocate_test.go
Normal file
206
internal/engine/internal/mlablocate/mlablocate_test.go
Normal 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
|
||||
}
|
||||
45
internal/engine/internal/mlablocatev2/fake_test.go
Normal file
45
internal/engine/internal/mlablocatev2/fake_test.go
Normal 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
|
||||
}
|
||||
152
internal/engine/internal/mlablocatev2/mlablocatev2.go
Normal file
152
internal/engine/internal/mlablocatev2/mlablocatev2.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
232
internal/engine/internal/mlablocatev2/mlablocatev2_test.go
Normal file
232
internal/engine/internal/mlablocatev2/mlablocatev2_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
191
internal/engine/internal/mockable/mockable.go
Normal file
191
internal/engine/internal/mockable/mockable.go
Normal 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{}
|
||||
66
internal/engine/internal/multierror/multierror.go
Normal file
66
internal/engine/internal/multierror/multierror.go
Normal 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()
|
||||
}
|
||||
85
internal/engine/internal/multierror/multierror_test.go
Normal file
85
internal/engine/internal/multierror/multierror_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
46
internal/engine/internal/platform/platform.go
Normal file
46
internal/engine/internal/platform/platform.go
Normal 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"
|
||||
}
|
||||
31
internal/engine/internal/platform/platform_cgo.go
Normal file
31
internal/engine/internal/platform/platform_cgo.go
Normal 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
|
||||
}
|
||||
7
internal/engine/internal/platform/platform_otherwise.go
Normal file
7
internal/engine/internal/platform/platform_otherwise.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// +build !cgo
|
||||
|
||||
package platform
|
||||
|
||||
func cgoname() string {
|
||||
return "unknown"
|
||||
}
|
||||
68
internal/engine/internal/platform/platform_test.go
Normal file
68
internal/engine/internal/platform/platform_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
136
internal/engine/internal/psiphonx/psiphonx.go
Normal file
136
internal/engine/internal/psiphonx/psiphonx.go
Normal 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
|
||||
}
|
||||
188
internal/engine/internal/psiphonx/psiphonx_test.go
Normal file
188
internal/engine/internal/psiphonx/psiphonx_test.go
Normal 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
|
||||
}
|
||||
50
internal/engine/internal/randx/randx.go
Normal file
50
internal/engine/internal/randx/randx.go
Normal 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
|
||||
}
|
||||
34
internal/engine/internal/randx/randx_test.go
Normal file
34
internal/engine/internal/randx/randx_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
12
internal/engine/internal/runtimex/runtimex.go
Normal file
12
internal/engine/internal/runtimex/runtimex.go
Normal 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))
|
||||
}
|
||||
}
|
||||
27
internal/engine/internal/runtimex/runtimex_test.go
Normal file
27
internal/engine/internal/runtimex/runtimex_test.go
Normal 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
|
||||
}
|
||||
85
internal/engine/internal/sessionresolver/sessionresolver.go
Normal file
85
internal/engine/internal/sessionresolver/sessionresolver.go
Normal 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 ""
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
63
internal/engine/internal/tlsx/tlsx.go
Normal file
63
internal/engine/internal/tlsx/tlsx.go
Normal 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)
|
||||
}
|
||||
30
internal/engine/internal/tlsx/tlsx_test.go
Normal file
30
internal/engine/internal/tlsx/tlsx_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
137
internal/engine/internal/torx/torx.go
Normal file
137
internal/engine/internal/torx/torx.go
Normal 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")
|
||||
}
|
||||
14
internal/engine/internal/torx/torx_internal_test.go
Normal file
14
internal/engine/internal/torx/torx_internal_test.go
Normal 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,
|
||||
}
|
||||
}
|
||||
209
internal/engine/internal/torx/torx_test.go
Normal file
209
internal/engine/internal/torx/torx_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
64
internal/engine/internal/tunnel/tunnel.go
Normal file
64
internal/engine/internal/tunnel/tunnel.go
Normal 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
|
||||
}
|
||||
80
internal/engine/internal/tunnel/tunnel_test.go
Normal file
80
internal/engine/internal/tunnel/tunnel_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue