arblant
firefox-vpn-client / core / internal / proxy / conntest.go
conntest.go161 lines · 5.4 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161
package proxy

import (
	"context"
	"crypto/tls"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/http/httptrace"
	"sync"
	"time"
)

const (
	// ConnectionTestURL answers 204 with no body; requesting it through the
	// tunnel measures the full data path without downloading anything.
	ConnectionTestURL     = "https://www.google.com/generate_204"
	ConnectionTestTimeout = 15 * time.Second
)

// ConnectionTestResult is the outcome of a test request sent through the
// tunnel. ICMP ping cannot cross the CONNECT proxy, so the test measures
// the stages of an HTTPS request instead.
type ConnectionTestResult struct {
	At    time.Time `json:"at"`
	OK    bool      `json:"ok"`
	Error string    `json:"error,omitempty"`
	// ErrorCode is the Failure code of Error.
	ErrorCode  string `json:"error_code,omitempty"`
	Target     string `json:"target"`
	HTTPStatus int    `json:"http_status,omitempty"`
	// The stages follow each other: opening the tunnel, the TLS handshake,
	// then waiting for the response (ResponseMS). FirstByteMS counts from
	// the start, so it includes the stages before it.
	TunnelMS    int64  `json:"tunnel_ms"`
	TLSMS       int64  `json:"tls_ms"`
	ResponseMS  int64  `json:"response_ms"`
	FirstByteMS int64  `json:"first_byte_ms"`
	TotalMS     int64  `json:"total_ms"`
	ExitIPv4    string `json:"exit_ipv4,omitempty"`
	ExitIPv6    string `json:"exit_ipv6,omitempty"`
	ExitCountry string `json:"exit_country,omitempty"`
	ExitCity    string `json:"exit_city,omitempty"`
	ExitNetwork string `json:"exit_network,omitempty"`
	ExitError   string `json:"exit_error,omitempty"`
}

// Exit returns the public IP part of the result.
func (r ConnectionTestResult) Exit() ExitProbeResult {
	return ExitProbeResult{IPv4: r.ExitIPv4, IPv6: r.ExitIPv6, CountryCode: r.ExitCountry, City: r.ExitCity, Network: r.ExitNetwork}
}

// RunConnectionTest requests ConnectionTestURL through opener and then
// finds the public IP and its place, waiting exitTimeout for each service
// (0 skips that check).
func RunConnectionTest(opener tunnelOpener, target string, exitTimeout, timeout time.Duration) ConnectionTestResult {
	result := ConnectionTestResult{At: time.Now(), Target: target}
	var tunnelOpened, tlsStart, tlsDone, firstByte time.Time
	// tunnelUp is set once Mozilla opened the tunnel to the test site; a
	// dial still running after a timeout may set it late.
	var tunnelMu sync.Mutex
	tunnelUp := false

	transport := &http.Transport{
		Proxy:               nil,
		DisableKeepAlives:   true,
		TLSHandshakeTimeout: timeout,
		TLSClientConfig:     &tls.Config{MinVersion: tls.VersionTLS12},
		DialContext: func(ctx context.Context, _, authority string) (net.Conn, error) {
			conn, err := openTunnelContext(ctx, opener, authority)
			tunnelOpened = time.Now()
			if err == nil {
				tunnelMu.Lock()
				tunnelUp = true
				tunnelMu.Unlock()
			}
			return conn, err
		},
	}
	defer transport.CloseIdleConnections()
	client := &http.Client{Transport: transport, Timeout: timeout}

	trace := &httptrace.ClientTrace{
		TLSHandshakeStart:    func() { tlsStart = time.Now() },
		TLSHandshakeDone:     func(tls.ConnectionState, error) { tlsDone = time.Now() },
		GotFirstResponseByte: func() { firstByte = time.Now() },
	}
	ctx := httptrace.WithClientTrace(context.Background(), trace)
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
	if err != nil {
		result.Error, result.ErrorCode = err.Error(), FailureOther
		return result
	}
	req.Header.Set("User-Agent", "firefox-vpn-connection-test/1")

	started := time.Now()
	resp, err := client.Do(req)
	if err == nil {
		_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64*1024))
		_ = resp.Body.Close()
		result.HTTPStatus = resp.StatusCode
	}
	result.TotalMS = time.Since(started).Milliseconds()
	result.TunnelMS = elapsedMS(started, tunnelOpened)
	result.TLSMS = elapsedMS(tlsStart, tlsDone)
	result.FirstByteMS = elapsedMS(started, firstByte)
	sent := tlsDone
	if sent.IsZero() {
		sent = tunnelOpened
	}
	result.ResponseMS = elapsedMS(sent, firstByte)
	tunnelMu.Lock()
	up := tunnelUp
	tunnelMu.Unlock()
	switch {
	case err != nil && up:
		// Mozilla connected; the test site, or the way to it from
		// Mozilla's exit, failed.
		result.Error, result.ErrorCode = err.Error(), FailureTestSite
	case err != nil:
		result.Error, result.ErrorCode = err.Error(), FailureCode(err)
	case resp.StatusCode >= 400:
		result.Error = fmt.Sprintf("the test site answered HTTP %d", resp.StatusCode)
		result.ErrorCode = FailureTestSite
	default:
		result.OK = true
	}

	if exitTimeout > 0 {
		exit, err := VerifyExit(opener, exitTimeout)
		if err != nil {
			result.ExitError = err.Error()
		} else {
			result.ExitIPv4, result.ExitIPv6 = exit.IPv4, exit.IPv6
			result.ExitCountry, result.ExitCity, result.ExitNetwork = exit.CountryCode, exit.City, exit.Network
		}
	}
	return result
}

// HTTPClient sends the program's own requests, such as list downloads,
// through the proxy's tunnels.
func (c *Controller) HTTPClient(timeout time.Duration) *http.Client {
	transport := &http.Transport{
		Proxy:               nil,
		TLSHandshakeTimeout: timeout,
		TLSClientConfig:     &tls.Config{MinVersion: tls.VersionTLS12},
		DialContext: func(ctx context.Context, _, authority string) (net.Conn, error) {
			return openTunnelContext(ctx, c, authority)
		},
	}
	return &http.Client{Transport: transport, Timeout: timeout}
}

func elapsedMS(from, to time.Time) int64 {
	if from.IsZero() || to.IsZero() || to.Before(from) {
		return 0
	}
	return to.Sub(from).Milliseconds()
}