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, "aErr) {
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))
}