Files
gluetun/internal/publicip/api/resilient.go
Quentin McGaw a61302f135 feat(publicip): resilient public ip fetcher (#2518)
- `PUBLICIP_API` accepts a comma separated list of ip data sources, where the first one is the base default one, and sources after it are backup sources used if we are rate limited.
- `PUBLICIP_API` defaults to `ipinfo,ifconfigco,ip2location,cloudflare` such that it now has `ifconfigco,ip2location,cloudflare` as backup ip data sources.
- `PUBLICIP_API_TOKEN` accepts a comma separated list of ip data source tokens, each corresponding by position to the APIs listed in `PUBLICIP_API`.
- logs ip data source when logging public ip information
- assume a rate limiting error is for 30 days (no persistence)
- ready for future live settings updates
  - consider an ip data source no longer banned if the token changes
  - keeps track of ban times when updating the list of fetchers
2024-10-19 15:21:14 +02:00

154 lines
3.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"context"
"errors"
"fmt"
"net/netip"
"strings"
"sync"
"time"
"github.com/qdm12/gluetun/internal/models"
)
type ResilientFetcher struct {
fetchers []Fetcher
logger Warner
fetcherToBanTime map[Fetcher]time.Time
mutex sync.RWMutex
timeNow func() time.Time
}
// NewResilient creates a 'resilient' fetcher given multiple fetchers.
// For example, it can handle bans and move on to another fetcher if one fails.
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) isBanned(fetcher Fetcher) (banned bool) {
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()
for _, fetcher := range r.fetchers {
if r.isBanned(fetcher) {
continue
}
return fetcher.String()
}
return "<all-banned>"
}
func (r *ResilientFetcher) Token() string {
r.mutex.RLock()
defer r.mutex.RUnlock()
for _, fetcher := range r.fetchers {
if r.isBanned(fetcher) {
continue
}
return fetcher.Token()
}
return "<all-banned>"
}
// 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
}
var ErrFetchersAllRateLimited = errors.New("all fetchers are rate limited")
// 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.
// If a fetcher gets banned, the next one is tried until all have been exhausted.
// Fetchers still within their banned period are skipped.
// If an error unrelated to being banned is encountered, it is returned and more
// fetchers are tried.
func (r *ResilientFetcher) FetchInfo(ctx context.Context, ip netip.Addr) (
result models.PublicIP, err error,
) {
r.mutex.RLock()
defer r.mutex.RUnlock()
for _, fetcher := range r.fetchers {
if r.isBanned(fetcher) ||
(ip.IsValid() && !fetcher.CanFetchAnyIP()) {
continue
}
result, err = fetcher.FetchInfo(ctx, ip)
if err == nil || !errors.Is(err, ErrTooManyRequests) {
return result, err
}
// Fetcher is banned
r.fetcherToBanTime[fetcher] = r.timeNow()
r.logger.Warn(fetcher.String() + ": " + err.Error())
}
fetcherNames := make([]string, len(r.fetchers))
for i, fetcher := range r.fetchers {
fetcherNames[i] = fetcher.String()
}
return result, fmt.Errorf("%w (%s)",
ErrFetchersAllRateLimited,
strings.Join(fetcherNames, ", "))
}
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
}