arblant
firefox-vpn-client / core / internal / proxy / proxypass.go
proxypass.go175 lines · 6.3 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 162 163 164 165 166 167 168 169 170 171 172 173 174 175
package proxy

import (
	"context"
	"errors"
	"fmt"
	"strconv"
	"time"

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

const (
	// terminalRenewalRetryDelay spaces renewal attempts after a failure that
	// retrying cannot fix soon (sign-in required, region refused, no
	// entitlement, spent quota without a known reset time).
	terminalRenewalRetryDelay = 15 * time.Minute

	// maxQuotaResetWait caps how long renewal waits for a known quota reset.
	maxQuotaResetWait = 6 * time.Hour
)

var (
	// GuardianRetryInitialDelay and guardianRetryBudget bound the retries
	// of a transient ProxyPass fetch failure, matching Firefox (500 ms
	// doubling backoff inside a 30 s budget). Tests may shorten them.
	GuardianRetryInitialDelay = 500 * time.Millisecond
	guardianRetryBudget       = 30 * time.Second
)

// fetchProxyPassCtx fetches a ProxyPass, retrying transient failures
// (transport errors and HTTP 5xx) with exponential backoff inside
// guardianRetryBudget, like Firefox's pass rotation. A Retry-After that does
// not fit the remaining budget ends the retries. Every other answer is
// returned at once.
func fetchProxyPassCtx(ctx context.Context, endpoint, accessToken string) (*guardian.ProxyPassInfo, error) {
	budgetCtx, cancelBudget := context.WithTimeout(ctx, guardianRetryBudget)
	defer cancelBudget()
	delay := GuardianRetryInitialDelay
	for attempt := 1; ; attempt++ {
		attemptCtx, cancel := context.WithTimeout(budgetCtx, APIRequestTimeout)
		pass, err := guardian.FetchProxyPass(attemptCtx, endpoint, accessToken)
		cancel()
		if err == nil || !guardian.IsTransient(err) || ctx.Err() != nil {
			return pass, err
		}
		wait := delay
		if retryAfter := guardian.RetryAfter(err); retryAfter > wait {
			wait = retryAfter
		}
		if deadline, ok := budgetCtx.Deadline(); ok && time.Until(deadline) < wait {
			return nil, err
		}
		logx.Warn("guardian request failed; retrying attempt=%d delay=%s err=%s", attempt, wait, logx.Err(err))
		timer := time.NewTimer(wait)
		select {
		case <-timer.C:
		case <-budgetCtx.Done():
			timer.Stop()
			return nil, err
		}
		delay *= 2
	}
}

// GuardianFailureHint explains Guardian failures that retrying cannot fix.
func GuardianFailureHint(err error) string {
	switch {
	case errors.Is(err, guardian.ErrRegionUnavailable):
		return "Guardian refuses requests from this region (HTTP 451); set network.api_relay to your own relay in a supported region (scripts/relay)"
	case errors.Is(err, fxa.ErrReauthRequired):
		return "the saved Firefox Account credential was rejected; sign the account in again from the dashboard"
	case errors.Is(err, guardian.ErrQuotaExceeded):
		return "the account's monthly proxy quota is exhausted; wait for the quota reset"
	case errors.Is(err, guardian.ErrNotEntitled):
		return "the account is not entitled to the Firefox VPN proxy and activation did not grant access"
	}
	return ""
}

// isQuotaExhausted reports whether the proxy pass quota headers indicate
// that no monthly traffic is left. An unlimited account is never exhausted.
func isQuotaExhausted(pass *guardian.ProxyPassInfo) bool {
	if pass == nil {
		return false
	}
	if pass.Usage != nil {
		return pass.Usage.Exhausted()
	}
	if pass.QuotaLeft == "" {
		return false
	}
	left, err := strconv.ParseInt(pass.QuotaLeft, 10, 64)
	if err != nil {
		return false
	}
	return left <= 0
}

// recoverGuardianRejection applies Guardian's meaning of a rejected pass
// request, as Firefox's GuardianClient does: HTTP 401 means the OAuth access
// token was refused, so it is refreshed; HTTP 403 means the account lacks
// the proxy entitlement, so it is activated. It returns the auth now in use
// and the outcome of the last pass request. onRefresh, when set, receives a
// refreshed auth as soon as it exists.
func recoverGuardianRejection(ctx context.Context, endpoint string, auth *runtimeAuth, fetchErr error, onRefresh func(*runtimeAuth)) (*runtimeAuth, *guardian.ProxyPassInfo, error) {
	var pass *guardian.ProxyPassInfo
	if !errors.Is(fetchErr, guardian.ErrNotEntitled) {
		logx.Info("OAuth access was rejected by Guardian; refreshing token")
		refreshed, refreshErr := refreshRuntimeAuth(ctx, auth)
		if refreshErr != nil {
			logx.Warn("refreshing OAuth token after Guardian rejection failed: %s", logx.Err(refreshErr))
			if errors.Is(refreshErr, fxa.ErrReauthRequired) {
				return auth, nil, refreshErr
			}
			return auth, nil, fetchErr
		}
		auth = refreshed
		if onRefresh != nil {
			onRefresh(refreshed)
		}
		pass, fetchErr = fetchProxyPassCtx(ctx, endpoint, auth.Token.AccessToken)
		if !errors.Is(fetchErr, guardian.ErrNotEntitled) {
			return auth, pass, fetchErr
		}
	}

	logx.Info("Guardian reports no proxy entitlement for this account; activating")
	activateCtx, cancel := context.WithTimeout(ctx, APIRequestTimeout)
	_, activateErr := guardian.Activate(activateCtx, endpoint, auth.Token.AccessToken)
	cancel()
	if activateErr != nil {
		logx.Warn("activating Guardian account failed: %s", logx.Err(activateErr))
		if errors.Is(activateErr, guardian.ErrRegionUnavailable) {
			return auth, nil, activateErr
		}
		return auth, nil, fetchErr
	}
	logx.Info("Guardian account activated")
	pass, fetchErr = fetchProxyPassCtx(ctx, endpoint, auth.Token.AccessToken)
	return auth, pass, fetchErr
}

// passQuotaError reports a pass whose quota headers show no traffic left.
type passQuotaError struct {
	usage *guardian.ProxyUsage
}

func (e *passQuotaError) Error() string {
	if e.usage != nil && !e.usage.Reset.IsZero() {
		return fmt.Sprintf("%s: no quota remaining until %s", guardian.ErrQuotaExceeded, e.usage.Reset.Format(time.RFC3339))
	}
	return fmt.Sprintf("%s: no quota remaining", guardian.ErrQuotaExceeded)
}

func (e *passQuotaError) Unwrap() error { return guardian.ErrQuotaExceeded }

// quotaUsageFromError returns the quota state carried by a Guardian or
// exhausted-pass error, or nil.
func quotaUsageFromError(err error) *guardian.ProxyUsage {
	var quotaErr *passQuotaError
	if errors.As(err, &quotaErr) {
		return quotaErr.usage
	}
	return guardian.UsageFromError(err)
}

func LogPassTimeCorrection(pass *guardian.ProxyPassInfo) {
	if pass == nil || pass.ClaimTimeCorrection() == 0 {
		return
	}
	logx.Warn("proxy pass JWT claim time correction applied offset=%s expires_at=%s", pass.ClaimTimeCorrection(), pass.ExpiresAt().Format(time.RFC3339))
}