From beda1764b15f0a4775b365bf0848c26ad8ed38a5 Mon Sep 17 00:00:00 2001 From: Quentin McGaw Date: Wed, 21 Jan 2026 12:43:54 +0000 Subject: [PATCH 1/2] feat(protonvpn): updater finds more servers using app-version linux-vpn --- go.mod | 2 +- internal/provider/protonvpn/updater/api.go | 46 ++++++++++------- .../provider/protonvpn/updater/version.go | 49 ++++++++++++++++++- 3 files changed, 75 insertions(+), 22 deletions(-) diff --git a/go.mod b/go.mod index 5a46d65d5..e49916fe7 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 diff --git a/internal/provider/protonvpn/updater/api.go b/internal/provider/protonvpn/updater/api.go index dab78eb4c..b6fffd565 100644 --- a/internal/provider/protonvpn/updater/api.go +++ b/internal/provider/protonvpn/updater/api.go @@ -22,11 +22,12 @@ import ( // 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 } // newAPIClient returns an [apiClient] with sane defaults matching Proton's @@ -46,17 +47,22 @@ 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, }, nil } @@ -64,10 +70,10 @@ func newAPIClient(ctx context.Context, httpClient *http.Client) (client *apiClie // 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) } @@ -159,7 +165,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 { @@ -244,7 +250,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) @@ -315,7 +321,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) @@ -422,7 +428,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) @@ -573,7 +579,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 { diff --git a/internal/provider/protonvpn/updater/version.go b/internal/provider/protonvpn/updater/version.go index 1d3053046..832b003be 100644 --- a/internal/provider/protonvpn/updater/version.go +++ b/internal/provider/protonvpn/updater/version.go @@ -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 { @@ -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 +} From 00d944e713e4ca72c27261c43c84daa8031170d8 Mon Sep 17 00:00:00 2001 From: Quentin McGaw Date: Thu, 21 May 2026 16:58:33 +0000 Subject: [PATCH 2/2] fix(protonvpn/updater): fallback to email if username is empty in auth info response --- internal/provider/protonvpn/updater/api.go | 21 ++++++++++++++----- .../provider/protonvpn/updater/servers.go | 2 +- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/internal/provider/protonvpn/updater/api.go b/internal/provider/protonvpn/updater/api.go index b6fffd565..0e2f3a0c1 100644 --- a/internal/provider/protonvpn/updater/api.go +++ b/internal/provider/protonvpn/updater/api.go @@ -16,6 +16,7 @@ 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 @@ -28,11 +29,12 @@ type apiClient struct { 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) @@ -63,6 +65,7 @@ func newAPIClient(ctx context.Context, httpClient *http.Client) (client *apiClie vpnGtkAppVersion: vpnGtkAppVersion, userAgent: userAgent, generator: generator, + warner: warner, }, nil } @@ -104,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) } @@ -297,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, @@ -364,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 { diff --git a/internal/provider/protonvpn/updater/servers.go b/internal/provider/protonvpn/updater/servers.go index ff9199371..fc8af45a1 100644 --- a/internal/provider/protonvpn/updater/servers.go +++ b/internal/provider/protonvpn/updater/servers.go @@ -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) }