arblant
firefox-vpn-client / core / internal / service / ping.go
ping.go130 lines · 4.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
package service

import (
	"context"
	"errors"
	"time"

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

const (
	// pingReuseAge is how long a measurement is reused when the "fastest"
	// location is chosen at an engine start.
	pingReuseAge = 30 * time.Minute
)

// errPingNeedsProxy reports a latency measurement without a running proxy.
var errPingNeedsProxy = errors.New("start the proxy first: latency is measured through the tunnel with the active account's proxy pass")

// PingAll measures the current server list through the running proxy's
// pass and keeps the result for the dashboard and the terminal screen.
// codes limits the measurement to those country codes; empty measures
// every location. Without a running proxy (the phone app turned it off)
// it measures with a pass of its own (offPass).
func (d *Daemon) PingAll(ctx context.Context, codes []string) ([]proxy.LocationPing, time.Time, error) {
	engine := d.currentEngine()
	if engine == nil {
		token, err := d.offPass(ctx)
		if err != nil {
			return nil, time.Time{}, err
		}
		results, at := d.measureLocations(ctx, token, codes)
		return results, at, nil
	}
	// A real-delay test wakes a sleeping proxy and keeps it awake.
	controller, release, err := d.useController(engine, wakeForPing)
	if err != nil {
		return nil, time.Time{}, err
	}
	defer release()
	token := controller.CurrentToken()
	if token == "" {
		return nil, time.Time{}, errPingNeedsProxy
	}
	results, at := d.measureLocations(ctx, token, codes)
	return results, at, nil
}

// offPassMargin is how long before its expiry a kept pass is replaced.
const offPassMargin = 2 * time.Minute

// offPass returns a pass for measurements while the proxy is off: the kept
// one while it is valid, else a new one from the account pool.
func (d *Daemon) offPass(ctx context.Context) (string, error) {
	d.pingMu.Lock()
	defer d.pingMu.Unlock()
	if p := d.pingPass; p != nil && time.Until(p.ExpiresAt()) > offPassMargin {
		return p.RawToken, nil
	}
	if d.mgr.Len() == 0 {
		return "", errNoAccounts
	}
	ctx, cancel := context.WithTimeout(ctx, proxy.PassRenewTimeout)
	defer cancel()
	_, pass, err := d.pool.AcquirePass(ctx, d.config().Network.GuardianEndpoint)
	if err != nil {
		return "", err
	}
	d.pingPass = pass
	return pass.RawToken, nil
}

// measureLocations measures the given locations (all when codes is
// empty) and merges the results into the kept measurement, so testing one
// tab keeps the other locations' earlier values.
func (d *Daemon) measureLocations(ctx context.Context, token string, codes []string) ([]proxy.LocationPing, time.Time) {
	d.pingMu.Lock()
	defer d.pingMu.Unlock()
	d.mu.Lock()
	countries := d.countries
	d.mu.Unlock()
	if len(countries) == 0 {
		countries = d.refreshLocations(d.config())
	}
	targets := proxy.FilterTargets(proxy.PingTargets(countries), codes)
	logx.Info("testing the real delay through the tunnel locations=%d target=%s", len(targets), proxy.PingTarget)
	results := proxy.PingLocations(ctx, targets, proxy.TunnelMeter(token))
	now := time.Now()
	d.mu.Lock()
	d.pings, d.pingedAt = proxy.MergePings(d.pings, results), now
	d.mu.Unlock()
	reachable := 0
	for _, r := range results {
		if r.OK() {
			reachable++
		}
	}
	if reachable > 0 {
		logx.Info("location latency measured locations=%d reachable=%d fastest=%s ping_ms=%d", len(results), reachable, results[0].Code, results[0].PingMS)
	} else {
		logx.Warn("location latency measured; no location answered locations=%d", len(results))
	}
	return results, now
}

// lastPings returns the most recent measurement, if any.
func (d *Daemon) lastPings() ([]proxy.LocationPing, time.Time) {
	d.mu.Lock()
	defer d.mu.Unlock()
	return append([]proxy.LocationPing(nil), d.pings...), d.pingedAt
}

// resolveFastestCountry returns the country code to use for
// proxy.country = "fastest": from a recent measurement, or measured now
// with token. It returns "" (the recommended location) when nothing could
// be measured.
func (d *Daemon) resolveFastestCountry(token string) string {
	results, at := d.lastPings()
	if time.Since(at) > pingReuseAge {
		results, _ = d.measureLocations(context.Background(), token, nil)
	}
	best, ok := proxy.FastestLocation(results)
	if !ok {
		logx.Warn("no location answered the latency check; using the recommended location")
		return ""
	}
	logx.Info("fastest location selected country_code=%s ping_ms=%d", best.Code, best.PingMS)
	return best.Code
}