Compare commits

...

2 Commits

4 changed files with 92 additions and 28 deletions
+1 -1
View File
@@ -27,6 +27,7 @@ require (
github.com/ulikunitz/xz v0.5.15 github.com/ulikunitz/xz v0.5.15
github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c 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/net v0.51.0
golang.org/x/sys v0.42.0 golang.org/x/sys v0.42.0
golang.org/x/text v0.35.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/qdm12/goservices v0.1.1-0.20251104135713-6bee97bd4978 // indirect
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect
golang.org/x/crypto v0.48.0 // 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/sync v0.20.0 // indirect
golang.org/x/tools v0.42.0 // indirect golang.org/x/tools v0.42.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
+43 -24
View File
@@ -16,22 +16,25 @@ import (
"strings" "strings"
srp "github.com/ProtonMail/go-srp" 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 // apiClient is a minimal Proton v4 API client which can handle all the
// oddities of Proton's authentication flow they want to keep hidden // oddities of Proton's authentication flow they want to keep hidden
// from the public. // from the public.
type apiClient struct { type apiClient struct {
apiURLBase string apiURLBase string
httpClient *http.Client httpClient *http.Client
appVersion string appVersion string
userAgent string vpnGtkAppVersion string
generator *rand.ChaCha8 userAgent string
generator *rand.ChaCha8
warner common.Warner
} }
// newAPIClient returns an [apiClient] with sane defaults matching Proton's // newAPIClient returns an [apiClient] with sane defaults matching Proton's
// insane expectations. // 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 var seed [32]byte
_, _ = crand.Read(seed[:]) _, _ = crand.Read(seed[:])
generator := rand.NewChaCha8(seed) generator := rand.NewChaCha8(seed)
@@ -46,17 +49,23 @@ func newAPIClient(ctx context.Context, httpClient *http.Client) (client *apiClie
} }
userAgent := userAgents[generator.Uint64()%uint64(len(userAgents))] userAgent := userAgents[generator.Uint64()%uint64(len(userAgents))]
appVersion, err := getMostRecentStableTag(ctx, httpClient) appVersion, err := getMostRecentStableWebAccountTag(ctx, httpClient)
if err != nil { 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{ return &apiClient{
apiURLBase: "https://account.proton.me/api", apiURLBase: "https://account.proton.me/api",
httpClient: httpClient, httpClient: httpClient,
appVersion: appVersion, appVersion: appVersion,
userAgent: userAgent, vpnGtkAppVersion: vpnGtkAppVersion,
generator: generator, userAgent: userAgent,
generator: generator,
warner: warner,
}, nil }, nil
} }
@@ -64,10 +73,10 @@ func newAPIClient(ctx context.Context, httpClient *http.Client) (client *apiClie
// to succeed without being blocked by their "security" measures. // to succeed without being blocked by their "security" measures.
// See for example [getMostRecentStableTag] on how the app version must // See for example [getMostRecentStableTag] on how the app version must
// be set to a recent version or they block your request. "SeCuRiTy"... // 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("Cookie", cookie.String())
request.Header.Set("User-Agent", c.userAgent) 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-locale", "en_US")
request.Header.Set("x-pm-uid", cookie.uid) request.Header.Set("x-pm-uid", cookie.uid)
} }
@@ -98,7 +107,11 @@ func (c *apiClient) authenticate(ctx context.Context, email, password string,
} }
username, modulusPGPClearSigned, serverEphemeralBase64, saltBase64, username, modulusPGPClearSigned, serverEphemeralBase64, saltBase64,
srpSessionHex, version, err := c.authInfo(ctx, email, unauthCookie) 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) return cookie{}, fmt.Errorf("getting auth information: %w", err)
} }
@@ -159,7 +172,7 @@ func (c *apiClient) getUnauthSession(ctx context.Context, sessionID string) (
unauthCookie := cookie{ unauthCookie := cookie{
sessionID: sessionID, sessionID: sessionID,
} }
c.setHeaders(request, unauthCookie) c.setHeaders(request, unauthCookie, c.appVersion)
response, err := c.httpClient.Do(request) response, err := c.httpClient.Do(request)
if err != nil { if err != nil {
@@ -244,7 +257,7 @@ func (c *apiClient) cookieToken(ctx context.Context, sessionID, tokenType, acces
uid: uid, uid: uid,
sessionID: sessionID, sessionID: sessionID,
} }
c.setHeaders(request, unauthCookie) c.setHeaders(request, unauthCookie, c.appVersion)
request.Header.Set("Authorization", tokenType+" "+accessToken) request.Header.Set("Authorization", tokenType+" "+accessToken)
response, err := c.httpClient.Do(request) response, err := c.httpClient.Do(request)
@@ -291,6 +304,8 @@ func (c *apiClient) cookieToken(ctx context.Context, sessionID, tokenType, acces
return "", errors.New("auth cookie not found") return "", errors.New("auth cookie not found")
} }
var errUsernameEmpty = errors.New("username is empty in response")
// authInfo fetches SRP parameters for the account. // authInfo fetches SRP parameters for the account.
func (c *apiClient) authInfo(ctx context.Context, email string, unauthCookie cookie) ( func (c *apiClient) authInfo(ctx context.Context, email string, unauthCookie cookie) (
username, modulusPGPClearSigned, serverEphemeralBase64, saltBase64, srpSessionHex string, username, modulusPGPClearSigned, serverEphemeralBase64, saltBase64, srpSessionHex string,
@@ -315,7 +330,7 @@ func (c *apiClient) authInfo(ctx context.Context, email string, unauthCookie coo
if err != nil { if err != nil {
return "", "", "", "", "", 0, fmt.Errorf("creating request: %w", err) 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") request.Header.Set("Content-Type", "application/json")
response, err := c.httpClient.Do(request) response, err := c.httpClient.Do(request)
@@ -358,15 +373,17 @@ func (c *apiClient) authInfo(ctx context.Context, email string, unauthCookie coo
return "", "", "", "", "", 0, errors.New("salt is empty in response") return "", "", "", "", "", 0, errors.New("salt is empty in response")
case info.SRPSession == "": case info.SRPSession == "":
return "", "", "", "", "", 0, errors.New("SRP session is empty in response") 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: case info.Version == nil:
return "", "", "", "", "", 0, errors.New("version is missing in response") 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 version = int(*info.Version) //nolint:gosec
return info.Username, info.Modulus, info.ServerEphemeral, info.Salt, return info.Username, info.Modulus, info.ServerEphemeral, info.Salt,
info.SRPSession, version, nil info.SRPSession, version, err
} }
type cookie struct { type cookie struct {
@@ -422,7 +439,7 @@ func (c *apiClient) auth(ctx context.Context, unauthCookie cookie,
if err != nil { if err != nil {
return cookie{}, fmt.Errorf("creating request: %w", err) 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") request.Header.Set("Content-Type", "application/json")
response, err := c.httpClient.Do(request) response, err := c.httpClient.Do(request)
@@ -573,7 +590,9 @@ func (c *apiClient) fetchServers(ctx context.Context, cookie cookie) (
if err != nil { if err != nil {
return data, err 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) response, err := c.httpClient.Do(request)
if err != nil { if err != nil {
@@ -20,7 +20,7 @@ func (u *Updater) FetchServers(ctx context.Context, minServers int) (
return nil, fmt.Errorf("%w: password is empty", common.ErrCredentialsMissing) 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 { if err != nil {
return nil, fmt.Errorf("creating API client: %w", err) return nil, fmt.Errorf("creating API client: %w", err)
} }
+47 -2
View File
@@ -7,15 +7,18 @@ import (
"io" "io"
"net/http" "net/http"
"regexp" "regexp"
"sort"
"strings" "strings"
"time" "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 // 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 // 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. // 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 page := 1
regexVersion := regexp.MustCompile(`^proton-account@(\d+\.\d+\.\d+\.\d+)$`) regexVersion := regexp.MustCompile(`^proton-account@(\d+\.\d+\.\d+\.\d+)$`)
for ctx.Err() == nil { 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) 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
}