Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Category filtering for NordVPN #1806

Merged
merged 1 commit into from
Mar 22, 2024
Merged
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
1 change: 1 addition & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ issues:
- goerr113
- containedctx
- goconst
- maintidx
- path: "internal\\/server\\/.+\\.go"
linters:
- dupl
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ ENV VPN_SERVICE_PROVIDER=pia \
SERVER_COUNTRIES= \
SERVER_CITIES= \
SERVER_HOSTNAMES= \
SERVER_CATEGORIES= \
# # Mullvad only:
ISP= \
OWNED_ONLY=no \
Expand Down
1 change: 1 addition & 0 deletions internal/configuration/settings/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import "errors"
var (
ErrCityNotValid = errors.New("the city specified is not valid")
ErrControlServerPrivilegedPort = errors.New("cannot use privileged port without running as root")
ErrCategoryNotValid = errors.New("the category specified is not valid")
ErrCountryNotValid = errors.New("the country specified is not valid")
ErrFilepathMissing = errors.New("filepath is missing")
ErrFirewallZeroPort = errors.New("cannot have a zero port")
Expand Down
16 changes: 15 additions & 1 deletion internal/configuration/settings/serverselection.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ type ServerSelection struct { //nolint:maligned
// state, and can be set to the unspecified address to indicate
// there is not target IP address to use.
TargetIP netip.Addr `json:"target_ip"`
// Counties is the list of countries to filter VPN servers with.
// Countries is the list of countries to filter VPN servers with.
AdamHebby marked this conversation as resolved.
Show resolved Hide resolved
Countries []string `json:"countries"`
// Categories is the list of categories to filter VPN servers with.
Categories []string `json:"categories"`
// Regions is the list of regions to filter VPN servers with.
Regions []string `json:"regions"`
// Cities is the list of cities to filter VPN servers with.
Expand Down Expand Up @@ -224,6 +226,11 @@ func validateServerFilters(settings ServerSelection, filterChoices models.Filter
return fmt.Errorf("%w: %w", ErrNameNotValid, err)
}

err = validate.AreAllOneOfCaseInsensitive(settings.Categories, filterChoices.Categories)
if err != nil {
return fmt.Errorf("%w: %w", ErrCategoryNotValid, err)
}

return nil
}

Expand All @@ -232,6 +239,7 @@ func (ss *ServerSelection) copy() (copied ServerSelection) {
VPN: ss.VPN,
TargetIP: ss.TargetIP,
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),
Expand All @@ -253,6 +261,7 @@ func (ss *ServerSelection) mergeWith(other ServerSelection) {
ss.VPN = gosettings.MergeWithString(ss.VPN, other.VPN)
ss.TargetIP = gosettings.MergeWithValidator(ss.TargetIP, other.TargetIP)
ss.Countries = gosettings.MergeWithSlice(ss.Countries, other.Countries)
ss.Categories = gosettings.MergeWithSlice(ss.Categories, other.Categories)
ss.Regions = gosettings.MergeWithSlice(ss.Regions, other.Regions)
ss.Cities = gosettings.MergeWithSlice(ss.Cities, other.Cities)
ss.ISPs = gosettings.MergeWithSlice(ss.ISPs, other.ISPs)
Expand All @@ -274,6 +283,7 @@ func (ss *ServerSelection) overrideWith(other ServerSelection) {
ss.VPN = gosettings.OverrideWithString(ss.VPN, other.VPN)
ss.TargetIP = gosettings.OverrideWithValidator(ss.TargetIP, other.TargetIP)
ss.Countries = gosettings.OverrideWithSlice(ss.Countries, other.Countries)
ss.Categories = gosettings.OverrideWithSlice(ss.Categories, other.Categories)
ss.Regions = gosettings.OverrideWithSlice(ss.Regions, other.Regions)
ss.Cities = gosettings.OverrideWithSlice(ss.Cities, other.Cities)
ss.ISPs = gosettings.OverrideWithSlice(ss.ISPs, other.ISPs)
Expand Down Expand Up @@ -318,6 +328,10 @@ func (ss ServerSelection) toLinesNode() (node *gotree.Node) {
node.Appendf("Countries: %s", strings.Join(ss.Countries, ", "))
}

if len(ss.Categories) > 0 {
node.Appendf("Categories: %s", strings.Join(ss.Categories, ", "))
}

if len(ss.Regions) > 0 {
node.Appendf("Regions: %s", strings.Join(ss.Regions, ", "))
}
Expand Down
22 changes: 22 additions & 0 deletions internal/configuration/settings/validation/servers.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,28 @@ func ExtractCountries(servers []models.Server) (values []string) {
return values
}

func ExtractCategories(servers []models.Server) (values []string) {
seen := make(map[string]struct{}, len(servers))
values = make([]string, 0, len(servers))
for _, server := range servers {
categories := server.Categories
if len(categories) == 0 {
continue
}

for _, value := range categories {
_, alreadySeen := seen[value]
if alreadySeen {
continue
}
seen[value] = struct{}{}

values = sortedInsert(values, value)
}
}
return values
}

func ExtractRegions(servers []models.Server) (values []string) {
seen := make(map[string]struct{}, len(servers))
values = make([]string, 0, len(servers))
Expand Down
1 change: 1 addition & 0 deletions internal/configuration/sources/env/serverselection.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ func (s *Source) readServerSelection(vpnProvider, vpnType string) (
ss.Hostnames = s.env.CSV("SERVER_HOSTNAMES", env.RetroKeys("SERVER_HOSTNAME"))
ss.Names = s.env.CSV("SERVER_NAMES", env.RetroKeys("SERVER_NAME"))
ss.Numbers, err = s.env.CSVUint16("SERVER_NUMBER")
ss.Categories = s.env.CSV("SERVER_CATEGORIES")
if err != nil {
return ss, err
}
Expand Down
13 changes: 7 additions & 6 deletions internal/models/filters.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
package models

type FilterChoices struct {
Countries []string
Regions []string
Cities []string
ISPs []string
Names []string
Hostnames []string
Countries []string
Regions []string
Cities []string
Categories []string
ISPs []string
Names []string
Hostnames []string
}
5 changes: 4 additions & 1 deletion internal/models/markdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ func markdownTableHeading(legendFields ...string) (markdown string) {
const (
cityHeader = "City"
countryHeader = "Country"
categoriesHeader = "Categories"
freeHeader = "Free"
hostnameHeader = "Hostname"
ispHeader = "ISP"
Expand Down Expand Up @@ -51,6 +52,8 @@ func (s *Server) ToMarkdown(headers ...string) (markdown string) {
fields[i] = s.City
case countryHeader:
fields[i] = s.Country
case categoriesHeader:
fields[i] = strings.Join(s.Categories, ", ")
case freeHeader:
fields[i] = boolToMarkdown(s.Free)
case hostnameHeader:
Expand Down Expand Up @@ -120,7 +123,7 @@ func getMarkdownHeaders(vpnProvider string) (headers []string) {
case providers.Mullvad:
return []string{countryHeader, cityHeader, ispHeader, ownedHeader, hostnameHeader, vpnHeader}
case providers.Nordvpn:
return []string{countryHeader, regionHeader, cityHeader, hostnameHeader}
return []string{countryHeader, regionHeader, cityHeader, hostnameHeader, categoriesHeader}
case providers.Perfectprivacy:
return []string{cityHeader, tcpHeader, udpHeader}
case providers.Privado:
Expand Down
1 change: 1 addition & 0 deletions internal/models/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type Server struct {
Region string `json:"region,omitempty"`
City string `json:"city,omitempty"`
ISP string `json:"isp,omitempty"`
Categories []string `json:"categories,omitempty"`
Owned bool `json:"owned,omitempty"`
Number uint16 `json:"number,omitempty"`
ServerName string `json:"server_name,omitempty"`
Expand Down
13 changes: 13 additions & 0 deletions internal/provider/nordvpn/updater/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,19 @@ func (s *serverData) hasVPNService(services map[uint32]serviceData) (ok bool) {
return false
}

// categories returns the list of categories for the server.
func (s *serverData) categories(groups map[uint32]groupData) (categories []string) {
categories = make([]string, 0, len(s.GroupIDs))
for _, groupID := range s.GroupIDs {
data, ok := groups[groupID]
if !ok || data.Type.Identifier == "regions" {
continue
}
categories = append(categories, data.Title)
}
return categories
}

// ips returns the list of IP addresses for the server.
func (s *serverData) ips() (ips []netip.Addr) {
ips = make([]netip.Addr, 0, len(s.IPs))
Expand Down
11 changes: 6 additions & 5 deletions internal/provider/nordvpn/updater/servers.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,12 @@ func extractServers(jsonServer serverData, groups map[uint32]groupData,
}

server := models.Server{
Country: location.Country.Name,
Region: jsonServer.region(groups),
City: location.Country.City.Name,
Hostname: jsonServer.Hostname,
IPs: jsonServer.ips(),
Country: location.Country.Name,
Region: jsonServer.region(groups),
City: location.Country.City.Name,
Categories: jsonServer.categories(groups),
Hostname: jsonServer.Hostname,
IPs: jsonServer.ips(),
}

number, err := parseServerName(jsonServer.Name)
Expand Down
18 changes: 18 additions & 0 deletions internal/provider/utils/filtering.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ func filterServer(server models.Server,
return true
}

if filterAnyByPossibilities(server.Categories, selection.Categories) {
return true
}

if filterByPossibilities(server.Region, selection.Regions) {
return true
}
Expand Down Expand Up @@ -101,3 +105,17 @@ func filterByPossibilities[T string | uint16](value T, possibilities []T) (filte
}
return true
}

func filterAnyByPossibilities(values, possibilities []string) (filtered bool) {
if len(possibilities) == 0 {
return false
}

for _, value := range values {
if !filterByPossibilities(value, possibilities) {
return false // found a valid value
}
}

return true
}
13 changes: 13 additions & 0 deletions internal/provider/utils/filtering_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,19 @@ func Test_FilterServers(t *testing.T) {
{City: "b", VPN: vpn.OpenVPN, UDP: true},
},
},
"filter by category": {
selection: settings.ServerSelection{
Categories: []string{"legacy_p2p"},
}.WithDefaults(providers.Nordvpn),
servers: []models.Server{
{Categories: []string{"legacy_p2p"}, VPN: vpn.OpenVPN, UDP: true},
{Categories: []string{"legacy_standard"}, VPN: vpn.OpenVPN, UDP: true},
{VPN: vpn.OpenVPN, UDP: true},
},
filtered: []models.Server{
{Categories: []string{"legacy_p2p"}, VPN: vpn.OpenVPN, UDP: true},
},
},
"filter by ISP": {
selection: settings.ServerSelection{
ISPs: []string{"b"},
Expand Down
13 changes: 7 additions & 6 deletions internal/storage/choices.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ func (s *Storage) GetFilterChoices(provider string) models.FilterChoices {
serversObject := s.getMergedServersObject(provider)
servers := serversObject.Servers
return models.FilterChoices{
Countries: validation.ExtractCountries(servers),
Regions: validation.ExtractRegions(servers),
Cities: validation.ExtractCities(servers),
ISPs: validation.ExtractISPs(servers),
Names: validation.ExtractServerNames(servers),
Hostnames: validation.ExtractHostnames(servers),
Countries: validation.ExtractCountries(servers),
Categories: validation.ExtractCategories(servers),
Regions: validation.ExtractRegions(servers),
Cities: validation.ExtractCities(servers),
ISPs: validation.ExtractISPs(servers),
Names: validation.ExtractServerNames(servers),
Hostnames: validation.ExtractHostnames(servers),
}
}
18 changes: 18 additions & 0 deletions internal/storage/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ func filterServer(server models.Server,
return true
}

if filterAnyByPossibilities(server.Categories, selection.Categories) {
return true
}

if filterByPossibilities(server.Region, selection.Regions) {
return true
}
Expand Down Expand Up @@ -123,6 +127,20 @@ func filterByPossibilities[T string | uint16](value T, possibilities []T) (filte
return true
}

func filterAnyByPossibilities(values, possibilities []string) (filtered bool) {
if len(possibilities) == 0 {
return false
}

for _, value := range values {
if !filterByPossibilities(value, possibilities) {
return false // found a valid value
}
}

return true
}

func filterByProtocol(selection settings.ServerSelection,
serverTCP, serverUDP bool) (filtered bool) {
switch selection.VPN {
Expand Down
10 changes: 10 additions & 0 deletions internal/storage/formatting.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ func noServerFoundError(selection settings.ServerSelection) (err error) {
messageParts = append(messageParts, part)
}

switch len(selection.Categories) {
case 0:
case 1:
part := "category " + selection.Categories[0]
messageParts = append(messageParts, part)
default:
part := "categories " + commaJoin(selection.Categories)
messageParts = append(messageParts, part)
}

switch len(selection.Regions) {
case 0:
case 1:
Expand Down
Loading