Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 146 additions & 28 deletions internal/configuration/settings/serverselection.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@ type ServerSelection struct {
// port forwarding should be filtered. This is used with PIA
// and ProtonVPN.
PortForwardOnly *bool `json:"port_forward_only"`
// PureVPNServerTypes selects PureVPN servers by hostname-inferred traits.
// Allowed values are: regular, portforwarding, quantumresistant, obfuscation, p2p.
PureVPNServerTypes []string `json:"purevpn_server_types"`
// PureVPNCountryCodes filters PureVPN servers by deterministic
// 2-letter country code parsed from the hostname prefix.
PureVPNCountryCodes []string `json:"purevpn_country_codes"`
// PureVPNLocationCodes filters PureVPN servers by deterministic
// location code parsed from the hostname prefix (for example usca, ukm).
PureVPNLocationCodes []string `json:"purevpn_location_codes"`
// SecureCoreOnly is true if VPN servers without secure core should
// be filtered. This is used with ProtonVPN.
SecureCoreOnly *bool `json:"secure_core_only"`
Expand All @@ -72,15 +81,21 @@ type ServerSelection struct {
}

var (
ErrOwnedOnlyNotSupported = errors.New("owned only filter is not supported")
ErrFreeOnlyNotSupported = errors.New("free only filter is not supported")
ErrPremiumOnlyNotSupported = errors.New("premium only filter is not supported")
ErrStreamOnlyNotSupported = errors.New("stream only filter is not supported")
ErrMultiHopOnlyNotSupported = errors.New("multi hop only filter is not supported")
ErrPortForwardOnlyNotSupported = errors.New("port forwarding only filter is not supported")
ErrFreePremiumBothSet = errors.New("free only and premium only filters are both set")
ErrSecureCoreOnlyNotSupported = errors.New("secure core only filter is not supported")
ErrTorOnlyNotSupported = errors.New("tor only filter is not supported")
ErrOwnedOnlyNotSupported = errors.New("owned only filter is not supported")
ErrFreeOnlyNotSupported = errors.New("free only filter is not supported")
ErrPremiumOnlyNotSupported = errors.New("premium only filter is not supported")
ErrStreamOnlyNotSupported = errors.New("stream only filter is not supported")
ErrMultiHopOnlyNotSupported = errors.New("multi hop only filter is not supported")
ErrPortForwardOnlyNotSupported = errors.New("port forwarding only filter is not supported")
ErrPureVPNServerTypeNotSupported = errors.New("purevpn server type filter is not supported")
ErrPureVPNServerTypeNotValid = errors.New("purevpn server type is not valid")
ErrPureVPNCountryCodesNotSupported = errors.New("purevpn country codes filter is not supported")
ErrPureVPNCountryCodeNotValid = errors.New("purevpn country code is not valid")
ErrPureVPNLocationCodesNotSupported = errors.New("purevpn location codes filter is not supported")
ErrPureVPNLocationCodeNotValid = errors.New("purevpn location code is not valid")
ErrFreePremiumBothSet = errors.New("free only and premium only filters are both set")
ErrSecureCoreOnlyNotSupported = errors.New("secure core only filter is not supported")
ErrTorOnlyNotSupported = errors.New("tor only filter is not supported")
)

func (ss *ServerSelection) validate(vpnServiceProvider string,
Expand Down Expand Up @@ -284,36 +299,71 @@ func validateFeatureFilters(settings ServerSelection, vpnServiceProvider string)
case *settings.PortForwardOnly &&
!helpers.IsOneOf(vpnServiceProvider, providers.PrivateInternetAccess, providers.Protonvpn):
return fmt.Errorf("%w", ErrPortForwardOnlyNotSupported)
case len(settings.PureVPNServerTypes) > 0 && vpnServiceProvider != providers.Purevpn:
return fmt.Errorf("%w", ErrPureVPNServerTypeNotSupported)
case len(settings.PureVPNCountryCodes) > 0 && vpnServiceProvider != providers.Purevpn:
return fmt.Errorf("%w", ErrPureVPNCountryCodesNotSupported)
case len(settings.PureVPNLocationCodes) > 0 && vpnServiceProvider != providers.Purevpn:
return fmt.Errorf("%w", ErrPureVPNLocationCodesNotSupported)
case *settings.SecureCoreOnly && vpnServiceProvider != providers.Protonvpn:
return fmt.Errorf("%w", ErrSecureCoreOnlyNotSupported)
case *settings.TorOnly && vpnServiceProvider != providers.Protonvpn:
return fmt.Errorf("%w", ErrTorOnlyNotSupported)
default:
for _, serverType := range settings.PureVPNServerTypes {
if !helpers.IsOneOf(serverType,
"regular", "portforwarding", "quantumresistant", "obfuscation", "p2p") {
return fmt.Errorf("%w: %q", ErrPureVPNServerTypeNotValid, serverType)
}
}
for _, code := range settings.PureVPNCountryCodes {
if len(code) != 2 || !isASCIIAlpha(code) {
return fmt.Errorf("%w: %q", ErrPureVPNCountryCodeNotValid, code)
}
}
for _, code := range settings.PureVPNLocationCodes {
if len(code) < 2 || len(code) > 5 || !isASCIIAlpha(code) {
return fmt.Errorf("%w: %q", ErrPureVPNLocationCodeNotValid, code)
}
}
return nil
}
}

