Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(enginenetx): introduce loadable TLSDialer policy #1290

Merged
merged 5 commits into from
Sep 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions internal/enginenetx/httpsdialer_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,13 @@ func TestHTTPSDialerTacticsEmitter(t *testing.T) {

var tactics []HTTPSDialerTactic
for idx := 0; idx < 255; idx++ {
tactics = append(tactics, &httpsDialerNullTactic{
Address: fmt.Sprintf("10.0.0.%d", idx),
Delay: 0,
Domain: "www.example.com",
tactics = append(tactics, &HTTPSDialerLoadableTacticWrapper{
Tactic: &HTTPSDialerLoadableTactic{
IPAddr: fmt.Sprintf("10.0.0.%d", idx),
InitialDelay: 0,
SNI: "www.example.com",
VerifyHostname: "www.example.com",
},
})
}

Expand Down Expand Up @@ -65,4 +68,17 @@ func TestHTTPSDialerVerifyCertificateChain(t *testing.T) {
t.Fatal("unexpected error", err)
}
})

t.Run("with an empty hostname", func(t *testing.T) {
tlsConn := &mocks.TLSConn{
MockConnectionState: func() tls.ConnectionState {
return tls.ConnectionState{} // empty but should not be an issue
},
}
certPool := netxlite.NewMozillaCertPool()
err := httpsDialerVerifyCertificateChain("", tlsConn, certPool)
if !errors.Is(err, errEmptyVerifyHostname) {
t.Fatal("unexpected error", err)
}
})
}
194 changes: 194 additions & 0 deletions internal/enginenetx/httpsdialer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package enginenetx_test

import (
"context"
"encoding/json"
"testing"
"time"

"github.com/apex/log"
"github.com/google/go-cmp/cmp"
Expand All @@ -11,6 +13,7 @@ import (
"github.com/ooni/probe-cli/v3/internal/model"
"github.com/ooni/probe-cli/v3/internal/netemx"
"github.com/ooni/probe-cli/v3/internal/netxlite"
"github.com/ooni/probe-cli/v3/internal/runtimex"
"github.com/ooni/probe-cli/v3/internal/testingx"
)

Expand Down Expand Up @@ -439,3 +442,194 @@ func TestHTTPSDialerWAI(t *testing.T) {
})
}
}

func TestLoadHTTPSDialerPolicy(t *testing.T) {
// testcase is a test case implemented by this function
type testcase struct {
// name is the test case name
name string

// input contains the serialized input bytes
input []byte

// expectErr contains the expected error string or the empty string on success
expectErr string

// expectPolicy contains the expected policy we loaded or nil
expectedPolicy *enginenetx.HTTPSDialerLoadablePolicy
}

cases := []testcase{{
name: "with nil input",
input: nil,
expectErr: "unexpected end of JSON input",
expectedPolicy: nil,
}, {
name: "with invalid serialized JSON",
input: []byte(`{`),
expectErr: "unexpected end of JSON input",
expectedPolicy: nil,
}, {
name: "with empty serialized JSON",
input: []byte(`{}`),
expectErr: "",
expectedPolicy: &enginenetx.HTTPSDialerLoadablePolicy{},
}, {
name: "with real serialized policy",
input: (func() []byte {
return runtimex.Try1(json.Marshal(&enginenetx.HTTPSDialerLoadablePolicy{
Domains: map[string][]*enginenetx.HTTPSDialerLoadableTactic{
"api.ooni.io": {{
IPAddr: "162.55.247.208",
InitialDelay: 0,
SNI: "api.ooni.io",
VerifyHostname: "api.ooni.io",
}, {
IPAddr: "46.101.82.151",
InitialDelay: 300 * time.Millisecond,
SNI: "api.ooni.io",
VerifyHostname: "api.ooni.io",
}, {
IPAddr: "2a03:b0c0:1:d0::ec4:9001",
InitialDelay: 600 * time.Millisecond,
SNI: "api.ooni.io",
VerifyHostname: "api.ooni.io",
}, {
IPAddr: "46.101.82.151",
InitialDelay: 3000 * time.Millisecond,
SNI: "www.example.com",
VerifyHostname: "api.ooni.io",
}, {
IPAddr: "2a03:b0c0:1:d0::ec4:9001",
InitialDelay: 3300 * time.Millisecond,
SNI: "www.example.com",
VerifyHostname: "api.ooni.io",
}},
},
}))
})(),
expectErr: "",
expectedPolicy: &enginenetx.HTTPSDialerLoadablePolicy{
Domains: map[string][]*enginenetx.HTTPSDialerLoadableTactic{
"api.ooni.io": {{
IPAddr: "162.55.247.208",
InitialDelay: 0,
SNI: "api.ooni.io",
VerifyHostname: "api.ooni.io",
}, {
IPAddr: "46.101.82.151",
InitialDelay: 300 * time.Millisecond,
SNI: "api.ooni.io",
VerifyHostname: "api.ooni.io",
}, {
IPAddr: "2a03:b0c0:1:d0::ec4:9001",
InitialDelay: 600 * time.Millisecond,
SNI: "api.ooni.io",
VerifyHostname: "api.ooni.io",
}, {
IPAddr: "46.101.82.151",
InitialDelay: 3000 * time.Millisecond,
SNI: "www.example.com",
VerifyHostname: "api.ooni.io",
}, {
IPAddr: "2a03:b0c0:1:d0::ec4:9001",
InitialDelay: 3300 * time.Millisecond,
SNI: "www.example.com",
VerifyHostname: "api.ooni.io",
}},
},
},
}}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
policy, err := enginenetx.LoadHTTPSDialerPolicy(tc.input)

switch {
case err != nil && tc.expectErr == "":
t.Fatal("expected", tc.expectErr, "got", err)

case err == nil && tc.expectErr != "":
t.Fatal("expected", tc.expectErr, "got", err)

case err != nil && tc.expectErr != "":
if diff := cmp.Diff(tc.expectErr, err.Error()); diff != "" {
t.Fatal(diff)
}

case err == nil && tc.expectErr == "":
// all good
}

if diff := cmp.Diff(tc.expectedPolicy, policy); diff != "" {
t.Fatal(diff)
}
})
}
}

