diff --git a/atsf_server/controller/geoip.go b/atsf_server/controller/geoip.go new file mode 100644 index 00000000..5eeea27b --- /dev/null +++ b/atsf_server/controller/geoip.go @@ -0,0 +1,36 @@ +package controller + +import ( + "atsflare/service" + + "github.com/gin-gonic/gin" +) + +type geoIPLookupRequest struct { + Provider string `json:"provider"` + IP string `json:"ip"` +} + +// LookupGeoIP godoc +// @Summary Test GeoIP lookup +// @Tags Options +// @Accept json +// @Produce json +// @Param payload body geoIPLookupRequest true "GeoIP lookup payload" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]interface{} +// @Router /api/option/geoip/lookup [post] +func LookupGeoIP(c *gin.Context) { + var request geoIPLookupRequest + if err := decodeJSONBody(c.Request.Body, &request); err != nil { + respondBadRequest(c, "") + return + } + + view, err := service.LookupGeoIP(request.Provider, request.IP) + if err != nil { + respondFailure(c, err.Error()) + return + } + respondSuccess(c, view) +} diff --git a/atsf_server/router/api-router.go b/atsf_server/router/api-router.go index ea5b39e8..312f9335 100644 --- a/atsf_server/router/api-router.go +++ b/atsf_server/router/api-router.go @@ -54,6 +54,7 @@ func SetApiRouter(router *gin.Engine) { { optionRoute.GET("/", controller.GetOptions) optionRoute.PUT("/", controller.UpdateOption) + optionRoute.POST("/geoip/lookup", controller.LookupGeoIP) } updateRoute := apiRouter.Group("/update") updateRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth()) diff --git a/atsf_server/service/geoip_lookup.go b/atsf_server/service/geoip_lookup.go new file mode 100644 index 00000000..ea9e0a0d --- /dev/null +++ b/atsf_server/service/geoip_lookup.go @@ -0,0 +1,50 @@ +package service + +import ( + "atsflare/utils/geoip" + "errors" + "net" + "strings" +) + +type GeoIPLookupView struct { + Provider string `json:"provider"` + IP string `json:"ip"` + ISOCode string `json:"iso_code"` + Name string `json:"name"` + Latitude *float64 `json:"latitude,omitempty"` + Longitude *float64 `json:"longitude,omitempty"` +} + +func LookupGeoIP(provider string, rawIP string) (*GeoIPLookupView, error) { + trimmedProvider := strings.TrimSpace(provider) + if !geoip.IsValidProvider(trimmedProvider) { + return nil, errors.New("归属方式仅支持 disabled、mmdb、ip-api、geojs、ipinfo") + } + + trimmedIP := strings.TrimSpace(rawIP) + if trimmedIP == "" { + return nil, errors.New("IP 不能为空") + } + parsedIP := net.ParseIP(trimmedIP) + if parsedIP == nil { + return nil, errors.New("IP 格式无效") + } + + info, err := geoip.LookupGeoInfoWithProvider(trimmedProvider, parsedIP) + if err != nil { + return nil, err + } + if info == nil { + return nil, errors.New("未获取到 IP 归属结果") + } + + return &GeoIPLookupView{ + Provider: trimmedProvider, + IP: parsedIP.String(), + ISOCode: info.ISOCode, + Name: info.Name, + Latitude: info.Latitude, + Longitude: info.Longitude, + }, nil +} diff --git a/atsf_server/service/geoip_lookup_test.go b/atsf_server/service/geoip_lookup_test.go new file mode 100644 index 00000000..375d490a --- /dev/null +++ b/atsf_server/service/geoip_lookup_test.go @@ -0,0 +1,64 @@ +package service + +import ( + "atsflare/utils/geoip" + "net" + "testing" +) + +type fakeLookupProvider struct{} + +func (f *fakeLookupProvider) Name() string { + return "fake-lookup" +} + +func (f *fakeLookupProvider) GetGeoInfo(ip net.IP) (*geoip.GeoInfo, error) { + return &geoip.GeoInfo{ + ISOCode: "US", + Name: "United States", + Latitude: geoipFloat(37.7749), + Longitude: geoipFloat(-122.4194), + }, nil +} + +func (f *fakeLookupProvider) UpdateDatabase() error { + return nil +} + +func (f *fakeLookupProvider) Close() error { + return nil +} + +func TestLookupGeoIP(t *testing.T) { + previousFactory := geoip.ProviderFactoryForTest() + geoip.SetProviderFactoryForTest(func(provider string) (geoip.GeoIPService, error) { + return &fakeLookupProvider{}, nil + }) + defer geoip.SetProviderFactoryForTest(previousFactory) + + view, err := LookupGeoIP("ipinfo", "8.8.8.8") + if err != nil { + t.Fatalf("LookupGeoIP failed: %v", err) + } + if view.Provider != "ipinfo" { + t.Fatalf("expected provider ipinfo, got %s", view.Provider) + } + if view.IP != "8.8.8.8" { + t.Fatalf("expected IP 8.8.8.8, got %s", view.IP) + } + if view.ISOCode != "US" || view.Name != "United States" { + t.Fatalf("unexpected lookup view: %+v", view) + } + if view.Latitude == nil || view.Longitude == nil { + t.Fatalf("expected coordinates, got %+v", view) + } +} + +func TestLookupGeoIPRejectsInvalidInput(t *testing.T) { + if _, err := LookupGeoIP("invalid", "8.8.8.8"); err == nil { + t.Fatal("expected invalid provider to fail") + } + if _, err := LookupGeoIP("ipinfo", "not-an-ip"); err == nil { + t.Fatal("expected invalid IP to fail") + } +} diff --git a/atsf_server/utils/geoip/geoip.go b/atsf_server/utils/geoip/geoip.go index 4dcc6133..3579bdeb 100644 --- a/atsf_server/utils/geoip/geoip.go +++ b/atsf_server/utils/geoip/geoip.go @@ -16,6 +16,7 @@ import ( var CurrentProvider GeoIPService var geoCache *providerCache var providerMutex sync.RWMutex +var providerFactory = newProvider const ( ProviderDisabled = "disabled" @@ -111,7 +112,7 @@ func GetRegionUnicodeEmoji(isoCode string) string { func InitGeoIP() { providerName := normalizeProvider(common.GeoIPProvider) - nextProvider, err := newProvider(providerName) + nextProvider, err := providerFactory(providerName) if err != nil { slog.Error("initialize GeoIP provider failed", "provider", providerName, "error", err) nextProvider = &EmptyProvider{} @@ -142,6 +143,24 @@ func GetGeoInfo(ip net.IP) (*GeoInfo, error) { return info, err } +func LookupGeoInfoWithProvider(providerName string, ip net.IP) (*GeoInfo, error) { + if ip == nil { + return nil, fmt.Errorf("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) +} + func UpdateDatabase() error { err := getProvider().UpdateDatabase() if err == nil { @@ -210,3 +229,15 @@ func getProvider() GeoIPService { func float64Pointer(value float64) *float64 { return &value } + +func ProviderFactoryForTest() func(string) (GeoIPService, error) { + return providerFactory +} + +func SetProviderFactoryForTest(factory func(string) (GeoIPService, error)) { + if factory == nil { + providerFactory = newProvider + return + } + providerFactory = factory +} diff --git a/atsf_server/utils/geoip/geoip_test.go b/atsf_server/utils/geoip/geoip_test.go index 0e64d0af..c3726d0a 100644 --- a/atsf_server/utils/geoip/geoip_test.go +++ b/atsf_server/utils/geoip/geoip_test.go @@ -79,3 +79,21 @@ func TestIsValidProvider(t *testing.T) { } } } + +func TestLookupGeoInfoWithProviderUsesTemporaryProvider(t *testing.T) { + previousFactory := providerFactory + providerFactory = func(provider string) (GeoIPService, 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) + } +} diff --git a/atsf_server/web/features/settings/api/settings.ts b/atsf_server/web/features/settings/api/settings.ts index 00151b4b..16d52fb5 100644 --- a/atsf_server/web/features/settings/api/settings.ts +++ b/atsf_server/web/features/settings/api/settings.ts @@ -2,6 +2,7 @@ import { apiRequest } from '@/lib/api/client'; import type { BootstrapTokenPayload, + GeoIPLookupResult, OptionItem, SettingsProfile, UpdateSelfPayload, @@ -18,6 +19,13 @@ export function updateOption(key: string, value: string) { }); } +export function lookupGeoIP(provider: string, ip: string) { + return apiRequest('/option/geoip/lookup', { + method: 'POST', + body: JSON.stringify({ provider, ip }), + }); +} + export function getBootstrapToken() { return apiRequest('/nodes/bootstrap-token'); } diff --git a/atsf_server/web/features/settings/components/settings-page.tsx b/atsf_server/web/features/settings/components/settings-page.tsx index 456bc3aa..155f9ebf 100644 --- a/atsf_server/web/features/settings/components/settings-page.tsx +++ b/atsf_server/web/features/settings/components/settings-page.tsx @@ -21,12 +21,14 @@ import { getBootstrapToken, getOptions, getSettingsProfile, + lookupGeoIP, rotateBootstrapToken, updateOption, updateSelf, } from '@/features/settings/api/settings'; import type { BootstrapTokenPayload, + GeoIPLookupResult, OptionItem, UpdateSelfPayload, } from '@/features/settings/types'; @@ -230,6 +232,7 @@ export function SettingsPage() { const [emailAddress, setEmailAddress] = useState(''); const [emailCode, setEmailCode] = useState(''); const [emailTurnstileToken, setEmailTurnstileToken] = useState(''); + const [geoIPTestIP, setGeoIPTestIP] = useState('8.8.8.8'); const isRoot = (user?.role ?? 0) >= 100; @@ -443,6 +446,11 @@ export function SettingsPage() { }, }); + const geoIPLookupMutation = useMutation({ + mutationFn: ({ provider, ip }: { provider: string; ip: string }) => + lookupGeoIP(provider, ip), + }); + const discoveryToken = bootstrapQuery.data?.discovery_token ?? ''; const discoveryCommand = isRoot && operationFields.ServerAddress && discoveryToken @@ -505,6 +513,17 @@ export function SettingsPage() { setFeedback({ tone: 'success', message: successMessage }); }; + const handleGeoIPLookup = () => { + geoIPLookupMutation.reset(); + geoIPLookupMutation.mutate({ + provider: operationFields.GeoIPProvider, + ip: geoIPTestIP.trim(), + }); + }; + + const geoIPLookupResult: GeoIPLookupResult | undefined = + geoIPLookupMutation.data; + const handleProfileSave = () => { void runBusyAction('profile', async () => { await updateSelf({ @@ -946,7 +965,10 @@ export function SettingsPage() { [ ['AgentHeartbeatInterval', String(heartbeat)], ['NodeOfflineThreshold', String(offline)], - ['AgentUpdateRepo', operationFields.AgentUpdateRepo.trim()], + [ + 'AgentUpdateRepo', + operationFields.AgentUpdateRepo.trim(), + ], ['GeoIPProvider', operationFields.GeoIPProvider], ], '运维设置已保存。', @@ -1006,7 +1028,8 @@ export function SettingsPage() { Agent 更新仓库

