diff --git a/Dockerfile b/Dockerfile index dc5eac45e..903fe66c6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -190,6 +190,7 @@ ENV VPN_SERVICE_PROVIDER=pia \ # Public IP PUBLICIP_FILE="/tmp/gluetun/ip" \ PUBLICIP_PERIOD=12h \ + PUBLICIP_API=ipinfo \ PUBLICIP_API_TOKEN= \ # Pprof PPROF_ENABLED=no \ diff --git a/cmd/gluetun/main.go b/cmd/gluetun/main.go index 02ec772c9..5ee9694e1 100644 --- a/cmd/gluetun/main.go +++ b/cmd/gluetun/main.go @@ -396,7 +396,8 @@ func _main(ctx context.Context, buildInfo models.BuildInformation, go unboundLooper.RunRestartTicker(dnsTickerCtx, dnsTickerDone) controlGroupHandler.Add(dnsTickerHandler) - ipFetcher, err := pubipapi.New(pubipapi.IPInfo, httpClient, *allSettings.PublicIP.APIToken) + publicipAPI, _ := pubipapi.ParseProvider(allSettings.PublicIP.API) + ipFetcher, err := pubipapi.New(publicipAPI, httpClient, *allSettings.PublicIP.APIToken) if err != nil { return fmt.Errorf("creating public IP API client: %w", err) } diff --git a/internal/configuration/settings/publicip.go b/internal/configuration/settings/publicip.go index 3e34bc3e8..ed89db4ec 100644 --- a/internal/configuration/settings/publicip.go +++ b/internal/configuration/settings/publicip.go @@ -5,6 +5,7 @@ import ( "path/filepath" "time" + "github.com/qdm12/gluetun/internal/publicip/api" "github.com/qdm12/gosettings" "github.com/qdm12/gotree" ) @@ -21,6 +22,9 @@ type PublicIP struct { // to write to a file. It cannot be nil for the // internal state IPFilepath *string + // API is the API name to use to fetch public IP information. + // It can be ipinfo or ip2location. It defaults to ipinfo. + API string // APIToken is the token to use for the IP data service // such as ipinfo.io. It can be the empty string to // indicate not to use a token. It cannot be nil for the @@ -56,6 +60,11 @@ func (p PublicIP) validate() (err error) { } } + _, err = api.ParseProvider(p.API) + if err != nil { + return fmt.Errorf("API name: %w", err) + } + return nil } @@ -63,6 +72,7 @@ func (p *PublicIP) copy() (copied PublicIP) { return PublicIP{ Period: gosettings.CopyPointer(p.Period), IPFilepath: gosettings.CopyPointer(p.IPFilepath), + API: p.API, APIToken: gosettings.CopyPointer(p.APIToken), } } @@ -70,12 +80,14 @@ func (p *PublicIP) copy() (copied PublicIP) { func (p *PublicIP) mergeWith(other PublicIP) { p.Period = gosettings.MergeWithPointer(p.Period, other.Period) p.IPFilepath = gosettings.MergeWithPointer(p.IPFilepath, other.IPFilepath) + p.API = gosettings.MergeWithString(p.API, other.API) p.APIToken = gosettings.MergeWithPointer(p.APIToken, other.APIToken) } func (p *PublicIP) overrideWith(other PublicIP) { p.Period = gosettings.OverrideWithPointer(p.Period, other.Period) p.IPFilepath = gosettings.OverrideWithPointer(p.IPFilepath, other.IPFilepath) + p.API = gosettings.OverrideWithString(p.API, other.API) p.APIToken = gosettings.OverrideWithPointer(p.APIToken, other.APIToken) } @@ -83,6 +95,7 @@ func (p *PublicIP) setDefaults() { const defaultPeriod = 12 * time.Hour p.Period = gosettings.DefaultPointer(p.Period, defaultPeriod) p.IPFilepath = gosettings.DefaultPointer(p.IPFilepath, "/tmp/gluetun/ip") + p.API = gosettings.DefaultString(p.API, "ipinfo") p.APIToken = gosettings.DefaultPointer(p.APIToken, "") } @@ -108,6 +121,8 @@ func (p PublicIP) toLinesNode() (node *gotree.Node) { node.Appendf("IP file path: %s", *p.IPFilepath) } + node.Appendf("Public IP data API: %s", p.API) + if *p.APIToken != "" { node.Appendf("API token: %s", gosettings.ObfuscateKey(*p.APIToken)) } diff --git a/internal/configuration/settings/settings_test.go b/internal/configuration/settings/settings_test.go index 3bbaa24d6..c86947b0a 100644 --- a/internal/configuration/settings/settings_test.go +++ b/internal/configuration/settings/settings_test.go @@ -84,7 +84,8 @@ func Test_Settings_String(t *testing.T) { | └── Process GID: 1000 ├── Public IP settings: | ├── Fetching: every 12h0m0s -| └── IP file path: /tmp/gluetun/ip +| ├── IP file path: /tmp/gluetun/ip +| └── Public IP data API: ipinfo └── Version settings: └── Enabled: yes`, }, diff --git a/internal/configuration/sources/env/publicip.go b/internal/configuration/sources/env/publicip.go index 50bec915f..934de70a2 100644 --- a/internal/configuration/sources/env/publicip.go +++ b/internal/configuration/sources/env/publicip.go @@ -14,6 +14,8 @@ func (s *Source) readPublicIP() (publicIP settings.PublicIP, err error) { publicIP.IPFilepath = s.env.Get("PUBLICIP_FILE", env.ForceLowercase(false), env.RetroKeys("IP_STATUS_FILE")) + publicIP.API = s.env.String("PUBLICIP_API") + publicIP.APIToken = s.env.Get("PUBLICIP_API_TOKEN") return publicIP, nil diff --git a/internal/publicip/api/api.go b/internal/publicip/api/api.go index 9e86add01..e5e19252a 100644 --- a/internal/publicip/api/api.go +++ b/internal/publicip/api/api.go @@ -19,7 +19,8 @@ type API interface { type Provider string const ( - IPInfo Provider = "ipinfo" + IPInfo Provider = "ipinfo" + IP2Location Provider = "ip2location" ) func New(provider Provider, client *http.Client, token string) ( //nolint:ireturn @@ -27,6 +28,8 @@ func New(provider Provider, client *http.Client, token string) ( //nolint:iretur switch provider { case IPInfo: return newIPInfo(client, token), nil + case IP2Location: + return newIP2Location(client, token), nil default: panic("provider not valid: " + provider) } @@ -40,6 +43,8 @@ func ParseProvider(s string) (provider Provider, err error) { switch strings.ToLower(s) { case "ipinfo": return IPInfo, nil + case "ip2location": + return IP2Location, nil default: return "", fmt.Errorf(`%w: %q can only be "ipinfo" or "ip2location"`, ErrProviderNotValid, s) diff --git a/internal/publicip/api/ip2location.go b/internal/publicip/api/ip2location.go new file mode 100644 index 000000000..db3dc0832 --- /dev/null +++ b/internal/publicip/api/ip2location.go @@ -0,0 +1,97 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/netip" + "strings" + + "github.com/qdm12/gluetun/internal/models" +) + +type ip2Location struct { + client *http.Client + token string +} + +func newIP2Location(client *http.Client, token string) *ip2Location { + return &ip2Location{ + client: client, + token: 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) { + 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("%w: %s", ErrTokenNotValid, 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("%w from %s: %d %s", + ErrBadHTTPStatus, 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 string `json:"latitude,omitempty"` + Longitude string `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) + } + + result = models.PublicIP{ + IP: data.IP, + Region: data.RegionName, + Country: data.CountryName, + City: data.CityName, + Hostname: "", // no hostname + Location: fmt.Sprintf("%s,%s", data.Latitude, data.Longitude), + Organization: data.As, + PostalCode: data.ZipCode, + Timezone: data.Timezone, + } + return result, nil +}