fix(protonvpn/updater): API authentification fix using email

- `UPDATER_PROTONVPN_USERNAME` ->  `UPDATER_PROTONVPN_EMAIL`
- `-proton-username` -> `-proton-email`
- fix authentication flow to use email or username when appropriate
- fix #2985
This commit is contained in:
Quentin McGaw 2025-11-18 13:34:59 +00:00
parent 9963e18a8a
commit 74d059dd77
No known key found for this signature in database
9 changed files with 56 additions and 59 deletions

View File

@ -207,7 +207,7 @@ ENV VPN_SERVICE_PROVIDER=pia \
UPDATER_PERIOD=0 \ UPDATER_PERIOD=0 \
UPDATER_MIN_RATIO=0.8 \ UPDATER_MIN_RATIO=0.8 \
UPDATER_VPN_SERVICE_PROVIDERS= \ UPDATER_VPN_SERVICE_PROVIDERS= \
UPDATER_PROTONVPN_USERNAME= \ UPDATER_PROTONVPN_EMAIL= \
UPDATER_PROTONVPN_PASSWORD= \ UPDATER_PROTONVPN_PASSWORD= \
# Public IP # Public IP
PUBLICIP_FILE="/tmp/gluetun/ip" \ PUBLICIP_FILE="/tmp/gluetun/ip" \

View File

