diff --git a/go.mod b/go.mod index fbb8a148..23a0b86a 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,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 @@ -57,7 +58,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 fb8c3016..45b4976c 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 } @@ -66,10 +72,10 @@ var ErrCodeNotSuccess = errors.New("response code is not success") // 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) } @@ -165,7 +171,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 { @@ -252,7 +258,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) @@ -325,7 +331,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) @@ -438,7 +444,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) @@ -590,7 +596,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 bdf0587f..6f2a4e6f 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("%w: %s", ErrHTTPStatusCodeNotOK, 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 +}