- `-proton-username` flag for cli update - `-proton-password` flag for cli update - `UPDATER_PROTONVPN_USERNAME` option for periodic updates - `UPDATER_PROTONVPN_PASSWORD` option for periodic updates
642 lines
21 KiB
Go
642 lines
21 KiB
Go
package updater
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
crand "crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math/rand/v2"
|
|
"net/http"
|
|
"net/netip"
|
|
"slices"
|
|
"strings"
|
|
|
|
srp "github.com/ProtonMail/go-srp"
|
|
)
|
|
|
|
// apiClient is a minimal Proton v4 API client which can handle all the
|
|
// oddities of Proton's authentication flow they want to keep hidden
|
|
// from the public.
|
|
type apiClient struct {
|
|
apiURLBase string
|
|
httpClient *http.Client
|
|
appVersion string
|
|
userAgent string
|
|
generator *rand.ChaCha8
|
|
}
|
|
|
|
// newAPIClient returns an [apiClient] with sane defaults matching Proton's
|
|
// insane expectations.
|
|
func newAPIClient(ctx context.Context, httpClient *http.Client) (client *apiClient, err error) {
|
|
var seed [32]byte
|
|
_, _ = crand.Read(seed[:])
|
|
generator := rand.NewChaCha8(seed)
|
|
|
|
// Pick a random user agent from this list. Because I'm not going to tell
|
|
// Proton shit on where all these funny requests are coming from, given their
|
|
// unhelpfulness in figuring out their authentication flow.
|
|
userAgents := [...]string{
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:143.0) Gecko/20100101 Firefox/143.0",
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:143.0) Gecko/20100101 Firefox/143.0",
|
|
"Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Gecko/20100101 Firefox/143.0",
|
|
}
|
|
userAgent := userAgents[generator.Uint64()%uint64(len(userAgents))]
|
|
|
|
appVersion, err := getMostRecentStableTag(ctx, httpClient)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting most recent version for proton app: %w", err)
|
|
}
|
|
|
|
return &apiClient{
|
|
apiURLBase: "https://account.proton.me/api",
|
|
httpClient: httpClient,
|
|
appVersion: appVersion,
|
|
userAgent: userAgent,
|
|
generator: generator,
|
|
}, nil
|
|
}
|
|
|
|
var ErrCodeNotSuccess = errors.New("response code is not success")
|
|
|
|
// setHeaders sets the minimal necessary headers for Proton API requests
|
|
// to succeed without being blocked by their "security" measures.
|
|
// See for example [getMostRecentStableTag] on how the app version must
|
|
// be set to a recent version or they block your request. "SeCuRiTy"...
|
|
func (c *apiClient) setHeaders(request *http.Request, cookie cookie) {
|
|
request.Header.Set("Cookie", cookie.String())
|
|
request.Header.Set("User-Agent", c.userAgent)
|
|
request.Header.Set("x-pm-appversion", c.appVersion)
|
|
request.Header.Set("x-pm-locale", "en_US")
|
|
request.Header.Set("x-pm-uid", cookie.uid)
|
|
}
|
|
|
|
// authenticate performs the full Proton authentication flow
|
|
// to obtain an authenticated cookie (uid, token and session ID).
|
|
func (c *apiClient) authenticate(ctx context.Context, username, password string,
|
|
) (authCookie cookie, err error) {
|
|
sessionID, err := c.getSessionID(ctx)
|
|
if err != nil {
|
|
return cookie{}, fmt.Errorf("getting session ID: %w", err)
|
|
}
|
|
|
|
tokenType, accessToken, refreshToken, uid, err := c.getUnauthSession(ctx, sessionID)
|
|
if err != nil {
|
|
return cookie{}, fmt.Errorf("getting unauthenticated session data: %w", err)
|
|
}
|
|
|
|
cookieToken, err := c.cookieToken(ctx, sessionID, tokenType, accessToken, refreshToken, uid)
|
|
if err != nil {
|
|
return cookie{}, fmt.Errorf("getting cookie token: %w", err)
|
|
}
|
|
|
|
unauthCookie := cookie{
|
|
uid: uid,
|
|
token: cookieToken,
|
|
sessionID: sessionID,
|
|
}
|
|
modulusPGPClearSigned, serverEphemeralBase64, saltBase64,
|
|
srpSessionHex, version, err := c.authInfo(ctx, username, unauthCookie)
|
|
if err != nil {
|
|
return cookie{}, fmt.Errorf("getting auth information: %w", err)
|
|
}
|
|
|
|
// Prepare SRP proof generator using Proton's official SRP parameters and hashing.
|
|
srpAuth, err := srp.NewAuth(version, username, []byte(password),
|
|
saltBase64, modulusPGPClearSigned, serverEphemeralBase64)
|
|
if err != nil {
|
|
return cookie{}, fmt.Errorf("initializing SRP auth: %w", err)
|
|
}
|
|
|
|
// Generate SRP proofs (A, M1) with the usual 2048-bit modulus.
|
|
const modulusBits = 2048
|
|
proofs, err := srpAuth.GenerateProofs(modulusBits)
|
|
if err != nil {
|
|
return cookie{}, fmt.Errorf("generating SRP proofs: %w", err)
|
|
}
|
|
|
|
authCookie, err = c.auth(ctx, unauthCookie, username, srpSessionHex, proofs)
|
|
if err != nil {
|
|
return cookie{}, fmt.Errorf("authentifying: %w", err)
|
|
}
|
|
|
|
return authCookie, nil
|
|
}
|
|
|
|
var ErrSessionIDNotFound = errors.New("session ID not found in cookies")
|
|
|
|
func (c *apiClient) getSessionID(ctx context.Context) (sessionID string, err error) {
|
|
const url = "https://account.proton.me/vpn"
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("creating request: %w", err)
|
|
}
|
|
|
|
response, err := c.httpClient.Do(request)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
err = response.Body.Close()
|
|
if err != nil {
|
|
return "", fmt.Errorf("closing response body: %w", err)
|
|
}
|
|
|
|
for _, cookie := range response.Cookies() {
|
|
if cookie.Name == "Session-Id" {
|
|
return cookie.Value, nil
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("%w", ErrSessionIDNotFound)
|
|
}
|
|
|
|
var ErrDataFieldMissing = errors.New("data field missing in response")
|
|
|
|
func (c *apiClient) getUnauthSession(ctx context.Context, sessionID string) (
|
|
tokenType, accessToken, refreshToken, uid string, err error,
|
|
) {
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURLBase+"/auth/v4/sessions", nil)
|
|
if err != nil {
|
|
return "", "", "", "", fmt.Errorf("creating request: %w", err)
|
|
}
|
|
unauthCookie := cookie{
|
|
sessionID: sessionID,
|
|
}
|
|
c.setHeaders(request, unauthCookie)
|
|
|
|
response, err := c.httpClient.Do(request)
|
|
if err != nil {
|
|
return "", "", "", "", err
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
responseBody, err := io.ReadAll(response.Body)
|
|
if err != nil {
|
|
return "", "", "", "", fmt.Errorf("reading response body: %w", err)
|
|
} else if response.StatusCode != http.StatusOK {
|
|
return "", "", "", "", buildError(response.StatusCode, responseBody)
|
|
}
|
|
|
|
var data struct {
|
|
Code uint `json:"Code"` // 1000 on success
|
|
AccessToken string `json:"AccessToken"` // 32-chars lowercase and digits
|
|
RefreshToken string `json:"RefreshToken"` // 32-chars lowercase and digits
|
|
TokenType string `json:"TokenType"` // "Bearer"
|
|
Scopes []string `json:"Scopes"` // should be [] for our usage
|
|
UID string `json:"UID"` // 32-chars lowercase and digits
|
|
LocalID uint `json:"LocalID"` // 0 in my case
|
|
}
|
|
|
|
err = json.Unmarshal(responseBody, &data)
|
|
if err != nil {
|
|
return "", "", "", "", fmt.Errorf("decoding response body: %w", err)
|
|
}
|
|
|
|
const successCode = 1000
|
|
switch {
|
|
case data.Code != successCode:
|
|
return "", "", "", "", fmt.Errorf("%w: expected %d got %d",
|
|
ErrCodeNotSuccess, successCode, data.Code)
|
|
case data.AccessToken == "":
|
|
return "", "", "", "", fmt.Errorf("%w: access token is empty", ErrDataFieldMissing)
|
|
case data.RefreshToken == "":
|
|
return "", "", "", "", fmt.Errorf("%w: refresh token is empty", ErrDataFieldMissing)
|
|
case data.TokenType == "":
|
|
return "", "", "", "", fmt.Errorf("%w: token type is empty", ErrDataFieldMissing)
|
|
case data.UID == "":
|
|
return "", "", "", "", fmt.Errorf("%w: UID is empty", ErrDataFieldMissing)
|
|
}
|
|
// Ignore Scopes and LocalID fields, we don't use them.
|
|
|
|
return data.TokenType, data.AccessToken, data.RefreshToken, data.UID, nil
|
|
}
|
|
|
|
var ErrUIDMismatch = errors.New("UID in response does not match request UID")
|
|
|
|
func (c *apiClient) cookieToken(ctx context.Context, sessionID, tokenType, accessToken,
|
|
refreshToken, uid string,
|
|
) (cookieToken string, err error) {
|
|
type requestBodySchema struct {
|
|
GrantType string `json:"GrantType"` // "refresh_token"
|
|
Persistent uint `json:"Persistent"` // 0
|
|
RedirectURI string `json:"RedirectURI"` // "https://protonmail.com"
|
|
RefreshToken string `json:"RefreshToken"` // 32-chars lowercase and digits
|
|
ResponseType string `json:"ResponseType"` // "token"
|
|
State string `json:"State"` // 24-chars letters and digits
|
|
UID string `json:"UID"` // 32-chars lowercase and digits
|
|
}
|
|
requestBody := requestBodySchema{
|
|
GrantType: "refresh_token",
|
|
Persistent: 0,
|
|
RedirectURI: "https://protonmail.com",
|
|
RefreshToken: refreshToken,
|
|
ResponseType: "token",
|
|
State: generateLettersDigits(c.generator, 24), //nolint:mnd
|
|
UID: uid,
|
|
}
|
|
|
|
buffer := bytes.NewBuffer(nil)
|
|
encoder := json.NewEncoder(buffer)
|
|
if err := encoder.Encode(requestBody); err != nil {
|
|
return "", fmt.Errorf("encoding request body: %w", err)
|
|
}
|
|
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURLBase+"/core/v4/auth/cookies", buffer)
|
|
if err != nil {
|
|
return "", fmt.Errorf("creating request: %w", err)
|
|
}
|
|
unauthCookie := cookie{
|
|
uid: uid,
|
|
sessionID: sessionID,
|
|
}
|
|
c.setHeaders(request, unauthCookie)
|
|
request.Header.Set("Authorization", tokenType+" "+accessToken)
|
|
|
|
response, err := c.httpClient.Do(request)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
responseBody, err := io.ReadAll(response.Body)
|
|
if err != nil {
|
|
return "", fmt.Errorf("reading response body: %w", err)
|
|
} else if response.StatusCode != http.StatusOK {
|
|
return "", buildError(response.StatusCode, responseBody)
|
|
}
|
|
|
|
var cookies struct {
|
|
Code uint `json:"Code"` // 1000 on success
|
|
UID string `json:"UID"` // should match request UID
|
|
LocalID uint `json:"LocalID"` // 0
|
|
RefreshCounter uint `json:"RefreshCounter"` // 1
|
|
}
|
|
err = json.Unmarshal(responseBody, &cookies)
|
|
if err != nil {
|
|
return "", fmt.Errorf("decoding response body: %w", err)
|
|
}
|
|
|
|
const successCode = 1000
|
|
switch {
|
|
case cookies.Code != successCode:
|
|
return "", fmt.Errorf("%w: expected %d got %d",
|
|
ErrCodeNotSuccess, successCode, cookies.Code)
|
|
case cookies.UID != requestBody.UID:
|
|
return "", fmt.Errorf("%w: expected %s got %s",
|
|
ErrUIDMismatch, requestBody.UID, cookies.UID)
|
|
}
|
|
// Ignore LocalID and RefreshCounter fields, we don't use them.
|
|
|
|
for _, cookie := range response.Cookies() {
|
|
if cookie.Name == "AUTH-"+uid {
|
|
return cookie.Value, nil
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("%w", ErrAuthCookieNotFound)
|
|
}
|
|
|
|
var (
|
|
ErrUsernameDoesNotExist = errors.New("username does not exist")
|
|
ErrUsernameMismatch = errors.New("username in response does not match request username")
|
|
)
|
|
|
|
// authInfo fetches SRP parameters for the account.
|
|
func (c *apiClient) authInfo(ctx context.Context, username string, unauthCookie cookie) (
|
|
modulusPGPClearSigned, serverEphemeralBase64, saltBase64, srpSessionHex string,
|
|
version int, err error,
|
|
) {
|
|
type requestBodySchema struct {
|
|
Intent string `json:"Intent"` // "Proton"
|
|
Username string `json:"Username"` // username without @domain.com
|
|
}
|
|
requestBody := requestBodySchema{
|
|
Intent: "Proton",
|
|
Username: username,
|
|
}
|
|
|
|
buffer := bytes.NewBuffer(nil)
|
|
encoder := json.NewEncoder(buffer)
|
|
if err := encoder.Encode(requestBody); err != nil {
|
|
return "", "", "", "", 0, fmt.Errorf("encoding request body: %w", err)
|
|
}
|
|
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURLBase+"/core/v4/auth/info", buffer)
|
|
if err != nil {
|
|
return "", "", "", "", 0, fmt.Errorf("creating request: %w", err)
|
|
}
|
|
c.setHeaders(request, unauthCookie)
|
|
|
|
response, err := c.httpClient.Do(request)
|
|
if err != nil {
|
|
return "", "", "", "", 0, err
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
responseBody, err := io.ReadAll(response.Body)
|
|
if err != nil {
|
|
return "", "", "", "", 0, fmt.Errorf("reading response body: %w", err)
|
|
} else if response.StatusCode != http.StatusOK {
|
|
return "", "", "", "", 0, buildError(response.StatusCode, responseBody)
|
|
}
|
|
|
|
var info struct {
|
|
Code uint `json:"Code"` // 1000 on success
|
|
Modulus string `json:"Modulus"` // PGP clearsigned modulus string
|
|
ServerEphemeral string `json:"ServerEphemeral"` // base64
|
|
Version *uint `json:"Version,omitempty"` // 4 as of 2025-10-26
|
|
Salt string `json:"Salt"` // base64
|
|
SRPSession string `json:"SRPSession"` // hexadecimal
|
|
Username string `json:"Username"` // user without @domain.com. Mine has its first letter capitalized.
|
|
}
|
|
err = json.Unmarshal(responseBody, &info)
|
|
if err != nil {
|
|
return "", "", "", "", 0, fmt.Errorf("decoding response body: %w", err)
|
|
}
|
|
|
|
const successCode = 1000
|
|
switch {
|
|
case info.Code != successCode:
|
|
return "", "", "", "", 0, fmt.Errorf("%w: expected %d got %d",
|
|
ErrCodeNotSuccess, successCode, info.Code)
|
|
case info.Modulus == "":
|
|
return "", "", "", "", 0, fmt.Errorf("%w: modulus is empty", ErrDataFieldMissing)
|
|
case info.ServerEphemeral == "":
|
|
return "", "", "", "", 0, fmt.Errorf("%w: server ephemeral is empty", ErrDataFieldMissing)
|
|
case info.Salt == "":
|
|
return "", "", "", "", 0, fmt.Errorf("%w (salt data field is empty)", ErrUsernameDoesNotExist)
|
|
case info.SRPSession == "":
|
|
return "", "", "", "", 0, fmt.Errorf("%w: SRP session is empty", ErrDataFieldMissing)
|
|
|
|
case info.Username != username:
|
|
return "", "", "", "", 0, fmt.Errorf("%w: expected %s got %s",
|
|
ErrUsernameMismatch, username, info.Username)
|
|
case info.Version == nil:
|
|
return "", "", "", "", 0, fmt.Errorf("%w: version is missing", ErrDataFieldMissing)
|
|
}
|
|
|
|
version = int(*info.Version) //nolint:gosec
|
|
return info.Modulus, info.ServerEphemeral, info.Salt,
|
|
info.SRPSession, version, nil
|
|
}
|
|
|
|
type cookie struct {
|
|
uid string
|
|
token string
|
|
sessionID string
|
|
}
|
|
|
|
func (c *cookie) String() string {
|
|
s := ""
|
|
if c.token != "" {
|
|
s += fmt.Sprintf("AUTH-%s=%s; ", c.uid, c.token)
|
|
}
|
|
if c.sessionID != "" {
|
|
s += fmt.Sprintf("Session-Id=%s; ", c.sessionID)
|
|
}
|
|
if c.token != "" {
|
|
s += "Tag=default; iaas=W10; Domain=proton.me; Feature=VPNDashboard:A"
|
|
}
|
|
return s
|
|
}
|
|
|
|
var (
|
|
// ErrServerProofNotValid indicates the M2 from the server didn't match the expected proof.
|
|
ErrServerProofNotValid = errors.New("server proof from server is not valid")
|
|
ErrVPNScopeNotFound = errors.New("VPN scope not found in scopes")
|
|
ErrTwoFANotSupported = errors.New("two factor authentication not supported in this client")
|
|
ErrAuthCookieNotFound = errors.New("auth cookie not found")
|
|
)
|
|
|
|
// auth performs the SRP proof submission (and optionally TOTP) to obtain tokens.
|
|
func (c *apiClient) auth(ctx context.Context, unauthCookie cookie,
|
|
username, srpSession string, proofs *srp.Proofs,
|
|
) (authCookie cookie, err error) {
|
|
clientEphemeral := base64.StdEncoding.EncodeToString(proofs.ClientEphemeral)
|
|
clientProof := base64.StdEncoding.EncodeToString(proofs.ClientProof)
|
|
|
|
type requestBodySchema struct {
|
|
ClientEphemeral string `json:"ClientEphemeral"` // base64(A)
|
|
ClientProof string `json:"ClientProof"` // base64(M1)
|
|
Payload map[string]string `json:"Payload,omitempty"` // not sure
|
|
SRPSession string `json:"SRPSession"` // hexadecimal
|
|
Username string `json:"Username"` // user@protonmail.com
|
|
}
|
|
requestBody := requestBodySchema{
|
|
ClientEphemeral: clientEphemeral,
|
|
ClientProof: clientProof,
|
|
SRPSession: srpSession,
|
|
Username: username,
|
|
}
|
|
|
|
buffer := bytes.NewBuffer(nil)
|
|
encoder := json.NewEncoder(buffer)
|
|
if err := encoder.Encode(requestBody); err != nil {
|
|
return cookie{}, fmt.Errorf("encoding request body: %w", err)
|
|
}
|
|
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURLBase+"/core/v4/auth", buffer)
|
|
if err != nil {
|
|
return cookie{}, fmt.Errorf("creating request: %w", err)
|
|
}
|
|
c.setHeaders(request, unauthCookie)
|
|
|
|
response, err := c.httpClient.Do(request)
|
|
if err != nil {
|
|
return cookie{}, err
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
responseBody, err := io.ReadAll(response.Body)
|
|
if err != nil {
|
|
return cookie{}, fmt.Errorf("reading response body: %w", err)
|
|
} else if response.StatusCode != http.StatusOK {
|
|
return cookie{}, buildError(response.StatusCode, responseBody)
|
|
}
|
|
|
|
type twoFAStatus uint
|
|
//nolint:unused
|
|
const (
|
|
twoFADisabled twoFAStatus = iota
|
|
twoFAHasTOTP
|
|
twoFAHasFIDO2
|
|
twoFAHasFIDO2AndTOTP
|
|
)
|
|
type twoFAInfo struct {
|
|
Enabled twoFAStatus `json:"Enabled"`
|
|
FIDO2 struct {
|
|
AuthenticationOptions any `json:"AuthenticationOptions"`
|
|
RegisteredKeys []any `json:"RegisteredKeys"`
|
|
} `json:"FIDO2"`
|
|
TOTP uint `json:"TOTP"`
|
|
}
|
|
|
|
var auth struct {
|
|
Code uint `json:"Code"` // 1000 on success
|
|
LocalID uint `json:"LocalID"` // 7 in my case
|
|
Scopes []string `json:"Scopes"` // this should contain "vpn". Same as `Scope` field value.
|
|
UID string `json:"UID"` // same as `Uid` field value
|
|
UserID string `json:"UserID"` // base64
|
|
EventID string `json:"EventID"` // base64
|
|
PasswordMode uint `json:"PasswordMode"` // 1 in my case
|
|
ServerProof string `json:"ServerProof"` // base64(M2)
|
|
TwoFactor uint `json:"TwoFactor"` // 0 if 2FA not required
|
|
TwoFA twoFAInfo `json:"2FA"`
|
|
TemporaryPassword uint `json:"TemporaryPassword"` // 0 in my case
|
|
}
|
|
|
|
err = json.Unmarshal(responseBody, &auth)
|
|
if err != nil {
|
|
return cookie{}, fmt.Errorf("decoding response body: %w", err)
|
|
}
|
|
|
|
m2, err := base64.StdEncoding.DecodeString(auth.ServerProof)
|
|
if err != nil {
|
|
return cookie{}, fmt.Errorf("decoding server proof: %w", err)
|
|
}
|
|
if !bytes.Equal(m2, proofs.ExpectedServerProof) {
|
|
return cookie{}, fmt.Errorf("%w: expected %x got %x",
|
|
ErrServerProofNotValid, proofs.ExpectedServerProof, m2)
|
|
}
|
|
|
|
const successCode = 1000
|
|
switch {
|
|
case auth.Code != successCode:
|
|
return cookie{}, fmt.Errorf("%w: expected %d got %d",
|
|
ErrCodeNotSuccess, successCode, auth.Code)
|
|
case auth.UID != unauthCookie.uid:
|
|
return cookie{}, fmt.Errorf("%w: expected %s got %s",
|
|
ErrUIDMismatch, unauthCookie.uid, auth.UID)
|
|
case auth.TwoFactor != 0:
|
|
return cookie{}, fmt.Errorf("%w", ErrTwoFANotSupported)
|
|
case !slices.Contains(auth.Scopes, "vpn"):
|
|
return cookie{}, fmt.Errorf("%w: in %v", ErrVPNScopeNotFound, auth.Scopes)
|
|
}
|
|
|
|
for _, setCookieHeader := range response.Header.Values("Set-Cookie") {
|
|
parts := strings.Split(setCookieHeader, ";")
|
|
for _, part := range parts {
|
|
if strings.HasPrefix(part, "AUTH-"+unauthCookie.uid+"=") {
|
|
authCookie = unauthCookie
|
|
authCookie.token = strings.TrimPrefix(part, "AUTH-"+unauthCookie.uid+"=")
|
|
return authCookie, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return cookie{}, fmt.Errorf("%w: in HTTP headers %s",
|
|
ErrAuthCookieNotFound, httpHeadersToString(response.Header))
|
|
}
|
|
|
|
// generateLettersDigits mimicing Proton's own random string generator:
|
|
// https://github.com/ProtonMail/WebClients/blob/e4d7e4ab9babe15b79a131960185f9f8275512cd/packages/utils/generateLettersDigits.ts
|
|
func generateLettersDigits(rng *rand.ChaCha8, length uint) string {
|
|
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
|
return generateFromCharset(rng, length, charset)
|
|
}
|
|
|
|
func generateFromCharset(rng *rand.ChaCha8, length uint, charset string) string {
|
|
result := make([]byte, length)
|
|
randomBytes := make([]byte, length)
|
|
_, _ = rng.Read(randomBytes)
|
|
for i := range length {
|
|
result[i] = charset[int(randomBytes[i])%len(charset)]
|
|
}
|
|
return string(result)
|
|
}
|
|
|
|
func httpHeadersToString(headers http.Header) string {
|
|
var builder strings.Builder
|
|
first := true
|
|
for key, values := range headers {
|
|
for _, value := range values {
|
|
if !first {
|
|
builder.WriteString(", ")
|
|
}
|
|
builder.WriteString(fmt.Sprintf("%s: %s", key, value))
|
|
first = false
|
|
}
|
|
}
|
|
return builder.String()
|
|
}
|
|
|
|
type apiData struct {
|
|
LogicalServers []logicalServer `json:"LogicalServers"`
|
|
}
|
|
|
|
type logicalServer struct {
|
|
Name string `json:"Name"`
|
|
ExitCountry string `json:"ExitCountry"`
|
|
Region *string `json:"Region"`
|
|
City *string `json:"City"`
|
|
Servers []physicalServer `json:"Servers"`
|
|
Features uint16 `json:"Features"`
|
|
Tier *uint8 `json:"Tier,omitempty"`
|
|
}
|
|
|
|
type physicalServer struct {
|
|
EntryIP netip.Addr `json:"EntryIP"`
|
|
ExitIP netip.Addr `json:"ExitIP"`
|
|
Domain string `json:"Domain"`
|
|
Status uint8 `json:"Status"`
|
|
X25519PublicKey string `json:"X25519PublicKey"`
|
|
}
|
|
|
|
func (c *apiClient) fetchServers(ctx context.Context, cookie cookie) (
|
|
data apiData, err error,
|
|
) {
|
|
const url = "https://account.proton.me/api/vpn/logicals"
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return data, err
|
|
}
|
|
c.setHeaders(request, cookie)
|
|
|
|
response, err := c.httpClient.Do(request)
|
|
if err != nil {
|
|
return data, err
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
if response.StatusCode != http.StatusOK {
|
|
b, _ := io.ReadAll(response.Body)
|
|
return data, buildError(response.StatusCode, b)
|
|
}
|
|
|
|
decoder := json.NewDecoder(response.Body)
|
|
if err := decoder.Decode(&data); err != nil {
|
|
return data, fmt.Errorf("decoding response body: %w", err)
|
|
}
|
|
|
|
return data, nil
|
|
}
|
|
|
|
var ErrHTTPStatusCodeNotOK = errors.New("HTTP status code not OK")
|
|
|
|
func buildError(httpCode int, body []byte) error {
|
|
prettyCode := http.StatusText(httpCode)
|
|
var protonError struct {
|
|
Code *int `json:"Code,omitempty"`
|
|
Error *string `json:"Error,omitempty"`
|
|
Details map[string]string `json:"Details"`
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(body))
|
|
decoder.DisallowUnknownFields()
|
|
err := decoder.Decode(&protonError)
|
|
if err != nil || protonError.Error == nil || protonError.Code == nil {
|
|
return fmt.Errorf("%w: %s: %s",
|
|
ErrHTTPStatusCodeNotOK, prettyCode, body)
|
|
}
|
|
|
|
details := make([]string, 0, len(protonError.Details))
|
|
for key, value := range protonError.Details {
|
|
details = append(details, fmt.Sprintf("%s: %s", key, value))
|
|
}
|
|
|
|
return fmt.Errorf("%w: %s: %s (code %d with details: %s)",
|
|
ErrHTTPStatusCodeNotOK, prettyCode, *protonError.Error, *protonError.Code, strings.Join(details, ", "))
|
|
}
|