fix(protonvpn): authenticated servers data updating (#2878)
- `-proton-username` flag for cli update - `-proton-password` flag for cli update - `UPDATER_PROTONVPN_USERNAME` option for periodic updates - `UPDATER_PROTONVPN_PASSWORD` option for periodic updates
This commit is contained in:
@@ -76,7 +76,7 @@ func (c *CLI) OpenvpnConfig(logger OpenvpnConfigLogger, reader *reader.Reader,
|
||||
openvpnFileExtractor := extract.New()
|
||||
|
||||
providers := provider.NewProviders(storage, time.Now, warner, client,
|
||||
unzipper, parallelResolver, ipFetcher, openvpnFileExtractor)
|
||||
unzipper, parallelResolver, ipFetcher, openvpnFileExtractor, allSettings.Updater)
|
||||
providerConf := providers.Get(allSettings.VPN.Provider.Name)
|
||||
connection, err := providerConf.GetConnection(
|
||||
allSettings.VPN.Provider.ServerSelection, ipv6Supported)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -24,6 +25,8 @@ import (
|
||||
var (
|
||||
ErrModeUnspecified = errors.New("at least one of -enduser or -maintainer must be specified")
|
||||
ErrNoProviderSpecified = errors.New("no provider was specified")
|
||||
ErrUsernameMissing = errors.New("username is required for this provider")
|
||||
ErrPasswordMissing = errors.New("password is required for this provider")
|
||||
)
|
||||
|
||||
type UpdaterLogger interface {
|
||||
@@ -35,7 +38,7 @@ type UpdaterLogger interface {
|
||||
func (c *CLI) Update(ctx context.Context, args []string, logger UpdaterLogger) error {
|
||||
options := settings.Updater{}
|
||||
var endUserMode, maintainerMode, updateAll bool
|
||||
var csvProviders, ipToken string
|
||||
var csvProviders, ipToken, protonUsername, protonPassword string
|
||||
flagSet := flag.NewFlagSet("update", flag.ExitOnError)
|
||||
flagSet.BoolVar(&endUserMode, "enduser", false, "Write results to /gluetun/servers.json (for end users)")
|
||||
flagSet.BoolVar(&maintainerMode, "maintainer", false,
|
||||
@@ -47,6 +50,8 @@ func (c *CLI) Update(ctx context.Context, args []string, logger UpdaterLogger) e
|
||||
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(&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(&protonPassword, "proton-password", "", "Password to use to authenticate with Proton")
|
||||
if err := flagSet.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -64,6 +69,11 @@ func (c *CLI) Update(ctx context.Context, args []string, logger UpdaterLogger) e
|
||||
options.Providers = strings.Split(csvProviders, ",")
|
||||
}
|
||||
|
||||
if slices.Contains(options.Providers, providers.Protonvpn) {
|
||||
options.ProtonUsername = &protonUsername
|
||||
options.ProtonPassword = &protonPassword
|
||||
}
|
||||
|
||||
options.SetDefaults(options.Providers[0])
|
||||
|
||||
err := options.Validate()
|
||||
@@ -94,7 +104,7 @@ func (c *CLI) Update(ctx context.Context, args []string, logger UpdaterLogger) e
|
||||
openvpnFileExtractor := extract.New()
|
||||
|
||||
providers := provider.NewProviders(storage, time.Now, logger, httpClient,
|
||||
unzipper, parallelResolver, ipFetcher, openvpnFileExtractor)
|
||||
unzipper, parallelResolver, ipFetcher, openvpnFileExtractor, options)
|
||||
|
||||
updater := updater.New(httpClient, storage, providers, logger)
|
||||
err = updater.UpdateServers(ctx, options.Providers, options.MinRatio)
|
||||
|
||||
@@ -36,6 +36,8 @@ var (
|
||||
ErrSystemPUIDNotValid = errors.New("process user id is not valid")
|
||||
ErrSystemTimezoneNotValid = errors.New("timezone is not valid")
|
||||
ErrUpdaterPeriodTooSmall = errors.New("VPN server data updater period is too small")
|
||||
ErrUpdaterProtonPasswordMissing = errors.New("proton password is missing")
|
||||
ErrUpdaterProtonUsernameMissing = errors.New("proton username is missing")
|
||||
ErrVPNProviderNameNotValid = errors.New("VPN provider name is not valid")
|
||||
ErrVPNTypeNotValid = errors.New("VPN type is not valid")
|
||||
ErrWireguardAllowedIPNotSet = errors.New("allowed IP is not set")
|
||||
|
||||
@@ -2,6 +2,7 @@ package settings
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -31,6 +32,10 @@ type Updater struct {
|
||||
// Providers is the list of VPN service providers
|
||||
// to update server information for.
|
||||
Providers []string
|
||||
// ProtonUsername is the username to authenticate with the Proton API.
|
||||
ProtonUsername *string
|
||||
// ProtonPassword is the password to authenticate with the Proton API.
|
||||
ProtonPassword *string
|
||||
}
|
||||
|
||||
func (u Updater) Validate() (err error) {
|
||||
@@ -51,6 +56,18 @@ func (u Updater) Validate() (err error) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrVPNProviderNameNotValid, err)
|
||||
}
|
||||
|
||||
if provider == providers.Protonvpn {
|
||||
authenticatedAPI := *u.ProtonUsername == "" || *u.ProtonPassword == ""
|
||||
if authenticatedAPI {
|
||||
switch {
|
||||
case *u.ProtonUsername == "":
|
||||
return fmt.Errorf("%w", ErrUpdaterProtonUsernameMissing)
|
||||
case *u.ProtonPassword == "":
|
||||
return fmt.Errorf("%w", ErrUpdaterProtonPasswordMissing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -58,10 +75,12 @@ func (u Updater) Validate() (err error) {
|
||||
|
||||
func (u *Updater) copy() (copied Updater) {
|
||||
return Updater{
|
||||
Period: gosettings.CopyPointer(u.Period),
|
||||
DNSAddress: u.DNSAddress,
|
||||
MinRatio: u.MinRatio,
|
||||
Providers: gosettings.CopySlice(u.Providers),
|
||||
Period: gosettings.CopyPointer(u.Period),
|
||||
DNSAddress: u.DNSAddress,
|
||||
MinRatio: u.MinRatio,
|
||||
Providers: gosettings.CopySlice(u.Providers),
|
||||
ProtonUsername: gosettings.CopyPointer(u.ProtonUsername),
|
||||
ProtonPassword: gosettings.CopyPointer(u.ProtonPassword),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +92,8 @@ func (u *Updater) overrideWith(other Updater) {
|
||||
u.DNSAddress = gosettings.OverrideWithComparable(u.DNSAddress, other.DNSAddress)
|
||||
u.MinRatio = gosettings.OverrideWithComparable(u.MinRatio, other.MinRatio)
|
||||
u.Providers = gosettings.OverrideWithSlice(u.Providers, other.Providers)
|
||||
u.ProtonUsername = gosettings.OverrideWithPointer(u.ProtonUsername, other.ProtonUsername)
|
||||
u.ProtonPassword = gosettings.OverrideWithPointer(u.ProtonPassword, other.ProtonPassword)
|
||||
}
|
||||
|
||||
func (u *Updater) SetDefaults(vpnProvider string) {
|
||||
@@ -87,6 +108,10 @@ func (u *Updater) SetDefaults(vpnProvider string) {
|
||||
if len(u.Providers) == 0 && vpnProvider != providers.Custom {
|
||||
u.Providers = []string{vpnProvider}
|
||||
}
|
||||
|
||||
// Set these to empty strings to avoid nil pointer panics
|
||||
u.ProtonUsername = gosettings.DefaultPointer(u.ProtonUsername, "")
|
||||
u.ProtonPassword = gosettings.DefaultPointer(u.ProtonPassword, "")
|
||||
}
|
||||
|
||||
func (u Updater) String() string {
|
||||
@@ -103,6 +128,10 @@ func (u Updater) toLinesNode() (node *gotree.Node) {
|
||||
node.Appendf("DNS address: %s", u.DNSAddress)
|
||||
node.Appendf("Minimum ratio: %.1f", u.MinRatio)
|
||||
node.Appendf("Providers to update: %s", strings.Join(u.Providers, ", "))
|
||||
if slices.Contains(u.Providers, providers.Protonvpn) {
|
||||
node.Appendf("Proton API username: %s", *u.ProtonUsername)
|
||||
node.Appendf("Proton API password: %s", gosettings.ObfuscateKey(*u.ProtonPassword))
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
@@ -125,6 +154,14 @@ func (u *Updater) read(r *reader.Reader) (err error) {
|
||||
|
||||
u.Providers = r.CSV("UPDATER_VPN_SERVICE_PROVIDERS")
|
||||
|
||||
u.ProtonUsername = r.Get("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")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ var (
|
||||
ErrNotEnoughServers = errors.New("not enough servers found")
|
||||
ErrHTTPStatusCodeNotOK = errors.New("HTTP status code not OK")
|
||||
ErrIPFetcherUnsupported = errors.New("IP fetcher not supported")
|
||||
ErrCredentialsMissing = errors.New("credentials missing")
|
||||
)
|
||||
|
||||
type Fetcher interface {
|
||||
|
||||
@@ -18,11 +18,12 @@ type Provider struct {
|
||||
|
||||
func New(storage common.Storage, randSource rand.Source,
|
||||
client *http.Client, updaterWarner common.Warner,
|
||||
username, password string,
|
||||
) *Provider {
|
||||
return &Provider{
|
||||
storage: storage,
|
||||
randSource: randSource,
|
||||
Fetcher: updater.New(client, updaterWarner),
|
||||
Fetcher: updater.New(client, updaterWarner, username, password),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,567 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
crand "crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
srp "github.com/ProtonMail/go-srp"
|
||||
)
|
||||
|
||||
var ErrHTTPStatusCodeNotOK = errors.New("HTTP status code not OK")
|
||||
// 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
|
||||
}
|
||||
|
||||
// newAPIClient returns an [apiClient] with sane defaults matching Proton's
|
||||
// insane expectations.
|
||||
func newAPIClient(ctx context.Context, httpClient *http.Client) (client *apiClient, err error) {
|
||||
var seed [32]byte
|
||||
_, _ = crand.Read(seed[:])
|
||||
generator := rand.NewChaCha8(seed)
|
||||
|
||||
// Pick a random user agent from this list. Because I'm not going to tell
|
||||
// Proton shit on where all these funny requests are coming from, given their
|
||||
// unhelpfulness in figuring out their authentication flow.
|
||||
userAgents := [...]string{
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:143.0) Gecko/20100101 Firefox/143.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:143.0) Gecko/20100101 Firefox/143.0",
|
||||
"Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Gecko/20100101 Firefox/143.0",
|
||||
}
|
||||
userAgent := userAgents[generator.Uint64()%uint64(len(userAgents))]
|
||||
|
||||
appVersion, err := getMostRecentStableTag(ctx, httpClient)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting most recent version for proton app: %w", err)
|
||||
}
|
||||
|
||||
return &apiClient{
|
||||
apiURLBase: "https://account.proton.me/api",
|
||||
httpClient: httpClient,
|
||||
appVersion: appVersion,
|
||||
userAgent: userAgent,
|
||||
generator: generator,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var ErrCodeNotSuccess = errors.New("response code is not success")
|
||||
|
||||
// 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) {
|
||||
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-locale", "en_US")
|
||||
request.Header.Set("x-pm-uid", cookie.uid)
|
||||
}
|
||||
|
||||
// authenticate performs the full Proton authentication flow
|
||||
// to obtain an authenticated cookie (uid, token and session ID).
|
||||
func (c *apiClient) authenticate(ctx context.Context, username, password string,
|
||||
) (authCookie cookie, err error) {
|
||||
sessionID, err := c.getSessionID(ctx)
|
||||
if err != nil {
|
||||
return cookie{}, fmt.Errorf("getting session ID: %w", err)
|
||||
}
|
||||
|
||||
tokenType, accessToken, refreshToken, uid, err := c.getUnauthSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return cookie{}, fmt.Errorf("getting unauthenticated session data: %w", err)
|
||||
}
|
||||
|
||||
cookieToken, err := c.cookieToken(ctx, sessionID, tokenType, accessToken, refreshToken, uid)
|
||||
if err != nil {
|
||||
return cookie{}, fmt.Errorf("getting cookie token: %w", err)
|
||||
}
|
||||
|
||||
unauthCookie := cookie{
|
||||
uid: uid,
|
||||
token: cookieToken,
|
||||
sessionID: sessionID,
|
||||
}
|
||||
modulusPGPClearSigned, serverEphemeralBase64, saltBase64,
|
||||
srpSessionHex, version, err := c.authInfo(ctx, username, unauthCookie)
|
||||
if err != nil {
|
||||
return cookie{}, fmt.Errorf("getting auth information: %w", err)
|
||||
}
|
||||
|
||||
// Prepare SRP proof generator using Proton's official SRP parameters and hashing.
|
||||
srpAuth, err := srp.NewAuth(version, username, []byte(password),
|
||||
saltBase64, modulusPGPClearSigned, serverEphemeralBase64)
|
||||
if err != nil {
|
||||
return cookie{}, fmt.Errorf("initializing SRP auth: %w", err)
|
||||
}
|
||||
|
||||
// Generate SRP proofs (A, M1) with the usual 2048-bit modulus.
|
||||
const modulusBits = 2048
|
||||
proofs, err := srpAuth.GenerateProofs(modulusBits)
|
||||
if err != nil {
|
||||
return cookie{}, fmt.Errorf("generating SRP proofs: %w", err)
|
||||
}
|
||||
|
||||
authCookie, err = c.auth(ctx, unauthCookie, username, srpSessionHex, proofs)
|
||||
if err != nil {
|
||||
return cookie{}, fmt.Errorf("authentifying: %w", err)
|
||||
}
|
||||
|
||||
return authCookie, nil
|
||||
}
|
||||
|
||||
var ErrSessionIDNotFound = errors.New("session ID not found in cookies")
|
||||
|
||||
func (c *apiClient) getSessionID(ctx context.Context) (sessionID string, err error) {
|
||||
const url = "https://account.proton.me/vpn"
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
err = response.Body.Close()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("closing response body: %w", err)
|
||||
}
|
||||
|
||||
for _, cookie := range response.Cookies() {
|
||||
if cookie.Name == "Session-Id" {
|
||||
return cookie.Value, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("%w", ErrSessionIDNotFound)
|
||||
}
|
||||
|
||||
var ErrDataFieldMissing = errors.New("data field missing in response")
|
||||
|
||||
func (c *apiClient) getUnauthSession(ctx context.Context, sessionID string) (
|
||||
tokenType, accessToken, refreshToken, uid string, err error,
|
||||
) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURLBase+"/auth/v4/sessions", nil)
|
||||
if err != nil {
|
||||
return "", "", "", "", fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
unauthCookie := cookie{
|
||||
sessionID: sessionID,
|
||||
}
|
||||
c.setHeaders(request, unauthCookie)
|
||||
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return "", "", "", "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
responseBody, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return "", "", "", "", fmt.Errorf("reading response body: %w", err)
|
||||
} else if response.StatusCode != http.StatusOK {
|
||||
return "", "", "", "", buildError(response.StatusCode, responseBody)
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Code uint `json:"Code"` // 1000 on success
|
||||
AccessToken string `json:"AccessToken"` // 32-chars lowercase and digits
|
||||
RefreshToken string `json:"RefreshToken"` // 32-chars lowercase and digits
|
||||
TokenType string `json:"TokenType"` // "Bearer"
|
||||
Scopes []string `json:"Scopes"` // should be [] for our usage
|
||||
UID string `json:"UID"` // 32-chars lowercase and digits
|
||||
LocalID uint `json:"LocalID"` // 0 in my case
|
||||
}
|
||||
|
||||
err = json.Unmarshal(responseBody, &data)
|
||||
if err != nil {
|
||||
return "", "", "", "", fmt.Errorf("decoding response body: %w", err)
|
||||
}
|
||||
|
||||
const successCode = 1000
|
||||
switch {
|
||||
case data.Code != successCode:
|
||||
return "", "", "", "", fmt.Errorf("%w: expected %d got %d",
|
||||
ErrCodeNotSuccess, successCode, data.Code)
|
||||
case data.AccessToken == "":
|
||||
return "", "", "", "", fmt.Errorf("%w: access token is empty", ErrDataFieldMissing)
|
||||
case data.RefreshToken == "":
|
||||
return "", "", "", "", fmt.Errorf("%w: refresh token is empty", ErrDataFieldMissing)
|
||||
case data.TokenType == "":
|
||||
return "", "", "", "", fmt.Errorf("%w: token type is empty", ErrDataFieldMissing)
|
||||
case data.UID == "":
|
||||
return "", "", "", "", fmt.Errorf("%w: UID is empty", ErrDataFieldMissing)
|
||||
}
|
||||
// Ignore Scopes and LocalID fields, we don't use them.
|
||||
|
||||
return data.TokenType, data.AccessToken, data.RefreshToken, data.UID, nil
|
||||
}
|
||||
|
||||
var ErrUIDMismatch = errors.New("UID in response does not match request UID")
|
||||
|
||||
func (c *apiClient) cookieToken(ctx context.Context, sessionID, tokenType, accessToken,
|
||||
refreshToken, uid string,
|
||||
) (cookieToken string, err error) {
|
||||
type requestBodySchema struct {
|
||||
GrantType string `json:"GrantType"` // "refresh_token"
|
||||
Persistent uint `json:"Persistent"` // 0
|
||||
RedirectURI string `json:"RedirectURI"` // "https://protonmail.com"
|
||||
RefreshToken string `json:"RefreshToken"` // 32-chars lowercase and digits
|
||||
ResponseType string `json:"ResponseType"` // "token"
|
||||
State string `json:"State"` // 24-chars letters and digits
|
||||
UID string `json:"UID"` // 32-chars lowercase and digits
|
||||
}
|
||||
requestBody := requestBodySchema{
|
||||
GrantType: "refresh_token",
|
||||
Persistent: 0,
|
||||
RedirectURI: "https://protonmail.com",
|
||||
RefreshToken: refreshToken,
|
||||
ResponseType: "token",
|
||||
State: generateLettersDigits(c.generator, 24), //nolint:mnd
|
||||
UID: uid,
|
||||
}
|
||||
|
||||
buffer := bytes.NewBuffer(nil)
|
||||
encoder := json.NewEncoder(buffer)
|
||||
if err := encoder.Encode(requestBody); err != nil {
|
||||
return "", fmt.Errorf("encoding request body: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURLBase+"/core/v4/auth/cookies", buffer)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
unauthCookie := cookie{
|
||||
uid: uid,
|
||||
sessionID: sessionID,
|
||||
}
|
||||
c.setHeaders(request, unauthCookie)
|
||||
request.Header.Set("Authorization", tokenType+" "+accessToken)
|
||||
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
responseBody, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading response body: %w", err)
|
||||
} else if response.StatusCode != http.StatusOK {
|
||||
return "", buildError(response.StatusCode, responseBody)
|
||||
}
|
||||
|
||||
var cookies struct {
|
||||
Code uint `json:"Code"` // 1000 on success
|
||||
UID string `json:"UID"` // should match request UID
|
||||
LocalID uint `json:"LocalID"` // 0
|
||||
RefreshCounter uint `json:"RefreshCounter"` // 1
|
||||
}
|
||||
err = json.Unmarshal(responseBody, &cookies)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decoding response body: %w", err)
|
||||
}
|
||||
|
||||
const successCode = 1000
|
||||
switch {
|
||||
case cookies.Code != successCode:
|
||||
return "", fmt.Errorf("%w: expected %d got %d",
|
||||
ErrCodeNotSuccess, successCode, cookies.Code)
|
||||
case cookies.UID != requestBody.UID:
|
||||
return "", fmt.Errorf("%w: expected %s got %s",
|
||||
ErrUIDMismatch, requestBody.UID, cookies.UID)
|
||||
}
|
||||
// Ignore LocalID and RefreshCounter fields, we don't use them.
|
||||
|
||||
for _, cookie := range response.Cookies() {
|
||||
if cookie.Name == "AUTH-"+uid {
|
||||
return cookie.Value, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("%w", ErrAuthCookieNotFound)
|
||||
}
|
||||
|
||||
var (
|
||||
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.
|
||||
func (c *apiClient) authInfo(ctx context.Context, username string, unauthCookie cookie) (
|
||||
modulusPGPClearSigned, serverEphemeralBase64, saltBase64, srpSessionHex string,
|
||||
version int, err error,
|
||||
) {
|
||||
type requestBodySchema struct {
|
||||
Intent string `json:"Intent"` // "Proton"
|
||||
Username string `json:"Username"` // username without @domain.com
|
||||
}
|
||||
requestBody := requestBodySchema{
|
||||
Intent: "Proton",
|
||||
Username: username,
|
||||
}
|
||||
|
||||
buffer := bytes.NewBuffer(nil)
|
||||
encoder := json.NewEncoder(buffer)
|
||||
if err := encoder.Encode(requestBody); err != nil {
|
||||
return "", "", "", "", 0, fmt.Errorf("encoding request body: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURLBase+"/core/v4/auth/info", buffer)
|
||||
if err != nil {
|
||||
return "", "", "", "", 0, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
c.setHeaders(request, unauthCookie)
|
||||
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return "", "", "", "", 0, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
responseBody, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return "", "", "", "", 0, fmt.Errorf("reading response body: %w", err)
|
||||
} else if response.StatusCode != http.StatusOK {
|
||||
return "", "", "", "", 0, buildError(response.StatusCode, responseBody)
|
||||
}
|
||||
|
||||
var info struct {
|
||||
Code uint `json:"Code"` // 1000 on success
|
||||
Modulus string `json:"Modulus"` // PGP clearsigned modulus string
|
||||
ServerEphemeral string `json:"ServerEphemeral"` // base64
|
||||
Version *uint `json:"Version,omitempty"` // 4 as of 2025-10-26
|
||||
Salt string `json:"Salt"` // base64
|
||||
SRPSession string `json:"SRPSession"` // hexadecimal
|
||||
Username string `json:"Username"` // user without @domain.com. Mine has its first letter capitalized.
|
||||
}
|
||||
err = json.Unmarshal(responseBody, &info)
|
||||
if err != nil {
|
||||
return "", "", "", "", 0, fmt.Errorf("decoding response body: %w", err)
|
||||
}
|
||||
|
||||
const successCode = 1000
|
||||
switch {
|
||||
case info.Code != successCode:
|
||||
return "", "", "", "", 0, fmt.Errorf("%w: expected %d got %d",
|
||||
ErrCodeNotSuccess, successCode, info.Code)
|
||||
case info.Modulus == "":
|
||||
return "", "", "", "", 0, fmt.Errorf("%w: modulus is empty", ErrDataFieldMissing)
|
||||
case info.ServerEphemeral == "":
|
||||
return "", "", "", "", 0, fmt.Errorf("%w: server ephemeral is empty", ErrDataFieldMissing)
|
||||
case info.Salt == "":
|
||||
return "", "", "", "", 0, fmt.Errorf("%w (salt data field is empty)", ErrUsernameDoesNotExist)
|
||||
case info.SRPSession == "":
|
||||
return "", "", "", "", 0, fmt.Errorf("%w: SRP session is empty", ErrDataFieldMissing)
|
||||
|
||||
case info.Username != username:
|
||||
return "", "", "", "", 0, fmt.Errorf("%w: expected %s got %s",
|
||||
ErrUsernameMismatch, username, info.Username)
|
||||
case info.Version == nil:
|
||||
return "", "", "", "", 0, fmt.Errorf("%w: version is missing", ErrDataFieldMissing)
|
||||
}
|
||||
|
||||
version = int(*info.Version) //nolint:gosec
|
||||
return info.Modulus, info.ServerEphemeral, info.Salt,
|
||||
info.SRPSession, version, nil
|
||||
}
|
||||
|
||||
type cookie struct {
|
||||
uid string
|
||||
token string
|
||||
sessionID string
|
||||
}
|
||||
|
||||
func (c *cookie) String() string {
|
||||
s := ""
|
||||
if c.token != "" {
|
||||
s += fmt.Sprintf("AUTH-%s=%s; ", c.uid, c.token)
|
||||
}
|
||||
if c.sessionID != "" {
|
||||
s += fmt.Sprintf("Session-Id=%s; ", c.sessionID)
|
||||
}
|
||||
if c.token != "" {
|
||||
s += "Tag=default; iaas=W10; Domain=proton.me; Feature=VPNDashboard:A"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrServerProofNotValid indicates the M2 from the server didn't match the expected proof.
|
||||
ErrServerProofNotValid = errors.New("server proof from server is not valid")
|
||||
ErrVPNScopeNotFound = errors.New("VPN scope not found in scopes")
|
||||
ErrTwoFANotSupported = errors.New("two factor authentication not supported in this client")
|
||||
ErrAuthCookieNotFound = errors.New("auth cookie not found")
|
||||
)
|
||||
|
||||
// auth performs the SRP proof submission (and optionally TOTP) to obtain tokens.
|
||||
func (c *apiClient) auth(ctx context.Context, unauthCookie cookie,
|
||||
username, srpSession string, proofs *srp.Proofs,
|
||||
) (authCookie cookie, err error) {
|
||||
clientEphemeral := base64.StdEncoding.EncodeToString(proofs.ClientEphemeral)
|
||||
clientProof := base64.StdEncoding.EncodeToString(proofs.ClientProof)
|
||||
|
||||
type requestBodySchema struct {
|
||||
ClientEphemeral string `json:"ClientEphemeral"` // base64(A)
|
||||
ClientProof string `json:"ClientProof"` // base64(M1)
|
||||
Payload map[string]string `json:"Payload,omitempty"` // not sure
|
||||
SRPSession string `json:"SRPSession"` // hexadecimal
|
||||
Username string `json:"Username"` // user@protonmail.com
|
||||
}
|
||||
requestBody := requestBodySchema{
|
||||
ClientEphemeral: clientEphemeral,
|
||||
ClientProof: clientProof,
|
||||
SRPSession: srpSession,
|
||||
Username: username,
|
||||
}
|
||||
|
||||
buffer := bytes.NewBuffer(nil)
|
||||
encoder := json.NewEncoder(buffer)
|
||||
if err := encoder.Encode(requestBody); err != nil {
|
||||
return cookie{}, fmt.Errorf("encoding request body: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURLBase+"/core/v4/auth", buffer)
|
||||
if err != nil {
|
||||
return cookie{}, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
c.setHeaders(request, unauthCookie)
|
||||
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return cookie{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
responseBody, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return cookie{}, fmt.Errorf("reading response body: %w", err)
|
||||
} else if response.StatusCode != http.StatusOK {
|
||||
return cookie{}, buildError(response.StatusCode, responseBody)
|
||||
}
|
||||
|
||||
type twoFAStatus uint
|
||||
//nolint:unused
|
||||
const (
|
||||
twoFADisabled twoFAStatus = iota
|
||||
twoFAHasTOTP
|
||||
twoFAHasFIDO2
|
||||
twoFAHasFIDO2AndTOTP
|
||||
)
|
||||
type twoFAInfo struct {
|
||||
Enabled twoFAStatus `json:"Enabled"`
|
||||
FIDO2 struct {
|
||||
AuthenticationOptions any `json:"AuthenticationOptions"`
|
||||
RegisteredKeys []any `json:"RegisteredKeys"`
|
||||
} `json:"FIDO2"`
|
||||
TOTP uint `json:"TOTP"`
|
||||
}
|
||||
|
||||
var auth struct {
|
||||
Code uint `json:"Code"` // 1000 on success
|
||||
LocalID uint `json:"LocalID"` // 7 in my case
|
||||
Scopes []string `json:"Scopes"` // this should contain "vpn". Same as `Scope` field value.
|
||||
UID string `json:"UID"` // same as `Uid` field value
|
||||
UserID string `json:"UserID"` // base64
|
||||
EventID string `json:"EventID"` // base64
|
||||
PasswordMode uint `json:"PasswordMode"` // 1 in my case
|
||||
ServerProof string `json:"ServerProof"` // base64(M2)
|
||||
TwoFactor uint `json:"TwoFactor"` // 0 if 2FA not required
|
||||
TwoFA twoFAInfo `json:"2FA"`
|
||||
TemporaryPassword uint `json:"TemporaryPassword"` // 0 in my case
|
||||
}
|
||||
|
||||
err = json.Unmarshal(responseBody, &auth)
|
||||
if err != nil {
|
||||
return cookie{}, fmt.Errorf("decoding response body: %w", err)
|
||||
}
|
||||
|
||||
m2, err := base64.StdEncoding.DecodeString(auth.ServerProof)
|
||||
if err != nil {
|
||||
return cookie{}, fmt.Errorf("decoding server proof: %w", err)
|
||||
}
|
||||
if !bytes.Equal(m2, proofs.ExpectedServerProof) {
|
||||
return cookie{}, fmt.Errorf("%w: expected %x got %x",
|
||||
ErrServerProofNotValid, proofs.ExpectedServerProof, m2)
|
||||
}
|
||||
|
||||
const successCode = 1000
|
||||
switch {
|
||||
case auth.Code != successCode:
|
||||
return cookie{}, fmt.Errorf("%w: expected %d got %d",
|
||||
ErrCodeNotSuccess, successCode, auth.Code)
|
||||
case auth.UID != unauthCookie.uid:
|
||||
return cookie{}, fmt.Errorf("%w: expected %s got %s",
|
||||
ErrUIDMismatch, unauthCookie.uid, auth.UID)
|
||||
case auth.TwoFactor != 0:
|
||||
return cookie{}, fmt.Errorf("%w", ErrTwoFANotSupported)
|
||||
case !slices.Contains(auth.Scopes, "vpn"):
|
||||
return cookie{}, fmt.Errorf("%w: in %v", ErrVPNScopeNotFound, auth.Scopes)
|
||||
}
|
||||
|
||||
for _, setCookieHeader := range response.Header.Values("Set-Cookie") {
|
||||
parts := strings.Split(setCookieHeader, ";")
|
||||
for _, part := range parts {
|
||||
if strings.HasPrefix(part, "AUTH-"+unauthCookie.uid+"=") {
|
||||
authCookie = unauthCookie
|
||||
authCookie.token = strings.TrimPrefix(part, "AUTH-"+unauthCookie.uid+"=")
|
||||
return authCookie, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cookie{}, fmt.Errorf("%w: in HTTP headers %s",
|
||||
ErrAuthCookieNotFound, httpHeadersToString(response.Header))
|
||||
}
|
||||
|
||||
// generateLettersDigits mimicing Proton's own random string generator:
|
||||
// https://github.com/ProtonMail/WebClients/blob/e4d7e4ab9babe15b79a131960185f9f8275512cd/packages/utils/generateLettersDigits.ts
|
||||
func generateLettersDigits(rng *rand.ChaCha8, length uint) string {
|
||||
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
return generateFromCharset(rng, length, charset)
|
||||
}
|
||||
|
||||
func generateFromCharset(rng *rand.ChaCha8, length uint, charset string) string {
|
||||
result := make([]byte, length)
|
||||
randomBytes := make([]byte, length)
|
||||
_, _ = rng.Read(randomBytes)
|
||||
for i := range length {
|
||||
result[i] = charset[int(randomBytes[i])%len(charset)]
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func httpHeadersToString(headers http.Header) string {
|
||||
var builder strings.Builder
|
||||
first := true
|
||||
for key, values := range headers {
|
||||
for _, value := range values {
|
||||
if !first {
|
||||
builder.WriteString(", ")
|
||||
}
|
||||
builder.WriteString(fmt.Sprintf("%s: %s", key, value))
|
||||
first = false
|
||||
}
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
type apiData struct {
|
||||
LogicalServers []logicalServer `json:"LogicalServers"`
|
||||
@@ -33,25 +585,25 @@ type physicalServer struct {
|
||||
X25519PublicKey string `json:"X25519PublicKey"`
|
||||
}
|
||||
|
||||
func fetchAPI(ctx context.Context, client *http.Client) (
|
||||
func (c *apiClient) fetchServers(ctx context.Context, cookie cookie) (
|
||||
data apiData, err error,
|
||||
) {
|
||||
const url = "https://api.protonmail.ch/vpn/logicals"
|
||||
|
||||
const url = "https://account.proton.me/api/vpn/logicals"
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
c.setHeaders(request, cookie)
|
||||
|
||||
response, err := client.Do(request)
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return data, fmt.Errorf("%w: %d %s", ErrHTTPStatusCodeNotOK,
|
||||
response.StatusCode, response.Status)
|
||||
b, _ := io.ReadAll(response.Body)
|
||||
return data, buildError(response.StatusCode, b)
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(response.Body)
|
||||
@@ -59,9 +611,31 @@ func fetchAPI(ctx context.Context, client *http.Client) (
|
||||
return data, fmt.Errorf("decoding response body: %w", err)
|
||||
}
|
||||
|
||||
if err := response.Body.Close(); err != nil {
|
||||
return data, err
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
var ErrHTTPStatusCodeNotOK = errors.New("HTTP status code not OK")
|
||||
|
||||
func buildError(httpCode int, body []byte) error {
|
||||
prettyCode := http.StatusText(httpCode)
|
||||
var protonError struct {
|
||||
Code *int `json:"Code,omitempty"`
|
||||
Error *string `json:"Error,omitempty"`
|
||||
Details map[string]string `json:"Details"`
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.DisallowUnknownFields()
|
||||
err := decoder.Decode(&protonError)
|
||||
if err != nil || protonError.Error == nil || protonError.Code == nil {
|
||||
return fmt.Errorf("%w: %s: %s",
|
||||
ErrHTTPStatusCodeNotOK, prettyCode, body)
|
||||
}
|
||||
|
||||
details := make([]string, 0, len(protonError.Details))
|
||||
for key, value := range protonError.Details {
|
||||
details = append(details, fmt.Sprintf("%s: %s", key, value))
|
||||
}
|
||||
|
||||
return fmt.Errorf("%w: %s: %s (code %d with details: %s)",
|
||||
ErrHTTPStatusCodeNotOK, prettyCode, *protonError.Error, *protonError.Code, strings.Join(details, ", "))
|
||||
}
|
||||
|
||||
@@ -13,9 +13,26 @@ import (
|
||||
func (u *Updater) FetchServers(ctx context.Context, minServers int) (
|
||||
servers []models.Server, err error,
|
||||
) {
|
||||
data, err := fetchAPI(ctx, u.client)
|
||||
switch {
|
||||
case u.username == "":
|
||||
return nil, fmt.Errorf("%w: username is empty", common.ErrCredentialsMissing)
|
||||
case u.password == "":
|
||||
return nil, fmt.Errorf("%w: password is empty", common.ErrCredentialsMissing)
|
||||
}
|
||||
|
||||
apiClient, err := newAPIClient(ctx, u.client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("creating API client: %w", err)
|
||||
}
|
||||
|
||||
cookie, err := apiClient.authenticate(ctx, u.username, u.password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("authentifying with Proton: %w", err)
|
||||
}
|
||||
|
||||
data, err := apiClient.fetchServers(ctx, cookie)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching logical servers: %w", err)
|
||||
}
|
||||
|
||||
countryCodes := constants.CountryCodes()
|
||||
|
||||
@@ -7,13 +7,17 @@ import (
|
||||
)
|
||||
|
||||
type Updater struct {
|
||||
client *http.Client
|
||||
warner common.Warner
|
||||
client *http.Client
|
||||
username string
|
||||
password string
|
||||
warner common.Warner
|
||||
}
|
||||
|
||||
func New(client *http.Client, warner common.Warner) *Updater {
|
||||
func New(client *http.Client, warner common.Warner, username, password string) *Updater {
|
||||
return &Updater{
|
||||
client: client,
|
||||
warner: warner,
|
||||
client: client,
|
||||
username: username,
|
||||
password: password,
|
||||
warner: warner,
|
||||
}
|
||||
}
|
||||
|
||||
64
internal/provider/protonvpn/updater/version.go
Normal file
64
internal/provider/protonvpn/updater/version.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// getMostRecentStableTag 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) {
|
||||
page := 1
|
||||
regexVersion := regexp.MustCompile(`^proton-account@(\d+\.\d+\.\d+\.\d+)$`)
|
||||
for ctx.Err() == nil {
|
||||
url := "https://api.github.com/repos/ProtonMail/WebClients/tags?per_page=30&page=" + fmt.Sprint(page)
|
||||
|
||||
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()
|
||||
|
||||
data, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading response body: %w", err)
|
||||
}
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("%w: %s: %s", ErrHTTPStatusCodeNotOK, response.Status, data)
|
||||
}
|
||||
|
||||
var tags []struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
err = json.Unmarshal(data, &tags)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decoding JSON response: %w", err)
|
||||
}
|
||||
|
||||
for _, tag := range tags {
|
||||
if !regexVersion.MatchString(tag.Name) {
|
||||
continue
|
||||
}
|
||||
version := "web-account@" + strings.TrimPrefix(tag.Name, "proton-account@")
|
||||
return version, nil
|
||||
}
|
||||
|
||||
page++
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("%w (queried %d pages)", context.Canceled, page)
|
||||
}
|
||||
@@ -54,7 +54,7 @@ type Extractor interface {
|
||||
func NewProviders(storage Storage, timeNow func() time.Time,
|
||||
updaterWarner common.Warner, client *http.Client, unzipper common.Unzipper,
|
||||
parallelResolver common.ParallelResolver, ipFetcher common.IPFetcher,
|
||||
extractor custom.Extractor,
|
||||
extractor custom.Extractor, credentials settings.Updater,
|
||||
) *Providers {
|
||||
randSource := rand.NewSource(timeNow().UnixNano())
|
||||
|
||||
@@ -75,7 +75,7 @@ func NewProviders(storage Storage, timeNow func() time.Time,
|
||||
providers.Privado: privado.New(storage, randSource, ipFetcher, unzipper, updaterWarner, parallelResolver),
|
||||
providers.PrivateInternetAccess: privateinternetaccess.New(storage, randSource, timeNow, client),
|
||||
providers.Privatevpn: privatevpn.New(storage, randSource, unzipper, updaterWarner, parallelResolver),
|
||||
providers.Protonvpn: protonvpn.New(storage, randSource, client, updaterWarner),
|
||||
providers.Protonvpn: protonvpn.New(storage, randSource, client, updaterWarner, *credentials.ProtonUsername, *credentials.ProtonPassword),
|
||||
providers.Purevpn: purevpn.New(storage, randSource, ipFetcher, unzipper, updaterWarner, parallelResolver),
|
||||
providers.SlickVPN: slickvpn.New(storage, randSource, client, updaterWarner, parallelResolver),
|
||||
providers.Surfshark: surfshark.New(storage, randSource, client, unzipper, updaterWarner, parallelResolver),
|
||||
|
||||
@@ -29,7 +29,7 @@ func (u *Updater) updateProvider(ctx context.Context, provider Provider,
|
||||
u.logger.Warn("note: if running the update manually, you can use the flag " +
|
||||
"-minratio to allow the update to succeed with less servers found")
|
||||
}
|
||||
return fmt.Errorf("getting servers: %w", err)
|
||||
return fmt.Errorf("getting %s servers: %w", providerName, err)
|
||||
}
|
||||
|
||||
for _, server := range servers {
|
||||
|
||||
@@ -2,9 +2,11 @@ package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/qdm12/gluetun/internal/provider/common"
|
||||
"github.com/qdm12/gluetun/internal/updater/unzip"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
@@ -48,22 +50,22 @@ func (u *Updater) UpdateServers(ctx context.Context, providers []string,
|
||||
// TODO support servers offering only TCP or only UDP
|
||||
// for NordVPN and PureVPN
|
||||
err := u.updateProvider(ctx, fetcher, minRatio)
|
||||
if err == nil {
|
||||
switch {
|
||||
case err == nil:
|
||||
continue
|
||||
}
|
||||
|
||||
// return the only error for the single provider.
|
||||
if len(providers) == 1 {
|
||||
case errors.Is(err, common.ErrCredentialsMissing):
|
||||
u.logger.Warn(err.Error() + " - skipping update for " + providerName)
|
||||
continue
|
||||
case len(providers) == 1:
|
||||
// return the only error for the single provider.
|
||||
return err
|
||||
case ctx.Err() != nil:
|
||||
// stop updating other providers if context is done
|
||||
return ctx.Err()
|
||||
default: // error encountered updating one of multiple providers
|
||||
// Log the error and continue updating the next provider.
|
||||
u.logger.Error(err.Error())
|
||||
}
|
||||
|
||||
// stop updating the next providers if context is canceled.
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return ctxErr
|
||||
}
|
||||
|
||||
// Log the error and continue updating the next provider.
|
||||
u.logger.Error(err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user