arblant
firefox-vpn-client / core / internal / guardian / guardian.go
guardian.go204 lines · 6.1 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 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204
// Package guardian is the client for Mozilla's Guardian API: it issues
// the ProxyPass bearer token for the Fastly CONNECT proxies, reports quota
// usage, and activates or reports the account's proxy entitlement.
package guardian

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"strings"
	"time"

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

// DefaultEndpoint is the production Guardian endpoint, matching Firefox's
// browser.ipProtection.guardian.endpoint preference.
const DefaultEndpoint = "https://vpn.mozilla.org"

type Entitlement struct {
	Subscribed       bool   `json:"subscribed"`
	UID              int    `json:"uid"`
	MaxBytes         string `json:"maxBytes"`
	LimitedBandwidth bool   `json:"limited_bandwidth"`
}

type proxyPassResponse struct {
	Token string `json:"token"`
}

// applyGuardianHeaders sets the request shape Firefox uses for every
// Guardian call: bearer auth, JSON content type, and fetch's "no-cache"
// mode, which Firefox sends as Cache-Control: max-age=0 (no Pragma).
func applyGuardianHeaders(req *http.Request, accessToken string) {
	req.Header.Set("Authorization", "Bearer "+accessToken)
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Cache-Control", "max-age=0")
	controlplane.ApplyFirefoxHeaders(req)
}

func guardianURL(endpoint, path string) string {
	if endpoint == "" {
		endpoint = DefaultEndpoint
	}
	return strings.TrimRight(endpoint, "/") + path
}

func fetchProxyPass(ctx context.Context, endpoint, accessToken string) (*ProxyPassInfo, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, guardianURL(endpoint, "/api/v1/fpn/token"), nil)
	if err != nil {
		return nil, err
	}
	applyGuardianHeaders(req, accessToken)

	resp, err := controlplane.Do(req)
	if err != nil {
		return nil, fmt.Errorf("guardian request failed: %w", err)
	}
	defer resp.Body.Close()
	receivedAt := time.Now()

	if resp.StatusCode != http.StatusOK {
		return nil, newHTTPError("guardian", resp)
	}

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("reading guardian response: %w", err)
	}

	var passResp proxyPassResponse
	if err := json.Unmarshal(body, &passResp); err != nil {
		return nil, &proxyPassFormatError{fmt.Errorf("parsing proxy pass response: %w", err)}
	}
	if passResp.Token == "" {
		return nil, &proxyPassFormatError{fmt.Errorf("empty token in guardian response")}
	}

	claims, err := parseJWTClaims(passResp.Token)
	if err != nil {
		return nil, &proxyPassFormatError{fmt.Errorf("parsing JWT claims: %w", err)}
	}
	offset, err := anchorProxyPassClaims(*claims, receivedAt)
	if err != nil {
		return nil, &proxyPassFormatError{err}
	}

	usage, usageErr := parseProxyUsage(resp.Header)
	info := &ProxyPassInfo{
		RawToken:        passResp.Token,
		Claims:          *claims,
		QuotaMax:        resp.Header.Get("X-Quota-Limit"),
		QuotaLeft:       resp.Header.Get("X-Quota-Remaining"),
		QuotaReset:      resp.Header.Get("X-Quota-Reset"),
		Usage:           usage,
		UsageErr:        usageErr,
		ReceivedAt:      receivedAt,
		claimTimeOffset: offset,
	}
	return info, nil
}

// fetchProxyUsage reads the quota headers with a HEAD request to the token
// endpoint, as Firefox does, without minting a new pass.
func fetchProxyUsage(ctx context.Context, endpoint, accessToken string) (*ProxyUsage, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodHead, guardianURL(endpoint, "/api/v1/fpn/token"), nil)
	if err != nil {
		return nil, err
	}
	applyGuardianHeaders(req, accessToken)

	resp, err := controlplane.Do(req)
	if err != nil {
		return nil, fmt.Errorf("guardian usage request failed: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		httpErr := newHTTPError("guardian usage", resp)
		if resp.StatusCode == http.StatusTooManyRequests && httpErr.Usage != nil {
			// A spent quota still reports valid usage headers.
			return httpErr.Usage, nil
		}
		return nil, httpErr
	}
	return parseProxyUsage(resp.Header)
}

func fetchUserInfo(ctx context.Context, endpoint, accessToken string) (*Entitlement, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, guardianURL(endpoint, "/api/v1/fpn/status"), nil)
	if err != nil {
		return nil, err
	}
	applyGuardianHeaders(req, accessToken)

	resp, err := controlplane.Do(req)
	if err != nil {
		return nil, fmt.Errorf("guardian user info request failed: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, newHTTPError("guardian user info", resp)
	}

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}

	var ent Entitlement
	if err := json.Unmarshal(body, &ent); err != nil {
		return nil, fmt.Errorf("parsing entitlement: %w", err)
	}
	return &ent, nil
}

func activateGuardian(ctx context.Context, endpoint, accessToken string) (*Entitlement, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, guardianURL(endpoint, "/api/v1/fpn/activate"), nil)
	if err != nil {
		return nil, err
	}
	applyGuardianHeaders(req, accessToken)

	resp, err := controlplane.Do(req)
	if err != nil {
		return nil, fmt.Errorf("guardian activate request failed: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, newHTTPError("guardian activate", resp)
	}

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}

	var ent Entitlement
	if err := json.Unmarshal(body, &ent); err != nil {
		return nil, fmt.Errorf("parsing activation entitlement: %w", err)
	}
	return &ent, nil
}

func FetchProxyPass(ctx context.Context, endpoint, accessToken string) (*ProxyPassInfo, error) {
	return fetchProxyPass(ctx, endpoint, accessToken)
}

// FetchProxyUsage returns the account's quota state without minting a pass.
func FetchProxyUsage(ctx context.Context, endpoint, accessToken string) (*ProxyUsage, error) {
	return fetchProxyUsage(ctx, endpoint, accessToken)
}

func FetchUserInfo(ctx context.Context, endpoint, accessToken string) (*Entitlement, error) {
	return fetchUserInfo(ctx, endpoint, accessToken)
}

func Activate(ctx context.Context, endpoint, accessToken string) (*Entitlement, error) {
	return activateGuardian(ctx, endpoint, accessToken)
}