arblant
firefox-vpn-client / core / internal / netcheck / netcheck.go
netcheck.go139 lines · 3.8 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
// Package netcheck tells whether this device reaches the internet at all,
// outside the tunnel, so a failed connection to Mozilla can be told apart
// from a device without internet: a network that filters Mozilla answers
// these checks, one without internet does not.
//
// It asks the connectivity checks of Google, Microsoft, and Apple, which
// operating systems use for the same question, and Baidu, which answers in
// China where Google's does not, all at once over plain HTTP; any expected
// answer means online. A captive portal's redirect or page is not one. Each
// call checks anew: the network may have changed since the last one.
package netcheck

import (
	"bytes"
	"context"
	"io"
	"net"
	"net/http"
	"sync"
	"time"

	"github.com/armanlife/firefox-vpn-client/core/internal/egress"
)

// Result is what a check found.
type Result int

const (
	// Unknown: no check was made (tests, or a check replaced).
	Unknown Result = iota
	// Online: at least one check site answered as expected.
	Online
	// Offline: none did within the timeout.
	Offline
)

// Timeout bounds one check.
const Timeout = 5 * time.Second

// site is a check URL and the answer it gives on a working internet.
type site struct {
	url string
	ok  func(status int, body []byte) bool
}

var sites = []site{
	{"http://connectivitycheck.gstatic.com/generate_204", func(status int, _ []byte) bool { return status == http.StatusNoContent }},
	{"http://www.msftconnecttest.com/connecttest.txt", func(status int, body []byte) bool {
		return status == http.StatusOK && bytes.HasPrefix(body, []byte("Microsoft Connect Test"))
	}},
	{"http://captive.apple.com/hotspot-detect.html", func(status int, body []byte) bool {
		return status == http.StatusOK && bytes.Contains(body, []byte("Success"))
	}},
	{"http://www.baidu.com/", func(status int, body []byte) bool {
		return status == http.StatusOK && bytes.Contains(body, []byte("baidu"))
	}},
}

// Check reports whether the internet is reachable; tests replace it.
var Check = check

var (
	mu      sync.Mutex
	running chan struct{}
	last    Result
)

// check runs one check; callers that arrive while one runs share its
// result.
func check(ctx context.Context) Result {
	mu.Lock()
	if running != nil {
		wait := running
		mu.Unlock()
		select {
		case <-wait:
		case <-ctx.Done():
			return Unknown
		}
		mu.Lock()
		defer mu.Unlock()
		return last
	}
	done := make(chan struct{})
	running = done
	mu.Unlock()

	result := probe(ctx, sites)

	mu.Lock()
	last, running = result, nil
	mu.Unlock()
	close(done)
	return result
}

// probe asks every site at once, directly (never through a proxy), and
// returns Online at the first expected answer.
func probe(ctx context.Context, list []site) Result {
	ctx, cancel := context.WithTimeout(ctx, Timeout)
	defer cancel()
	client := &http.Client{
		Transport: &http.Transport{
			Proxy: nil,
			// Outside the TUN adapter while the TUN mode runs: the check is
			// about the network itself.
			DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
				return egress.Dialer(Timeout).DialContext(ctx, network, address)
			},
			DisableKeepAlives: true,
		},
		// A redirect is a captive portal's, not the expected answer.
		CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
	}
	answers := make(chan bool, len(list))
	for _, s := range list {
		go func(s site) {
			req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.url, nil)
			if err != nil {
				answers <- false
				return
			}
			resp, err := client.Do(req)
			if err != nil {
				answers <- false
				return
			}
			defer resp.Body.Close()
			body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
			answers <- s.ok(resp.StatusCode, body)
		}(s)
	}
	for range list {
		if <-answers {
			return Online
		}
	}
	return Offline
}