@ -38,7 +38,7 @@ type UpdaterLogger interface {
func (c *CLI) Update(ctx context.Context, args []string, logger UpdaterLogger) error { func (c *CLI) Update(ctx context.Context, args []string, logger UpdaterLogger) error {
options := settings.Updater{} options := settings.Updater{}
var endUserMode, maintainerMode, updateAll bool var endUserMode, maintainerMode, updateAll bool
var csvProviders, ipToken, protonUsername, protonPassword string var csvProviders, ipToken, protonUsername, protonEmail, protonPassword string
flagSet := flag.NewFlagSet("update", flag.ExitOnError) flagSet := flag.NewFlagSet("update", flag.ExitOnError)
flagSet.BoolVar(&endUserMode, "enduser", false, "Write results to /gluetun/servers.json (for end users)") flagSet.BoolVar(&endUserMode, "enduser", false, "Write results to /gluetun/servers.json (for end users)")
flagSet.BoolVar(&maintainerMode, "maintainer", false, flagSet.BoolVar(&maintainerMode, "maintainer", false,
@ -50,7 +50,9 @@ func (c *CLI) Update(ctx context.Context, args []string, logger UpdaterLogger) e
flagSet.BoolVar(&updateAll, "all", false, "Update servers for all VPN providers") flagSet.BoolVar(&updateAll, "all", false, "Update servers for all VPN providers")
flagSet.StringVar(&csvProviders, "providers", "", "CSV string of VPN providers to update server data for") flagSet.StringVar(&csvProviders, "providers", "", "CSV string of VPN providers to update server data for")
flagSet.StringVar(&ipToken, "ip-token", "", "IP data service token (e.g. ipinfo.io) to use") flagSet.StringVar(&ipToken, "ip-token", "", "IP data service token (e.g. ipinfo.io) to use")
flagSet.StringVar(&protonUsername, "proton-username", "", "Username to use to authenticate with Proton") flagSet.StringVar(&protonUsername, "proton-username", "",
"(Retro-compatibility) Username to use to authenticate with Proton. Use -proton-email instead.") // v4 remove this
flagSet.StringVar(&protonEmail, "proton-email", "", "Email to use to authenticate with Proton")
flagSet.StringVar(&protonPassword, "proton-password", "", "Password to use to authenticate with Proton") flagSet.StringVar(&protonPassword, "proton-password", "", "Password to use to authenticate with Proton")
if err := flagSet.Parse(args); err != nil { if err := flagSet.Parse(args); err != nil {
return err return err
@ -70,7 +72,12 @@ func (c *CLI) Update(ctx context.Context, args []string, logger UpdaterLogger) e
} }
if slices.Contains(options.Providers, providers.Protonvpn) { if slices.Contains(options.Providers, providers.Protonvpn) {
options.ProtonUsername = &protonUsername if protonEmail == "" && protonUsername != "" {
protonEmail = protonUsername + "@protonmail.com"
logger.Warn("use -proton-email instead of -proton-username in the future. " +
"This assumes the email is " + protonEmail + " and may not work.")
}
options.ProtonEmail = &protonEmail
options.ProtonPassword = &protonPassword options.ProtonPassword = &protonPassword
} }

View File

@ -37,7 +37,7 @@ var (
ErrSystemTimezoneNotValid = errors.New("timezone is not valid") ErrSystemTimezoneNotValid = errors.New("timezone is not valid")
ErrUpdaterPeriodTooSmall = errors.New("VPN server data updater period is too small") ErrUpdaterPeriodTooSmall = errors.New("VPN server data updater period is too small")
ErrUpdaterProtonPasswordMissing = errors.New("proton password is missing") ErrUpdaterProtonPasswordMissing = errors.New("proton password is missing")
ErrUpdaterProtonUsernameMissing = errors.New("proton username is missing") ErrUpdaterProtonEmailMissing = errors.New("proton email is missing")
ErrVPNProviderNameNotValid = errors.New("VPN provider name is not valid") ErrVPNProviderNameNotValid = errors.New("VPN provider name is not valid")
ErrVPNTypeNotValid = errors.New("VPN type is not valid") ErrVPNTypeNotValid = errors.New("VPN type is not valid")
ErrWireguardAllowedIPNotSet = errors.New("allowed IP is not set") ErrWireguardAllowedIPNotSet = errors.New("allowed IP is not set")

View File

@ -32,8 +32,8 @@ type Updater struct {
// Providers is the list of VPN service providers // Providers is the list of VPN service providers
// to update server information for. // to update server information for.
Providers []string Providers []string
// ProtonUsername is the username to authenticate with the Proton API. // ProtonEmail is the email to authenticate with the Proton API.
ProtonUsername *string ProtonEmail *string
// ProtonPassword is the password to authenticate with the Proton API. // ProtonPassword is the password to authenticate with the Proton API.
ProtonPassword *string ProtonPassword *string
} }
@ -58,11 +58,11 @@ func (u Updater) Validate() (err error) {
} }
if provider == providers.Protonvpn { if provider == providers.Protonvpn {
authenticatedAPI := *u.ProtonUsername != "" || *u.ProtonPassword != "" authenticatedAPI := *u.ProtonEmail != "" || *u.ProtonPassword != ""
if authenticatedAPI { if authenticatedAPI {
switch { switch {
case *u.ProtonUsername == "": case *u.ProtonEmail == "":
return fmt.Errorf("%w", ErrUpdaterProtonUsernameMissing) return fmt.Errorf("%w", ErrUpdaterProtonEmailMissing)
case *u.ProtonPassword == "": case *u.ProtonPassword == "":
return fmt.Errorf("%w", ErrUpdaterProtonPasswordMissing) return fmt.Errorf("%w", ErrUpdaterProtonPasswordMissing)
} }
@ -79,7 +79,7 @@ func (u *Updater) copy() (copied Updater) {
DNSAddress: u.DNSAddress, DNSAddress: u.DNSAddress,
MinRatio: u.MinRatio, MinRatio: u.MinRatio,
Providers: gosettings.CopySlice(u.Providers), Providers: gosettings.CopySlice(u.Providers),
ProtonUsername: gosettings.CopyPointer(u.ProtonUsername), ProtonEmail: gosettings.CopyPointer(u.ProtonEmail),
ProtonPassword: gosettings.CopyPointer(u.ProtonPassword), ProtonPassword: gosettings.CopyPointer(u.ProtonPassword),
} }
} }
@ -92,7 +92,7 @@ func (u *Updater) overrideWith(other Updater) {
u.DNSAddress = gosettings.OverrideWithComparable(u.DNSAddress, other.DNSAddress) u.DNSAddress = gosettings.OverrideWithComparable(u.DNSAddress, other.DNSAddress)
u.MinRatio = gosettings.OverrideWithComparable(u.MinRatio, other.MinRatio) u.MinRatio = gosettings.OverrideWithComparable(u.MinRatio, other.MinRatio)
u.Providers = gosettings.OverrideWithSlice(u.Providers, other.Providers) u.Providers = gosettings.OverrideWithSlice(u.Providers, other.Providers)
u.ProtonUsername = gosettings.OverrideWithPointer(u.ProtonUsername, other.ProtonUsername) u.ProtonEmail = gosettings.OverrideWithPointer(u.ProtonEmail, other.ProtonEmail)
u.ProtonPassword = gosettings.OverrideWithPointer(u.ProtonPassword, other.ProtonPassword) u.ProtonPassword = gosettings.OverrideWithPointer(u.ProtonPassword, other.ProtonPassword)
} }
@ -110,7 +110,7 @@ func (u *Updater) SetDefaults(vpnProvider string) {
} }
// Set these to empty strings to avoid nil pointer panics // Set these to empty strings to avoid nil pointer panics
u.ProtonUsername = gosettings.DefaultPointer(u.ProtonUsername, "") u.ProtonEmail = gosettings.DefaultPointer(u.ProtonEmail, "")
u.ProtonPassword = gosettings.DefaultPointer(u.ProtonPassword, "") u.ProtonPassword = gosettings.DefaultPointer(u.ProtonPassword, "")
} }
@ -129,7 +129,7 @@ func (u Updater) toLinesNode() (node *gotree.Node) {
node.Appendf("Minimum ratio: %.1f", u.MinRatio) node.Appendf("Minimum ratio: %.1f", u.MinRatio)
node.Appendf("Providers to update: %s", strings.Join(u.Providers, ", ")) node.Appendf("Providers to update: %s", strings.Join(u.Providers, ", "))
if slices.Contains(u.Providers, providers.Protonvpn) { if slices.Contains(u.Providers, providers.Protonvpn) {
node.Appendf("Proton API username: %s", *u.ProtonUsername) node.Appendf("Proton API email: %s", *u.ProtonEmail)
node.Appendf("Proton API password: %s", gosettings.ObfuscateKey(*u.ProtonPassword)) node.Appendf("Proton API password: %s", gosettings.ObfuscateKey(*u.ProtonPassword))
} }
@ -154,12 +154,7 @@ func (u *Updater) read(r *reader.Reader) (err error) {
u.Providers = r.CSV("UPDATER_VPN_SERVICE_PROVIDERS") u.Providers = r.CSV("UPDATER_VPN_SERVICE_PROVIDERS")
u.ProtonUsername = r.Get("UPDATER_PROTONVPN_USERNAME") u.ProtonEmail = r.Get("UPDATER_PROTONVPN_EMAIL", reader.RetroKeys("UPDATER_PROTONVPN_USERNAME"))
if u.ProtonUsername != nil {
// Enforce to use the username not the email address
*u.ProtonUsername = strings.TrimSuffix(*u.ProtonUsername, "@protonmail.com")
*u.ProtonUsername = strings.TrimSuffix(*u.ProtonUsername, "@proton.me")
}
u.ProtonPassword = r.Get("UPDATER_PROTONVPN_PASSWORD") u.ProtonPassword = r.Get("UPDATER_PROTONVPN_PASSWORD")
return nil return nil

View File

@ -18,12 +18,12 @@ type Provider struct {
func New(storage common.Storage, randSource rand.Source, func New(storage common.Storage, randSource rand.Source,
client *http.Client, updaterWarner common.Warner, client *http.Client, updaterWarner common.Warner,
username, password string, email, password string,
) *Provider { ) *Provider {
return &Provider{ return &Provider{
storage: storage, storage: storage,
randSource: randSource, randSource: randSource,
Fetcher: updater.New(client, updaterWarner, username, password), Fetcher: updater.New(client, updaterWarner, email, password),
} }
} }

View File

@ -76,7 +76,7 @@ func (c *apiClient) setHeaders(request *http.Request, cookie cookie) {
// authenticate performs the full Proton authentication flow // authenticate performs the full Proton authentication flow
// to obtain an authenticated cookie (uid, token and session ID). // to obtain an authenticated cookie (uid, token and session ID).
func (c *apiClient) authenticate(ctx context.Context, username, password string, func (c *apiClient) authenticate(ctx context.Context, email, password string,
) (authCookie cookie, err error) { ) (authCookie cookie, err error) {
sessionID, err := c.getSessionID(ctx) sessionID, err := c.getSessionID(ctx)
if err != nil { if err != nil {
@ -98,8 +98,8 @@ func (c *apiClient) authenticate(ctx context.Context, username, password string,
token: cookieToken, token: cookieToken,
sessionID: sessionID, sessionID: sessionID,
} }
modulusPGPClearSigned, serverEphemeralBase64, saltBase64, username, modulusPGPClearSigned, serverEphemeralBase64, saltBase64,
srpSessionHex, version, err := c.authInfo(ctx, username, unauthCookie) srpSessionHex, version, err := c.authInfo(ctx, email, unauthCookie)
if err != nil { if err != nil {
return cookie{}, fmt.Errorf("getting auth information: %w", err) return cookie{}, fmt.Errorf("getting auth information: %w", err)
} }
@ -118,7 +118,7 @@ func (c *apiClient) authenticate(ctx context.Context, username, password string,
return cookie{}, fmt.Errorf("generating SRP proofs: %w", err) return cookie{}, fmt.Errorf("generating SRP proofs: %w", err)
} }
authCookie, err = c.auth(ctx, unauthCookie, username, srpSessionHex, proofs) authCookie, err = c.auth(ctx, unauthCookie, email, srpSessionHex, proofs)
if err != nil { if err != nil {
return cookie{}, fmt.Errorf("authentifying: %w", err) return cookie{}, fmt.Errorf("authentifying: %w", err)
} }
@ -299,48 +299,45 @@ func (c *apiClient) cookieToken(ctx context.Context, sessionID, tokenType, acces
return "", fmt.Errorf("%w", ErrAuthCookieNotFound) return "", fmt.Errorf("%w", ErrAuthCookieNotFound)
} }
var ( var ErrUsernameDoesNotExist = errors.New("username does not exist")
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. // authInfo fetches SRP parameters for the account.
func (c *apiClient) authInfo(ctx context.Context, username string, unauthCookie cookie) ( func (c *apiClient) authInfo(ctx context.Context, email string, unauthCookie cookie) (
modulusPGPClearSigned, serverEphemeralBase64, saltBase64, srpSessionHex string, username, modulusPGPClearSigned, serverEphemeralBase64, saltBase64, srpSessionHex string,
version int, err error, version int, err error,
) { ) {
type requestBodySchema struct { type requestBodySchema struct {
Intent string `json:"Intent"` // "Proton" Intent string `json:"Intent"` // "Proton"
Username string `json:"Username"` // username without @domain.com Username string `json:"Username"`
} }
requestBody := requestBodySchema{ requestBody := requestBodySchema{
Intent: "Proton", Intent: "Proton",
Username: username, Username: email,
} }
buffer := bytes.NewBuffer(nil) buffer := bytes.NewBuffer(nil)
encoder := json.NewEncoder(buffer) encoder := json.NewEncoder(buffer)
if err := encoder.Encode(requestBody); err != nil { if err := encoder.Encode(requestBody); err != nil {
return "", "", "", "", 0, fmt.Errorf("encoding request body: %w", err) return "", "", "", "", "", 0, fmt.Errorf("encoding request body: %w", err)
} }
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURLBase+"/core/v4/auth/info", buffer) request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURLBase+"/core/v4/auth/info", buffer)
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)
response, err := c.httpClient.Do(request) response, err := c.httpClient.Do(request)
if err != nil { if err != nil {
return "", "", "", "", 0, err return "", "", "", "", "", 0, err
} }
defer response.Body.Close() defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body) responseBody, err := io.ReadAll(response.Body)
if err != nil { if err != nil {
return "", "", "", "", 0, fmt.Errorf("reading response body: %w", err) return "", "", "", "", "", 0, fmt.Errorf("reading response body: %w", err)
} else if response.StatusCode != http.StatusOK { } else if response.StatusCode != http.StatusOK {
return "", "", "", "", 0, buildError(response.StatusCode, responseBody) return "", "", "", "", "", 0, buildError(response.StatusCode, responseBody)
} }
var info struct { var info struct {
@ -354,32 +351,30 @@ func (c *apiClient) authInfo(ctx context.Context, username string, unauthCookie
} }
err = json.Unmarshal(responseBody, &info) err = json.Unmarshal(responseBody, &info)
if err != nil { if err != nil {
return "", "", "", "", 0, fmt.Errorf("decoding response body: %w", err) return "", "", "", "", "", 0, fmt.Errorf("decoding response body: %w", err)
} }
const successCode = 1000 const successCode = 1000
switch { switch {
case info.Code != successCode: case info.Code != successCode:
return "", "", "", "", 0, fmt.Errorf("%w: expected %d got %d", return "", "", "", "", "", 0, fmt.Errorf("%w: expected %d got %d",
ErrCodeNotSuccess, successCode, info.Code) ErrCodeNotSuccess, successCode, info.Code)
case info.Modulus == "": case info.Modulus == "":
return "", "", "", "", 0, fmt.Errorf("%w: modulus is empty", ErrDataFieldMissing) return "", "", "", "", "", 0, fmt.Errorf("%w: modulus is empty", ErrDataFieldMissing)
case info.ServerEphemeral == "": case info.ServerEphemeral == "":
return "", "", "", "", 0, fmt.Errorf("%w: server ephemeral is empty", ErrDataFieldMissing) return "", "", "", "", "", 0, fmt.Errorf("%w: server ephemeral is empty", ErrDataFieldMissing)
case info.Salt == "": case info.Salt == "":
return "", "", "", "", 0, fmt.Errorf("%w (salt data field is empty)", ErrUsernameDoesNotExist) return "", "", "", "", "", 0, fmt.Errorf("%w (salt data field is empty)", ErrUsernameDoesNotExist)
case info.SRPSession == "": case info.SRPSession == "":
return "", "", "", "", 0, fmt.Errorf("%w: SRP session is empty", ErrDataFieldMissing) return "", "", "", "", "", 0, fmt.Errorf("%w: SRP session is empty", ErrDataFieldMissing)
case info.Username == "":
case !strings.EqualFold(info.Username, username): return "", "", "", "", "", 0, fmt.Errorf("%w: username is empty", ErrDataFieldMissing)
return "", "", "", "", 0, fmt.Errorf("%w: expected %s got %s",
ErrUsernameMismatch, username, info.Username)
case info.Version == nil: case info.Version == nil:
return "", "", "", "", 0, fmt.Errorf("%w: version is missing", ErrDataFieldMissing) return "", "", "", "", "", 0, fmt.Errorf("%w: version is missing", ErrDataFieldMissing)
} }
version = int(*info.Version) //nolint:gosec version = int(*info.Version) //nolint:gosec
return info.Modulus, info.ServerEphemeral, info.Salt, return info.Username, info.Modulus, info.ServerEphemeral, info.Salt,
info.SRPSession, version, nil info.SRPSession, version, nil
} }

View File

@ -14,8 +14,8 @@ func (u *Updater) FetchServers(ctx context.Context, minServers int) (
servers []models.Server, err error, servers []models.Server, err error,
) { ) {
switch { switch {
case u.username == "": case u.email == "":
return nil, fmt.Errorf("%w: username is empty", common.ErrCredentialsMissing) return nil, fmt.Errorf("%w: email is empty", common.ErrCredentialsMissing)
case u.password == "": case u.password == "":
return nil, fmt.Errorf("%w: password is empty", common.ErrCredentialsMissing) return nil, fmt.Errorf("%w: password is empty", common.ErrCredentialsMissing)
} }
@ -25,7 +25,7 @@ func (u *Updater) FetchServers(ctx context.Context, minServers int) (
return nil, fmt.Errorf("creating API client: %w", err) return nil, fmt.Errorf("creating API client: %w", err)
} }
cookie, err := apiClient.authenticate(ctx, u.username, u.password) cookie, err := apiClient.authenticate(ctx, u.email, u.password)
if err != nil { if err != nil {
return nil, fmt.Errorf("authentifying with Proton: %w", err) return nil, fmt.Errorf("authentifying with Proton: %w", err)
} }

View File

@ -8,15 +8,15 @@ import (
type Updater struct { type Updater struct {
client *http.Client client *http.Client
username string email string
password string password string
warner common.Warner warner common.Warner
} }
func New(client *http.Client, warner common.Warner, username, password string) *Updater { func New(client *http.Client, warner common.Warner, email, password string) *Updater {
return &Updater{ return &Updater{
client: client, client: client,
username: username, email: email,
password: password, password: password,
warner: warner, warner: warner,
} }

View File

@ -75,7 +75,7 @@ func NewProviders(storage Storage, timeNow func() time.Time,
providers.Privado: privado.New(storage, randSource, ipFetcher, unzipper, updaterWarner, parallelResolver), providers.Privado: privado.New(storage, randSource, ipFetcher, unzipper, updaterWarner, parallelResolver),
providers.PrivateInternetAccess: privateinternetaccess.New(storage, randSource, timeNow, client), providers.PrivateInternetAccess: privateinternetaccess.New(storage, randSource, timeNow, client),
providers.Privatevpn: privatevpn.New(storage, randSource, unzipper, updaterWarner, parallelResolver), providers.Privatevpn: privatevpn.New(storage, randSource, unzipper, updaterWarner, parallelResolver),
providers.Protonvpn: protonvpn.New(storage, randSource, client, updaterWarner, *credentials.ProtonUsername, *credentials.ProtonPassword), providers.Protonvpn: protonvpn.New(storage, randSource, client, updaterWarner, *credentials.ProtonEmail, *credentials.ProtonPassword),
providers.Purevpn: purevpn.New(storage, randSource, ipFetcher, unzipper, updaterWarner, parallelResolver), providers.Purevpn: purevpn.New(storage, randSource, ipFetcher, unzipper, updaterWarner, parallelResolver),
providers.SlickVPN: slickvpn.New(storage, randSource, client, updaterWarner, parallelResolver), providers.SlickVPN: slickvpn.New(storage, randSource, client, updaterWarner, parallelResolver),
providers.Surfshark: surfshark.New(storage, randSource, client, unzipper, updaterWarner, parallelResolver), providers.Surfshark: surfshark.New(storage, randSource, client, unzipper, updaterWarner, parallelResolver),