Compare commits

...

4 Commits

Author SHA1 Message Date
Quentin McGaw 00d944e713 fix(protonvpn/updater): fallback to email if username is empty in auth info response 2026-05-21 16:58:33 +00:00
Quentin McGaw beda1764b1 feat(protonvpn): updater finds more servers using app-version linux-vpn 2026-05-21 16:51:36 +00:00
Quentin McGaw 2210a0e9ad fix(command): fix rare race condition on log line stream at command completion 2026-05-21 15:44:21 +00:00
Quentin McGaw f8a677a424 hotfix(portforward): log both external and internal ports when they diverge
- useful for ProtonVPN only
- clarify things up for the user
2026-05-21 14:45:40 +00:00
8 changed files with 172 additions and 51 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
+9 -18
View File
@@ -21,7 +21,6 @@ func (c *Cmder) Start(cmd *exec.Cmd) (
func start(cmd execCmd) (stdoutLines, stderrLines <-chan string, func start(cmd execCmd) (stdoutLines, stderrLines <-chan string,
waitError <-chan error, startErr error, waitError <-chan error, startErr error,
) { ) {
stop := make(chan struct{})
stdoutReady := make(chan struct{}) stdoutReady := make(chan struct{})
stdoutLinesCh := make(chan string) stdoutLinesCh := make(chan string)
stdoutDone := make(chan struct{}) stdoutDone := make(chan struct{})
@@ -33,22 +32,20 @@ func start(cmd execCmd) (stdoutLines, stderrLines <-chan string,
if err != nil { if err != nil {
return nil, nil, nil, err return nil, nil, nil, err
} }
go streamToChannel(stdoutReady, stop, stdoutDone, stdout, stdoutLinesCh) go streamToChannel(stdoutReady, stdoutDone, stdout, stdoutLinesCh)
stderr, err := cmd.StderrPipe() stderr, err := cmd.StderrPipe()
if err != nil { if err != nil {
_ = stdout.Close() _ = stdout.Close()
close(stop)
<-stdoutDone <-stdoutDone
return nil, nil, nil, err return nil, nil, nil, err
} }
go streamToChannel(stderrReady, stop, stderrDone, stderr, stderrLinesCh) go streamToChannel(stderrReady, stderrDone, stderr, stderrLinesCh)
err = cmd.Start() err = cmd.Start()
if err != nil { if err != nil {
_ = stdout.Close() _ = stdout.Close()
_ = stderr.Close() _ = stderr.Close()
close(stop)
<-stdoutDone <-stdoutDone
<-stderrDone <-stderrDone
return nil, nil, nil, err return nil, nil, nil, err
@@ -57,19 +54,20 @@ func start(cmd execCmd) (stdoutLines, stderrLines <-chan string,
waitErrorCh := make(chan error) waitErrorCh := make(chan error)
go func() { go func() {
err := cmd.Wait() err := cmd.Wait()
_ = stdout.Close()
_ = stderr.Close()
close(stop)
<-stdoutDone <-stdoutDone
<-stderrDone <-stderrDone
_ = stdout.Close()
_ = stderr.Close()
waitErrorCh <- err waitErrorCh <- err
}() }()
<-stdoutReady
<-stderrReady
return stdoutLinesCh, stderrLinesCh, waitErrorCh, nil return stdoutLinesCh, stderrLinesCh, waitErrorCh, nil
} }
func streamToChannel(ready chan<- struct{}, func streamToChannel(ready chan<- struct{}, done chan<- struct{},
stop <-chan struct{}, done chan<- struct{},
stream io.Reader, lines chan<- string, stream io.Reader, lines chan<- string,
) { ) {
defer close(done) defer close(done)
@@ -89,12 +87,5 @@ func streamToChannel(ready chan<- struct{},
if err == nil || errors.Is(err, os.ErrClosed) { if err == nil || errors.Is(err, os.ErrClosed) {
return return
} }
lines <- "stream error: " + err.Error()
// ignore the error if it is stopped.
select {
case <-stop:
return
default:
lines <- "stream error: " + err.Error()
}
} }
+29
View File
@@ -2,6 +2,10 @@ package service
import ( import (
"fmt" "fmt"
"maps"
"slices"
"sort"
"strconv"
"strings" "strings"
) )
@@ -20,3 +24,28 @@ func portsToString(ports []uint16) (s string) {
" and " + portStrings[len(portStrings)-1] " and " + portStrings[len(portStrings)-1]
} }
} }
func portPairsToString(internalToExternalPort map[uint16]uint16) (s string) {
switch len(internalToExternalPort) {
case 0:
return "no port forwarded"
case 1:
internal := slices.Collect(maps.Keys(internalToExternalPort))[0]
return "port forwarded is " + portPairToString(internal, internalToExternalPort[internal])
default:
portStrings := make([]string, 0, len(internalToExternalPort))
for internal, external := range internalToExternalPort {
portStrings = append(portStrings, portPairToString(internal, external))
}
sort.StringSlice(portStrings).Sort()
return "ports forwarded are " + strings.Join(portStrings[:len(portStrings)-1], ", ") +
" and " + portStrings[len(portStrings)-1]
}
}
func portPairToString(internal, external uint16) string {
if internal == external {
return strconv.FormatUint(uint64(external), 10)
}
return fmt.Sprintf("%d (internal port %d)", external, internal)
}
@@ -40,3 +40,42 @@ func Test_portsToString(t *testing.T) {
}) })
} }
} }
func Test_externalInternalPortsToString(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
internalToExternalPort map[uint16]uint16
s string
}{
"no_port": {
s: "no port forwarded",
},
"one_port": {
internalToExternalPort: map[uint16]uint16{123: 123},
s: "port forwarded is 123",
},
"two_ports": {
internalToExternalPort: map[uint16]uint16{123: 123, 456: 456},
s: "ports forwarded are 123 and 456",
},
"two_ports_different_internal_external": {
internalToExternalPort: map[uint16]uint16{123: 124, 456: 457},
s: "ports forwarded are 124 (internal port 123) and 457 (internal port 456)",
},
"three_ports": {
internalToExternalPort: map[uint16]uint16{123: 123, 456: 456, 789: 789},
s: "ports forwarded are 123, 456 and 789",
},
}
for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
t.Parallel()
s := portPairsToString(testCase.internalToExternalPort)
assert.Equal(t, testCase.s, s)
})
}
}
+3 -5
View File
@@ -89,6 +89,9 @@ func (s *Service) Start(ctx context.Context) (runError <-chan error, err error)
} }
func (s *Service) onNewPorts(ctx context.Context, internalToExternalPorts map[uint16]uint16) (err error) { func (s *Service) onNewPorts(ctx context.Context, internalToExternalPorts map[uint16]uint16) (err error) {
s.logger.Info(portPairsToString(internalToExternalPorts))
externalPorts := slices.Collect(maps.Values(internalToExternalPorts))
autoRedirectionNeeded := false autoRedirectionNeeded := false
externalToInternalPorts := make(map[uint16]uint16, len(internalToExternalPorts)) externalToInternalPorts := make(map[uint16]uint16, len(internalToExternalPorts))
for internal, external := range internalToExternalPorts { for internal, external := range internalToExternalPorts {
@@ -97,12 +100,7 @@ func (s *Service) onNewPorts(ctx context.Context, internalToExternalPorts map[ui
autoRedirectionNeeded = true autoRedirectionNeeded = true
} }
} }
externalPorts := slices.Collect(maps.Keys(externalToInternalPorts))
slices.Sort(externalPorts) slices.Sort(externalPorts)
s.logger.Info(portsToString(externalPorts))
userRedirectionEnabled := !slices.Equal(s.settings.ListeningPorts, []uint16{0}) userRedirectionEnabled := !slices.Equal(s.settings.ListeningPorts, []uint16{0})
for i, port := range externalPorts { for i, port := range externalPorts {
internalPort := externalToInternalPorts[port] internalPort := externalToInternalPorts[port]
+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
}