func isASCIIAlpha(s string) bool {
for i := 0; i < len(s); i++ {
c := s[i]
if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') {
return false
}
}
return true
}

func (ss *ServerSelection) copy() (copied ServerSelection) {
return ServerSelection{
VPN: ss.VPN,
Countries: gosettings.CopySlice(ss.Countries),
Categories: gosettings.CopySlice(ss.Categories),
Regions: gosettings.CopySlice(ss.Regions),
Cities: gosettings.CopySlice(ss.Cities),
ISPs: gosettings.CopySlice(ss.ISPs),
Hostnames: gosettings.CopySlice(ss.Hostnames),
Names: gosettings.CopySlice(ss.Names),
Numbers: gosettings.CopySlice(ss.Numbers),
OwnedOnly: gosettings.CopyPointer(ss.OwnedOnly),
FreeOnly: gosettings.CopyPointer(ss.FreeOnly),
PremiumOnly: gosettings.CopyPointer(ss.PremiumOnly),
StreamOnly: gosettings.CopyPointer(ss.StreamOnly),
SecureCoreOnly: gosettings.CopyPointer(ss.SecureCoreOnly),
TorOnly: gosettings.CopyPointer(ss.TorOnly),
PortForwardOnly: gosettings.CopyPointer(ss.PortForwardOnly),
MultiHopOnly: gosettings.CopyPointer(ss.MultiHopOnly),
OpenVPN: ss.OpenVPN.copy(),
Wireguard: ss.Wireguard.copy(),
VPN: ss.VPN,
Countries: gosettings.CopySlice(ss.Countries),
Categories: gosettings.CopySlice(ss.Categories),
Regions: gosettings.CopySlice(ss.Regions),
Cities: gosettings.CopySlice(ss.Cities),
ISPs: gosettings.CopySlice(ss.ISPs),
Hostnames: gosettings.CopySlice(ss.Hostnames),
Names: gosettings.CopySlice(ss.Names),
Numbers: gosettings.CopySlice(ss.Numbers),
OwnedOnly: gosettings.CopyPointer(ss.OwnedOnly),
FreeOnly: gosettings.CopyPointer(ss.FreeOnly),
PremiumOnly: gosettings.CopyPointer(ss.PremiumOnly),
StreamOnly: gosettings.CopyPointer(ss.StreamOnly),
SecureCoreOnly: gosettings.CopyPointer(ss.SecureCoreOnly),
TorOnly: gosettings.CopyPointer(ss.TorOnly),
PortForwardOnly: gosettings.CopyPointer(ss.PortForwardOnly),
PureVPNServerTypes: gosettings.CopySlice(ss.PureVPNServerTypes),
PureVPNCountryCodes: gosettings.CopySlice(ss.PureVPNCountryCodes),
PureVPNLocationCodes: gosettings.CopySlice(ss.PureVPNLocationCodes),
MultiHopOnly: gosettings.CopyPointer(ss.MultiHopOnly),
OpenVPN: ss.OpenVPN.copy(),
Wireguard: ss.Wireguard.copy(),
}
}

