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

Collect connection address in connectivity test #223

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
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
121 changes: 121 additions & 0 deletions x/connectivity/connectivity.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,41 @@ import (
"context"
"errors"
"fmt"
"net"
"syscall"
"time"

"github.com/Jigsaw-Code/outline-sdk/dns"
"github.com/Jigsaw-Code/outline-sdk/transport"
"golang.org/x/net/dns/dnsmessage"
)

// ConnectivityResult captures the observed result of the connectivity test.
type ConnectivityResult struct {
// The result of the initial connect attempt
Connect ConnectResult
// Address of the connection that was selected
SelectedAddress string
// Observed error
Error *ConnectivityError
}

type ConnectResult struct {
// Address we dialed
DialedAddress string
// Address we selected
SelectedAddress string
// Lists each connection attempt
Attempts []ConnectionAttempt
// Observed error
Error *ConnectivityError
}

type ConnectionAttempt struct {
Address string
Error error
}

// ConnectivityError captures the observed error of the connectivity test.
type ConnectivityError struct {
// Which operation in the test that failed: "connect", "send" or "receive"
Expand Down Expand Up @@ -64,6 +92,99 @@ func makeConnectivityError(op string, err error) *ConnectivityError {
return &ConnectivityError{Op: op, PosixError: code, Err: err}
}

type WrapStreamDialer func(baseDialer transport.StreamDialer) (transport.StreamDialer, error)

// TestStreamConnectivityWithDNS tests weather we can get a response from a DNS resolver at resolverAddress over a stream connection. It sends testDomain as the query.
// It uses the baseDialer to create a first-hop connection to the proxy, and the wrap to apply the transport.
// The baseDialer is typically TCPDialer, but it can be replaced for remote measurements.
func TestStreamConnectivityWithDNS(ctx context.Context, baseDialer transport.StreamDialer, wrap WrapStreamDialer, resolverAddress string, testDomain string) (*ConnectivityResult, error) {
testResult := &ConnectivityResult{}
interceptDialer := transport.FuncStreamDialer(func(ctx context.Context, addr string) (transport.StreamConn, error) {
connectResult := &testResult.Connect
// Captures the address of the first hop, before resolution.
connectResult.DialedAddress = addr
connectResult.Attempts = make([]ConnectionAttempt, 0)
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ips, err := (&net.Resolver{PreferGo: false}).LookupHost(ctx, host)
var conn transport.StreamConn
for _, ip := range ips {
addr := net.JoinHostPort(ip, port)
attemptResult := ConnectionAttempt{Address: addr}
// TODO: This is slow. Race and overlap attempts instead.
deadline := time.Now().Add(5 * time.Second)
ipCtx, cancel := context.WithDeadline(ctx, deadline)
defer cancel()
conn, err = baseDialer.DialStream(ipCtx, addr)
if err != nil {
attemptResult.Error = err
}
connectResult.Attempts = append(connectResult.Attempts, attemptResult)
if err == nil {
connectResult.SelectedAddress = addr
break
}
}
return conn, err
})
dialer, err := wrap(interceptDialer)
if err != nil {
return nil, err
}
resolverConn, err := dialer.DialStream(ctx, resolverAddress)
if err != nil {
return nil, err
}
resolver := dns.NewTCPResolver(transport.FuncStreamDialer(func(ctx context.Context, addr string) (transport.StreamConn, error) {
return resolverConn, nil
}), resolverAddress)
testResult.Error, err = TestConnectivityWithResolver(ctx, resolver, testDomain)
if err != nil {
return nil, err
}
return testResult, nil
}

type WrapPacketDialer func(baseDialer transport.PacketDialer) (transport.PacketDialer, error)

// TestPacketConnectivityWithDNS tests weather we can get a response from a DNS resolver at resolverAddress over a packet connection. It sends testDomain as the query.
// It uses the baseDialer to create a first-hop connection to the proxy, and the wrap to apply the transport.
// The baseDialer is typically UDPDialer, but it can be replaced for remote measurements.
func TestPacketConnectivityWithDNS(ctx context.Context, baseDialer transport.PacketDialer, wrap WrapPacketDialer, resolverAddress string, testDomain string) (*ConnectivityResult, error) {
testResult := &ConnectivityResult{}
interceptDialer := transport.FuncPacketDialer(func(ctx context.Context, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ips, err := (&net.Resolver{PreferGo: false}).LookupHost(ctx, host)
var conn net.Conn
for _, ip := range ips {
addr := net.JoinHostPort(ip, port)
connResult := ConnectionResult{Address: addr}
conn, err = baseDialer.DialPacket(ctx, addr)
if err != nil {
connResult.Error = makeConnectivityError("connect", err)
}
testResult.Connections = append(testResult.Connections, connResult)
if err == nil {
testResult.SelectedAddress = addr
break
}
}
return conn, err
})
dialer, err := wrap(interceptDialer)
if err != nil {
return nil, err
}
resolver := dns.NewUDPResolver(dialer, resolverAddress)
testResult.Error, err = TestConnectivityWithResolver(ctx, resolver, testDomain)
return testResult, err
}

// TestConnectivityWithResolver tests weather we can get a response from the given [Resolver]. It can be used
// to test connectivity of its underlying [transport.StreamDialer] or [transport.PacketDialer].
// Invalid tests that cannot assert connectivity will return (nil, error).
Expand Down
77 changes: 59 additions & 18 deletions x/examples/test-connectivity/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import (
"strings"
"time"

"github.com/Jigsaw-Code/outline-sdk/dns"
"github.com/Jigsaw-Code/outline-sdk/transport"
"github.com/Jigsaw-Code/outline-sdk/x/config"
"github.com/Jigsaw-Code/outline-sdk/x/connectivity"
Expand All @@ -48,12 +47,26 @@ type connectivityReport struct {
// TODO(fortuna): add sanitized transport config.
Transport string `json:"transport"`

// The result for the connection.
Connect connectAttemptJSON `json:"connect"`
SelectedAddress *addressJSON `json:"selected_address,omitempty"`

// Observations
Time time.Time `json:"time"`
DurationMs int64 `json:"duration_ms"`
Error *errorJSON `json:"error"`
}

type connectAttemptJSON struct {
Address *addressJSON `json:"address,omitempty"`
Attempts []connectAttemptJSON `json:"attempts,omitempty"`
}

type connectionJSON struct {
Address *addressJSON `json:"address,omitempty"`
Error *errorJSON `json:"error"`
}

type errorJSON struct {
// TODO: add Shadowsocks/Transport error
Op string `json:"op,omitempty"`
Expand All @@ -63,6 +76,19 @@ type errorJSON struct {
Msg string `json:"msg,omitempty"`
}

type addressJSON struct {
Host string `json:"host"`
Port string `json:"port"`
}

func newAddressJSON(address string) (addressJSON, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return addressJSON{}, err
}
return addressJSON{host, port}, nil
}

func makeErrorRecord(result *connectivity.ConnectivityError) *errorJSON {
if result == nil {
return nil
Expand Down Expand Up @@ -168,46 +194,61 @@ func main() {
resolverAddress := net.JoinHostPort(resolverHost, "53")
for _, proto := range strings.Split(*protoFlag, ",") {
proto = strings.TrimSpace(proto)
var resolver dns.Resolver
var testResult *connectivity.ConnectivityResult
var testErr error
startTime := time.Now()
switch proto {
case "tcp":
streamDialer, err := configParser.WrapStreamDialer(&transport.TCPDialer{}, *transportFlag)
if err != nil {
log.Fatalf("Failed to create StreamDialer: %v", err)
wrap := func(baseDialer transport.StreamDialer) (transport.StreamDialer, error) {
return configParser.WrapStreamDialer(baseDialer, *transportFlag)
}
resolver = dns.NewTCPResolver(streamDialer, resolverAddress)
testResult, testErr = connectivity.TestStreamConnectivityWithDNS(context.Background(), &transport.TCPDialer{}, wrap, resolverAddress, *domainFlag)
case "udp":
packetDialer, err := configParser.WrapPacketDialer(&transport.UDPDialer{}, *transportFlag)
if err != nil {
log.Fatalf("Failed to create PacketDialer: %v", err)
wrap := func(baseDialer transport.PacketDialer) (transport.PacketDialer, error) {
return configParser.WrapPacketDialer(baseDialer, *transportFlag)
}
resolver = dns.NewUDPResolver(packetDialer, resolverAddress)
testResult, testErr = connectivity.TestPacketConnectivityWithDNS(context.Background(), &transport.UDPDialer{}, wrap, resolverAddress, *domainFlag)
default:
log.Fatalf(`Invalid proto %v. Must be "tcp" or "udp"`, proto)
}
startTime := time.Now()
result, err := connectivity.TestConnectivityWithResolver(context.Background(), resolver, *domainFlag)
if err != nil {
log.Fatalf("Connectivity test failed to run: %v", err)
if testErr != nil {
log.Fatalf("Connectivity test failed to run: %v", testErr)
}
testDuration := time.Since(startTime)
if result == nil {
if testResult.Error == nil {
success = true
}
debugLog.Printf("Test %v %v result: %v", proto, resolverAddress, result)
debugLog.Printf("Test %v %v result: %v", proto, resolverAddress, testResult)
sanitizedConfig, err := config.SanitizeConfig(*transportFlag)
if err != nil {
log.Fatalf("Failed to sanitize config: %v", err)
}
var r report.Report = connectivityReport{
r := connectivityReport{
Resolver: resolverAddress,
Proto: proto,
Time: startTime.UTC().Truncate(time.Second),
// TODO(fortuna): Add sanitized config:
Transport: sanitizedConfig,
DurationMs: testDuration.Milliseconds(),
Error: makeErrorRecord(result),
Error: makeErrorRecord(testResult.Error),
}
for _, cr := range testResult.Connections {
cj := connectionJSON{
Error: makeErrorRecord(cr.Error),
}
addressJSON, err := newAddressJSON(cr.Address)
if err == nil {
cj.Address = &addressJSON
}
r.Connections = append(r.Connections, cj)
}
if testResult.SelectedAddress != "" {
selectedAddressJSON, err := newAddressJSON(testResult.SelectedAddress)
if err == nil {
r.SelectedAddress = &selectedAddressJSON
}
}

if reportCollector != nil {
err = reportCollector.Collect(context.Background(), r)
if err != nil {
Expand Down
Loading