diff --git a/internal/configuration/settings/serverselection.go b/internal/configuration/settings/serverselection.go index 21f3424f7..7783b50fd 100644 --- a/internal/configuration/settings/serverselection.go +++ b/internal/configuration/settings/serverselection.go @@ -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"` @@ -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, @@ -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(), } } @@ -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) } @@ -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() } @@ -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 { @@ -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 @@ -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 +} diff --git a/internal/configuration/settings/serverselection_purevpn_test.go b/internal/configuration/settings/serverselection_purevpn_test.go new file mode 100644 index 000000000..81c414e81 --- /dev/null +++ b/internal/configuration/settings/serverselection_purevpn_test.go @@ -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) + } +} diff --git a/internal/provider/utils/filtering.go b/internal/provider/utils/filtering.go index 48e380d7a..31c34441c 100644 --- a/internal/provider/utils/filtering.go +++ b/internal/provider/utils/filtering.go @@ -59,6 +59,13 @@ func filterServer(server models.Server, return true } + if filterByPureVPNServerTypes(server, selection.PureVPNServerTypes) { + return true + } + if filterByPureVPNLocationCodes(server, selection.PureVPNCountryCodes, selection.PureVPNLocationCodes) { + return true + } + if *selection.SecureCoreOnly && !server.SecureCore { return true } @@ -104,6 +111,92 @@ func filterServer(server models.Server, return false } +func filterByPureVPNServerTypes(server models.Server, serverTypes []string) (filtered bool) { + isP2P := containsCategory(server.Categories, "p2p") + if len(serverTypes) == 0 { + return server.Obfuscated + } + + allowObfuscated := containsCategory(serverTypes, "obfuscation") + if server.Obfuscated && !allowObfuscated { + return true + } + + for _, serverType := range serverTypes { + switch serverType { + case "regular": + if server.PortForward || server.QuantumResistant || server.Obfuscated || isP2P { + return true + } + case "portforwarding": + if !server.PortForward { + return true + } + case "quantumresistant": + if !server.QuantumResistant { + return true + } + case "obfuscation": + if !server.Obfuscated { + return true + } + case "p2p": + if !isP2P { + return true + } + default: + return false + } + } + return false +} + +func containsCategory(categories []string, category string) bool { + for _, existingCategory := range categories { + if strings.EqualFold(existingCategory, category) { + return true + } + } + return false +} + +func filterByPureVPNLocationCodes(server models.Server, + countryCodes, locationCodes []string, +) (filtered bool) { + if len(countryCodes) == 0 && len(locationCodes) == 0 { + return false + } + + countryCode, locationCode := parsePureVPNLocationCodes(server.Hostname) + if len(countryCodes) > 0 && filterByPossibilities(countryCode, countryCodes) { + return true + } + if len(locationCodes) > 0 && filterByPossibilities(locationCode, locationCodes) { + return true + } + return false +} + +func parsePureVPNLocationCodes(hostname string) (countryCode, locationCode string) { + firstLabel := hostname + if dotIndex := strings.IndexByte(hostname, '.'); dotIndex > -1 { + firstLabel = hostname[:dotIndex] + } + + twoMinusIndex := strings.Index(firstLabel, "2-") + if twoMinusIndex <= 0 { + return "", "" + } + + locationCode = strings.ToLower(firstLabel[:twoMinusIndex]) + if len(locationCode) < 2 { + return "", "" + } + + countryCode = locationCode[:2] + return countryCode, locationCode +} + func filterByPossibilities[T string | uint16](value T, possibilities []T) (filtered bool) { if len(possibilities) == 0 { return false @@ -129,3 +222,15 @@ func filterAnyByPossibilities(values, possibilities []string) (filtered bool) { return true } + +func filterAllByPossibilities(values, possibilities []string) (filtered bool) { + if len(possibilities) == 0 { + return false + } + for _, possibility := range possibilities { + if !containsCategory(values, possibility) { + return true + } + } + return false +} diff --git a/internal/provider/utils/filtering_test.go b/internal/provider/utils/filtering_test.go index 75e733e4b..a329e226e 100644 --- a/internal/provider/utils/filtering_test.go +++ b/internal/provider/utils/filtering_test.go @@ -167,6 +167,101 @@ func Test_FilterServers(t *testing.T) { {PortForward: true, VPN: vpn.OpenVPN, UDP: true}, }, }, + "filter by purevpn regular server type": { + selection: settings.ServerSelection{ + PureVPNServerTypes: []string{"regular"}, + }.WithDefaults(providers.Purevpn), + servers: []models.Server{ + {PortForward: true, VPN: vpn.OpenVPN, UDP: true}, + {QuantumResistant: true, VPN: vpn.OpenVPN, UDP: true}, + {Obfuscated: true, VPN: vpn.OpenVPN, UDP: true}, + {VPN: vpn.OpenVPN, UDP: true}, + }, + filtered: []models.Server{ + {VPN: vpn.OpenVPN, UDP: true}, + }, + }, + "filter by purevpn portforwarding server type": { + selection: settings.ServerSelection{ + PureVPNServerTypes: []string{"portforwarding"}, + }.WithDefaults(providers.Purevpn), + servers: []models.Server{ + {PortForward: false, VPN: vpn.OpenVPN, UDP: true}, + {PortForward: true, VPN: vpn.OpenVPN, UDP: true}, + {PortForward: true, QuantumResistant: true, VPN: vpn.OpenVPN, UDP: true}, + }, + filtered: []models.Server{ + {PortForward: true, VPN: vpn.OpenVPN, UDP: true}, + {PortForward: true, QuantumResistant: true, VPN: vpn.OpenVPN, UDP: true}, + }, + }, + "filter by purevpn quantumresistant server type": { + selection: settings.ServerSelection{ + PureVPNServerTypes: []string{"quantumresistant"}, + }.WithDefaults(providers.Purevpn), + servers: []models.Server{ + {PortForward: true, VPN: vpn.OpenVPN, UDP: true}, + {QuantumResistant: true, VPN: vpn.OpenVPN, UDP: true}, + {PortForward: true, QuantumResistant: true, VPN: vpn.OpenVPN, UDP: true}, + }, + filtered: []models.Server{ + {QuantumResistant: true, VPN: vpn.OpenVPN, UDP: true}, + {PortForward: true, QuantumResistant: true, VPN: vpn.OpenVPN, UDP: true}, + }, + }, + "filter by purevpn obfuscation server type": { + selection: settings.ServerSelection{ + PureVPNServerTypes: []string{"obfuscation"}, + }.WithDefaults(providers.Purevpn), + servers: []models.Server{ + {QuantumResistant: true, VPN: vpn.OpenVPN, UDP: true}, + {Obfuscated: true, VPN: vpn.OpenVPN, UDP: true}, + {Obfuscated: true, PortForward: true, VPN: vpn.OpenVPN, UDP: true}, + }, + filtered: []models.Server{ + {Obfuscated: true, VPN: vpn.OpenVPN, UDP: true}, + {Obfuscated: true, PortForward: true, VPN: vpn.OpenVPN, UDP: true}, + }, + }, + "filter by purevpn p2p server type": { + selection: settings.ServerSelection{ + PureVPNServerTypes: []string{"p2p"}, + }.WithDefaults(providers.Purevpn), + servers: []models.Server{ + {Categories: []string{"standard"}, VPN: vpn.OpenVPN, UDP: true}, + {Categories: []string{"p2p"}, VPN: vpn.OpenVPN, UDP: true}, + {Categories: []string{"p2p", "other"}, VPN: vpn.OpenVPN, UDP: true}, + }, + filtered: []models.Server{ + {Categories: []string{"p2p"}, VPN: vpn.OpenVPN, UDP: true}, + {Categories: []string{"p2p", "other"}, VPN: vpn.OpenVPN, UDP: true}, + }, + }, + "filter by purevpn country codes": { + selection: settings.ServerSelection{ + PureVPNCountryCodes: []string{"us"}, + }.WithDefaults(providers.Purevpn), + servers: []models.Server{ + {Hostname: "usca2-auto-udp.ptoserver.com", VPN: vpn.OpenVPN, UDP: true}, + {Hostname: "de2-auto-udp.ptoserver.com", VPN: vpn.OpenVPN, UDP: true}, + }, + filtered: []models.Server{ + {Hostname: "usca2-auto-udp.ptoserver.com", VPN: vpn.OpenVPN, UDP: true}, + }, + }, + "filter by purevpn location codes": { + selection: settings.ServerSelection{ + PureVPNLocationCodes: []string{"usca"}, + }.WithDefaults(providers.Purevpn), + servers: []models.Server{ + {Hostname: "usca2-auto-udp.ptoserver.com", VPN: vpn.OpenVPN, UDP: true}, + {Hostname: "usny2-auto-udp.ptoserver.com", VPN: vpn.OpenVPN, UDP: true}, + {Hostname: "de2-auto-udp.ptoserver.com", VPN: vpn.OpenVPN, UDP: true}, + }, + filtered: []models.Server{ + {Hostname: "usca2-auto-udp.ptoserver.com", VPN: vpn.OpenVPN, UDP: true}, + }, + }, "filter by country": { selection: settings.ServerSelection{ Countries: []string{"b"}, diff --git a/internal/storage/filter.go b/internal/storage/filter.go index 4d74b6bc7..277487a20 100644 --- a/internal/storage/filter.go +++ b/internal/storage/filter.go @@ -81,6 +81,13 @@ func filterServer(server models.Server, return true } + if filterByPureVPNServerTypes(server, selection.PureVPNServerTypes) { + return true + } + if filterByPureVPNLocationCodes(server, selection.PureVPNCountryCodes, selection.PureVPNLocationCodes) { + return true + } + if *selection.SecureCoreOnly && !server.SecureCore { return true } @@ -126,6 +133,93 @@ func filterServer(server models.Server, return false } +func filterByPureVPNServerTypes(server models.Server, serverTypes []string) (filtered bool) { + isP2P := containsCategory(server.Categories, "p2p") + if len(serverTypes) == 0 { + // By default, avoid obfuscated endpoints unless explicitly requested. + return server.Obfuscated + } + + allowObfuscated := containsCategory(serverTypes, "obfuscation") + if server.Obfuscated && !allowObfuscated { + return true + } + + for _, serverType := range serverTypes { + switch serverType { + case "regular": + if server.PortForward || server.QuantumResistant || server.Obfuscated || isP2P { + return true + } + case "portforwarding": + if !server.PortForward { + return true + } + case "quantumresistant": + if !server.QuantumResistant { + return true + } + case "obfuscation": + if !server.Obfuscated { + return true + } + case "p2p": + if !isP2P { + return true + } + default: + return false + } + } + return false +} + +func containsCategory(categories []string, category string) bool { + for _, existingCategory := range categories { + if strings.EqualFold(existingCategory, category) { + return true + } + } + return false +} + +func filterByPureVPNLocationCodes(server models.Server, + countryCodes, locationCodes []string, +) (filtered bool) { + if len(countryCodes) == 0 && len(locationCodes) == 0 { + return false + } + + countryCode, locationCode := parsePureVPNLocationCodes(server.Hostname) + if len(countryCodes) > 0 && filterByPossibilities(countryCode, countryCodes) { + return true + } + if len(locationCodes) > 0 && filterByPossibilities(locationCode, locationCodes) { + return true + } + return false +} + +func parsePureVPNLocationCodes(hostname string) (countryCode, locationCode string) { + firstLabel := hostname + if dotIndex := strings.IndexByte(hostname, '.'); dotIndex > -1 { + firstLabel = hostname[:dotIndex] + } + + twoMinusIndex := strings.Index(firstLabel, "2-") + if twoMinusIndex <= 0 { + return "", "" + } + + locationCode = strings.ToLower(firstLabel[:twoMinusIndex]) + if len(locationCode) < 2 { + return "", "" + } + + countryCode = locationCode[:2] + return countryCode, locationCode +} + func filterByPossibilities[T string | uint16](value T, possibilities []T) (filtered bool) { if len(possibilities) == 0 { return false @@ -138,6 +232,18 @@ func filterByPossibilities[T string | uint16](value T, possibilities []T) (filte return true } +func filterAllByPossibilities(values, possibilities []string) (filtered bool) { + if len(possibilities) == 0 { + return false + } + for _, possibility := range possibilities { + if !containsCategory(values, possibility) { + return true + } + } + return false +} + func filterAnyByPossibilities(values, possibilities []string) (filtered bool) { if len(possibilities) == 0 { return false diff --git a/internal/storage/filter_purevpn_test.go b/internal/storage/filter_purevpn_test.go new file mode 100644 index 000000000..74a746041 --- /dev/null +++ b/internal/storage/filter_purevpn_test.go @@ -0,0 +1,261 @@ +package storage + +import ( + "testing" + + "github.com/qdm12/gluetun/internal/configuration/settings" + "github.com/qdm12/gluetun/internal/constants/providers" + "github.com/qdm12/gluetun/internal/constants/vpn" + "github.com/qdm12/gluetun/internal/models" + "github.com/stretchr/testify/assert" +) + +func Test_filterByPureVPNServerTypes_MultiTraitHost(t *testing.T) { + t.Parallel() + + server := models.Server{ + PortForward: true, + QuantumResistant: true, + Obfuscated: true, + Categories: []string{"p2p"}, + } + + testCases := map[string]struct { + serverTypes []string + filtered bool + }{ + "empty server types filters obfuscated host": { + serverTypes: nil, + filtered: true, + }, + "obfuscation keeps multi-trait host": { + serverTypes: []string{"obfuscation"}, + filtered: false, + }, + "portforwarding filters obfuscated host without explicit obfuscation": { + serverTypes: []string{"portforwarding"}, + filtered: true, + }, + "portforwarding and obfuscation keeps host": { + serverTypes: []string{"portforwarding", "obfuscation"}, + filtered: false, + }, + "all matching types keep host": { + serverTypes: []string{"portforwarding", "quantumresistant", "obfuscation", "p2p"}, + filtered: false, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + filtered := filterByPureVPNServerTypes(server, testCase.serverTypes) + assert.Equal(t, testCase.filtered, filtered) + }) + } +} + +func Test_filterByPureVPNServerTypes_NonObfuscatedMultiTraitHost(t *testing.T) { + t.Parallel() + + server := models.Server{ + PortForward: true, + QuantumResistant: true, + Obfuscated: false, + Categories: []string{"p2p"}, + } + + testCases := map[string]struct { + serverTypes []string + filtered bool + }{ + "empty keeps non-obfuscated host": { + serverTypes: nil, + filtered: false, + }, + "portforwarding keeps non-obfuscated host": { + serverTypes: []string{"portforwarding"}, + filtered: false, + }, + "quantumresistant and p2p keeps host": { + serverTypes: []string{"quantumresistant", "p2p"}, + filtered: false, + }, + "regular filters non-obfuscated multi-trait host": { + serverTypes: []string{"regular"}, + filtered: true, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + filtered := filterByPureVPNServerTypes(server, testCase.serverTypes) + assert.Equal(t, testCase.filtered, filtered) + }) + } +} + +func Test_filterServer_PureVPNServerTypesAndHostname(t *testing.T) { + t.Parallel() + + server := models.Server{ + VPN: vpn.OpenVPN, + Hostname: "usny2-auto-udp-obf.ptoserver.com", + PortForward: true, + QuantumResistant: true, + Obfuscated: true, + Categories: []string{"p2p"}, + UDP: true, + } + + testCases := map[string]struct { + serverTypes []string + filtered bool + }{ + "hostname match with obfuscation type": { + serverTypes: []string{"obfuscation"}, + filtered: false, + }, + "hostname match with portforwarding and obfuscation types": { + serverTypes: []string{"portforwarding", "obfuscation"}, + filtered: false, + }, + "hostname match with p2p type only": { + serverTypes: []string{"p2p"}, + filtered: true, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + selection := settings.ServerSelection{ + PureVPNServerTypes: testCase.serverTypes, + Hostnames: []string{server.Hostname}, + }.WithDefaults(providers.Purevpn) + filtered := filterServer(server, selection) + assert.Equal(t, testCase.filtered, filtered) + }) + } +} + +func Test_filterServer_CountryAndCity(t *testing.T) { + t.Parallel() + + server := models.Server{ + VPN: vpn.OpenVPN, + Country: "United States", + City: "New York", + UDP: true, + } + + testCases := map[string]struct { + countries []string + cities []string + filtered bool + }{ + "country match and city match": { + countries: []string{"United States", "Canada"}, + cities: []string{"New York"}, + filtered: false, + }, + "country mismatch despite city list": { + countries: []string{"Canada"}, + cities: []string{"New York", "Toronto"}, + filtered: true, + }, + "country match but no city match": { + countries: []string{"United States"}, + cities: []string{"Toronto"}, + filtered: true, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + selection := settings.ServerSelection{ + Countries: testCase.countries, + Cities: testCase.cities, + }.WithDefaults(providers.Purevpn) + filtered := filterServer(server, selection) + assert.Equal(t, testCase.filtered, filtered) + }) + } +} + +func Test_filterServer_CityWithoutCountry(t *testing.T) { + t.Parallel() + + server := models.Server{ + VPN: vpn.OpenVPN, + Country: "United States", + City: "New York", + UDP: true, + } + + testCases := map[string]struct { + cities []string + filtered bool + }{ + "city match without country": { + cities: []string{"New York"}, + filtered: false, + }, + "city mismatch without country": { + cities: []string{"Toronto"}, + filtered: true, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + selection := settings.ServerSelection{ + Cities: testCase.cities, + }.WithDefaults(providers.Purevpn) + filtered := filterServer(server, selection) + assert.Equal(t, testCase.filtered, filtered) + }) + } +} + +func Test_filterAllByPossibilities(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + values []string + possibilities []string + filtered bool + }{ + "no possibilities means no filter": { + values: []string{"p2p"}, + possibilities: nil, + filtered: false, + }, + "single category match": { + values: []string{"p2p", "streaming"}, + possibilities: []string{"p2p"}, + filtered: false, + }, + "all categories must match": { + values: []string{"p2p", "streaming", "gaming"}, + possibilities: []string{"p2p", "streaming"}, + filtered: false, + }, + "missing one category filters server": { + values: []string{"p2p"}, + possibilities: []string{"p2p", "streaming"}, + filtered: true, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + filtered := filterAllByPossibilities(testCase.values, testCase.possibilities) + assert.Equal(t, testCase.filtered, filtered) + }) + } +} diff --git a/internal/storage/formatting.go b/internal/storage/formatting.go index 3bf71b931..31bec5a17 100644 --- a/internal/storage/formatting.go +++ b/internal/storage/formatting.go @@ -141,6 +141,16 @@ func noServerFoundError(selection settings.ServerSelection) (err error) { messageParts = append(messageParts, "port forwarding only") } + if len(selection.PureVPNServerTypes) > 0 { + messageParts = append(messageParts, "purevpn server types "+commaJoin(selection.PureVPNServerTypes)) + } + if len(selection.PureVPNCountryCodes) > 0 { + messageParts = append(messageParts, "purevpn country codes "+commaJoin(selection.PureVPNCountryCodes)) + } + if len(selection.PureVPNLocationCodes) > 0 { + messageParts = append(messageParts, "purevpn location codes "+commaJoin(selection.PureVPNLocationCodes)) + } + if *selection.SecureCoreOnly { messageParts = append(messageParts, "secure core only") }