Expand All @@ -335,6 +385,9 @@ func (ss *ServerSelection) overrideWith(other ServerSelection) {
ss.TorOnly = gosettings.OverrideWithPointer(ss.TorOnly, other.TorOnly)
ss.MultiHopOnly = gosettings.OverrideWithPointer(ss.MultiHopOnly, other.MultiHopOnly)
ss.PortForwardOnly = gosettings.OverrideWithPointer(ss.PortForwardOnly, other.PortForwardOnly)
ss.PureVPNServerTypes = gosettings.OverrideWithSlice(ss.PureVPNServerTypes, other.PureVPNServerTypes)
ss.PureVPNCountryCodes = gosettings.OverrideWithSlice(ss.PureVPNCountryCodes, other.PureVPNCountryCodes)
ss.PureVPNLocationCodes = gosettings.OverrideWithSlice(ss.PureVPNLocationCodes, other.PureVPNLocationCodes)
ss.OpenVPN.overrideWith(other.OpenVPN)
ss.Wireguard.overrideWith(other.Wireguard)
}
Expand All @@ -351,6 +404,9 @@ func (ss *ServerSelection) setDefaults(vpnProvider string, portForwardingEnabled
defaultPortForwardOnly := portForwardingEnabled &&
helpers.IsOneOf(vpnProvider, providers.PrivateInternetAccess, providers.Protonvpn)
ss.PortForwardOnly = gosettings.DefaultPointer(ss.PortForwardOnly, defaultPortForwardOnly)
ss.PureVPNServerTypes = gosettings.DefaultSlice(ss.PureVPNServerTypes, nil)
ss.PureVPNCountryCodes = gosettings.DefaultSlice(ss.PureVPNCountryCodes, nil)
ss.PureVPNLocationCodes = gosettings.DefaultSlice(ss.PureVPNLocationCodes, nil)
ss.OpenVPN.setDefaults(vpnProvider)
ss.Wireguard.setDefaults()
}
Expand Down Expand Up @@ -430,6 +486,16 @@ func (ss ServerSelection) toLinesNode() (node *gotree.Node) {
node.Appendf("Port forwarding only servers: yes")
}

if len(ss.PureVPNServerTypes) > 0 {
node.Appendf("PureVPN server types: %s", strings.Join(ss.PureVPNServerTypes, ", "))
}
if len(ss.PureVPNCountryCodes) > 0 {
node.Appendf("PureVPN country codes: %s", strings.Join(ss.PureVPNCountryCodes, ", "))
}
if len(ss.PureVPNLocationCodes) > 0 {
node.Appendf("PureVPN location codes: %s", strings.Join(ss.PureVPNLocationCodes, ", "))
}

