diff --git a/internal/provider/nordvpn/updater/api.go b/internal/provider/nordvpn/updater/api.go index 4de78939..26c29164 100644 --- a/internal/provider/nordvpn/updater/api.go +++ b/internal/provider/nordvpn/updater/api.go @@ -13,35 +13,32 @@ var ( ) func fetchAPI(ctx context.Context, client *http.Client, - recommended bool, limit uint) (data []serverData, err error) { - url := "https://api.nordvpn.com/v1/servers" - if recommended { - url += "/recommendations" - } + limit uint) (data serversData, err error) { + url := "https://api.nordvpn.com/v2/servers" url += fmt.Sprintf("?limit=%d", limit) // 0 means no limit request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { - return nil, err + return serversData{}, err } response, err := client.Do(request) if err != nil { - return nil, err + return serversData{}, err } defer response.Body.Close() if response.StatusCode != http.StatusOK { - return nil, fmt.Errorf("%w: %s", ErrHTTPStatusCodeNotOK, response.Status) + return serversData{}, fmt.Errorf("%w: %s", ErrHTTPStatusCodeNotOK, response.Status) } decoder := json.NewDecoder(response.Body) if err := decoder.Decode(&data); err != nil { - return nil, fmt.Errorf("decoding response body: %w", err) + return serversData{}, fmt.Errorf("decoding response body: %w", err) } if err := response.Body.Close(); err != nil { - return nil, err + return serversData{}, err } return data, nil diff --git a/internal/provider/nordvpn/updater/models.go b/internal/provider/nordvpn/updater/models.go index cb2ea86b..c7c3e12d 100644 --- a/internal/provider/nordvpn/updater/models.go +++ b/internal/provider/nordvpn/updater/models.go @@ -7,7 +7,15 @@ import ( "net/netip" ) -// Check out the JSON data from https://api.nordvpn.com/v1/servers?limit=10 +// Check out the JSON data from https://api.nordvpn.com/v2/servers?limit=10 +type serversData struct { + Servers []serverData `json:"servers"` + Groups []groupData `json:"groups"` + Services []serviceData `json:"services"` + Locations []locationData `json:"locations"` + Technologies []technologyData `json:"technologies"` +} + type serverData struct { // Name is the server name, for example 'Poland #128' Name string `json:"name"` @@ -20,25 +28,12 @@ type serverData struct { Hostname string // Status is the server status, for example 'online' Status string `json:"status"` - // Locations is the list of locations for the server. + // Locations is the list of location IDs for the server. // Only the first location is taken into account for now. - Locations []struct { - Country struct { - // Name is the country name, for example 'Poland'. - Name string `json:"name"` - City struct { - // Name is the city name, for example 'Warsaw'. - Name string `json:"name"` - } `json:"city"` - } `json:"country"` - } `json:"locations"` + LocationIDs []uint32 `json:"location_ids"` Technologies []struct { - // Identifier is the technology id name, it can notably be: - // - openvpn_udp - // - openvpn_tcp - // - wireguard_udp - Identifier string `json:"identifier"` - // Metadata is notably useful for the Wireguard public key. + ID uint32 `json:"id"` + Status string `json:"status"` Metadata []struct { // Name can notably be 'public_key'. Name string `json:"name"` @@ -46,15 +41,8 @@ type serverData struct { Value string `json:"value"` } `json:"metadata"` } `json:"technologies"` - Groups []struct { - // Title can notably be the region name, for example 'Europe', - // if the group's type/identifier is 'regions'. - Title string `json:"title"` - Type struct { - // Identifier can be 'regions'. - Identifier string `json:"identifier"` - } `json:"type"` - } `json:"groups"` + GroupIDs []uint32 `json:"group_ids"` + ServiceIDs []uint32 `json:"service_ids"` // IPs is the list of IP addresses for the server. IPs []struct { // Type can notably be 'entry'. @@ -65,17 +53,72 @@ type serverData struct { } `json:"ips"` } -// country returns the country name of the server. -func (s *serverData) country() (country string) { - if len(s.Locations) == 0 { - return "" - } - return s.Locations[0].Country.Name +type groupData struct { + ID uint32 `json:"id"` + Title string `json:"title"` // "Europe", "Standard VPN servers", etc. + Type struct { + Identifier string `json:"identifier"` // 'regions', 'legacy_group_category', etc. + } `json:"type"` } -// region returns the region name of the server. -func (s *serverData) region() (region string) { +type serviceData struct { + ID uint32 `json:"id"` + Identifier string `json:"identifier"` // 'vpn', 'proxy', etc. +} + +type locationData struct { + ID uint32 `json:"id"` + Country struct { + Name string `json:"name"` // for example "Poland" + City struct { + Name string `json:"name"` // for example "Warsaw" + } `json:"city"` + } `json:"country"` +} + +type technologyData struct { + ID uint32 `json:"id"` + // Identifier is the technology identifier name and relevant values are: + // 'openvpn_udp', 'openvpn_tcp', 'openvpn_dedicated_udp', + // 'openvpn_dedicated_tcp' and 'wireguard_udp' + Identifier string `json:"identifier"` +} + +func (s serversData) idToData() ( + groups map[uint32]groupData, + services map[uint32]serviceData, + locations map[uint32]locationData, + technologies map[uint32]technologyData, +) { + groups = make(map[uint32]groupData, len(s.Groups)) for _, group := range s.Groups { + groups[group.ID] = group + } + + services = make(map[uint32]serviceData, len(s.Services)) + for _, service := range s.Services { + services[service.ID] = service + } + + locations = make(map[uint32]locationData, len(s.Locations)) + for _, location := range s.Locations { + locations[location.ID] = location + } + + technologies = make(map[uint32]technologyData, len(s.Technologies)) + for _, technology := range s.Technologies { + technologies[technology.ID] = technology + } + + return groups, services, locations, technologies +} + +func (s *serverData) region(groups map[uint32]groupData) (region string) { + for _, groupID := range s.GroupIDs { + group, ok := groups[groupID] + if !ok { + continue + } if group.Type.Identifier == "regions" { return group.Title } @@ -83,12 +126,17 @@ func (s *serverData) region() (region string) { return "" } -// city returns the city name of the server. -func (s *serverData) city() (city string) { - if len(s.Locations) == 0 { - return "" +func (s *serverData) hasVPNService(services map[uint32]serviceData) (ok bool) { + for _, serviceID := range s.ServiceIDs { + service, ok := services[serviceID] + if !ok { + continue + } + if service.Identifier == "vpn" { + return true + } } - return s.Locations[0].Country.City.Name + return false } // ips returns the list of IP addresses for the server. @@ -109,9 +157,11 @@ var ( ) // wireguardPublicKey returns the Wireguard public key for the server. -func (s *serverData) wireguardPublicKey() (wgPubKey string, err error) { +func (s *serverData) wireguardPublicKey(technologies map[uint32]technologyData) ( + wgPubKey string, err error) { for _, technology := range s.Technologies { - if technology.Identifier != "wireguard_udp" { + data, ok := technologies[technology.ID] + if !ok || data.Identifier != "wireguard_udp" { continue } for _, metadata := range technology.Metadata { diff --git a/internal/provider/nordvpn/updater/servers.go b/internal/provider/nordvpn/updater/servers.go index 1d830654..b2b7450e 100644 --- a/internal/provider/nordvpn/updater/servers.go +++ b/internal/provider/nordvpn/updater/servers.go @@ -17,74 +17,21 @@ var ( func (u *Updater) FetchServers(ctx context.Context, minServers int) ( servers []models.Server, err error) { - const recommended = true const limit = 0 - data, err := fetchAPI(ctx, u.client, recommended, limit) + data, err := fetchAPI(ctx, u.client, limit) if err != nil { return nil, err } - servers = make([]models.Server, 0, len(data)) + servers = make([]models.Server, 0, len(data.Servers)) + groups, services, locations, technologies := data.idToData() - for _, jsonServer := range data { - if jsonServer.Status != "online" { - u.warner.Warn(fmt.Sprintf("ignoring offline server %s", jsonServer.Name)) - continue - } - - server := models.Server{ - Country: jsonServer.country(), - Region: jsonServer.region(), - City: jsonServer.city(), - Hostname: jsonServer.Hostname, - IPs: jsonServer.ips(), - } - - number, err := parseServerName(jsonServer.Name) - switch { - case errors.Is(err, ErrNoIDInServerName): - u.warner.Warn(fmt.Sprintf("%s - leaving server number as 0", err)) - case err != nil: - u.warner.Warn(fmt.Sprintf("failed parsing server name: %s", err)) - continue - default: // no error - server.Number = number - } - - var wireguardFound, openvpnFound bool - wireguardServer := server - wireguardServer.VPN = vpn.Wireguard - openVPNServer := server // accumulate UDP+TCP technologies - openVPNServer.VPN = vpn.OpenVPN - - for _, technology := range jsonServer.Technologies { - switch technology.Identifier { - case "openvpn_udp": - openvpnFound = true - openVPNServer.UDP = true - case "openvpn_tcp": - openvpnFound = true - openVPNServer.TCP = true - case "wireguard_udp": - wireguardFound = true - wireguardServer.WgPubKey, err = jsonServer.wireguardPublicKey() - if err != nil { - u.warner.Warn(fmt.Sprintf("ignoring Wireguard server %s: %s", - jsonServer.Name, err)) - wireguardFound = false - continue - } - default: // Ignore other technologies - continue - } - } - - if openvpnFound { - servers = append(servers, openVPNServer) - } - if wireguardFound { - servers = append(servers, wireguardServer) + for _, jsonServer := range data.Servers { + newServers, warnings := extractServers(jsonServer, groups, services, locations, technologies) + for _, warning := range warnings { + u.warner.Warn(warning) } + servers = append(servers, newServers...) } if len(servers) < minServers { @@ -96,3 +43,107 @@ func (u *Updater) FetchServers(ctx context.Context, minServers int) ( return servers, nil } + +func extractServers(jsonServer serverData, groups map[uint32]groupData, + services map[uint32]serviceData, locations map[uint32]locationData, + technologies map[uint32]technologyData) (servers []models.Server, + warnings []string) { + ignoreReason := "" + switch { + case jsonServer.Status != "online": + ignoreReason = "status is " + jsonServer.Status + case len(jsonServer.LocationIDs) == 0: + ignoreReason = "no location" + case len(jsonServer.IPs) == 0: + ignoreReason = "no IP address" + case !jsonServer.hasVPNService(services): + ignoreReason = "no VPN service" + } + if ignoreReason != "" { + warning := fmt.Sprintf("ignoring server %s: %s", jsonServer.Name, ignoreReason) + return nil, []string{warning} + } + + location, ok := locations[jsonServer.LocationIDs[0]] + if !ok { + warning := fmt.Sprintf("location with id %d not found in %v", + jsonServer.LocationIDs[0], locations) + return nil, []string{warning} + } + + region := jsonServer.region(groups) + if region == "" { + warning := fmt.Sprintf("no region found for server %s", jsonServer.Name) + return nil, []string{warning} + } + + server := models.Server{ + Country: location.Country.Name, + Region: jsonServer.region(groups), + City: location.Country.City.Name, + Hostname: jsonServer.Hostname, + IPs: jsonServer.ips(), + } + + number, err := parseServerName(jsonServer.Name) + switch { + case errors.Is(err, ErrNoIDInServerName): + warning := fmt.Sprintf("%s - leaving server number as 0", err) + warnings = append(warnings, warning) + case err != nil: + warning := fmt.Sprintf("failed parsing server name: %s", err) + return nil, []string{warning} + default: // no error + server.Number = number + } + + var wireguardFound, openvpnFound bool + wireguardServer := server + wireguardServer.VPN = vpn.Wireguard + openVPNServer := server // accumulate UDP+TCP technologies + openVPNServer.VPN = vpn.OpenVPN + + for _, technology := range jsonServer.Technologies { + if technology.Status != "online" { + continue + } + technologyData, ok := technologies[technology.ID] + if !ok { + warning := fmt.Sprintf("technology with id %d not found in %v", + technology.ID, technologies) + warnings = append(warnings, warning) + continue + } + + switch technologyData.Identifier { + case "openvpn_udp", "openvpn_dedicated_udp": + openvpnFound = true + openVPNServer.UDP = true + case "openvpn_tcp", "openvpn_dedicated_tcp": + openvpnFound = true + openVPNServer.TCP = true + case "wireguard_udp": + wireguardFound = true + wireguardServer.WgPubKey, err = jsonServer.wireguardPublicKey(technologies) + if err != nil { + warning := fmt.Sprintf("ignoring Wireguard server %s: %s", jsonServer.Name, err) + warnings = append(warnings, warning) + wireguardFound = false + continue + } + default: // Ignore other technologies + continue + } + } + + const maxServers = 2 + servers = make([]models.Server, 0, maxServers) + if openvpnFound { + servers = append(servers, openVPNServer) + } + if wireguardFound { + servers = append(servers, wireguardServer) + } + + return servers, warnings +}