func TestHTTPSDialerLoadableTacticWrapper(t *testing.T) {
t.Run("IPAddr", func(t *testing.T) {
expected := "10.0.0.1"
ldt := &enginenetx.HTTPSDialerLoadableTacticWrapper{
Tactic: &enginenetx.HTTPSDialerLoadableTactic{
IPAddr: expected,
},
}
if got := ldt.IPAddr(); got != expected {
t.Fatal("expected", expected, "got", got)
}
})

t.Run("InitialDelay", func(t *testing.T) {
expected := time.Millisecond
ldt := &enginenetx.HTTPSDialerLoadableTacticWrapper{
Tactic: &enginenetx.HTTPSDialerLoadableTactic{
InitialDelay: expected,
},
}
if got := ldt.InitialDelay(); got != expected {
t.Fatal("expected", expected, "got", got)
}
})

t.Run("SNI", func(t *testing.T) {
expected := "x.org"
ldt := &enginenetx.HTTPSDialerLoadableTacticWrapper{
Tactic: &enginenetx.HTTPSDialerLoadableTactic{
SNI: expected,
},
}
if got := ldt.SNI(); got != expected {
t.Fatal("expected", expected, "got", got)
}
})

t.Run("String", func(t *testing.T) {
expected := "&{IPAddr:162.55.247.208 InitialDelay:150ms SNI:www.example.com VerifyHostname:api.ooni.io}"
ldt := &enginenetx.HTTPSDialerLoadableTacticWrapper{
Tactic: &enginenetx.HTTPSDialerLoadableTactic{
IPAddr: "162.55.247.208",
InitialDelay: 150 * time.Millisecond,
SNI: "www.example.com",
VerifyHostname: "api.ooni.io",
},
}
got := ldt.String()
if diff := cmp.Diff(expected, got); diff != "" {
t.Fatal(diff)
}
})

t.Run("VerifyHostname", func(t *testing.T) {
expected := "x.org"
ldt := &enginenetx.HTTPSDialerLoadableTacticWrapper{
Tactic: &enginenetx.HTTPSDialerLoadableTactic{
VerifyHostname: expected,
},
}
if got := ldt.VerifyHostname(); got != expected {
t.Fatal("expected", expected, "got", got)
}
})
}
9 changes: 9 additions & 0 deletions internal/enginenetx/httpsdialercore.go
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,9 @@ func httpsDialerTacticWaitReady(ctx context.Context, tactic HTTPSDialerTactic) e
// errNoPeerCertificate is an internal error returned when we don't have any peer certificate.
var errNoPeerCertificate = errors.New("no peer certificate")

// errEmptyVerifyHostname indicates there is no hostname to verify against
var errEmptyVerifyHostname = errors.New("empty VerifyHostname")

