diff --git a/go.mod b/go.mod index 5f12b4a4b..4ea2fae86 100644 --- a/go.mod +++ b/go.mod @@ -88,6 +88,7 @@ require ( github.com/coder/websocket v1.8.12 // indirect github.com/containerd/console v1.0.3 // indirect github.com/getlantern/fronted v0.0.0-20241212194832-a55b6db2616e // indirect + github.com/getlantern/geolookup v0.0.0-20230327091034-aebe73c6eef4 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/gookit/color v1.5.4 // indirect github.com/lithammer/fuzzysearch v1.1.8 // indirect diff --git a/go.sum b/go.sum index 4f8a4026e..8ae0313a7 100644 --- a/go.sum +++ b/go.sum @@ -323,6 +323,8 @@ github.com/getlantern/fronted v0.0.0-20241212194832-a55b6db2616e h1:qk62Xhg+ha1s github.com/getlantern/fronted v0.0.0-20241212194832-a55b6db2616e/go.mod h1:UOynqDcVIlDMFk3sdUyHzNyY1cz4GHtJ+8qvWESHWhg= github.com/getlantern/geo v0.0.0-20241129152027-2fc88c10f91e h1:vpikNz6IzvEoqVYmiK5Uq+lE4TCzvMDqbZdxFbtGK1g= github.com/getlantern/geo v0.0.0-20241129152027-2fc88c10f91e/go.mod h1:RjQ0krF8NTCc5xo2Q1995/vZBnYg33h8svn15do7dLg= +github.com/getlantern/geolookup v0.0.0-20230327091034-aebe73c6eef4 h1:Ju9l1RretVWJTNo2vpl/xAW8Dcuiyg5kJC6LRBpCigw= +github.com/getlantern/geolookup v0.0.0-20230327091034-aebe73c6eef4/go.mod h1:4UNvIsawdB8WclVxqYv46Oe1zzWJ8wMhUO+q6tUzATo= github.com/getlantern/go-socks5 v0.0.0-20171114193258-79d4dd3e2db5 h1:RBKofGGMt2k6eGBwX8mky9qunjL+KnAp9JdzXjiRkRw= github.com/getlantern/go-socks5 v0.0.0-20171114193258-79d4dd3e2db5/go.mod h1:kGHRXch95rnGLHjER/GhhFiHvfnqNz7KqWD9kGfATHY= github.com/getlantern/go-tun2socks v1.16.12-0.20201218023150-b68f09e5ae93 h1:CFLw2b6vgOmpxsRWRiTd46tiR6YKg2crIuTu4cINYcY= diff --git a/internalsdk/ios/config.go b/internalsdk/ios/config.go index 7985e4c6a..05e385b42 100644 --- a/internalsdk/ios/config.go +++ b/internalsdk/ios/config.go @@ -21,9 +21,10 @@ import ( "github.com/getlantern/flashlight/v7/config" "github.com/getlantern/flashlight/v7/email" "github.com/getlantern/flashlight/v7/embeddedconfig" - "github.com/getlantern/flashlight/v7/geolookup" "github.com/getlantern/flashlight/v7/proxied" + "github.com/getlantern/lantern-client/internalsdk/ios/geolookup" + "context" "github.com/getlantern/lantern-client/internalsdk/common" @@ -140,9 +141,8 @@ func (cf *configurer) Configure(userID int, proToken string, refreshProxies bool if frontingErr := setupFronting(); frontingErr != nil { log.Errorf("Unable to configure fronting, sticking with embedded configuration: %v", err) } else { - log.Debug("Refreshing geolookup") - go func() { + go geolookup.Refresh() cf.uc.Country = geolookup.GetCountry(1 * time.Minute) log.Debugf("Successful geolookup: country %s", cf.uc.Country) cf.uc.AllowProbes = global.FeatureEnabled( diff --git a/internalsdk/ios/geolookup/geolookup.go b/internalsdk/ios/geolookup/geolookup.go new file mode 100644 index 000000000..60e2e024d --- /dev/null +++ b/internalsdk/ios/geolookup/geolookup.go @@ -0,0 +1,262 @@ +package geolookup + +import ( + "context" + "encoding/json" + "fmt" + "math" + "net/http" + "os" + "sync" + "time" + + "github.com/getlantern/eventual/v2" + geo "github.com/getlantern/geolookup" + "github.com/getlantern/golog" + + "github.com/getlantern/flashlight/v7/ops" + "github.com/getlantern/flashlight/v7/proxied" +) + +var ( + log = golog.LoggerFor("ios.geolookup") + + refreshRequest = make(chan interface{}, 1) + currentGeoInfo = eventual.NewValue() + watchers []chan bool + persistToFile string + mx sync.Mutex + roundTripper http.RoundTripper +) + +const ( + maxTimeout = 10 * time.Minute + retryWaitMillis = 100 + maxRetryWait = 30 * time.Second +) + +type GeoInfo struct { + IP string + City *geo.City + FromDisk bool +} + +func init() { + SetDefaultRoundTripper() +} + +// GetIP gets the IP. If the IP hasn't been determined yet, waits up to the +// given timeout for an IP to become available. +func GetIP(timeout time.Duration) string { + gi, err := GetGeoInfo(timeout) + if err != nil { + log.Debugf("Could not get IP: %v", err) + return "" + } + return gi.IP +} + +// GetCountry gets the country. If the country hasn't been determined yet, waits +// up to the given timeout for a country to become available. +func GetCountry(timeout time.Duration) string { + gi, err := GetGeoInfo(timeout) + if err != nil { + log.Debugf("Could not get country: %v", err) + return "" + } + return gi.City.Country.IsoCode +} + +func GetGeoInfo(timeout time.Duration) (*GeoInfo, error) { + // We need to specially handle negative timeouts because some callers may use + // eventual.Forever (aka -1), expecting it to block forever. + if timeout < 0 { + timeout = maxTimeout + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + gi, err := currentGeoInfo.Get(ctx) + if err != nil { + return nil, fmt.Errorf( + "could not get geoinfo with timeout %v: %w", + timeout, + err, + ) + } + if gi == nil { + return nil, fmt.Errorf("no geo info after %v", timeout) + } + return gi.(*GeoInfo), nil +} + +// EnablePersistence enables persistence of the current geo info to disk at the named file and +// initializes current geo info from that file if necessary. +func EnablePersistence(geoFile string) { + mx.Lock() + defer mx.Unlock() + + // use this file going forward + persistToFile = geoFile + + log.Debugf("Will persist geolocation info to %v", persistToFile) + + // initialize from file if necessary + knownCountry := GetCountry(0) + if knownCountry == "" { + file, err := os.Open(persistToFile) + if err == nil { + log.Debugf("Initializing geolocation info from %v", persistToFile) + dec := json.NewDecoder(file) + gi := &GeoInfo{ + FromDisk: true, + } + decodeErr := dec.Decode(gi) + if decodeErr != nil { + log.Errorf( + "Error initializing geolocation info from %v: %v", + persistToFile, + decodeErr, + ) + return + } + setGeoInfo(gi, false) + } + } +} + +// Refresh refreshes the geolookup information by calling the remote geolookup +// service. It will keep calling the service until it's able to determine an IP +// and country. +func Refresh() { + select { + case refreshRequest <- true: + log.Debug("Requested refresh") + default: + log.Debug("Refresh already in progress") + } +} + +// OnRefresh creates a channel that caller can receive on when new geolocation +// information is got. +func OnRefresh() <-chan bool { + ch := make(chan bool, 1) + mx.Lock() + watchers = append(watchers, ch) + mx.Unlock() + return ch +} + +func init() { + go run() +} + +func run() { + for range refreshRequest { + log.Debug("Refreshing geolocation info") + geoInfo := lookup() + + // Check if the IP has changed and if the old IP is simply cached from + // disk. If it is cached, we should still notify anyone looking for + // a new IP because they won't have been notified of the IP on disk, + // as that is loaded very soon on startup. + if !isNew(geoInfo) { + log.Debug("public IP from network did not change - not notifying watchers") + continue + } + log.Debug("Setting new geolocation info") + mx.Lock() + setGeoInfo(geoInfo, true) + mx.Unlock() + } +} + +func isNew(newGeoInfo *GeoInfo) bool { + if newGeoInfo == nil { + return false + } + oldGeoInfo, err := GetGeoInfo(0) + if err != nil { + return true + } + if oldGeoInfo == nil { + return true + } + return oldGeoInfo.IP != newGeoInfo.IP || + oldGeoInfo.FromDisk != newGeoInfo.FromDisk +} + +func setGeoInfo(gi *GeoInfo, persist bool) { + currentGeoInfo.Set(gi) + w := watchers + for _, ch := range w { + select { + case ch <- true: + default: + } + } + if persist && persistToFile != "" { + b, err := json.Marshal(gi) + if err != nil { + log.Errorf( + "Unable to marshal geolocation info to JSON for persisting: %v", + err, + ) + return + } + writeErr := os.WriteFile(persistToFile, b, 0644) + if writeErr != nil { + log.Errorf( + "Error persisting geolocation info to %v: %v", + persistToFile, + err, + ) + } + } +} + +func lookup() *GeoInfo { + consecutiveFailures := 0 + + for { + gi, err := doLookup() + if err == nil { + return gi + } + log.Debugf("Unable to get current location: %s", err) + wait := time.Duration( + math.Pow( + 2, + float64(consecutiveFailures), + )*float64( + retryWaitMillis, + ), + ) * time.Millisecond + if wait > maxRetryWait { + wait = maxRetryWait + } + log.Debugf("Waiting %v before retrying", wait) + time.Sleep(wait) + consecutiveFailures++ + } +} + +func doLookup() (*GeoInfo, error) { + op := ops.Begin("geolookup") + defer op.End() + city, ip, err := geo.LookupIP("", roundTripper) + + if err != nil { + log.Errorf("Could not lookup IP %v", err) + return nil, op.FailIf(err) + } + return &GeoInfo{ + IP: ip, + City: city, + FromDisk: false}, + nil +} + +func SetDefaultRoundTripper() { + roundTripper = proxied.ParallelPreferChained() +} diff --git a/internalsdk/ios/geolookup/geolookup_test.go b/internalsdk/ios/geolookup/geolookup_test.go new file mode 100644 index 000000000..f0d3151ba --- /dev/null +++ b/internalsdk/ios/geolookup/geolookup_test.go @@ -0,0 +1,223 @@ +package geolookup + +import ( + "net" + "net/http" + "os" + "testing" + "time" + + "github.com/getlantern/eventual/v2" + "github.com/getlantern/fronted" + "github.com/stretchr/testify/require" +) + +const initialInfo = ` +{ + "City": { + "City": { + "GeoNameID": 4671654, + "Names": { + "de": "Austin", + "en": "Austin", + "es": "Austin", + "fr": "Austin", + "ja": "\u30aa\u30fc\u30b9\u30c6\u30a3\u30f3", + "pt-BR": "Austin", + "ru": "\u041e\u0441\u0442\u0438\u043d" + } + }, + "Continent": { + "Code": "NA", + "GeoNameID": 6255149, + "Names": { + "de": "Nordamerika", + "en": "North America", + "es": "Norteam\u00e9rica", + "fr": "Am\u00e9rique du Nord", + "ja": "\u5317\u30a2\u30e1\u30ea\u30ab", + "pt-BR": "Am\u00e9rica do Norte", + "ru": "\u0421\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0410\u043c\u0435\u0440\u0438\u043a\u0430", + "zh-CN": "\u5317\u7f8e\u6d32" + } + }, + "Country": { + "GeoNameID": 6252001, + "IsoCode": "FM", + "Names": { + "de": "USA", + "en": "United States", + "es": "Estados Unidos", + "fr": "\u00c9tats Unis", + "ja": "\u30a2\u30e1\u30ea\u30ab", + "pt-BR": "EUA", + "ru": "\u0421\u0428\u0410", + "zh-CN": "\u7f8e\u56fd" + } + }, + "Location": { + "Latitude": 30.2095, + "Longitude": -97.7972, + "MetroCode": 635, + "TimeZone": "America/Chicago" + }, + "Postal": { + "Code": "78745" + }, + "RegisteredCountry": { + "GeoNameID": 6252001, + "IsoCode": "US", + "Names": { + "de": "USA", + "en": "United States", + "es": "Estados Unidos", + "fr": "\u00c9tats Unis", + "ja": "\u30a2\u30e1\u30ea\u30ab", + "pt-BR": "EUA", + "ru": "\u0421\u0428\u0410", + "zh-CN": "\u7f8e\u56fd" + } + }, + "RepresentedCountry": { + "GeoNameID": 0, + "IsoCode": "", + "Names": null, + "Type": "" + }, + "Subdivisions": [ + { + "GeoNameID": 4736286, + "IsoCode": "TX", + "Names": { + "en": "Texas", + "es": "Texas", + "fr": "Texas", + "ja": "\u30c6\u30ad\u30b5\u30b9\u5dde", + "ru": "\u0422\u0435\u0445\u0430\u0441", + "zh-CN": "\u5fb7\u514b\u8428\u65af\u5dde" + } + } + ], + "Traits": { + "IsAnonymousProxy": false, + "IsSatelliteProvider": false + } + }, + "IP": "999.999.999.999" +} +` + +func TestGetIP(t *testing.T) { + currentGeoInfo = eventual.NewValue() + roundTripper = &http.Transport{ + Dial: (&net.Dialer{ + Timeout: 10 * time.Second, + }).Dial, + } + ip := GetIP(0) + require.Equal(t, "", ip) + go Refresh() + ip = GetIP(-1) + addr := net.ParseIP(ip) + require.NotNil(t, addr) +} + +func TestGetCountry(t *testing.T) { + currentGeoInfo = eventual.NewValue() + roundTripper = &http.Transport{ + Dial: (&net.Dialer{ + Timeout: 10 * time.Second, + }).Dial, + } + + country := GetCountry(0) + require.Equal(t, "", country) + go Refresh() + country = GetCountry(-1) + require.NotEmpty(t, country) +} + +func TestFronted(t *testing.T) { + currentGeoInfo = eventual.NewValue() + geoFile, err := os.CreateTemp("", "") + require.NoError(t, err) + defer os.Remove(geoFile.Name()) + + os.WriteFile(geoFile.Name(), []byte(initialInfo), 0644) + + fronted.ConfigureHostAlaisesForTest(t, map[string]string{ + "geo.getiantem.org": "d3u5fqukq7qrhd.cloudfront.net", + }) + + // test persistence + ch := OnRefresh() + EnablePersistence(geoFile.Name()) + country := GetCountry(0) + require.Equal(t, "FM", country, "Should immediately get persisted country") + select { + case <-ch: + // okay + case <-time.After(5 * time.Second): + t.Error("should update watcher after enabling persistence") + } + + // clear initial value to make sure we read value from network + currentGeoInfo.Reset() + Refresh() + country = GetCountry(60 * time.Second) + ip := GetIP(5 * time.Second) + require.Len(t, country, 2, "Bad country '%v' for ip %v", country, ip) + require.NotEqual( + t, + "FM", + country, + "Should have gotten a new country from network (note, this test will fail if run in Micronesia)", + ) + require.True(t, len(ip) >= 7, "Bad IP %s", ip) + + t.Log("Waiting for refresh again") + select { + case <-ch: + // okay + case <-time.After(5 * time.Second): + t.Error("should update watcher after network refresh") + } + + // Give persistence time to finish + time.Sleep(1 * time.Second) + b, err := os.ReadFile(geoFile.Name()) + require.NoError(t, err) + require.NotEmpty(t, b) + require.NotEqual( + t, + initialInfo, + string(b), + "persisted geolocation information should have changed", + ) +} + +func TestIsNew(t *testing.T) { + type args struct { + newGeoInfo *GeoInfo + oldGeoInfo *GeoInfo + } + tests := []struct { + name string + args args + want bool + }{ + {"nil new should be not new", args{nil, &GeoInfo{FromDisk: true}}, false}, + {"nil existing should be new", args{&GeoInfo{}, nil}, true}, + {"old from disk should be new", args{&GeoInfo{IP: "1.1.1.1", FromDisk: false}, &GeoInfo{IP: "1.1.1.1", FromDisk: true}}, true}, + {"old not from disk should not be new", args{&GeoInfo{IP: "1.1.1.1", FromDisk: false}, &GeoInfo{IP: "1.1.1.1", FromDisk: false}}, false}, + {"new IP should be new", args{&GeoInfo{IP: "1.1.1.2", FromDisk: false}, &GeoInfo{IP: "1.1.1.1", FromDisk: false}}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + currentGeoInfo.Set(tt.args.oldGeoInfo) + if got := isNew(tt.args.newGeoInfo); got != tt.want { + t.Errorf("isNew() = %v, want %v", got, tt.want) + } + }) + } +}