if ss.VPN == vpn.OpenVPN {
node.AppendNode(ss.OpenVPN.toLinesNode())
} else {
Expand Down Expand Up @@ -517,6 +583,10 @@ func (ss *ServerSelection) read(r *reader.Reader,
return err
}

ss.PureVPNServerTypes = parsePureVPNServerTypes(r.CSV("SERVER_TYPES"))
ss.PureVPNCountryCodes = normalizePureVPNCodes(r.CSV("PUREVPN_COUNTRY_CODES"))
ss.PureVPNLocationCodes = normalizePureVPNCodes(r.CSV("PUREVPN_LOCATION_CODES"))

err = ss.OpenVPN.read(r)
if err != nil {
return err
Expand All @@ -529,3 +599,51 @@ func (ss *ServerSelection) read(r *reader.Reader,

return nil
}

func parsePureVPNServerTypes(rawValues []string) (types []string) {
set := make(map[string]struct{}, len(rawValues))
for _, raw := range rawValues {
value := strings.ToLower(strings.TrimSpace(raw))
value = strings.ReplaceAll(value, "_", "")
value = strings.ReplaceAll(value, "-", "")
value = strings.ReplaceAll(value, " ", "")

switch value {
case "":
continue
case "regular":
value = "regular"
case "portforwarding", "portforward", "pf":
value = "portforwarding"
case "quantumresistant", "quantum", "qr":
value = "quantumresistant"
case "obfuscation", "obfuscated", "obf":
value = "obfuscation"
case "p2p":
value = "p2p"
}

if _, ok := set[value]; ok {
continue
}
set[value] = struct{}{}
types = append(types, value)
}
return types
}

func normalizePureVPNCodes(rawCodes []string) (codes []string) {
set := make(map[string]struct{}, len(rawCodes))
for _, raw := range rawCodes {
code := strings.TrimSpace(strings.ToLower(raw))
if code == "" {
continue
}
if _, ok := set[code]; ok {
continue
}
set[code] = struct{}{}
codes = append(codes, code)
}
return codes
}
147 changes: 147 additions & 0 deletions internal/configuration/settings/serverselection_purevpn_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package settings

import (
"testing"

"github.com/qdm12/gluetun/internal/constants/providers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func Test_parsePureVPNServerTypes(t *testing.T) {
t.Parallel()

raw := []string{
"",
"regular",
"pf",
"port_forwarding",
"qr",
"quantum-resistant",
"obf",
"obfuscated",
"p2p",
"fast",
"regular",
}

parsed := parsePureVPNServerTypes(raw)
assert.Equal(t,
[]string{"regular", "portforwarding", "quantumresistant", "obfuscation", "p2p", "fast"},
parsed)
}

func Test_validateFeatureFilters_PureVPNServerTypes(t *testing.T) {
t.Parallel()

testCases := map[string]struct {
provider string
serverTypes []string
err error
}{
"valid with purevpn": {
provider: providers.Purevpn,
serverTypes: []string{"obfuscation", "p2p"},
},
"invalid provider": {
provider: providers.Mullvad,
serverTypes: []string{"regular"},
err: ErrPureVPNServerTypeNotSupported,
},
"invalid value": {
provider: providers.Purevpn,
serverTypes: []string{"regular", "fast"},
err: ErrPureVPNServerTypeNotValid,
},
}

for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
t.Parallel()

selection := ServerSelection{PureVPNServerTypes: testCase.serverTypes}.WithDefaults(testCase.provider)
err := validateFeatureFilters(selection, testCase.provider)

if testCase.err == nil {
require.NoError(t, err)
return
}

require.Error(t, err)
assert.ErrorIs(t, err, testCase.err)
})
}
}

func Test_normalizePureVPNCodes(t *testing.T) {
t.Parallel()

codes := normalizePureVPNCodes([]string{" US ", "us", "de", "", "DE"})
assert.Equal(t, []string{"us", "de"}, codes)
}

func Test_validateFeatureFilters_PureVPNLocationCodeFilters(t *testing.T) {
t.Parallel()

testCases := map[string]struct {
provider string
countryCodes []string
locationCodes []string
err error
}{
"valid country and location codes with purevpn": {
provider: providers.Purevpn,
countryCodes: []string{"us", "de"},
locationCodes: []string{"usca", "ukm"},
},
"country codes not supported on other providers": {
provider: providers.Mullvad,
countryCodes: []string{"us"},
err: ErrPureVPNCountryCodesNotSupported,
},
"location codes not supported on other providers": {
provider: providers.Mullvad,
locationCodes: []string{"usca"},
err: ErrPureVPNLocationCodesNotSupported,
},
"invalid country code": {
provider: providers.Purevpn,
countryCodes: []string{"usa"},
err: ErrPureVPNCountryCodeNotValid,
},
"invalid location code": {
provider: providers.Purevpn,
locationCodes: []string{"us1"},
err: ErrPureVPNLocationCodeNotValid,
},
}

for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
t.Parallel()

selection := ServerSelection{
PureVPNCountryCodes: testCase.countryCodes,
PureVPNLocationCodes: testCase.locationCodes,
}.WithDefaults(testCase.provider)
err := validateFeatureFilters(selection, testCase.provider)

if testCase.err == nil {
require.NoError(t, err)
return
}

require.Error(t, err)
assert.ErrorIs(t, err, testCase.err)
})
}
}

func Test_ServerSelection_WithDefaults_PureVPNTypesUseDefaultProtocol(t *testing.T) {
t.Parallel()

for _, serverTypes := range [][]string{{"regular"}, {"obfuscation"}, {"p2p", "quantumresistant"}} {
selection := ServerSelection{PureVPNServerTypes: serverTypes}.WithDefaults(providers.Purevpn)
assert.Equal(t, "udp", selection.OpenVPN.Protocol)
}
}
Loading