mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-12 10:16:37 +08:00
refactor(backend): rename OpenFlare directory to lowercase openflare
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package geoip
|
||||
|
||||
import "strings"
|
||||
|
||||
type countryCentroid struct {
|
||||
lat float64
|
||||
lon float64
|
||||
}
|
||||
|
||||
var countryCentroidsByName = map[string]countryCentroid{
|
||||
"Afghanistan": {lat: 33.833494, lon: 65.992544},
|
||||
"Albania": {lat: 41.179283, lon: 20.035804},
|
||||
"Algeria": {lat: 28.158203, lon: 2.617517},
|
||||
"Angola": {lat: -12.334558, lon: 17.563198},
|
||||
"Argentina": {lat: -35.178772, lon: -65.156706},
|
||||
"Armenia": {lat: 40.298846, lon: 44.929380},
|
||||
"Australia": {lat: -25.574956, lon: 134.361181},
|
||||
"Austria": {lat: 47.570428, lon: 14.101658},
|
||||
"Azerbaijan": {lat: 40.331151, lon: 47.636778},
|
||||
"Bangladesh": {lat: 23.887936, lon: 90.210098},
|
||||
"Belarus": {lat: 53.534329, lon: 28.048953},
|
||||
"Belgium": {lat: 50.646495, lon: 4.628130},
|
||||
"Belize": {lat: 17.170731, lon: -88.720961},
|
||||
"Benin": {lat: 9.630483, lon: 2.323508},
|
||||
"Bhutan": {lat: 27.401336, lon: 90.405624},
|
||||
"Bolivia": {lat: -16.701415, lon: -64.691050},
|
||||
"Bosnia and Herzegovina": {lat: 44.163989, lon: 17.762462},
|
||||
"Botswana": {lat: -22.185334, lon: 23.791789},
|
||||
"Brazil": {lat: -10.827996, lon: -53.113858},
|
||||
"Brunei": {lat: 4.451218, lon: 114.542293},
|
||||
"Bulgaria": {lat: 42.777857, lon: 25.225364},
|
||||
"Burkina Faso": {lat: 12.260370, lon: -1.775743},
|
||||
"Burundi": {lat: -3.380283, lon: 29.873066},
|
||||
"Cambodia": {lat: 12.717933, lon: 104.907023},
|
||||
"Cameroon": {lat: 5.692692, lon: 12.739868},
|
||||
"Canada": {lat: 57.944575, lon: -102.317776},
|
||||
"Central African Republic": {lat: 6.563625, lon: 20.490782},
|
||||
"Chad": {lat: 15.327299, lon: 18.645816},
|
||||
"Chile": {lat: -35.980286, lon: -71.348917},
|
||||
"China": {lat: 36.610942, lon: 103.798043},
|
||||
"Colombia": {lat: 3.923562, lon: -73.077941},
|
||||
"Costa Rica": {lat: 9.986463, lon: -84.217425},
|
||||
"Croatia": {lat: 45.185678, lon: 16.458685},
|
||||
"Cuba": {lat: 21.621368, lon: -78.895403},
|
||||
"Cyprus": {lat: 34.913044, lon: 33.024092},
|
||||
"Czech Republic": {lat: 49.701608, lon: 15.330767},
|
||||
"Democratic Republic of the Congo": {lat: -2.877553, lon: 23.648117},
|
||||
"Denmark": {lat: 74.725352, lon: -41.290889},
|
||||
"Djibouti": {lat: 11.745989, lon: 42.567886},
|
||||
"Dominican Republic": {lat: 18.896879, lon: -70.485959},
|
||||
"East Timor": {lat: -8.858374, lon: 125.782226},
|
||||
"Ecuador": {lat: -1.445457, lon: -78.383848},
|
||||
"Egypt": {lat: 26.492865, lon: 29.867399},
|
||||
"El Salvador": {lat: 13.734573, lon: -88.865936},
|
||||
"Equatorial Guinea": {lat: 1.566080, lon: 10.481671},
|
||||
"Eritrea": {lat: 15.359810, lon: 38.835037},
|
||||
"Estonia": {lat: 58.693030, lon: 25.811350},
|
||||
"Ethiopia": {lat: 8.621596, lon: 39.603726},
|
||||
"Fiji": {lat: -17.827004, lon: 177.984152},
|
||||
"Finland": {lat: 64.522797, lon: 26.289584},
|
||||
"France": {lat: -21.285376, lon: 165.438171},
|
||||
"Gabon": {lat: -0.585980, lon: 11.774049},
|
||||
"Gambia": {lat: 13.370627, lon: -16.213050},
|
||||
"Georgia": {lat: 42.175028, lon: 43.486503},
|
||||
"Germany": {lat: 51.083853, lon: 10.380497},
|
||||
"Ghana": {lat: 7.940789, lon: -1.215016},
|
||||
"Greece": {lat: 39.517735, lon: 22.534352},
|
||||
"Guatemala": {lat: 15.679044, lon: -90.353440},
|
||||
"Guinea": {lat: 10.440780, lon: -10.943568},
|
||||
"Guinea Bissau": {lat: 12.050889, lon: -14.929646},
|
||||
"Guyana": {lat: 4.800330, lon: -58.978019},
|
||||
"Haiti": {lat: 18.916196, lon: -72.678666},
|
||||
"Honduras": {lat: 14.821030, lon: -86.656518},
|
||||
"Hong Kong": {lat: 22.278300, lon: 114.174700},
|
||||
"Hungary": {lat: 47.168543, lon: 19.410867},
|
||||
"Iceland": {lat: 65.030350, lon: -18.509671},
|
||||
"India": {lat: 22.901587, lon: 79.586369},
|
||||
"Indonesia": {lat: -0.446813, lon: 101.522374},
|
||||
"Iran": {lat: 32.576164, lon: 54.266247},
|
||||
"Iraq": {lat: 33.034146, lon: 43.740065},
|
||||
"Ireland": {lat: 53.234132, lon: -8.117695},
|
||||
"Israel": {lat: 31.947585, lon: 35.241574},
|
||||
"Italy": {lat: 43.512811, lon: 12.162338},
|
||||
"Ivory Coast": {lat: 7.626173, lon: -5.569772},
|
||||
"Jamaica": {lat: 18.172958, lon: -77.328505},
|
||||
"Japan": {lat: 36.589925, lon: 137.973525},
|
||||
"Jordan": {lat: 31.237145, lon: 36.761315},
|
||||
"Kashmir": {lat: 35.415739, lon: 77.087337},
|
||||
"Kazakhstan": {lat: 48.155989, lon: 67.278154},
|
||||
"Kenya": {lat: 0.596719, lon: 37.795079},
|
||||
"Kosovo": {lat: 42.517832, lon: 20.851519},
|
||||
"Kuwait": {lat: 29.316145, lon: 47.566177},
|
||||
"Kyrgyzstan": {lat: 41.483635, lon: 74.582247},
|
||||
"Laos": {lat: 18.494355, lon: 103.752638},
|
||||
"Latvia": {lat: 56.853144, lon: 24.894962},
|
||||
"Lebanon": {lat: 33.915290, lon: 35.885023},
|
||||
"Lesotho": {lat: -29.573512, lon: 28.243038},
|
||||
"Liberia": {lat: 6.453996, lon: -9.325868},
|
||||
"Libya": {lat: 27.031457, lon: 18.008785},
|
||||
"Lithuania": {lat: 55.314246, lon: 23.872794},
|
||||
"Luxembourg": {lat: 49.767461, lon: 6.070340},
|
||||
"Macedonia": {lat: 41.603736, lon: 21.704642},
|
||||
"Madagascar": {lat: -19.374777, lon: 46.701725},
|
||||
"Malawi": {lat: -13.200363, lon: 34.288911},
|
||||
"Malaysia": {lat: 3.577242, lon: 114.692015},
|
||||
"Mali": {lat: 17.347810, lon: -3.532183},
|
||||
"Mauritania": {lat: 20.259794, lon: -10.343079},
|
||||
"Mexico": {lat: 23.943713, lon: -102.518498},
|
||||
"Moldova": {lat: 47.201792, lon: 28.446473},
|
||||
"Mongolia": {lat: 46.825609, lon: 103.064235},
|
||||
"Montenegro": {lat: 42.783955, lon: 19.233365},
|
||||
"Morocco": {lat: 29.839324, lon: -8.459193},
|
||||
"Mozambique": {lat: -17.272975, lon: 35.531737},
|
||||
"Myanmar": {lat: 21.222015, lon: 96.495888},
|
||||
"Namibia": {lat: -22.132303, lon: 17.208945},
|
||||
"Nepal": {lat: 28.243336, lon: 83.923410},
|
||||
"Netherlands": {lat: 52.277341, lon: 5.646194},
|
||||
"New Zealand": {lat: -43.955382, lon: 170.540471},
|
||||
"Nicaragua": {lat: 12.835549, lon: -85.032299},
|
||||
"Niger": {lat: 17.417582, lon: 9.384787},
|
||||
"Nigeria": {lat: 9.589590, lon: 8.074235},
|
||||
"North Korea": {lat: 40.144857, lon: 127.215518},
|
||||
"Northern Cyprus": {lat: 35.225753, lon: 33.390792},
|
||||
"Norway": {lat: 64.322498, lon: 13.994248},
|
||||
"Oman": {lat: 20.573738, lon: 56.082719},
|
||||
"Pakistan": {lat: 29.951586, lon: 69.329881},
|
||||
"Panama": {lat: 8.521692, lon: -80.059706},
|
||||
"Papua New Guinea": {lat: -6.607045, lon: 144.228968},
|
||||
"Paraguay": {lat: -23.223896, lon: -58.399006},
|
||||
"Peru": {lat: -9.156316, lon: -74.388502},
|
||||
"Philippines": {lat: 15.948824, lon: 121.457548},
|
||||
"Poland": {lat: 52.119631, lon: 19.379892},
|
||||
"Portugal": {lat: 39.685490, lon: -7.977839},
|
||||
"Qatar": {lat: 25.328766, lon: 51.170816},
|
||||
"Republic of Serbia": {lat: 44.199984, lon: 20.766250},
|
||||
"Republic of the Congo": {lat: -0.856400, lon: 15.212074},
|
||||
"Romania": {lat: 45.845608, lon: 24.971632},
|
||||
"Russia": {lat: 61.677044, lon: 99.052282},
|
||||
"Rwanda": {lat: -1.995363, lon: 29.917651},
|
||||
"Saudi Arabia": {lat: 24.122854, lon: 44.534287},
|
||||
"Senegal": {lat: 14.338614, lon: -14.472411},
|
||||
"Sierra Leone": {lat: 8.575093, lon: -11.820132},
|
||||
"Singapore": {lat: 1.352100, lon: 103.819800},
|
||||
"Slovakia": {lat: 48.715407, lon: 19.472965},
|
||||
"Slovenia": {lat: 46.108070, lon: 14.771193},
|
||||
"Solomon Islands": {lat: -9.628590, lon: 160.156397},
|
||||
"Somalia": {lat: 4.747961, lon: 45.706540},
|
||||
"Somaliland": {lat: 9.729995, lon: 46.255133},
|
||||
"South Africa": {lat: -29.008774, lon: 25.160630},
|
||||
"South Korea": {lat: 36.475158, lon: 127.872804},
|
||||
"South Sudan": {lat: 7.307277, lon: 30.253828},
|
||||
"Spain": {lat: 40.391565, lon: -3.570837},
|
||||
"Sri Lanka": {lat: 7.649883, lon: 80.700551},
|
||||
"Sudan": {lat: 15.992059, lon: 29.946244},
|
||||
"Suriname": {lat: 4.126766, lon: -55.910298},
|
||||
"Swaziland": {lat: -26.563546, lon: 31.476645},
|
||||
"Sweden": {lat: 62.835488, lon: 16.752551},
|
||||
"Switzerland": {lat: 46.805544, lon: 8.205813},
|
||||
"Syria": {lat: 35.017694, lon: 38.504490},
|
||||
"Taiwan": {lat: 23.697800, lon: 120.960500},
|
||||
"Tajikistan": {lat: 38.552044, lon: 70.987127},
|
||||
"Thailand": {lat: 15.132319, lon: 101.010603},
|
||||
"The Bahamas": {lat: 24.719920, lon: -78.027339},
|
||||
"Togo": {lat: 8.576561, lon: 0.949514},
|
||||
"Trinidad and Tobago": {lat: 10.339930, lon: -61.270459},
|
||||
"Tunisia": {lat: 34.126731, lon: 9.546833},
|
||||
"Turkey": {lat: 38.998407, lon: 35.424986},
|
||||
"Turkmenistan": {lat: 39.109817, lon: 59.392923},
|
||||
"Uganda": {lat: 1.275770, lon: 32.365755},
|
||||
"Ukraine": {lat: 49.013453, lon: 31.381193},
|
||||
"United Arab Emirates": {lat: 23.905698, lon: 54.303458},
|
||||
"United Kingdom": {lat: -54.468348, lon: -36.367083},
|
||||
"United Republic of Tanzania": {lat: -6.276146, lon: 34.794901},
|
||||
"United States of America": {lat: 39.526894, lon: -99.146697},
|
||||
"Uruguay": {lat: -32.806517, lon: -56.016656},
|
||||
"Uzbekistan": {lat: 41.750262, lon: 63.148827},
|
||||
"Vanuatu": {lat: -15.266627, lon: 166.842287},
|
||||
"Venezuela": {lat: 7.118300, lon: -66.188217},
|
||||
"Vietnam": {lat: 16.630882, lon: 106.299391},
|
||||
"Western Sahara": {lat: 24.221136, lon: -12.218085},
|
||||
"Yemen": {lat: 15.935826, lon: 47.546352},
|
||||
"Zambia": {lat: -13.460954, lon: 27.775322},
|
||||
"Zimbabwe": {lat: -19.006775, lon: 29.850564},
|
||||
}
|
||||
|
||||
// CountryCentroidByName returns latitude and longitude for a country name, if found.
|
||||
// Accepts composite labels such as "Hong Kong, Hong Kong, HK" from GeoIP providers.
|
||||
func CountryCentroidByName(name string) (lat float64, lon float64, ok bool) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return 0, 0, false
|
||||
}
|
||||
if v, found := countryCentroidsByName[name]; found {
|
||||
return v.lat, v.lon, true
|
||||
}
|
||||
// Try comma-separated parts (city / region / country / ISO).
|
||||
for part := range strings.SplitSeq(name, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if v, found := countryCentroidsByName[part]; found {
|
||||
return v.lat, v.lon, true
|
||||
}
|
||||
if lat, lon, ok = CountryCentroidByISO(part); ok {
|
||||
return lat, lon, true
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
// CountryCentroidByISO returns latitude and longitude for an ISO country code, if found.
|
||||
func CountryCentroidByISO(iso string) (lat float64, lon float64, ok bool) {
|
||||
name, ok := isoToCountryName[strings.ToUpper(strings.TrimSpace(iso))]
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
return CountryCentroidByName(name)
|
||||
}
|
||||
|
||||
var isoToCountryName = map[string]string{
|
||||
"AT": "Austria",
|
||||
"AU": "Australia",
|
||||
"BR": "Brazil",
|
||||
"CA": "Canada",
|
||||
"CH": "Switzerland",
|
||||
"CN": "China",
|
||||
"DE": "Germany",
|
||||
"ES": "Spain",
|
||||
"FI": "Finland",
|
||||
"FR": "France",
|
||||
"GB": "United Kingdom",
|
||||
"HK": "Hong Kong",
|
||||
"ID": "Indonesia",
|
||||
"IE": "Ireland",
|
||||
"IN": "India",
|
||||
"IT": "Italy",
|
||||
"JP": "Japan",
|
||||
"KR": "South Korea",
|
||||
"MY": "Malaysia",
|
||||
"NL": "Netherlands",
|
||||
"NO": "Norway",
|
||||
"PL": "Poland",
|
||||
"RU": "Russia",
|
||||
"SE": "Sweden",
|
||||
"SG": "Singapore",
|
||||
"TH": "Thailand",
|
||||
"TW": "Taiwan",
|
||||
"US": "United States of America",
|
||||
"VN": "Vietnam",
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package geoip
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCountryCentroidGermany(t *testing.T) {
|
||||
lat, lon, ok := CountryCentroidByISO("DE")
|
||||
if !ok {
|
||||
t.Fatal("expected DE centroid")
|
||||
}
|
||||
if lat < 47 || lat > 55 || lon < 5 || lon > 15 {
|
||||
t.Fatalf("unexpected Germany centroid: %f,%f", lat, lon)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountryCentroidHongKongSingaporeTaiwan(t *testing.T) {
|
||||
cases := []struct {
|
||||
iso string
|
||||
name string
|
||||
minLat float64
|
||||
maxLat float64
|
||||
minLon float64
|
||||
maxLon float64
|
||||
}{
|
||||
{iso: "HK", name: "Hong Kong", minLat: 22, maxLat: 23, minLon: 113, maxLon: 115},
|
||||
{iso: "SG", name: "Singapore", minLat: 1, maxLat: 2, minLon: 103, maxLon: 104},
|
||||
{iso: "TW", name: "Taiwan", minLat: 22, maxLat: 26, minLon: 119, maxLon: 122},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
lat, lon, ok := CountryCentroidByISO(tc.iso)
|
||||
if !ok {
|
||||
t.Fatalf("expected %s centroid by ISO", tc.iso)
|
||||
}
|
||||
if lat < tc.minLat || lat > tc.maxLat || lon < tc.minLon || lon > tc.maxLon {
|
||||
t.Fatalf("%s ISO centroid out of range: %f,%f", tc.iso, lat, lon)
|
||||
}
|
||||
lat, lon, ok = CountryCentroidByName(tc.name)
|
||||
if !ok {
|
||||
t.Fatalf("expected %s centroid by name", tc.name)
|
||||
}
|
||||
if lat < tc.minLat || lat > tc.maxLat || lon < tc.minLon || lon > tc.maxLon {
|
||||
t.Fatalf("%s name centroid out of range: %f,%f", tc.name, lat, lon)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package geoip
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
)
|
||||
|
||||
// EmptyProvider is a no-op GeoIP backend used before a real provider is configured.
|
||||
type EmptyProvider struct{}
|
||||
|
||||
// Name returns the provider identifier.
|
||||
func (e *EmptyProvider) Name() string {
|
||||
return "EmptyProvider"
|
||||
}
|
||||
|
||||
// Initialize prepares the empty provider for use.
|
||||
func (e *EmptyProvider) Initialize() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetGeoInfo reports that no GeoIP provider has been configured.
|
||||
func (e *EmptyProvider) GetGeoInfo(_ net.IP) (*GeoInfo, error) {
|
||||
return nil, errors.New("you are using an empty GeoIP provider, please set a valid provider")
|
||||
}
|
||||
|
||||
// UpdateDatabase reports that no GeoIP provider has been configured.
|
||||
func (e *EmptyProvider) UpdateDatabase() error {
|
||||
return errors.New("you are using an empty GeoIP provider, please set a valid provider")
|
||||
}
|
||||
|
||||
// Close releases resources held by the empty provider.
|
||||
func (e *EmptyProvider) Close() error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package geoip resolves geographic information for IP addresses.
|
||||
package geoip
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
ristretto "github.com/dgraph-io/ristretto/v2"
|
||||
)
|
||||
|
||||
// CurrentProvider is the active GeoIP backend used by package-level helpers.
|
||||
var CurrentProvider Service
|
||||
var geoCache *providerCache
|
||||
var providerMutex sync.RWMutex
|
||||
var providerFactory = newProvider
|
||||
|
||||
// Supported GeoIP provider identifiers.
|
||||
const (
|
||||
// ProviderDisabled disables GeoIP lookups.
|
||||
ProviderDisabled = "disabled"
|
||||
ProviderMaxMind = "mmdb"
|
||||
ProviderIPAPI = "ip-api"
|
||||
ProviderGeoJS = "geojs"
|
||||
ProviderIPInfo = "ipinfo"
|
||||
|
||||
defaultGeoCacheDuration = 48 * time.Hour
|
||||
isoCountryCodeLength = 2
|
||||
geoipDataDirPerm = 0o750
|
||||
)
|
||||
|
||||
// GeoInfo contains normalized geographic metadata for an IP address.
|
||||
type GeoInfo struct {
|
||||
ISOCode string
|
||||
Name string
|
||||
Latitude *float64
|
||||
Longitude *float64
|
||||
}
|
||||
|
||||
func init() {
|
||||
CurrentProvider = &EmptyProvider{}
|
||||
geoCache = newProviderCache(defaultGeoCacheDuration)
|
||||
}
|
||||
|
||||
// Service defines the core GeoIP lookup operations implemented by providers.
|
||||
type Service interface {
|
||||
Name() string
|
||||
GetGeoInfo(ip net.IP) (*GeoInfo, error)
|
||||
UpdateDatabase() error
|
||||
Close() error
|
||||
}
|
||||
|
||||
type cachedGeoInfo struct {
|
||||
info *GeoInfo
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type providerCache struct {
|
||||
items *ristretto.Cache[string, cachedGeoInfo]
|
||||
duration time.Duration
|
||||
}
|
||||
|
||||
func newProviderCache(duration time.Duration) *providerCache {
|
||||
items, err := ristretto.NewCache(&ristretto.Config[string, cachedGeoInfo]{
|
||||
NumCounters: 1e5,
|
||||
MaxCost: 2e4,
|
||||
BufferItems: 64,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return &providerCache{
|
||||
items: items,
|
||||
duration: duration,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *providerCache) Get(key string) (*GeoInfo, bool) {
|
||||
entry, ok := c.items.Get(key)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
c.items.Del(key)
|
||||
return nil, false
|
||||
}
|
||||
return entry.info, true
|
||||
}
|
||||
|
||||
func (c *providerCache) Set(key string, info *GeoInfo) {
|
||||
c.items.Set(key, cachedGeoInfo{
|
||||
info: info,
|
||||
expiresAt: time.Now().Add(c.duration),
|
||||
}, 1)
|
||||
c.items.Wait()
|
||||
}
|
||||
|
||||
func (c *providerCache) Flush() {
|
||||
c.items.Clear()
|
||||
}
|
||||
|
||||
// GetRegionUnicodeEmoji returns the regional indicator emoji for a two-letter ISO code.
|
||||
func GetRegionUnicodeEmoji(isoCode string) string {
|
||||
if len(isoCode) != isoCountryCodeLength {
|
||||
return ""
|
||||
}
|
||||
isoCode = strings.ToUpper(isoCode)
|
||||
|
||||
if !unicode.IsLetter(rune(isoCode[0])) || !unicode.IsLetter(rune(isoCode[1])) {
|
||||
return ""
|
||||
}
|
||||
|
||||
rune1 := 0x1F1E6 + (rune(isoCode[0]) - 'A')
|
||||
rune2 := 0x1F1E6 + (rune(isoCode[1]) - 'A')
|
||||
return string(rune1) + string(rune2)
|
||||
}
|
||||
|
||||
// InitGeoIP configures the active GeoIP provider.
|
||||
func InitGeoIP(provider string) {
|
||||
providerName := normalizeProvider(provider)
|
||||
nextProvider, err := providerFactory(providerName)
|
||||
if err != nil {
|
||||
slog.Error("initialize GeoIP provider failed", "provider", providerName, "error", err)
|
||||
nextProvider = &EmptyProvider{}
|
||||
}
|
||||
setProvider(nextProvider)
|
||||
if providerName == ProviderDisabled {
|
||||
slog.Info("GeoIP provider disabled")
|
||||
return
|
||||
}
|
||||
slog.Info("GeoIP provider configured", "provider", CurrentProvider.Name())
|
||||
}
|
||||
|
||||
// GetGeoInfo looks up geographic information for ip using the active provider.
|
||||
func GetGeoInfo(ip net.IP) (*GeoInfo, error) {
|
||||
if ip == nil {
|
||||
return nil, errors.New("IP address cannot be nil")
|
||||
}
|
||||
provider := getProvider()
|
||||
cacheKey := provider.Name() + ":" + ip.String()
|
||||
|
||||
if cachedInfo, found := geoCache.Get(cacheKey); found {
|
||||
return cachedInfo, nil
|
||||
}
|
||||
|
||||
info, err := provider.GetGeoInfo(ip)
|
||||
if err == nil && info != nil {
|
||||
geoCache.Set(cacheKey, info)
|
||||
}
|
||||
return info, err
|
||||
}
|
||||
|
||||
// LookupGeoInfoWithProvider looks up geographic information using a temporary provider.
|
||||
func LookupGeoInfoWithProvider(providerName string, ip net.IP) (*GeoInfo, error) {
|
||||
if ip == nil {
|
||||
return nil, errors.New("IP address cannot be nil")
|
||||
}
|
||||
|
||||
provider, err := providerFactory(normalizeProvider(providerName))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := provider.Close(); closeErr != nil {
|
||||
slog.Warn("close temporary GeoIP provider failed", "provider", provider.Name(), "error", closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
return provider.GetGeoInfo(ip)
|
||||
}
|
||||
|
||||
// UpdateDatabase refreshes the active provider database and clears cached lookups.
|
||||
func UpdateDatabase() error {
|
||||
err := getProvider().UpdateDatabase()
|
||||
if err == nil {
|
||||
geoCache.Flush()
|
||||
slog.Info("GeoIP cache cleared due to database update.")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// IsValidProvider reports whether provider names a supported GeoIP backend.
|
||||
func IsValidProvider(provider string) bool {
|
||||
switch normalizeProvider(provider) {
|
||||
case ProviderDisabled, ProviderMaxMind, ProviderIPAPI, ProviderGeoJS, ProviderIPInfo:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeProvider(provider string) string {
|
||||
normalized := strings.TrimSpace(strings.ToLower(provider))
|
||||
if normalized == "" {
|
||||
return ProviderDisabled
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func newProvider(provider string) (Service, error) {
|
||||
switch provider {
|
||||
case ProviderDisabled:
|
||||
return &EmptyProvider{}, nil
|
||||
case ProviderMaxMind:
|
||||
return NewMaxMindGeoIPService()
|
||||
case ProviderIPAPI:
|
||||
return NewIPAPIService()
|
||||
case ProviderGeoJS:
|
||||
return NewGeoJSService()
|
||||
case ProviderIPInfo:
|
||||
return NewIPInfoService()
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported GeoIP provider %q", provider)
|
||||
}
|
||||
}
|
||||
|
||||
func setProvider(provider Service) {
|
||||
providerMutex.Lock()
|
||||
previous := CurrentProvider
|
||||
CurrentProvider = provider
|
||||
providerMutex.Unlock()
|
||||
geoCache.Flush()
|
||||
if previous != nil && previous != provider {
|
||||
if err := previous.Close(); err != nil {
|
||||
slog.Warn("close previous GeoIP provider failed", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getProvider() Service {
|
||||
providerMutex.RLock()
|
||||
defer providerMutex.RUnlock()
|
||||
if CurrentProvider == nil {
|
||||
return &EmptyProvider{}
|
||||
}
|
||||
return CurrentProvider
|
||||
}
|
||||
|
||||
func float64Pointer(value float64) *float64 {
|
||||
return &value
|
||||
}
|
||||
|
||||
// ProviderFactoryForTest returns the provider factory used by package helpers.
|
||||
func ProviderFactoryForTest() func(string) (Service, error) {
|
||||
return providerFactory
|
||||
}
|
||||
|
||||
// SetProviderFactoryForTest replaces the provider factory for tests.
|
||||
func SetProviderFactoryForTest(factory func(string) (Service, error)) {
|
||||
if factory == nil {
|
||||
providerFactory = newProvider
|
||||
return
|
||||
}
|
||||
providerFactory = factory
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package geoip
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeProvider struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeProvider) Name() string {
|
||||
return "fake"
|
||||
}
|
||||
|
||||
func (f *fakeProvider) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
|
||||
f.calls++
|
||||
return &GeoInfo{
|
||||
ISOCode: "CN",
|
||||
Name: "China",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *fakeProvider) UpdateDatabase() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeProvider) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGetGeoInfoCachesByProviderAndIP(t *testing.T) {
|
||||
originalProvider := CurrentProvider
|
||||
geoCache.Flush()
|
||||
fake := &fakeProvider{}
|
||||
CurrentProvider = fake
|
||||
defer func() {
|
||||
CurrentProvider = originalProvider
|
||||
}()
|
||||
|
||||
ip := net.ParseIP("8.8.8.8")
|
||||
record, err := GetGeoInfo(ip)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
if record == nil || record.ISOCode != "CN" {
|
||||
t.Fatalf("expected cached record, got %#v", record)
|
||||
}
|
||||
|
||||
_, err = GetGeoInfo(ip)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error on second call, got %v", err)
|
||||
}
|
||||
if fake.calls != 1 {
|
||||
t.Fatalf("expected provider to be called once, got %d", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnicodeEmoji(t *testing.T) {
|
||||
emoji := GetRegionUnicodeEmoji("CN")
|
||||
if emoji != "🇨🇳" {
|
||||
t.Errorf("expected emoji for CN, got %s", emoji)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidProvider(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"disabled": true,
|
||||
"mmdb": true,
|
||||
"ip-api": true,
|
||||
"geojs": true,
|
||||
"ipinfo": true,
|
||||
"unknown": false,
|
||||
}
|
||||
|
||||
for provider, want := range cases {
|
||||
if got := IsValidProvider(provider); got != want {
|
||||
t.Fatalf("provider %s validity mismatch: want %v, got %v", provider, want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupGeoInfoWithProviderUsesTemporaryProvider(t *testing.T) {
|
||||
previousFactory := providerFactory
|
||||
providerFactory = func(provider string) (Service, error) {
|
||||
return &fakeProvider{}, nil
|
||||
}
|
||||
defer func() {
|
||||
providerFactory = previousFactory
|
||||
}()
|
||||
|
||||
info, err := LookupGeoInfoWithProvider("ipinfo", net.ParseIP("8.8.8.8"))
|
||||
if err != nil {
|
||||
t.Fatalf("expected lookup to succeed, got %v", err)
|
||||
}
|
||||
if info == nil || info.ISOCode != "CN" || info.Name != "China" {
|
||||
t.Fatalf("unexpected geo info: %#v", info)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package geoip
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GeoJSService resolves geographic information using the geojs.io service.
|
||||
type GeoJSService struct {
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
// geoJSResponse 定义了 geojs.io 服务返回的 JSON 响应的结构。
|
||||
// 我们只定义我们需要的字段。
|
||||
type geoJSResponse struct {
|
||||
Country string `json:"country"`
|
||||
CountryCode string `json:"country_code"`
|
||||
Latitude float64 `json:"latitude,string"`
|
||||
Longitude float64 `json:"longitude,string"`
|
||||
// 可以根据需要添加其他字段,例如:
|
||||
// City string `json:"city"`
|
||||
// Region string `json:"region"`
|
||||
}
|
||||
|
||||
// NewGeoJSService 创建并返回一个 GeoJSService 的新实例。
|
||||
func NewGeoJSService() (*GeoJSService, error) {
|
||||
return &GeoJSService{
|
||||
Client: &http.Client{
|
||||
Timeout: 5 * time.Second, // 设置一个合理的超时时间
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Name 返回服务的名称。
|
||||
func (s *GeoJSService) Name() string {
|
||||
return "geojs.io"
|
||||
}
|
||||
|
||||
// GetGeoInfo 使用 geojs.io 服务检索给定 IP 地址的地理位置信息。
|
||||
func (s *GeoJSService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
|
||||
// GeoJS 的 API 端点
|
||||
apiURL := fmt.Sprintf("https://get.geojs.io/v1/ip/geo/%s.json", ip.String())
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request for geojs.io: %w", err)
|
||||
}
|
||||
resp, err := s.Client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get geo info from geojs.io: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// 检查响应状态码
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("geojs.io returned non-200 status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var apiResp geoJSResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode geojs.io response: %w", err)
|
||||
}
|
||||
|
||||
// 检查国家代码是否为空,因为 geojs 对无效/私有IP可能返回200 OK但内容为空
|
||||
if apiResp.CountryCode == "" {
|
||||
return nil, fmt.Errorf("geojs.io returned empty geo info for ip: %s", ip.String())
|
||||
}
|
||||
|
||||
return &GeoInfo{
|
||||
ISOCode: apiResp.CountryCode,
|
||||
Name: apiResp.Country,
|
||||
Latitude: float64Pointer(apiResp.Latitude),
|
||||
Longitude: float64Pointer(apiResp.Longitude),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateDatabase 对于 geojs.io 是一个空操作,因为它是一个 Web 服务。
|
||||
func (s *GeoJSService) UpdateDatabase() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close 对于 geojs.io 是一个空操作。
|
||||
func (s *GeoJSService) Close() error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package geoip
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IPAPIService resolves geographic information using the ip-api.com service.
|
||||
type IPAPIService struct {
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
// ipAPIResponse 定义了 ip-api.com 服务返回的 JSON 响应的结构。
|
||||
type ipAPIResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"` // 当 status 为 fail 时出现
|
||||
Country string `json:"country"`
|
||||
CountryCode string `json:"countryCode"`
|
||||
Region string `json:"region"`
|
||||
RegionName string `json:"regionName"`
|
||||
City string `json:"city"`
|
||||
Zip string `json:"zip"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
Timezone string `json:"timezone"`
|
||||
ISP string `json:"isp"`
|
||||
Org string `json:"org"`
|
||||
As string `json:"as"`
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
// Name returns the provider identifier for the ip-api.com service.
|
||||
func (s *IPAPIService) Name() string {
|
||||
return "ip-api.com"
|
||||
}
|
||||
|
||||
// NewIPAPIService 创建并返回一个 IPAPIService 的新实例。
|
||||
func NewIPAPIService() (*IPAPIService, error) {
|
||||
return &IPAPIService{
|
||||
Client: &http.Client{
|
||||
Timeout: 5 * time.Second, // 设置请求超时
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetGeoInfo 使用 ip-api.com 服务检索给定 IP 地址的地理位置信息。
|
||||
func (s *IPAPIService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
|
||||
// API URL, 使用 fields 参数来仅请求需要的字段
|
||||
apiURL := fmt.Sprintf("http://ip-api.com/json/%s?fields=status,message,country,countryCode", ip.String())
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request for ip-api.com: %w", err)
|
||||
}
|
||||
resp, err := s.Client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get geo info from ip-api.com: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var apiResp ipAPIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode ip-api.com response: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.Status != "success" {
|
||||
return nil, fmt.Errorf("ip-api.com returned an error: %s", apiResp.Message)
|
||||
}
|
||||
|
||||
return &GeoInfo{
|
||||
ISOCode: apiResp.CountryCode,
|
||||
Name: apiResp.Country,
|
||||
Latitude: float64Pointer(apiResp.Lat),
|
||||
Longitude: float64Pointer(apiResp.Lon),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateDatabase 对于 ip-api.com 是一个空操作,因为它是一个 Web 服务。
|
||||
func (s *IPAPIService) UpdateDatabase() error {
|
||||
// 无需执行任何操作,因为数据由外部服务提供
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close 对于 ip-api.com 是一个空操作,因为没有需要关闭的持久连接。
|
||||
func (s *IPAPIService) Close() error {
|
||||
// 无需执行任何操作
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package geoip
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IPInfoService resolves geographic information using the ipinfo.io service.
|
||||
type IPInfoService struct {
|
||||
Client *http.Client
|
||||
// 每天 1000 次请求,限制由 IP 地址的所有人共享。
|
||||
// APIToken string
|
||||
}
|
||||
|
||||
// ipInfoResponse 定义了 ipinfo.io 服务返回的 JSON 响应的结构,只包含免费额度可用的字段。
|
||||
type ipInfoResponse struct {
|
||||
IP string `json:"ip"`
|
||||
Hostname string `json:"hostname"`
|
||||
City string `json:"city"`
|
||||
Region string `json:"region"`
|
||||
Country string `json:"country"`
|
||||
CountryCode string `json:"countryCode"` // ipinfo.io 返回 "country" 的 ISO 代码,这里为了与 GeoInfo 保持一致,额外添加一个 CountryCode
|
||||
Loc string `json:"loc"` // Latitude,Longitude
|
||||
Org string `json:"org"`
|
||||
Postal string `json:"postal"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
|
||||
// NewIPInfoService 创建并返回一个 IPInfoService 的新实例。
|
||||
func NewIPInfoService() (*IPInfoService, error) {
|
||||
return &IPInfoService{
|
||||
Client: &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Name 返回服务的名称。
|
||||
func (s *IPInfoService) Name() string {
|
||||
return "ipinfo.io"
|
||||
}
|
||||
|
||||
// GetGeoInfo 使用 ipinfo.io 服务检索给定 IP 地址的地理位置信息。
|
||||
// 免费额度主要提供国家信息。
|
||||
func (s *IPInfoService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
|
||||
// IPinfo 免费额度不需要 API token 就可以查询基本的 IP 信息。
|
||||
// API URL: https://ipinfo.io/json (查询自身IP) 或 https://ipinfo.io/YOUR_IP/json
|
||||
apiURL := fmt.Sprintf("https://ipinfo.io/%s/json", ip.String())
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request for ipinfo.io: %w", err)
|
||||
}
|
||||
resp, err := s.Client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get geo info from ipinfo.io: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("ipinfo.io returned non-200 status: %d %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
var apiResp ipInfoResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode ipinfo.io response: %w", err)
|
||||
}
|
||||
|
||||
latitude, longitude := parseIPInfoCoordinates(apiResp.Loc)
|
||||
|
||||
// IPinfo 的 "country" 字段直接返回 ISO 2-letter code,例如 "US", "CN"
|
||||
// 我们需要将 "country" 字段作为 ISOCode,并尝试获取其对应的国家名称。
|
||||
// IPinfo 响应中通常不直接提供完整的国家名称,但我们可以通过 CountryCode 映射。
|
||||
// 为了简化并符合 GeoInfo 结构,我们直接使用 Country 作为 ISOCode,并尝试从 CountryCode 获取名称。
|
||||
// 实际上,IPinfo 的 'country' 字段就是 ISO 2-letter code。
|
||||
// 如果需要完整的国家名称,可能需要一个本地的 ISO 代码到名称的映射。
|
||||
// 为了与 GetRegionUnicodeEmoji 函数兼容,我们直接使用 country 作为 ISOCode。
|
||||
name := formatIPInfoLocation(apiResp)
|
||||
return &GeoInfo{
|
||||
ISOCode: apiResp.Country,
|
||||
Name: name,
|
||||
Latitude: latitude,
|
||||
Longitude: longitude,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateDatabase 对于 ipinfo.io 是一个空操作,因为它是一个 Web 服务。
|
||||
func (s *IPInfoService) UpdateDatabase() error {
|
||||
// 无需执行任何操作,因为数据由外部服务提供
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close 对于 ipinfo.io 是一个空操作,因为没有需要关闭的持久连接。
|
||||
func (s *IPInfoService) Close() error {
|
||||
// 无需执行任何操作
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseIPInfoCoordinates(value string) (*float64, *float64) {
|
||||
parts := strings.Split(strings.TrimSpace(value), ",")
|
||||
if len(parts) != isoCountryCodeLength {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
latitudeValue, latErr := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
|
||||
longitudeValue, lonErr := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
|
||||
if latErr != nil || lonErr != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return float64Pointer(latitudeValue), float64Pointer(longitudeValue)
|
||||
}
|
||||
|
||||
func formatIPInfoLocation(resp ipInfoResponse) string {
|
||||
parts := make([]string, 0, 3) //nolint:mnd // 3 parts: city, region, country
|
||||
if city := strings.TrimSpace(resp.City); city != "" {
|
||||
parts = append(parts, city)
|
||||
}
|
||||
if region := strings.TrimSpace(resp.Region); region != "" {
|
||||
parts = append(parts, region)
|
||||
}
|
||||
if country := strings.TrimSpace(resp.Country); country != "" {
|
||||
parts = append(parts, country)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package iputil provides helpers for parsing, normalizing, and scoring IP addresses.
|
||||
package iputil
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
scorePublic = 2
|
||||
scorePrivate = 1
|
||||
)
|
||||
|
||||
// NormalizeIP parses and normalizes a raw IP address string, preferring the IPv4 form for mapped addresses.
|
||||
func NormalizeIP(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
ip := net.ParseIP(trimmed)
|
||||
if ip == nil {
|
||||
return ""
|
||||
}
|
||||
if ipv4 := ip.To4(); ipv4 != nil {
|
||||
return ipv4.String()
|
||||
}
|
||||
return ip.String()
|
||||
}
|
||||
|
||||
// NormalizeRemoteAddr extracts and normalizes the IP address from a host:port remote address string.
|
||||
func NormalizeRemoteAddr(remoteAddr string) string {
|
||||
trimmed := strings.TrimSpace(remoteAddr)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(trimmed); err == nil {
|
||||
return NormalizeIP(host)
|
||||
}
|
||||
return NormalizeIP(trimmed)
|
||||
}
|
||||
|
||||
// IsPublic reports whether the given IP address is a publicly routable unicast address.
|
||||
func IsPublic(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
if ipv4 := ip.To4(); ipv4 != nil {
|
||||
ip = ipv4
|
||||
}
|
||||
if !ip.IsGlobalUnicast() || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsMulticast() || ip.IsUnspecified() {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// IsPublicString parses raw and reports whether it represents a publicly routable IP address.
|
||||
func IsPublicString(raw string) bool {
|
||||
ip := net.ParseIP(strings.TrimSpace(raw))
|
||||
return IsPublic(ip)
|
||||
}
|
||||
|
||||
// Score returns a preference score for the IP address: 2 for public, 1 for private, -1 for invalid or non-unicast.
|
||||
func Score(ip net.IP) int {
|
||||
if ip == nil {
|
||||
return -1
|
||||
}
|
||||
if ipv4 := ip.To4(); ipv4 != nil {
|
||||
ip = ipv4
|
||||
}
|
||||
if !ip.IsGlobalUnicast() || ip.IsLoopback() || ip.IsMulticast() || ip.IsUnspecified() {
|
||||
return -1
|
||||
}
|
||||
if IsPublic(ip) {
|
||||
return scorePublic
|
||||
}
|
||||
return scorePrivate
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package iputil
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeIP(t *testing.T) {
|
||||
if got := NormalizeIP(" 8.8.8.8 "); got != "8.8.8.8" {
|
||||
t.Fatalf("unexpected normalized ipv4: %q", got)
|
||||
}
|
||||
if got := NormalizeIP("[::1]"); got != "" {
|
||||
t.Fatalf("expected invalid bracketed host to be rejected, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRemoteAddr(t *testing.T) {
|
||||
if got := NormalizeRemoteAddr("203.0.113.10:8443"); got != "203.0.113.10" {
|
||||
t.Fatalf("unexpected remote addr normalization: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPublic(t *testing.T) {
|
||||
if !IsPublic(net.ParseIP("8.8.8.8")) {
|
||||
t.Fatal("expected public ip to be detected")
|
||||
}
|
||||
if IsPublic(net.ParseIP("10.0.0.8")) {
|
||||
t.Fatal("expected private ip to be rejected")
|
||||
}
|
||||
if IsPublic(net.ParseIP("127.0.0.1")) {
|
||||
t.Fatal("expected loopback ip to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScore(t *testing.T) {
|
||||
if got := Score(net.ParseIP("8.8.8.8")); got != 2 {
|
||||
t.Fatalf("unexpected score for public ip: %d", got)
|
||||
}
|
||||
if got := Score(net.ParseIP("10.0.0.8")); got != 1 {
|
||||
t.Fatalf("unexpected score for private ip: %d", got)
|
||||
}
|
||||
if got := Score(net.ParseIP("127.0.0.1")); got != -1 {
|
||||
t.Fatalf("unexpected score for loopback ip: %d", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package geoip
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/oschwald/maxminddb-golang"
|
||||
)
|
||||
|
||||
// GeoIPURL is the default download URL for the MaxMind GeoLite2 country database.
|
||||
var GeoIPURL = "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/GeoLite2-Country.mmdb"
|
||||
|
||||
// GeoIPFilePath is the default local path for the MaxMind country database file.
|
||||
var GeoIPFilePath = "./data/GeoLite2-Country.mmdb"
|
||||
|
||||
// Record is the MaxMind database record structure for country lookups.
|
||||
type Record struct {
|
||||
Country struct {
|
||||
ISOCode string `maxminddb:"iso_code"`
|
||||
Names map[string]string `maxminddb:"names"`
|
||||
} `maxminddb:"country"`
|
||||
}
|
||||
|
||||
// MaxMindGeoIPService resolves geographic information using a local MaxMind MMDB database.
|
||||
type MaxMindGeoIPService struct {
|
||||
maxMindDBReader *maxminddb.Reader
|
||||
dbFilePath string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// Name returns the provider identifier for the MaxMind database service.
|
||||
func (s *MaxMindGeoIPService) Name() string {
|
||||
return "MaxMind"
|
||||
}
|
||||
|
||||
// NewMaxMindGeoIPService creates a MaxMind service using the default database path and URL.
|
||||
func NewMaxMindGeoIPService() (*MaxMindGeoIPService, error) {
|
||||
return NewMaxMindGeoIPServiceWithContext(context.Background(), GeoIPFilePath, GeoIPURL)
|
||||
}
|
||||
|
||||
// NewMaxMindGeoIPServiceWithContext creates a MaxMind service with a caller-supplied
|
||||
// context so the initial database download honors request cancellation.
|
||||
func NewMaxMindGeoIPServiceWithContext(ctx context.Context, dbFilePath string, downloadURL string) (*MaxMindGeoIPService, error) {
|
||||
if dbFilePath == "" {
|
||||
dbFilePath = GeoIPFilePath
|
||||
}
|
||||
if downloadURL == "" {
|
||||
downloadURL = GeoIPURL
|
||||
}
|
||||
service := &MaxMindGeoIPService{
|
||||
dbFilePath: dbFilePath,
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(service.dbFilePath), geoipDataDirPerm); err != nil {
|
||||
return nil, fmt.Errorf("failed to create data directory for MaxMind database: %w", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(service.dbFilePath); os.IsNotExist(err) {
|
||||
if err := DownloadMaxMindDatabase(ctx, service.dbFilePath, downloadURL); err != nil {
|
||||
return nil, fmt.Errorf("failed to download initial MaxMind database: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := service.initialize(); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize MaxMind database: %w", err)
|
||||
}
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
func (s *MaxMindGeoIPService) initialize() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.maxMindDBReader != nil {
|
||||
_ = s.maxMindDBReader.Close()
|
||||
s.maxMindDBReader = nil
|
||||
}
|
||||
|
||||
reader, err := maxminddb.Open(s.dbFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error opening MaxMind database at %s: %w", s.dbFilePath, err)
|
||||
}
|
||||
s.maxMindDBReader = reader
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetGeoInfo looks up geographic information for ip in the MaxMind database.
|
||||
func (s *MaxMindGeoIPService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if s.maxMindDBReader == nil {
|
||||
return nil, errors.New("MaxMind database is not initialized or failed to open")
|
||||
}
|
||||
if ip == nil {
|
||||
return nil, errors.New("IP address cannot be nil")
|
||||
}
|
||||
|
||||
var record Record
|
||||
if err := s.maxMindDBReader.Lookup(ip, &record); err != nil {
|
||||
return nil, fmt.Errorf("error looking up IP %s in MaxMind database: %w", ip.String(), err)
|
||||
}
|
||||
|
||||
geoInfo := &GeoInfo{
|
||||
ISOCode: record.Country.ISOCode,
|
||||
Name: record.Country.Names["en"],
|
||||
}
|
||||
if geoInfo.Name == "" && geoInfo.ISOCode != "" {
|
||||
geoInfo.Name = geoInfo.ISOCode
|
||||
}
|
||||
|
||||
return geoInfo, nil
|
||||
}
|
||||
|
||||
// UpdateDatabase downloads the latest MaxMind database and reloads the reader.
|
||||
func (s *MaxMindGeoIPService) UpdateDatabase() error {
|
||||
if err := DownloadMaxMindDatabase(context.Background(), s.dbFilePath, GeoIPURL); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.initialize()
|
||||
}
|
||||
|
||||
// DownloadMaxMindDatabase downloads the MaxMind database from downloadURL to dbFilePath.
|
||||
func DownloadMaxMindDatabase(ctx context.Context, dbFilePath string, downloadURL string) error {
|
||||
if dbFilePath == "" {
|
||||
dbFilePath = GeoIPFilePath
|
||||
}
|
||||
if downloadURL == "" {
|
||||
downloadURL = GeoIPURL
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) //nolint:gosec // URL from trusted GeoIP provider config
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initiate MaxMind database download: %w", err)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initiate MaxMind database download: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("failed to download MaxMind database: HTTP status %s", resp.Status)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dbFilePath), geoipDataDirPerm); err != nil {
|
||||
return fmt.Errorf("failed to create data directory for MaxMind database update: %w", err)
|
||||
}
|
||||
|
||||
tempPath := dbFilePath + ".download"
|
||||
out, err := os.Create(tempPath) //nolint:gosec // tempPath is derived from configured dbFilePath
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create MaxMind database file at %s: %w", tempPath, err)
|
||||
}
|
||||
defer func() {
|
||||
_ = out.Close()
|
||||
}()
|
||||
|
||||
if _, err = io.Copy(out, resp.Body); err != nil {
|
||||
return fmt.Errorf("failed to write MaxMind database file: %w", err)
|
||||
}
|
||||
if err = out.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close MaxMind database file: %w", err)
|
||||
}
|
||||
if err = os.Rename(tempPath, dbFilePath); err != nil {
|
||||
return fmt.Errorf("failed to move MaxMind database file into place: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the MaxMind database reader.
|
||||
func (s *MaxMindGeoIPService) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.maxMindDBReader != nil {
|
||||
err := s.maxMindDBReader.Close()
|
||||
s.maxMindDBReader = nil
|
||||
if err != nil {
|
||||
return fmt.Errorf("error closing MaxMind database: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package geoip
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"Wavelet/openflare/share/geoip/iputil"
|
||||
)
|
||||
|
||||
const defaultOutboundIPLookupTimeout = 5 * time.Second
|
||||
|
||||
// OutboundIPStrategy defines a lookup strategy for the current public egress IP.
|
||||
type OutboundIPStrategy interface {
|
||||
Name() string
|
||||
GetOutboundIP(ctx context.Context) (net.IP, error)
|
||||
}
|
||||
|
||||
// OutboundIPAPIAdapter adapts a third-party HTTP API response into an IP value.
|
||||
type OutboundIPAPIAdapter interface {
|
||||
Name() string
|
||||
Endpoint() string
|
||||
DecodeIP(io.Reader) (net.IP, error)
|
||||
}
|
||||
|
||||
// HTTPOutboundIPStrategy resolves the public egress IP via an HTTP API adapter.
|
||||
type HTTPOutboundIPStrategy struct {
|
||||
Client *http.Client
|
||||
Adapter OutboundIPAPIAdapter
|
||||
}
|
||||
|
||||
// NewHTTPOutboundIPStrategy creates a strategy that queries adapter over HTTP.
|
||||
func NewHTTPOutboundIPStrategy(adapter OutboundIPAPIAdapter, client *http.Client) *HTTPOutboundIPStrategy {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: defaultOutboundIPLookupTimeout}
|
||||
}
|
||||
return &HTTPOutboundIPStrategy{
|
||||
Client: client,
|
||||
Adapter: adapter,
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the strategy or adapter identifier.
|
||||
func (s *HTTPOutboundIPStrategy) Name() string {
|
||||
if s == nil || s.Adapter == nil {
|
||||
return "http-outbound-ip"
|
||||
}
|
||||
return s.Adapter.Name()
|
||||
}
|
||||
|
||||
// GetOutboundIP queries the configured HTTP endpoint for the current public IP.
|
||||
func (s *HTTPOutboundIPStrategy) GetOutboundIP(ctx context.Context) (net.IP, error) {
|
||||
if s == nil || s.Adapter == nil {
|
||||
return nil, errors.New("outbound IP adapter is nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, errors.New("context is required")
|
||||
}
|
||||
|
||||
if s.Client != nil {
|
||||
return s.query(ctx, s.Client)
|
||||
}
|
||||
|
||||
dialer := &net.Dialer{
|
||||
Timeout: defaultOutboundIPLookupTimeout,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}
|
||||
|
||||
// Try IPv4 first
|
||||
ipv4Client := &http.Client{
|
||||
Timeout: defaultOutboundIPLookupTimeout,
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: func(ctx context.Context, _, addr string) (net.Conn, error) {
|
||||
return dialer.DialContext(ctx, "tcp4", addr)
|
||||
},
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
},
|
||||
}
|
||||
ip, err := s.query(ctx, ipv4Client)
|
||||
if err == nil && ip != nil {
|
||||
if ipv4 := ip.To4(); ipv4 != nil {
|
||||
return ipv4, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to standard client (dual-stack: tcp)
|
||||
fallbackClient := &http.Client{
|
||||
Timeout: defaultOutboundIPLookupTimeout,
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: func(ctx context.Context, _, addr string) (net.Conn, error) {
|
||||
return dialer.DialContext(ctx, "tcp", addr)
|
||||
},
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
},
|
||||
}
|
||||
ip, err = s.query(ctx, fallbackClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Always prioritize IPv4 for relay compatibility
|
||||
if ipv4 := ip.To4(); ipv4 != nil {
|
||||
return ipv4, nil
|
||||
}
|
||||
// Return IPv6 only if no IPv4 is available
|
||||
return ip, nil
|
||||
}
|
||||
|
||||
func (s *HTTPOutboundIPStrategy) query(ctx context.Context, client *http.Client) (net.IP, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, s.Adapter.Endpoint(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s create request failed: %w", s.Name(), err)
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s request failed: %w", s.Name(), err)
|
||||
}
|
||||
defer func() { _ = response.Body.Close() }()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s returned non-200 status: %d %s", s.Name(), response.StatusCode, response.Status)
|
||||
}
|
||||
ip, err := s.Adapter.DecodeIP(response.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s decode response failed: %w", s.Name(), err)
|
||||
}
|
||||
if !iputil.IsPublic(ip) {
|
||||
return nil, fmt.Errorf("%s returned non-public IP: %s", s.Name(), ip.String())
|
||||
}
|
||||
return ip, nil
|
||||
}
|
||||
|
||||
// RealIPCCAdapter decodes public IP responses from realip.cc.
|
||||
type RealIPCCAdapter struct {
|
||||
URL string
|
||||
}
|
||||
|
||||
type realIPCCResponse struct {
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
// NewRealIPCCOutboundIPStrategy creates the default realip.cc lookup strategy.
|
||||
func NewRealIPCCOutboundIPStrategy() *HTTPOutboundIPStrategy {
|
||||
return NewHTTPOutboundIPStrategy(RealIPCCAdapter{}, nil)
|
||||
}
|
||||
|
||||
// Name returns the realip.cc adapter identifier.
|
||||
func (a RealIPCCAdapter) Name() string {
|
||||
return "realip.cc"
|
||||
}
|
||||
|
||||
// Endpoint returns the realip.cc API URL.
|
||||
func (a RealIPCCAdapter) Endpoint() string {
|
||||
if strings.TrimSpace(a.URL) != "" {
|
||||
return strings.TrimSpace(a.URL)
|
||||
}
|
||||
return "https://realip.cc"
|
||||
}
|
||||
|
||||
// DecodeIP parses a realip.cc JSON response into a public IP address.
|
||||
func (a RealIPCCAdapter) DecodeIP(reader io.Reader) (net.IP, error) {
|
||||
var payload realIPCCResponse
|
||||
if err := json.NewDecoder(reader).Decode(&payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ip := net.ParseIP(strings.TrimSpace(payload.IP))
|
||||
if ip == nil {
|
||||
return nil, fmt.Errorf("invalid IP %q", payload.IP)
|
||||
}
|
||||
if ipv4 := ip.To4(); ipv4 != nil {
|
||||
return ipv4, nil
|
||||
}
|
||||
return ip, nil
|
||||
}
|
||||
|
||||
// PlainTextIPAdapter decodes public IP responses from raw plain text endpoints.
|
||||
type PlainTextIPAdapter struct {
|
||||
ProviderName string
|
||||
URL string
|
||||
}
|
||||
|
||||
// Name returns the provider name.
|
||||
func (a PlainTextIPAdapter) Name() string {
|
||||
return a.ProviderName
|
||||
}
|
||||
|
||||
// Endpoint returns the plain text API URL.
|
||||
func (a PlainTextIPAdapter) Endpoint() string {
|
||||
return a.URL
|
||||
}
|
||||
|
||||
// DecodeIP parses a plain text response into a public IP address.
|
||||
func (a PlainTextIPAdapter) DecodeIP(reader io.Reader) (net.IP, error) {
|
||||
body, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ipStr := strings.TrimSpace(string(body))
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
return nil, fmt.Errorf("invalid plain text IP %q", ipStr)
|
||||
}
|
||||
if ipv4 := ip.To4(); ipv4 != nil {
|
||||
return ipv4, nil
|
||||
}
|
||||
return ip, nil
|
||||
}
|
||||
|
||||
// DefaultOutboundIPStrategies returns the built-in public egress IP lookup strategies.
|
||||
func DefaultOutboundIPStrategies() []OutboundIPStrategy {
|
||||
return []OutboundIPStrategy{
|
||||
NewRealIPCCOutboundIPStrategy(),
|
||||
NewHTTPOutboundIPStrategy(PlainTextIPAdapter{ProviderName: "ifconfig.me", URL: "https://ifconfig.me"}, nil),
|
||||
NewHTTPOutboundIPStrategy(PlainTextIPAdapter{ProviderName: "ip.sb", URL: "https://api.ip.sb/ip"}, nil),
|
||||
NewHTTPOutboundIPStrategy(PlainTextIPAdapter{ProviderName: "icanhazip.com", URL: "https://icanhazip.com"}, nil),
|
||||
}
|
||||
}
|
||||
|
||||
// GetOutboundIP tries each strategy until one returns a public egress IP.
|
||||
func GetOutboundIP(ctx context.Context, strategies ...OutboundIPStrategy) (net.IP, error) {
|
||||
if len(strategies) == 0 {
|
||||
strategies = DefaultOutboundIPStrategies()
|
||||
}
|
||||
var errs []error
|
||||
for _, strategy := range strategies {
|
||||
if strategy == nil {
|
||||
continue
|
||||
}
|
||||
ip, err := strategy.GetOutboundIP(ctx)
|
||||
if err == nil && ip != nil {
|
||||
return ip, nil
|
||||
}
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("%s: %w", strategy.Name(), err))
|
||||
}
|
||||
}
|
||||
if len(errs) == 0 {
|
||||
return nil, errors.New("no outbound IP lookup strategy configured")
|
||||
}
|
||||
return nil, errors.Join(errs...)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package geoip
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeOutboundIPStrategy struct {
|
||||
name string
|
||||
ip net.IP
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeOutboundIPStrategy) Name() string {
|
||||
return f.name
|
||||
}
|
||||
|
||||
func (f fakeOutboundIPStrategy) GetOutboundIP(ctx context.Context) (net.IP, error) {
|
||||
return f.ip, f.err
|
||||
}
|
||||
|
||||
func TestRealIPCCAdapterDecodeIP(t *testing.T) {
|
||||
ip, err := RealIPCCAdapter{}.DecodeIP(strings.NewReader(`{"ip":"8.8.8.8","country":"United States"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeIP failed: %v", err)
|
||||
}
|
||||
if ip.String() != "8.8.8.8" {
|
||||
t.Fatalf("unexpected IP: %s", ip.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPOutboundIPStrategyUsesAdapter(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Fatalf("unexpected method: %s", r.Method)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ip":"8.8.4.4"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
strategy := NewHTTPOutboundIPStrategy(RealIPCCAdapter{URL: server.URL}, server.Client())
|
||||
ip, err := strategy.GetOutboundIP(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetOutboundIP failed: %v", err)
|
||||
}
|
||||
if ip.String() != "8.8.4.4" {
|
||||
t.Fatalf("unexpected outbound IP: %s", ip.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOutboundIPFallsBackToNextStrategy(t *testing.T) {
|
||||
ip, err := GetOutboundIP(
|
||||
context.Background(),
|
||||
fakeOutboundIPStrategy{name: "first", err: errors.New("temporary failure")},
|
||||
fakeOutboundIPStrategy{name: "second", ip: net.ParseIP("1.1.1.1")},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOutboundIP failed: %v", err)
|
||||
}
|
||||
if ip.String() != "1.1.1.1" {
|
||||
t.Fatalf("unexpected outbound IP: %s", ip.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPOutboundIPStrategyRejectsPrivateIP(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ip":"172.17.0.2"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
strategy := NewHTTPOutboundIPStrategy(RealIPCCAdapter{URL: server.URL}, server.Client())
|
||||
if _, err := strategy.GetOutboundIP(context.Background()); err == nil {
|
||||
t.Fatal("expected private IP to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPOutboundIPStrategyPrioritizesIPv4(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "IPv4 address",
|
||||
response: `{"ip":"8.8.8.8"}`,
|
||||
want: "8.8.8.8",
|
||||
},
|
||||
{
|
||||
name: "IPv6 address normalized to IPv4",
|
||||
response: `{"ip":"::ffff:8.8.8.8"}`,
|
||||
want: "8.8.8.8",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(tt.response))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
strategy := NewHTTPOutboundIPStrategy(RealIPCCAdapter{URL: server.URL}, server.Client())
|
||||
ip, err := strategy.GetOutboundIP(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetOutboundIP failed: %v", err)
|
||||
}
|
||||
if ip.String() != tt.want {
|
||||
t.Errorf("GetOutboundIP() = %v, want %v", ip.String(), tt.want)
|
||||
}
|
||||
// Ensure it's an IPv4 address (4 bytes)
|
||||
if ipv4 := ip.To4(); ipv4 == nil {
|
||||
t.Errorf("expected IPv4 address, got %v", ip)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user