chore(updater): move updater packages to pkg/updaters/<name>

This commit is contained in:
Quentin McGaw
2026-04-23 03:47:57 +00:00
parent 5b01324d5f
commit 13503b0ae0
268 changed files with 1602 additions and 1026 deletions
+138
View File
@@ -0,0 +1,138 @@
package api
import (
"fmt"
"maps"
"net/http"
"net/url"
"regexp"
"slices"
"strings"
)
type Provider string
const (
Cloudflare Provider = "cloudflare"
IfConfigCo Provider = "ifconfigco"
IPInfo Provider = "ipinfo"
IP2Location Provider = "ip2location"
)
const echoipPrefix = "echoip#"
type NameToken struct {
Name string
Token string
}
func New(nameTokenPairs []NameToken, client *http.Client) (
fetchers []Fetcher, err error,
) {
fetchers = make([]Fetcher, len(nameTokenPairs))
for i, nameTokenPair := range nameTokenPairs {
provider, err := ParseProvider(nameTokenPair.Name)
if err != nil {
return nil, fmt.Errorf("parsing API name: %w", err)
}
switch {
case provider == Cloudflare:
fetchers[i] = newCloudflare(client)
case provider == IfConfigCo:
const ifConfigCoURL = "https://ifconfig.co"
fetchers[i] = newEchoip(client, ifConfigCoURL)
case provider == IPInfo:
fetchers[i] = newIPInfo(client, nameTokenPair.Token)
case provider == IP2Location:
fetchers[i] = newIP2Location(client, nameTokenPair.Token)
case strings.HasPrefix(string(provider), echoipPrefix):
url := strings.TrimPrefix(string(provider), echoipPrefix)
fetchers[i] = newEchoip(client, url)
default:
panic("provider not valid: " + provider)
}
}
return fetchers, nil
}
var regexEchoipURL = regexp.MustCompile(`^http(s|):\/\/.+$`)
func ParseProvider(s string) (provider Provider, err error) {
possibleProviders := []Provider{
Cloudflare,
IfConfigCo,
IP2Location,
IPInfo,
}
stringToProvider := make(map[string]Provider, len(possibleProviders))
for _, provider := range possibleProviders {
stringToProvider[string(provider)] = provider
}
provider, ok := stringToProvider[strings.ToLower(s)]
if ok {
return provider, nil
}
customPrefixToURLRegex := map[string]*regexp.Regexp{
echoipPrefix: regexEchoipURL,
}
for prefix, urlRegex := range customPrefixToURLRegex {
match, err := checkCustomURL(s, prefix, urlRegex)
if !match {
continue
} else if err != nil {
return "", err
}
return Provider(s), nil
}
providerStrings := make([]string, 0, len(stringToProvider)+len(customPrefixToURLRegex))
for _, providerString := range slices.Sorted(maps.Keys(stringToProvider)) {
providerStrings = append(providerStrings, `"`+providerString+`"`)
}
for _, prefix := range slices.Sorted(maps.Keys(customPrefixToURLRegex)) {
providerStrings = append(providerStrings, "a custom "+prefix+" url")
}
return "", fmt.Errorf("API name is not valid: %q can only be %s",
s, orStrings(providerStrings))
}
func checkCustomURL(s, prefix string, regex *regexp.Regexp) (match bool, err error) {
if !strings.HasPrefix(s, prefix) {
return false, nil
}
s = strings.TrimPrefix(s, prefix)
_, err = url.Parse(s)
if err != nil {
return true, fmt.Errorf("%s custom URL is not valid: %w", prefix, err)
}
if regex.MatchString(s) {
return true, nil
}
return true, fmt.Errorf("%s custom URL is not valid: "+
"%q does not match regular expression: %s", prefix, s, regex)
}
func orStrings(strings []string) (result string) {
return joinStrings(strings, "or")
}
func joinStrings(strings []string, lastJoin string) (result string) {
if len(strings) == 0 {
return ""
}
result = strings[0]
for i := 1; i < len(strings); i++ {
if i < len(strings)-1 {
result += ", " + strings[i]
} else {
result += " " + lastJoin + " " + strings[i]
}
}
return result
}
+64
View File
@@ -0,0 +1,64 @@
package api
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_ParseProvider(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
s string
provider Provider
errMessage string
}{
"empty": {
errMessage: `API name is not valid: "" can only be ` +
`"cloudflare", "ifconfigco", "ip2location", "ipinfo" or a custom echoip# url`,
},
"invalid": {
s: "xyz",
errMessage: `API name is not valid: "xyz" can only be ` +
`"cloudflare", "ifconfigco", "ip2location", "ipinfo" or a custom echoip# url`,
},
"ipinfo": {
s: "ipinfo",
provider: IPInfo,
},
"IpInfo": {
s: "IpInfo",
provider: IPInfo,
},
"echoip_url_empty": {
s: "echoip#",
errMessage: `echoip# custom URL is not valid: "" ` +
`does not match regular expression: ^http(s|):\/\/.+$`,
},
"echoip_url_invalid": {
s: "echoip#postgres://localhost:3451",
errMessage: `echoip# custom URL is not valid: "postgres://localhost:3451" ` +
`does not match regular expression: ^http(s|):\/\/.+$`,
},
"echoip_url_valid": {
s: "echoip#http://localhost:3451",
provider: Provider("echoip#http://localhost:3451"),
},
}
for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
t.Parallel()
provider, err := ParseProvider(testCase.s)
assert.Equal(t, testCase.provider, provider)
if testCase.errMessage != "" {
assert.EqualError(t, err, testCase.errMessage)
} else {
assert.NoError(t, err)
}
})
}
}
+113
View File
@@ -0,0 +1,113 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/netip"
"strings"
"time"
"github.com/qdm12/gluetun/pkg/publicip/models"
"github.com/qdm12/gluetun/pkg/updaters/constants"
)
type cloudflare struct {
client *http.Client
}
func newCloudflare(client *http.Client) *cloudflare {
return &cloudflare{
client: client,
}
}
func (c *cloudflare) String() string {
return string(Cloudflare)
}
func (c *cloudflare) CanFetchAnyIP() bool {
return false
}
func (c *cloudflare) Token() (token string) {
return ""
}
// FetchInfo obtains information on the public IP address of the machine,
// and returns an error if the `ip` argument is set since the Cloudflare API
// can only be used to provide details about the current machine public IP.
func (c *cloudflare) FetchInfo(ctx context.Context, ip netip.Addr) (
result models.PublicIP, err error,
) {
// Define a timeout since the default client has a large timeout and we don't
// want to wait too long.
const timeout = 15 * time.Second
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
urlBase := "https://speed.cloudflare.com"
url := urlBase + "/meta"
if ip.IsValid() {
return result, fmt.Errorf("service is limited: "+
"cloudflare cannot provide information on the arbitrary IP address %s", ip)
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return result, err
}
request.Header.Add("Referer", urlBase) // returns HTTP 403 otherwise
response, err := c.client.Do(request)
if err != nil {
return result, err
}
defer response.Body.Close()
switch response.StatusCode {
case http.StatusOK:
case http.StatusTooManyRequests:
return result, fmt.Errorf("%w from %s: %d %s",
ErrTooManyRequests, url, response.StatusCode, response.Status)
default:
return result, fmt.Errorf("bad HTTP status received from %s: %d %s",
url, response.StatusCode, response.Status)
}
decoder := json.NewDecoder(response.Body)
var data struct {
Hostname string `json:"hostname,omitempty"`
ClientIP netip.Addr `json:"clientIp,omitempty"`
ASOrganization string `json:"asOrganization,omitempty"`
Country string `json:"country,omitempty"`
City string `json:"city,omitempty"`
Region string `json:"region,omitempty"`
PostalCode string `json:"postalCode,omitempty"`
Latitude string `json:"latitude,omitempty"`
Longitude string `json:"longitude,omitempty"`
}
if err := decoder.Decode(&data); err != nil {
return result, fmt.Errorf("decoding response: %w", err)
}
countryCode := strings.ToLower(data.Country)
country, ok := constants.CountryCodes()[countryCode]
if ok {
data.Country = country
}
result = models.PublicIP{
IP: data.ClientIP,
Region: data.Region,
Country: data.Country,
City: data.City,
Hostname: data.Hostname,
Location: data.Latitude + "," + data.Longitude,
Organization: data.ASOrganization,
PostalCode: data.PostalCode,
Timezone: "", // no timezone
}
return result, nil
}
+109
View File
@@ -0,0 +1,109 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/netip"
"strings"
"time"
"github.com/qdm12/gluetun/pkg/publicip/models"
)
type echoip struct {
client *http.Client
url string
}
func newEchoip(client *http.Client, url string) *echoip {
return &echoip{
client: client,
url: url,
}
}
func (e *echoip) String() string {
s := e.url
s = strings.TrimPrefix(s, "http://")
s = strings.TrimPrefix(s, "https://")
return s
}
func (e *echoip) CanFetchAnyIP() bool {
return true
}
func (e *echoip) Token() string {
return ""
}
// FetchInfo obtains information on the ip address provided
// using the echoip API at the url given. If the ip is the zero value,
// the public IP address of the machine is used as the IP.
func (e *echoip) FetchInfo(ctx context.Context, ip netip.Addr) (
result models.PublicIP, err error,
) {
// Define a timeout since the default client has a large timeout and we don't
// want to wait too long.
const timeout = 15 * time.Second
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
url := e.url + "/json"
if ip.IsValid() {
url += "?ip=" + ip.String()
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return result, err
}
response, err := e.client.Do(request)
if err != nil {
return result, err
}
defer response.Body.Close()
switch response.StatusCode {
case http.StatusOK:
case http.StatusTooManyRequests:
return result, fmt.Errorf("%w from %s: %s", ErrTooManyRequests, url, response.Status)
default:
return result, fmt.Errorf("bad HTTP status received from %s: %s", url, response.Status)
}
decoder := json.NewDecoder(response.Body)
var data struct {
IP netip.Addr `json:"ip,omitempty"`
Country string `json:"country,omitempty"`
RegionName string `json:"region_name,omitempty"`
ZipCode string `json:"zip_code,omitempty"`
City string `json:"city,omitempty"`
Latitude float32 `json:"latitude,omitempty"`
Longitude float32 `json:"longitude,omitempty"`
Hostname string `json:"hostname,omitempty"`
// Timezone in the form America/Montreal
Timezone string `json:"time_zone,omitempty"`
AsnOrg string `json:"asn_org,omitempty"`
}
err = decoder.Decode(&data)
if err != nil {
return result, fmt.Errorf("decoding response: %w", err)
}
result = models.PublicIP{
IP: data.IP,
Region: data.RegionName,
Country: data.Country,
City: data.City,
Hostname: data.Hostname,
Location: fmt.Sprintf("%f,%f", data.Latitude, data.Longitude),
Organization: data.AsnOrg,
PostalCode: data.ZipCode,
Timezone: data.Timezone,
}
return result, nil
}
+5
View File
@@ -0,0 +1,5 @@
package api
import "errors"
var ErrTooManyRequests = errors.New("too many requests sent for this month")
+24
View File
@@ -0,0 +1,24 @@
package api
import (
"context"
"net/netip"
"github.com/qdm12/gluetun/pkg/publicip/models"
)
type Fetcher interface {
String() string
CanFetchAnyIP() bool
Token() (token string)
InfoFetcher
}
type InfoFetcher interface {
FetchInfo(ctx context.Context, ip netip.Addr) (
result models.PublicIP, err error)
}
type Warner interface {
Warn(message string)
}
+138
View File
@@ -0,0 +1,138 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/netip"
"strings"
"time"
"github.com/qdm12/gluetun/pkg/publicip/models"
"github.com/qdm12/gluetun/pkg/updaters/constants"
)
type ip2Location struct {
client *http.Client
token string
countryCodes map[string]string
}
func newIP2Location(client *http.Client, token string) *ip2Location {
return &ip2Location{
client: client,
token: token,
countryCodes: constants.CountryCodes(),
}
}
func (i *ip2Location) String() string {
return string(IP2Location)
}
func (i *ip2Location) CanFetchAnyIP() bool {
return true
}
func (i *ip2Location) Token() string {
return i.token
}
// FetchInfo obtains information on the ip address provided
// using the api.ip2location.io API. If the ip is the zero value,
// the public IP address of the machine is used as the IP.
func (i *ip2Location) FetchInfo(ctx context.Context, ip netip.Addr) (
result models.PublicIP, err error,
) {
// Define a timeout since the default client has a large timeout and we don't
// want to wait too long.
const timeout = 15 * time.Second
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
url := "https://api.ip2location.io/"
if ip.IsValid() {
url += "?ip=" + ip.String()
}
if i.token != "" {
if !strings.Contains(url, "?") {
url += "?"
}
url += "&key=" + i.token
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return result, err
}
response, err := i.client.Do(request)
if err != nil {
return result, err
}
defer response.Body.Close()
if i.token != "" && response.StatusCode == http.StatusUnauthorized {
return result, fmt.Errorf("token is not valid: %s", response.Status)
}
switch response.StatusCode {
case http.StatusOK:
case http.StatusTooManyRequests, http.StatusForbidden:
return result, fmt.Errorf("%w from %s: %d %s",
ErrTooManyRequests, url, response.StatusCode, response.Status)
default:
return result, fmt.Errorf("bad HTTP status received from %s: %d %s",
url, response.StatusCode, response.Status)
}
decoder := json.NewDecoder(response.Body)
var data struct {
IP netip.Addr `json:"ip,omitempty"`
CountryName string `json:"country_name,omitempty"`
RegionName string `json:"region_name,omitempty"`
CityName string `json:"city_name,omitempty"`
Latitude float32 `json:"latitude,omitempty"`
Longitude float32 `json:"longitude,omitempty"`
ZipCode string `json:"zip_code,omitempty"`
// Timezone in the form -07:00
Timezone string `json:"time_zone,omitempty"`
As string `json:"as,omitempty"`
}
if err := decoder.Decode(&data); err != nil {
return result, fmt.Errorf("decoding response: %w", err)
}
// Remove parentheses from country name
idx := strings.Index(data.CountryName, " (")
if idx != -1 {
data.CountryName = data.CountryName[:idx]
}
// Rename country to match country string obtained from other IP data sources
countryRenames := map[string]string{
"Netherlands": i.countryCodes["nl"],
"United States of America": i.countryCodes["us"],
"United Kingdom of Great Britain and Northern Ireland": i.countryCodes["gb"],
"Czechia": i.countryCodes["cz"],
"Korea": i.countryCodes["kr"],
}
if newName, ok := countryRenames[data.CountryName]; ok {
data.CountryName = newName
}
result = models.PublicIP{
IP: data.IP,
Region: data.RegionName,
Country: data.CountryName,
City: data.CityName,
Hostname: "", // no hostname
Location: fmt.Sprintf("%f,%f", data.Latitude, data.Longitude),
Organization: data.As,
PostalCode: data.ZipCode,
Timezone: data.Timezone,
}
return result, nil
}
+120
View File
@@ -0,0 +1,120 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/netip"
"strings"
"time"
"github.com/qdm12/gluetun/pkg/publicip/models"
"github.com/qdm12/gluetun/pkg/updaters/constants"
)
type ipInfo struct {
client *http.Client
token string
}
func newIPInfo(client *http.Client, token string) *ipInfo {
return &ipInfo{
client: client,
token: token,
}
}
func (i *ipInfo) String() string {
return string(IPInfo)
}
func (i *ipInfo) CanFetchAnyIP() bool {
return true
}
func (i *ipInfo) Token() string {
return i.token
}
// FetchInfo obtains information on the ip address provided
// using the ipinfo.io API. If the ip is the zero value, the public IP address
// of the machine is used as the IP.
func (i *ipInfo) FetchInfo(ctx context.Context, ip netip.Addr) (
result models.PublicIP, err error,
) {
// Define a timeout since the default client has a large timeout and we don't
// want to wait too long.
const timeout = 15 * time.Second
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
url := "https://ipinfo.io/"
switch {
case ip.Is6():
url = "https://v6.ipinfo.io/" + ip.String()
case ip.Is4():
url = "https://ipinfo.io/" + ip.String()
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return result, err
}
request.Header.Set("Authorization", "Bearer "+i.token)
response, err := i.client.Do(request)
if err != nil {
return result, err
}
defer response.Body.Close()
if i.token != "" && response.StatusCode == http.StatusUnauthorized {
return result, fmt.Errorf("token is not valid: %s", response.Status)
}
switch response.StatusCode {
case http.StatusOK:
case http.StatusTooManyRequests, http.StatusForbidden:
return result, fmt.Errorf("%w from %s: %d %s",
ErrTooManyRequests, url, response.StatusCode, response.Status)
default:
return result, fmt.Errorf("bad HTTP status received from %s: %d %s",
url, response.StatusCode, response.Status)
}
decoder := json.NewDecoder(response.Body)
var data struct {
IP netip.Addr `json:"ip,omitempty"`
Region string `json:"region,omitempty"`
Country string `json:"country,omitempty"`
City string `json:"city,omitempty"`
Hostname string `json:"hostname,omitempty"`
Loc string `json:"loc,omitempty"`
Org string `json:"org,omitempty"`
Postal string `json:"postal,omitempty"`
Timezone string `json:"timezone,omitempty"`
}
if err := decoder.Decode(&data); err != nil {
return result, fmt.Errorf("decoding response: %w", err)
}
countryCode := strings.ToLower(data.Country)
country, ok := constants.CountryCodes()[countryCode]
if ok {
data.Country = country
}
result = models.PublicIP{
IP: data.IP,
Region: data.Region,
Country: data.Country,
City: data.City,
Hostname: data.Hostname,
Location: data.Loc,
Organization: data.Org,
PostalCode: data.Postal,
Timezone: data.Timezone,
}
return result, nil
}
+57
View File
@@ -0,0 +1,57 @@
package api
import (
"context"
"net/netip"
"github.com/qdm12/gluetun/pkg/publicip/models"
)
// FetchMultiInfo obtains the public IP address information for every IP
// addresses provided and returns a slice of results with the corresponding
// order as to the IP addresses slice order.
// If an error is encountered, all the operations are canceled and
// an error is returned, so the results returned should be considered
// incomplete in this case.
func FetchMultiInfo(ctx context.Context, fetcher InfoFetcher, ips []netip.Addr) (
results []models.PublicIP, err error,
) {
ctx, cancel := context.WithCancel(ctx)
type asyncResult struct {
index int
result models.PublicIP
err error
}
resultsCh := make(chan asyncResult)
for i, ip := range ips {
go func(index int, ip netip.Addr) {
aResult := asyncResult{
index: index,
}
aResult.result, aResult.err = fetcher.FetchInfo(ctx, ip)
resultsCh <- aResult
}(i, ip)
}
results = make([]models.PublicIP, len(ips))
for range ips {
aResult := <-resultsCh
if aResult.err != nil {
if err == nil {
// Cancel on the first error encountered
err = aResult.err
cancel()
}
continue // ignore errors after the first one
}
results[aResult.index] = aResult.result
}
close(resultsCh)
cancel()
return results, err
}
+327
View File
@@ -0,0 +1,327 @@
package api
import (
"context"
"errors"
"fmt"
"net/netip"
"strings"
"sync"
"time"
"unicode"
"github.com/qdm12/gluetun/pkg/publicip/models"
"golang.org/x/text/runes"
"golang.org/x/text/transform"
"golang.org/x/text/unicode/norm"
)
// ResilientFetcher is a fetcher implementation using multiple fetchers.
// If a fetcher fails, it tries the next one.
// To fetch public IP information for a specific IP address,
// it fetches from all sources to find the best result, since data
// from a single source can be wrong.
type ResilientFetcher struct {
fetchers []Fetcher
logger Warner
fetcherToBanTime map[Fetcher]time.Time
banMutex sync.RWMutex
mutex sync.RWMutex
timeNow func() time.Time
}
// NewResilient creates a 'resilient' fetcher given multiple fetchers.
func NewResilient(fetchers []Fetcher, logger Warner) *ResilientFetcher {
return &ResilientFetcher{
fetchers: fetchers,
logger: logger,
fetcherToBanTime: make(map[Fetcher]time.Time, len(fetchers)),
timeNow: time.Now,
}
}
func (r *ResilientFetcher) setBanned(fetcher Fetcher) {
r.banMutex.Lock()
defer r.banMutex.Unlock()
r.fetcherToBanTime[fetcher] = r.timeNow()
}
func (r *ResilientFetcher) isBanned(fetcher Fetcher) (banned bool) {
r.banMutex.Lock()
defer r.banMutex.Unlock()
banTime, banned := r.fetcherToBanTime[fetcher]
if !banned {
return false
}
const banDuration = 30 * 24 * time.Hour
banExpiryTime := banTime.Add(banDuration)
now := r.timeNow()
if now.After(banExpiryTime) {
delete(r.fetcherToBanTime, fetcher)
return false
}
return true
}
func (r *ResilientFetcher) String() string {
r.mutex.RLock()
defer r.mutex.RUnlock()
names := make([]string, 0, len(r.fetchers))
for _, fetcher := range r.fetchers {
if r.isBanned(fetcher) {
continue
}
names = append(names, fetcher.String())
}
if len(names) == 0 {
return "<all-banned>"
}
return strings.Join(names, "+")
}
func (r *ResilientFetcher) Token() string {
panic("invalid call")
}
// CanFetchAnyIP returns true if any of the fetchers
// can fetch any IP address and is not banned.
func (r *ResilientFetcher) CanFetchAnyIP() bool {
r.mutex.RLock()
defer r.mutex.RUnlock()
for _, fetcher := range r.fetchers {
if !fetcher.CanFetchAnyIP() || r.isBanned(fetcher) {
continue
}
return true
}
return false
}
// FetchInfo obtains information on the ip address provided.
// If the ip is the zero value, the public IP address of the machine
// is used as the IP.
// It queries all non-banned fetchers in parallel to obtain the most popular result.
// It only returns an error if all fetchers fail to return information.
func (r *ResilientFetcher) FetchInfo(ctx context.Context, ip netip.Addr) (
result models.PublicIP, err error,
) {
r.mutex.RLock()
defer r.mutex.RUnlock()
type resultData struct {
i int
result models.PublicIP
err error
}
resultsCh := make(chan resultData)
fetchersStarted := 0
for range r.fetchers {
fetcher := r.fetchers[fetchersStarted]
if r.isBanned(fetcher) ||
(ip.IsValid() && !fetcher.CanFetchAnyIP()) {
continue
}
go func(i int, fetcher Fetcher) {
result, err := fetcher.FetchInfo(ctx, ip)
resultsCh <- resultData{
i: i,
result: result,
err: err,
}
}(fetchersStarted, fetcher)
fetchersStarted++
}
// Collect resultDatas from goroutines first, which takes I/O time
// so that we don't lock the ban map mutex for too long.
resultDatas := make([]resultData, fetchersStarted)
for range resultDatas {
data := <-resultsCh
resultDatas[data.i] = data
}
// Mutex lock ban map and process results
results := make([]models.PublicIP, 0, fetchersStarted)
errs := make([]error, 0, fetchersStarted)
for _, data := range resultDatas {
fetcher := r.fetchers[data.i]
if data.err != nil {
if errors.Is(data.err, ErrTooManyRequests) {
r.setBanned(fetcher)
}
errs = append(errs, fmt.Errorf("%s: %w", fetcher, data.err))
continue
}
results = append(results, data.result)
}
if len(results) == 0 { // all failed
return models.PublicIP{}, fmt.Errorf("all fetchers failed: %w", errors.Join(errs...))
}
return getMostPopularResult(results), nil
}
// getMostPopularResult finds the most popular [models.PublicIP] from
// a slice of results. It does so by first checking the country, then
// region, then city fields. The other fields are ignored in this comparison.
func getMostPopularResult(results []models.PublicIP) models.PublicIP {
if len(results) == 0 {
panic("no results to choose from")
}
// 1. Filter by Country
countries := make([]string, len(results))
for i, r := range results {
countries[i] = r.Country
}
_, countryMembers := getMostPopularString(countries)
results = filterInPlace(results, countryMembers)
// 2. Filter by Region
regions := make([]string, len(results))
for i, r := range results {
regions[i] = r.Region
}
_, regionMembers := getMostPopularString(regions)
results = filterInPlace(results, regionMembers)
// 3. Filter by City
cities := make([]string, len(results))
for i, r := range results {
cities[i] = r.City
}
winnerIdx, _ := getMostPopularString(cities)
return results[winnerIdx]
}
// filterInPlace moves selected indices to the front and trims the slice.
func filterInPlace(results []models.PublicIP, indices []int) []models.PublicIP {
for i, originalIdx := range indices {
results[i] = results[originalIdx]
}
return results[:len(indices)]
}
// getMostPopularString returns the index of the representative winner
// and a slice of all indexes that belong to that winner's cluster.
func getMostPopularString(values []string) (winnerIdx int, memberIdxs []int) {
if len(values) == 0 {
return -1, nil
}
type cluster struct {
firstIndex int
normRep string
members []int
}
var groups []cluster
for i, value := range values {
normP := normalize(value)
found := false
for j := range groups {
if levenshteinDistance(normP, groups[j].normRep) <= 1 {
groups[j].members = append(groups[j].members, i)
found = true
break
}
}
if !found {
groups = append(groups, cluster{
firstIndex: i,
normRep: normP,
members: []int{i},
})
}
}
maxCount := -1
var bestGroup cluster
for _, g := range groups {
if len(g.members) > maxCount {
maxCount = len(g.members)
bestGroup = g
}
}
return bestGroup.firstIndex, bestGroup.members
}
func (r *ResilientFetcher) UpdateFetchers(fetchers []Fetcher) {
newFetcherNameToFetcher := make(map[string]Fetcher, len(fetchers))
for _, fetcher := range fetchers {
newFetcherNameToFetcher[fetcher.String()] = fetcher
}
r.mutex.Lock()
defer r.mutex.Unlock()
newFetcherToBanTime := make(map[Fetcher]time.Time, len(r.fetcherToBanTime))
for bannedFetcher, banTime := range r.fetcherToBanTime {
if !r.isBanned(bannedFetcher) {
// fetcher is no longer in its ban period.
continue
}
bannedName := bannedFetcher.String()
newFetcher, isNewFetcher := newFetcherNameToFetcher[bannedName]
if isNewFetcher && newFetcher.Token() == bannedFetcher.Token() {
newFetcherToBanTime[newFetcher] = banTime
}
}
r.fetchers = fetchers
r.fetcherToBanTime = newFetcherToBanTime
}
// normalize removes accents, trims space, and lowercases the string.
func normalize(s string) string {
firstParentheseIndex := strings.Index(s, " (")
if firstParentheseIndex != -1 {
s = s[:firstParentheseIndex]
}
transformer := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
result, _, err := transform.String(transformer, s)
if err != nil {
panic(err)
}
return strings.ToLower(strings.TrimSpace(result))
}
// levenshteinDistance calculates the edit distance
// between two strings a and b.
func levenshteinDistance(a, b string) int {
switch {
case len(a) == 0:
return len(b)
case len(b) == 0:
return len(a)
}
column := make([]int, len(b)+1)
for i := 0; i <= len(b); i++ {
column[i] = i
}
for i := 1; i <= len(a); i++ {
column[0] = i
lastValue := i - 1
for j := 1; j <= len(b); j++ {
oldValue := column[j]
cost := 0
if a[i-1] != b[j-1] {
cost = 1
}
column[j] = min(column[j]+1, min(column[j-1]+1, lastValue+cost))
lastValue = oldValue
}
}
return column[len(b)]
}
+70
View File
@@ -0,0 +1,70 @@
package api
import (
"testing"
"github.com/qdm12/gluetun/pkg/publicip/models"
"github.com/stretchr/testify/assert"
)
func Test_GetMostPopularResult(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
input []models.PublicIP
expected models.PublicIP
}{
"exact_matches": {
input: []models.PublicIP{
{Country: "France", City: "Paris"},
{Country: "USA", City: "New York"},
{Country: "France", City: "Paris"},
},
expected: models.PublicIP{Country: "France", City: "Paris"},
},
"fuzzy_country_matching": {
input: []models.PublicIP{
{Country: "Germany", Region: "Bavaria", City: "Munich"},
{Country: "Germani", Region: "Bavaria", City: "Munich"},
{Country: "France", Region: "IDF", City: "Paris"},
},
expected: models.PublicIP{Country: "Germany", Region: "Bavaria", City: "Munich"},
},
"hierarchy_priority": {
input: []models.PublicIP{
{Country: "Italy", Region: "Sicily", City: "Syracuse"},
{Country: "Italy", Region: "Sicily", City: "Syracuse"},
{Country: "USA", Region: "New York", City: "Syracuse"},
{Country: "Italy", Region: "Sicily", City: "Syracuse"},
},
expected: models.PublicIP{Country: "Italy", Region: "Sicily", City: "Syracuse"},
},
"normalization_check": {
input: []models.PublicIP{
{Country: "Canada", City: "Montréal"},
{Country: "Canada", City: "Montreal "},
{Country: "UK", City: "London"},
},
expected: models.PublicIP{Country: "Canada", City: "Montréal"},
},
"all_different": {
input: []models.PublicIP{
{Country: "Canada", City: "Montréal"},
{Country: "US", City: "New York"},
{Country: "UK", City: "London"},
},
expected: models.PublicIP{Country: "US", City: "New York"},
},
}
for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
t.Parallel()
result := getMostPopularResult(testCase.input)
assert.Equal(t, testCase.expected.Country, result.Country)
assert.Equal(t, testCase.expected.Region, result.Region)
assert.Equal(t, testCase.expected.City, result.City)
})
}
}