Feature: Protonvpn support (#437 clone on #434)

This commit is contained in:
Quentin McGaw
2021-04-25 15:44:45 -04:00
committed by GitHub
parent b02a80abbd
commit 954e3c70b2
22 changed files with 2209 additions and 10 deletions

View File

@@ -30,6 +30,7 @@ func (c *cli) Update(ctx context.Context, args []string, os os.OS) error {
flagSet.BoolVar(&options.PIA, "pia", false, "Update Private Internet Access post-summer 2020 servers")
flagSet.BoolVar(&options.Privado, "privado", false, "Update Privado servers")
flagSet.BoolVar(&options.Privatevpn, "privatevpn", false, "Update Private VPN servers")
flagSet.BoolVar(&options.Protonvpn, "protonvpn", false, "Update Protonvpn servers")
flagSet.BoolVar(&options.Purevpn, "purevpn", false, "Update Purevpn servers")
flagSet.BoolVar(&options.Surfshark, "surfshark", false, "Update Surfshark servers")
flagSet.BoolVar(&options.Torguard, "torguard", false, "Update Torguard servers")

View File

@@ -62,7 +62,7 @@ var (
func (settings *OpenVPN) read(r reader) (err error) {
vpnsp, err := r.env.Inside("VPNSP", []string{
"cyberghost", "fastestvpn", "hidemyass", "mullvad", "nordvpn",
"privado", "pia", "private internet access", "privatevpn",
"privado", "pia", "private internet access", "privatevpn", "protonvpn",
"purevpn", "surfshark", "torguard", "vyprvpn", "windscribe"},
params.Default("private internet access"))
if err != nil {
@@ -141,6 +141,8 @@ func (settings *OpenVPN) read(r reader) (err error) {
readProvider = settings.Provider.readPrivateInternetAccess
case constants.Privatevpn:
readProvider = settings.Provider.readPrivatevpn
case constants.Protonvpn:
readProvider = settings.Provider.readProtonvpn
case constants.Purevpn:
readProvider = settings.Provider.readPurevpn
case constants.Surfshark:

View File

@@ -16,10 +16,43 @@ func Test_OpenVPN_JSON(t *testing.T) {
Name: "name",
},
}
data, err := json.Marshal(in)
data, err := json.MarshalIndent(in, "", " ")
require.NoError(t, err)
//nolint:lll
assert.Equal(t, `{"user":"","password":"","verbosity":0,"mssfix":0,"run_as_root":true,"cipher":"","auth":"","provider":{"name":"name","server_selection":{"network_protocol":"","regions":null,"group":"","countries":null,"cities":null,"hostnames":null,"isps":null,"owned":false,"custom_port":0,"numbers":null,"encryption_preset":""},"extra_config":{"encryption_preset":"","openvpn_ipv6":false},"port_forwarding":{"enabled":false,"filepath":""}},"custom_config":""}`, string(data))
assert.Equal(t, `{
"user": "",
"password": "",
"verbosity": 0,
"mssfix": 0,
"run_as_root": true,
"cipher": "",
"auth": "",
"provider": {
"name": "name",
"server_selection": {
"network_protocol": "",
"regions": null,
"group": "",
"countries": null,
"cities": null,
"hostnames": null,
"names": null,
"isps": null,
"owned": false,
"custom_port": 0,
"numbers": null,
"encryption_preset": ""
},
"extra_config": {
"encryption_preset": "",
"openvpn_ipv6": false
},
"port_forwarding": {
"enabled": false,
"filepath": ""
}
},
"custom_config": ""
}`, string(data))
var out OpenVPN
err = json.Unmarshal(data, &out)
require.NoError(t, err)

View File

@@ -0,0 +1,75 @@
package configuration
import (
"github.com/qdm12/gluetun/internal/constants"
)
func (settings *Provider) protonvpnLines() (lines []string) {
if len(settings.ServerSelection.Countries) > 0 {
lines = append(lines, lastIndent+"Countries: "+commaJoin(settings.ServerSelection.Countries))
}
if len(settings.ServerSelection.Regions) > 0 {
lines = append(lines, lastIndent+"Regions: "+commaJoin(settings.ServerSelection.Regions))
}
if len(settings.ServerSelection.Cities) > 0 {
lines = append(lines, lastIndent+"Cities: "+commaJoin(settings.ServerSelection.Cities))
}
if len(settings.ServerSelection.Names) > 0 {
lines = append(lines, lastIndent+"Names: "+commaJoin(settings.ServerSelection.Names))
}
if len(settings.ServerSelection.Hostnames) > 0 {
lines = append(lines, lastIndent+"Hostnames: "+commaJoin(settings.ServerSelection.Hostnames))
}
return lines
}
func (settings *Provider) readProtonvpn(r reader) (err error) {
settings.Name = constants.Protonvpn
settings.ServerSelection.Protocol, err = readProtocol(r.env)
if err != nil {
return err
}
settings.ServerSelection.TargetIP, err = readTargetIP(r.env)
if err != nil {
return err
}
settings.ServerSelection.CustomPort, err = readPortOrZero(r.env, "PORT")
if err != nil {
return err
}
settings.ServerSelection.Countries, err = r.env.CSVInside("COUNTRY", constants.ProtonvpnCountryChoices())
if err != nil {
return err
}
settings.ServerSelection.Regions, err = r.env.CSVInside("REGION", constants.ProtonvpnRegionChoices())
if err != nil {
return err
}
settings.ServerSelection.Cities, err = r.env.CSVInside("CITY", constants.ProtonvpnCityChoices())
if err != nil {
return err
}
settings.ServerSelection.Hostnames, err = r.env.CSVInside("SERVER_NAME", constants.ProtonvpnNameChoices())
if err != nil {
return err
}
settings.ServerSelection.Hostnames, err = r.env.CSVInside("SERVER_HOSTNAME", constants.ProtonvpnHostnameChoices())
if err != nil {
return err
}
return nil
}

View File

@@ -45,6 +45,8 @@ func (settings *Provider) lines() (lines []string) {
providerLines = settings.privatevpnLines()
case "private internet access":
providerLines = settings.privateinternetaccessLines()
case "protonvpn":
providerLines = settings.protonvpnLines()
case "purevpn":
providerLines = settings.purevpnLines()
case "surfshark":

View File

@@ -148,6 +148,28 @@ func Test_Provider_lines(t *testing.T) {
" |--Hostnames: a, b",
},
},
"protonvpn": {
settings: Provider{
Name: constants.Protonvpn,
ServerSelection: ServerSelection{
Protocol: constants.UDP,
Countries: []string{"a", "b"},
Regions: []string{"c", "d"},
Cities: []string{"e", "f"},
Names: []string{"g", "h"},
Hostnames: []string{"i", "j"},
},
},
lines: []string{
"|--Protonvpn settings:",
" |--Network protocol: udp",
" |--Countries: a, b",
" |--Regions: c, d",
" |--Cities: e, f",
" |--Names: g, h",
" |--Hostnames: i, j",
},
},
"private internet access": {
settings: Provider{
Name: constants.PrivateInternetAccess,

View File

@@ -9,15 +9,16 @@ type ServerSelection struct {
Protocol string `json:"network_protocol"`
TargetIP net.IP `json:"target_ip,omitempty"`
// TODO comments
// Cyberghost, PIA, Surfshark, Windscribe, Vyprvpn, NordVPN
// Cyberghost, PIA, Protonvpn, Surfshark, Windscribe, Vyprvpn, NordVPN
Regions []string `json:"regions"`
// Cyberghost
Group string `json:"group"`
Countries []string `json:"countries"` // Fastestvpn, HideMyAss, Mullvad, PrivateVPN, PureVPN
Cities []string `json:"cities"` // HideMyAss, Mullvad, PrivateVPN, PureVPN, Windscribe
Hostnames []string `json:"hostnames"` // Fastestvpn, HideMyAss, PrivateVPN, Windscribe, Privado
Countries []string `json:"countries"` // Fastestvpn, HideMyAss, Mullvad, PrivateVPN, Protonvpn, PureVPN
Cities []string `json:"cities"` // HideMyAss, Mullvad, PrivateVPN, Protonvpn, PureVPN, Windscribe
Hostnames []string `json:"hostnames"` // Fastestvpn, HideMyAss, PrivateVPN, Windscribe, Privado, Protonvpn
Names []string `json:"names"` // Protonvpn
// Mullvad
ISPs []string `json:"isps"`

View File

@@ -18,6 +18,7 @@ type Updater struct {
PIA bool `json:"pia"`
Privado bool `json:"privado"`
Privatevpn bool `json:"privatevpn"`
Protonvpn bool `json:"protonvpn"`
Purevpn bool `json:"purevpn"`
Surfshark bool `json:"surfshark"`
Torguard bool `json:"torguard"`
@@ -53,6 +54,7 @@ func (settings *Updater) read(r reader) (err error) {
settings.PIA = true
settings.Privado = true
settings.Privatevpn = true
settings.Protonvpn = true
settings.Purevpn = true
settings.Surfshark = true
settings.Torguard = true

File diff suppressed because it is too large Load Diff

View File

@@ -41,6 +41,11 @@ func GetAllServers() (allServers models.AllServers) {
Timestamp: 1613861528,
Servers: PrivatevpnServers(),
},
Protonvpn: models.ProtonvpnServers{
Version: 1,
Timestamp: 1618605078,
Servers: ProtonvpnServers(),
},
Pia: models.PiaServers{
Version: 4,
Timestamp: 1619272345,

View File

@@ -74,6 +74,11 @@ func Test_versions(t *testing.T) {
version: allServers.Privatevpn.Version,
digest: "cba13d78",
},
"Protonvpn": {
model: models.ProtonvpnServer{},
version: allServers.Protonvpn.Version,
digest: "b964085b",
},
"Purevpn": {
model: models.PurevpnServer{},
version: allServers.Purevpn.Version,
@@ -175,6 +180,11 @@ func Test_timestamps(t *testing.T) {
timestamp: allServers.Privatevpn.Timestamp,
digest: "8ce3fba1",
},
"Protonvpn": {
servers: allServers.Protonvpn.Servers,
timestamp: allServers.Protonvpn.Timestamp,
digest: "c342020e",
},
"Purevpn": {
servers: allServers.Purevpn.Servers,
timestamp: allServers.Purevpn.Timestamp,

View File

@@ -17,6 +17,8 @@ const (
PrivateInternetAccess = "private internet access"
// Privatevpn is a VPN provider.
Privatevpn = "privatevpn"
// Protonvpn is a VPN provider.
Protonvpn = "protonvpn"
// PureVPN is a VPN provider.
Purevpn = "purevpn"
// Surfshark is a VPN provider.

View File

@@ -108,6 +108,21 @@ func (s *PrivatevpnServer) String() string {
s.Country, s.City, s.Hostname, goStringifyIPs(s.IPs))
}
type ProtonvpnServer struct {
Country string `json:"country"`
Region string `json:"region"`
City string `json:"city"`
Name string `json:"name"`
Hostname string `json:"hostname"`
EntryIP net.IP `json:"entry_ip"`
ExitIP net.IP `json:"exit_ip"` // TODO verify it matches with public IP once connected
}
func (s *ProtonvpnServer) String() string {
return fmt.Sprintf("{Country: %q, Region: %q, City: %q, Name: %q, Hostname: %q, EntryIP: %s, ExitIP: %s}",
s.Country, s.Region, s.City, s.Name, s.Hostname, goStringifyIP(s.EntryIP), goStringifyIP(s.ExitIP))
}
type PurevpnServer struct {
Country string `json:"country"`
Region string `json:"region"`

View File

@@ -10,6 +10,7 @@ type AllServers struct {
Privado PrivadoServers `json:"privado"`
Pia PiaServers `json:"pia"`
Privatevpn PrivatevpnServers `json:"privatevpn"`
Protonvpn ProtonvpnServers `json:"protonvpn"`
Purevpn PurevpnServers `json:"purevpn"`
Surfshark SurfsharkServers `json:"surfshark"`
Torguard TorguardServers `json:"torguard"`
@@ -26,6 +27,7 @@ func (a *AllServers) Count() int {
len(a.Privado.Servers) +
len(a.Pia.Servers) +
len(a.Privatevpn.Servers) +
len(a.Protonvpn.Servers) +
len(a.Purevpn.Servers) +
len(a.Surfshark.Servers) +
len(a.Torguard.Servers) +
@@ -73,6 +75,11 @@ type PrivatevpnServers struct {
Timestamp int64 `json:"timestamp"`
Servers []PrivatevpnServer `json:"servers"`
}
type ProtonvpnServers struct {
Version uint16 `json:"version"`
Timestamp int64 `json:"timestamp"`
Servers []ProtonvpnServer `json:"servers"`
}
type PurevpnServers struct {
Version uint16 `json:"version"`
Timestamp int64 `json:"timestamp"`

View File

@@ -0,0 +1,214 @@
package provider
import (
"context"
"fmt"
"math/rand"
"net"
"net/http"
"strconv"
"github.com/qdm12/gluetun/internal/configuration"
"github.com/qdm12/gluetun/internal/constants"
"github.com/qdm12/gluetun/internal/firewall"
"github.com/qdm12/gluetun/internal/models"
"github.com/qdm12/golibs/logging"
"github.com/qdm12/golibs/os"
)
type protonvpn struct {
servers []models.ProtonvpnServer
randSource rand.Source
}
func newProtonvpn(servers []models.ProtonvpnServer, timeNow timeNowFunc) *protonvpn {
return &protonvpn{
servers: servers,
randSource: rand.NewSource(timeNow().UnixNano()),
}
}
func (p *protonvpn) GetOpenVPNConnection(selection configuration.ServerSelection) (
connection models.OpenVPNConnection, err error) {
port, err := p.getPort(selection)
if err != nil {
return connection, err
}
if selection.TargetIP != nil {
return models.OpenVPNConnection{
IP: selection.TargetIP,
Port: port,
Protocol: selection.Protocol,
}, nil
}
servers := p.filterServers(selection.Countries, selection.Regions,
selection.Cities, selection.Names, selection.Hostnames)
if len(servers) == 0 {
return connection, p.notFoundErr(selection)
}
connections := make([]models.OpenVPNConnection, len(servers))
for i := range servers {
connections[i] = models.OpenVPNConnection{
IP: servers[i].EntryIP,
Port: port,
Protocol: selection.Protocol,
}
}
return pickRandomConnection(connections, p.randSource), nil
}
func (p *protonvpn) BuildConf(connection models.OpenVPNConnection,
username string, settings configuration.OpenVPN) (lines []string) {
if len(settings.Cipher) == 0 {
settings.Cipher = aes256cbc
}
if len(settings.Auth) == 0 {
settings.Auth = "SHA512"
}
const defaultMSSFix = 1450
if settings.MSSFix == 0 {
settings.MSSFix = defaultMSSFix
}
lines = []string{
"client",
"dev tun",
"nobind",
"persist-key",
"remote-cert-tls server",
"tls-exit",
// Protonvpn specific
"tun-mtu 1500",
"tun-mtu-extra 32",
"mssfix " + strconv.Itoa(int(settings.MSSFix)),
"reneg-sec 0",
"fast-io",
"key-direction 1",
"pull",
"comp-lzo no",
// Added constant values
"auth-nocache",
"mute-replay-warnings",
"pull-filter ignore \"auth-token\"", // prevent auth failed loops
"pull-filter ignore \"block-outside-dns\"",
`pull-filter ignore "ping-restart"`,
"auth-retry nointeract",
"suppress-timestamps",
// Modified variables
"verb " + strconv.Itoa(settings.Verbosity),
"auth-user-pass " + constants.OpenVPNAuthConf,
"proto " + connection.Protocol,
"remote " + connection.IP.String() + " " + strconv.Itoa(int(connection.Port)),
"cipher " + settings.Cipher,
"auth " + settings.Auth,
}
if !settings.Root {
lines = append(lines, "user "+username)
}
lines = append(lines, []string{
"<ca>",
"-----BEGIN CERTIFICATE-----",
constants.ProtonvpnCertificate,
"-----END CERTIFICATE-----",
"</ca>",
}...)
lines = append(lines, []string{
"<tls-auth>",
"-----BEGIN OpenVPN Static key V1-----",
constants.ProtonvpnOpenvpnStaticKeyV1,
"-----END OpenVPN Static key V1-----",
"</tls-auth>",
"",
}...)
return lines
}
func (p *protonvpn) PortForward(ctx context.Context, client *http.Client,
openFile os.OpenFileFunc, pfLogger logging.Logger, gateway net.IP, fw firewall.Configurator,
syncState func(port uint16) (pfFilepath string)) {
panic("port forwarding is not supported for protonvpn")
}
func (p *protonvpn) getPort(selection configuration.ServerSelection) (port uint16, err error) {
if selection.CustomPort == 0 {
switch selection.Protocol {
case constants.TCP:
const defaultTCPPort = 443
return defaultTCPPort, nil
case constants.UDP:
const defaultUDPPort = 1194
return defaultUDPPort, nil
}
}
port = selection.CustomPort
switch selection.Protocol {
case constants.TCP:
switch port {
case 443, 5995, 8443: //nolint:gomnd
default:
return 0, fmt.Errorf("%w: %d for protocol %s",
ErrInvalidPort, port, selection.Protocol)
}
case constants.UDP:
switch port {
case 80, 443, 1194, 4569, 5060: //nolint:gomnd
default:
return 0, fmt.Errorf("%w: %d for protocol %s",
ErrInvalidPort, port, selection.Protocol)
}
}
return port, nil
}
func (p *protonvpn) filterServers(countries, regions, cities, names, hostnames []string) (
servers []models.ProtonvpnServer) {
for _, server := range p.servers {
switch {
case
filterByPossibilities(server.Country, countries),
filterByPossibilities(server.Region, regions),
filterByPossibilities(server.City, cities),
filterByPossibilities(server.Name, names),
filterByPossibilities(server.Hostname, hostnames):
default:
servers = append(servers, server)
}
}
return servers
}
func (p *protonvpn) notFoundErr(selection configuration.ServerSelection) error {
message := "no server found for protocol " + selection.Protocol
if len(selection.Countries) > 0 {
message += " + countries " + commaJoin(selection.Countries)
}
if len(selection.Regions) > 0 {
message += " + regions " + commaJoin(selection.Regions)
}
if len(selection.Cities) > 0 {
message += " + cities " + commaJoin(selection.Cities)
}
if len(selection.Names) > 0 {
message += " + names " + commaJoin(selection.Names)
}
if len(selection.Hostnames) > 0 {
message += " + hostnames " + commaJoin(selection.Hostnames)
}
return fmt.Errorf(message)
}

View File

@@ -41,6 +41,8 @@ func New(provider string, allServers models.AllServers, timeNow timeNowFunc) Pro
return newPrivateInternetAccess(allServers.Pia.Servers, timeNow)
case constants.Privatevpn:
return newPrivatevpn(allServers.Privatevpn.Servers, timeNow)
case constants.Protonvpn:
return newProtonvpn(allServers.Protonvpn.Servers, timeNow)
case constants.Purevpn:
return newPurevpn(allServers.Purevpn.Servers, timeNow)
case constants.Surfshark:

View File

@@ -25,6 +25,7 @@ func (s *storage) mergeServers(hardcoded, persisted models.AllServers) models.Al
Privado: s.mergePrivado(hardcoded.Privado, persisted.Privado),
Pia: s.mergePIA(hardcoded.Pia, persisted.Pia),
Privatevpn: s.mergePrivatevpn(hardcoded.Privatevpn, persisted.Privatevpn),
Protonvpn: s.mergeProtonvpn(hardcoded.Protonvpn, persisted.Protonvpn),
Purevpn: s.mergePureVPN(hardcoded.Purevpn, persisted.Purevpn),
Surfshark: s.mergeSurfshark(hardcoded.Surfshark, persisted.Surfshark),
Torguard: s.mergeTorguard(hardcoded.Torguard, persisted.Torguard),
@@ -140,6 +141,22 @@ func (s *storage) mergePrivatevpn(hardcoded, persisted models.PrivatevpnServers)
return persisted
}
func (s *storage) mergeProtonvpn(hardcoded, persisted models.ProtonvpnServers) models.ProtonvpnServers {
if persisted.Timestamp <= hardcoded.Timestamp {
return hardcoded
}
versionDiff := hardcoded.Version - persisted.Version
if versionDiff > 0 {
s.logger.Info(
"Protonvpn servers from file discarded because they are %d versions behind",
versionDiff)
return hardcoded
}
s.logger.Info("Using Protonvpn servers from file (%s more recent)",
getUnixTimeDifference(persisted.Timestamp, hardcoded.Timestamp))
return persisted
}
func (s *storage) mergePureVPN(hardcoded, persisted models.PurevpnServers) models.PurevpnServers {
if persisted.Timestamp <= hardcoded.Timestamp {
return hardcoded

View File

@@ -25,6 +25,7 @@ func countServers(allServers models.AllServers) int {
len(allServers.Privado.Servers) +
len(allServers.Pia.Servers) +
len(allServers.Privatevpn.Servers) +
len(allServers.Protonvpn.Servers) +
len(allServers.Purevpn.Servers) +
len(allServers.Surfshark.Servers) +
len(allServers.Torguard.Servers) +

View File

@@ -0,0 +1,141 @@
package updater
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"sort"
"strings"
"github.com/qdm12/gluetun/internal/constants"
"github.com/qdm12/gluetun/internal/models"
)
func (u *updater) updateProtonvpn(ctx context.Context) (err error) {
servers, warnings, err := findProtonvpnServers(ctx, u.client)
if u.options.CLI {
for _, warning := range warnings {
u.logger.Warn("Protonvpn: %s", warning)
}
}
if err != nil {
return fmt.Errorf("cannot update Protonvpn servers: %w", err)
}
if u.options.Stdout {
u.println(stringifyProtonvpnServers(servers))
}
u.servers.Protonvpn.Timestamp = u.timeNow().Unix()
u.servers.Protonvpn.Servers = servers
return nil
}
func findProtonvpnServers(ctx context.Context, client *http.Client) (
servers []models.ProtonvpnServer, warnings []string, err error) {
const url = "https://api.protonmail.ch/vpn/logicals"
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, nil, err
}
response, err := client.Do(request)
if err != nil {
return nil, nil, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("%w: %s for %s", ErrHTTPStatusCodeNotOK, response.Status, url)
}
decoder := json.NewDecoder(response.Body)
var data struct {
LogicalServers []struct {
Name string
ExitCountry string
Region *string
City *string
Servers []struct {
EntryIP net.IP
ExitIP net.IP
Domain string
Status uint8
}
}
}
if err := decoder.Decode(&data); err != nil {
return nil, nil, err
}
if err := response.Body.Close(); err != nil {
return nil, nil, err
}
countryCodesMapping := constants.CountryCodes()
for _, logicalServer := range data.LogicalServers {
for _, physicalServer := range logicalServer.Servers {
if physicalServer.Status == 0 {
warnings = append(warnings, "ignoring server "+physicalServer.Domain+" as its status is 0")
continue
}
countryCode := strings.ToLower(logicalServer.ExitCountry)
country, ok := countryCodesMapping[countryCode]
if !ok {
warnings = append(warnings, "country not found for country code "+countryCode)
country = logicalServer.ExitCountry
}
server := models.ProtonvpnServer{
// Note: for multi-hop use the server name or hostname instead of the country
Country: country,
Region: getStringValue(logicalServer.Region),
City: getStringValue(logicalServer.City),
Name: logicalServer.Name,
Hostname: physicalServer.Domain,
EntryIP: physicalServer.EntryIP,
ExitIP: physicalServer.ExitIP,
}
servers = append(servers, server)
}
}
sort.Slice(servers, func(i, j int) bool {
a, b := servers[i], servers[j]
if a.Country == b.Country { //nolint:nestif
if a.Region == b.Region {
if a.City == b.City {
if a.Name == b.Name {
return a.Hostname < b.Hostname
}
return a.Name < b.Name
}
return a.City < b.City
}
return a.Region < b.Region
}
return a.Country < b.Country
})
return servers, warnings, nil
}
func getStringValue(ptr *string) string {
if ptr == nil {
return ""
}
return *ptr
}
func stringifyProtonvpnServers(servers []models.ProtonvpnServer) (s string) {
s = "func ProtonvpnServers() []models.ProtonvpnServer {\n"
s += " return []models.ProtonvpnServer{\n"
for _, server := range servers {
s += " " + server.String() + ",\n"
}
s += " }\n"
s += "}"
return s
}

View File

@@ -131,6 +131,16 @@ func (u *updater) UpdateServers(ctx context.Context) (allServers models.AllServe
}
}
if u.options.Protonvpn {
u.logger.Info("updating Protonvpn servers...")
if err := u.updateProtonvpn(ctx); err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return allServers, ctxErr
}
u.logger.Error(err)
}
}
if u.options.Purevpn {
u.logger.Info("updating PureVPN servers...")
// TODO support servers offering only TCP or only UDP