Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ require (
github.com/ulikunitz/xz v0.5.15
github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c
golang.org/x/mod v0.33.0
golang.org/x/net v0.51.0
golang.org/x/sys v0.42.0
golang.org/x/text v0.35.0
Expand Down Expand Up @@ -58,7 +59,6 @@ require (
github.com/qdm12/goservices v0.1.1-0.20251104135713-6bee97bd4978 // indirect
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/tools v0.42.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
Expand Down
67 changes: 43 additions & 24 deletions internal/provider/protonvpn/updater/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,25 @@ import (
"strings"

srp "github.com/ProtonMail/go-srp"
"github.com/qdm12/gluetun/internal/provider/common"
)

// 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
apiURLBase string
httpClient *http.Client
appVersion string
vpnGtkAppVersion string
userAgent string
generator *rand.ChaCha8
warner common.Warner
}

// newAPIClient returns an [apiClient] with sane defaults matching Proton's
// insane expectations.
func newAPIClient(ctx context.Context, httpClient *http.Client) (client *apiClient, err error) {
func newAPIClient(ctx context.Context, httpClient *http.Client, warner common.Warner) (client *apiClient, err error) {
var seed [32]byte
_, _ = crand.Read(seed[:])
generator := rand.NewChaCha8(seed)
Expand All @@ -46,28 +49,34 @@ func newAPIClient(ctx context.Context, httpClient *http.Client) (client *apiClie
}
userAgent := userAgents[generator.Uint64()%uint64(len(userAgents))]

appVersion, err := getMostRecentStableTag(ctx, httpClient)
appVersion, err := getMostRecentStableWebAccountTag(ctx, httpClient)
if err != nil {
return nil, fmt.Errorf("getting most recent version for proton app: %w", err)
return nil, fmt.Errorf("getting most recent version for web-account: %w", err)
}
vpnGtkAppVersion, err := getMostRecentStableVPNGtkAppTag(ctx, httpClient)
if err != nil {
return nil, fmt.Errorf("getting most recent version for linux VPN GTK app: %w", err)
}

return &apiClient{
apiURLBase: "https://account.proton.me/api",
httpClient: httpClient,
appVersion: appVersion,
userAgent: userAgent,
generator: generator,
apiURLBase: "https://account.proton.me/api",
httpClient: httpClient,
appVersion: appVersion,
vpnGtkAppVersion: vpnGtkAppVersion,
userAgent: userAgent,
generator: generator,
warner: warner,
}, nil
}

// 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) {
func (c *apiClient) setHeaders(request *http.Request, cookie cookie, appVersion string) {
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-appversion", appVersion)
request.Header.Set("x-pm-locale", "en_US")
request.Header.Set("x-pm-uid", cookie.uid)
}
Expand Down Expand Up @@ -98,7 +107,11 @@ func (c *apiClient) authenticate(ctx context.Context, email, password string,
}
username, modulusPGPClearSigned, serverEphemeralBase64, saltBase64,
srpSessionHex, version, err := c.authInfo(ctx, email, unauthCookie)
if err != nil {
switch {
case errors.Is(err, errUsernameEmpty):
c.warner.Warn("Username is empty in auth info response, trying with email address instead")
username = email
case err != nil:
return cookie{}, fmt.Errorf("getting auth information: %w", err)
}

Expand Down Expand Up @@ -159,7 +172,7 @@ func (c *apiClient) getUnauthSession(ctx context.Context, sessionID string) (
unauthCookie := cookie{
sessionID: sessionID,
}
c.setHeaders(request, unauthCookie)
c.setHeaders(request, unauthCookie, c.appVersion)

response, err := c.httpClient.Do(request)
if err != nil {
Expand Down Expand Up @@ -244,7 +257,7 @@ func (c *apiClient) cookieToken(ctx context.Context, sessionID, tokenType, acces
uid: uid,
sessionID: sessionID,
}
c.setHeaders(request, unauthCookie)
c.setHeaders(request, unauthCookie, c.appVersion)
request.Header.Set("Authorization", tokenType+" "+accessToken)

response, err := c.httpClient.Do(request)
Expand Down Expand Up @@ -291,6 +304,8 @@ func (c *apiClient) cookieToken(ctx context.Context, sessionID, tokenType, acces
return "", errors.New("auth cookie not found")
}

var errUsernameEmpty = errors.New("username is empty in response")

// authInfo fetches SRP parameters for the account.
func (c *apiClient) authInfo(ctx context.Context, email string, unauthCookie cookie) (
username, modulusPGPClearSigned, serverEphemeralBase64, saltBase64, srpSessionHex string,
Expand All @@ -315,7 +330,7 @@ func (c *apiClient) authInfo(ctx context.Context, email string, unauthCookie coo
if err != nil {
return "", "", "", "", "", 0, fmt.Errorf("creating request: %w", err)
}
c.setHeaders(request, unauthCookie)
c.setHeaders(request, unauthCookie, c.appVersion)
request.Header.Set("Content-Type", "application/json")

response, err := c.httpClient.Do(request)
Expand Down Expand Up @@ -358,15 +373,17 @@ func (c *apiClient) authInfo(ctx context.Context, email string, unauthCookie coo
return "", "", "", "", "", 0, errors.New("salt is empty in response")
case info.SRPSession == "":
return "", "", "", "", "", 0, errors.New("SRP session is empty in response")
case info.Username == "":
return "", "", "", "", "", 0, errors.New("username is empty in response")
case info.Version == nil:
return "", "", "", "", "", 0, errors.New("version is missing in response")
case info.Username == "":
// Return a sentinel error the caller can handle to try with the email address instead of the username.
// Some accounts seem to have no username.
err = fmt.Errorf("%w", errUsernameEmpty)
}

version = int(*info.Version) //nolint:gosec
return info.Username, info.Modulus, info.ServerEphemeral, info.Salt,
info.SRPSession, version, nil
info.SRPSession, version, err
}

type cookie struct {
Expand Down Expand Up @@ -422,7 +439,7 @@ func (c *apiClient) auth(ctx context.Context, unauthCookie cookie,
if err != nil {
return cookie{}, fmt.Errorf("creating request: %w", err)
}
c.setHeaders(request, unauthCookie)
c.setHeaders(request, unauthCookie, c.appVersion)
request.Header.Set("Content-Type", "application/json")

response, err := c.httpClient.Do(request)
Expand Down Expand Up @@ -573,7 +590,9 @@ func (c *apiClient) fetchServers(ctx context.Context, cookie cookie) (
if err != nil {
return data, err
}
c.setHeaders(request, cookie)
// Note we use the vpnGtkAppVersion field given it produces an output of more servers
c.setHeaders(request, cookie, c.vpnGtkAppVersion)
request.Header.Set("x-pm-appversion", "linux-vpn@4.15.2")

response, err := c.httpClient.Do(request)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion internal/provider/protonvpn/updater/servers.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func (u *Updater) FetchServers(ctx context.Context, minServers int) (
return nil, fmt.Errorf("%w: password is empty", common.ErrCredentialsMissing)
}

apiClient, err := newAPIClient(ctx, u.client)
apiClient, err := newAPIClient(ctx, u.client, u.warner)
if err != nil {
return nil, fmt.Errorf("creating API client: %w", err)
}
Expand Down
49 changes: 47 additions & 2 deletions internal/provider/protonvpn/updater/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@ import (
"io"
"net/http"
"regexp"
"sort"
"strings"
"time"

"golang.org/x/mod/semver"
)

// getMostRecentStableTag finds the most recent proton-account stable tag version,
// getMostRecentStableWebAccountTag finds the most recent proton-account stable tag version,
// in order to use it in the x-pm-appversion http request header. Because if we do
// fall behind on versioning, Proton doesn't like it because they like to create
// complications where there is no need for it. Hence this function.
func getMostRecentStableTag(ctx context.Context, client *http.Client) (version string, err error) {
func getMostRecentStableWebAccountTag(ctx context.Context, client *http.Client) (version string, err error) {
page := 1
regexVersion := regexp.MustCompile(`^proton-account@(\d+\.\d+\.\d+\.\d+)$`)
for ctx.Err() == nil {
Expand Down Expand Up @@ -69,3 +72,45 @@ func getMostRecentStableTag(ctx context.Context, client *http.Client) (version s

return "", fmt.Errorf("%w (queried %d pages)", context.Canceled, page)
}

// getMostRecentStableVPNGtkAppTag finds the latest proton-vpn-gtk-app semver tag,
// in order to use it in the x-pm-appversion http request header ONLY to fetch servers
// data. Because if we do fall behind on versioning, Proton doesn't like it because they like
// to create complications where there is no need for it. Hence this function.
func getMostRecentStableVPNGtkAppTag(ctx context.Context, client *http.Client) (version string, err error) {
const url = "https://api.github.com/repos/ProtonVPN/proton-vpn-gtk-app/tags?per_page=30"

request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
request.Header.Set("Accept", "application/vnd.github.v3+json")

response, err := client.Do(request)
if err != nil {
return "", err
}
defer response.Body.Close()

if response.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP status code not OK: %s", response.Status)
}

decoder := json.NewDecoder(response.Body)
var data []struct {
Name string `json:"name"`
}
err = decoder.Decode(&data)
if err != nil {
return "", fmt.Errorf("decoding JSON response: %w", err)
}

// Sort tags by semver. Invalid tags are placed at the end and we ignore them.
// Yes, proton does push invalid semver tag names sometimes. Good job yet again.
sort.Slice(data, func(i, j int) bool {
return semver.Compare(data[i].Name, data[j].Name) > 0
})

version = "linux-vpn@" + data[0].Name[1:] // remove leading v
return version, nil
}
Loading