- 自动更新和手动更新动作在节点页触发,这里维护 Agent 自更新使用的仓库地址。 + 自动更新和手动更新动作在节点页触发,这里维护 Agent + 自更新使用的仓库地址。

@@ -1031,7 +1054,8 @@ export function SettingsPage() { IP 归属方式

- 控制世界地图等场景使用的 IP 归属解析来源。选择 MaxMind 时会按需下载本地 mmdb 数据库。 + 控制世界地图等场景使用的 IP 归属解析来源。选择 MaxMind + 时会按需下载本地 mmdb 数据库。

@@ -1056,6 +1080,84 @@ export function SettingsPage() {
+
+
+ + + setGeoIPTestIP(event.target.value) + } + placeholder="例如 8.8.8.8" + /> + + + {geoIPLookupMutation.isPending + ? '查询中...' + : '查询归属'} + +
+ +
+ {geoIPLookupMutation.isError ? ( + + ) : null} + + {geoIPLookupResult ? ( +
+
+

+ 查询 IP +

+

+ {geoIPLookupResult.ip} +

+
+
+

+ 国家 / 地区 +

+

+ {geoIPLookupResult.name || '—'} +

+
+
+

+ ISO Code +

+
+ +
+
+
+

+ 经纬度 +

+

+ {geoIPLookupResult.latitude !== undefined && + geoIPLookupResult.latitude !== null && + geoIPLookupResult.longitude !== undefined && + geoIPLookupResult.longitude !== null + ? `${geoIPLookupResult.latitude.toFixed(4)}, ${geoIPLookupResult.longitude.toFixed(4)}` + : '—'} +

+
+
+ ) : null} +
+
@@ -1281,7 +1383,9 @@ export function SettingsPage() { } disabled={busyKey === 'system-general'} > - {busyKey === 'system-general' ? '保存中...' : '保存通用设置'} + {busyKey === 'system-general' + ? '保存中...' + : '保存通用设置'} } @@ -1904,9 +2008,7 @@ export function SettingsPage() { return (
- + {feedback ? ( diff --git a/atsf_server/web/features/settings/types.ts b/atsf_server/web/features/settings/types.ts index baba25bf..00d7f374 100644 --- a/atsf_server/web/features/settings/types.ts +++ b/atsf_server/web/features/settings/types.ts @@ -9,6 +9,15 @@ export interface BootstrapTokenPayload { discovery_token: string; } +export interface GeoIPLookupResult { + provider: string; + ip: string; + iso_code: string; + name: string; + latitude?: number | null; + longitude?: number | null; +} + export interface UpdateSelfPayload { username: string; display_name: string;