// httpsDialerVerifyCertificateChain verifies the certificate chain with the given hostname.
func httpsDialerVerifyCertificateChain(hostname string, conn model.TLSConn, rootCAs *x509.CertPool) error {
// This code comes from the example in the Go source tree that shows
Expand All @@ -399,6 +402,12 @@ func httpsDialerVerifyCertificateChain(hostname string, conn model.TLSConn, root
//
// See https://github.com/golang/go/blob/go1.21.0/src/crypto/tls/handshake_client.go#L962.

// Protect against a programming or configuration error where the
// programmer or user has not set the hostname.
if hostname == "" {
return errEmptyVerifyHostname
}

state := conn.ConnectionState()
opts := x509.VerifyOptions{
DNSName: hostname, // note: here we're using the real hostname
Expand Down
109 changes: 109 additions & 0 deletions internal/enginenetx/httpsdialerloadable.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package enginenetx

import (
"context"
"encoding/json"
"fmt"
"time"

"github.com/ooni/probe-cli/v3/internal/model"
"github.com/ooni/probe-cli/v3/internal/netxlite"
)

// HTTPSDialerLoadablePolicy is an [HTTPSDialerPolicy] that you
// can load from its JSON serialization on disk.
type HTTPSDialerLoadablePolicy struct {
// Domains maps each domain to its policy. When there is
// no domain, the code falls back to the default "null" policy
// implemented by HTTPSDialerNullPolicy.
Domains map[string][]*HTTPSDialerLoadableTactic
}

// LoadHTTPSDialerPolicy loads the [HTTPSDialerPolicy] from
// the given bytes containing a serialized JSON object.
func LoadHTTPSDialerPolicy(data []byte) (*HTTPSDialerLoadablePolicy, error) {
var p HTTPSDialerLoadablePolicy
if err := json.Unmarshal(data, &p); err != nil {
return nil, err
}
return &p, nil
}

// HTTPSDialerLoadableTactic is an [HTTPSDialerTactic] that you
// can load from JSON as part of [HTTPSDialerLoadablePolicy].
type HTTPSDialerLoadableTactic struct {
// IPAddr is the IP address to use for dialing.
IPAddr string

// InitialDelay is the time in nanoseconds after which
// you would like to start this policy.
InitialDelay time.Duration

// SNI is the TLS ServerName to send over the wire.
SNI string

// VerifyHostname is the hostname using during
// the X.509 certificate verification.
VerifyHostname string
}

// HTTPSDialerLoadableTacticWrapper wraps [HTTPSDialerLoadableTactic]
// to make it implements the [HTTPSDialerTactic] interface.
type HTTPSDialerLoadableTacticWrapper struct {
Tactic *HTTPSDialerLoadableTactic
}

// IPAddr implements HTTPSDialerTactic.
func (ldt *HTTPSDialerLoadableTacticWrapper) IPAddr() string {
return ldt.Tactic.IPAddr
}

// InitialDelay implements HTTPSDialerTactic.
func (ldt *HTTPSDialerLoadableTacticWrapper) InitialDelay() time.Duration {
return ldt.Tactic.InitialDelay
}

// NewTLSHandshaker implements HTTPSDialerTactic.
func (ldt *HTTPSDialerLoadableTacticWrapper) NewTLSHandshaker(netx *netxlite.Netx, logger model.Logger) model.TLSHandshaker {
return netxlite.NewTLSHandshakerStdlib(logger)
}

// OnStarting implements HTTPSDialerTactic.
func (ldt *HTTPSDialerLoadableTacticWrapper) OnStarting() {
// TODO(bassosimone): implement
}

// OnSuccess implements HTTPSDialerTactic.
func (ldt *HTTPSDialerLoadableTacticWrapper) OnSuccess() {
// TODO(bassosimone): implement
}

// OnTCPConnectError implements HTTPSDialerTactic.
func (ldt *HTTPSDialerLoadableTacticWrapper) OnTCPConnectError(ctx context.Context, err error) {
// TODO(bassosimone): implement
}

// OnTLSHandshakeError implements HTTPSDialerTactic.
func (ldt *HTTPSDialerLoadableTacticWrapper) OnTLSHandshakeError(ctx context.Context, err error) {
// TODO(bassosimone): implement
}

// OnTLSVerifyError implements HTTPSDialerTactic.
func (ldt *HTTPSDialerLoadableTacticWrapper) OnTLSVerifyError(ctz context.Context, err error) {
// TODO(bassosimone): implement
}

// SNI implements HTTPSDialerTactic.
func (ldt *HTTPSDialerLoadableTacticWrapper) SNI() string {
return ldt.Tactic.SNI
}

// String implements HTTPSDialerTactic.
func (ldt *HTTPSDialerLoadableTacticWrapper) String() string {
return fmt.Sprintf("%+v", ldt.Tactic)
}

// VerifyHostname implements HTTPSDialerTactic.
func (ldt *HTTPSDialerLoadableTacticWrapper) VerifyHostname() string {
return ldt.Tactic.VerifyHostname
}
Loading