fix(openflare): Agent 上报 IP 后由 Server 自动解析节点地理位置

- 启动时按 of_options.GeoIPProvider 初始化 pkg/geoip(bootstrap + runtime)
- mmdb 模式从内置 GeoLite2 种子到 data/;保存归属方式后热刷新 Provider
- Agent/Relay 心跳在服务端根据 IP 写入 geo 字段,尊重 geo_manual_override
- ipinfo 归属名称改为 City, Region, Country 可读格式
This commit is contained in:
ryan
2026-06-20 19:36:17 +08:00
parent ec53629971
commit 498a9ed3ff
11 changed files with 287 additions and 35 deletions
+52
View File
@@ -0,0 +1,52 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package geoip
import (
"context"
"net"
"strings"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/pkg/logger"
)
// ApplyNodeGeoFromIP resolves geographic metadata for node when geo is not manually locked.
func ApplyNodeGeoFromIP(ctx context.Context, node *model.OpenFlareNode, rawIP string) {
if node == nil || node.GeoManualOverride {
return
}
node.GeoName = ""
node.GeoLatitude = nil
node.GeoLongitude = nil
ip := net.ParseIP(strings.TrimSpace(rawIP))
if ip == nil {
return
}
info, err := GeoInfoFromIP(ip)
if err != nil {
logger.WarnF(ctx, "[GeoIP] resolve node geo failed: ip=%s error=%v", ip.String(), err)
return
}
if info == nil {
return
}
if strings.TrimSpace(info.Name) != "" {
node.GeoName = strings.TrimSpace(info.Name)
}
if info.Latitude != nil && info.Longitude != nil {
node.GeoLatitude = cloneCoordinate(info.Latitude)
node.GeoLongitude = cloneCoordinate(info.Longitude)
}
}
func cloneCoordinate(value *float64) *float64 {
if value == nil {
return nil
}
cloned := *value
return &cloned
}
+121
View File
@@ -0,0 +1,121 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package geoip
import (
"context"
"io/fs"
"os"
"path/filepath"
"strings"
"sync"
"github.com/Rain-kl/Wavelet/internal/apps/agent/geoipdata"
"github.com/Rain-kl/Wavelet/internal/model"
pkggeoip "github.com/Rain-kl/Wavelet/pkg/geoip"
"github.com/Rain-kl/Wavelet/pkg/logger"
)
const (
serverMMDBRelativePath = "data/GeoLite2-Country.mmdb"
serverMMDBDirPerm = 0o750
serverMMDBFilePerm = 0o644
)
var (
runtimeOnce sync.Once
runtimeInitErr error
currentProviderMu sync.RWMutex
currentProvider string
)
// EnsureRuntimeProvider loads OpenFlare options once and configures pkg/geoip.
func EnsureRuntimeProvider(ctx context.Context) error {
runtimeOnce.Do(func() {
if err := model.InitOptionMap(ctx); err != nil {
runtimeInitErr = err
return
}
runtimeInitErr = applyProviderFromModel()
})
return runtimeInitErr
}
// RefreshRuntimeProvider reapplies GeoIPProvider after option updates.
func RefreshRuntimeProvider(ctx context.Context) error {
if err := model.InitOptionMap(ctx); err != nil {
return err
}
return applyProviderFromModel()
}
func applyProviderFromModel() error {
model.OptionMapRWMutex.RLock()
provider := strings.TrimSpace(model.GeoIPProvider)
model.OptionMapRWMutex.RUnlock()
return ApplyProvider(provider)
}
// ApplyProvider switches the process-wide GeoIP backend.
func ApplyProvider(provider string) error {
normalized := strings.TrimSpace(strings.ToLower(provider))
if normalized == "" {
normalized = pkggeoip.ProviderDisabled
}
currentProviderMu.Lock()
if currentProvider == normalized {
currentProviderMu.Unlock()
return nil
}
currentProvider = normalized
currentProviderMu.Unlock()
if normalized == pkggeoip.ProviderMaxMind {
path, err := ensureServerMMDB()
if err != nil {
logger.WarnF(context.Background(), "[GeoIP] seed MaxMind database failed: %v", err)
}
if path != "" {
pkggeoip.GeoIPFilePath = path
}
}
pkggeoip.InitGeoIP(normalized)
return nil
}
func ensureServerMMDB() (string, error) {
path, err := filepath.Abs(serverMMDBRelativePath)
if err != nil {
return "", err
}
if _, err := os.Stat(path); err == nil {
return path, nil
}
if !os.IsNotExist(err) {
return "", err
}
data, err := fs.ReadFile(geoipdata.FS, geoipdata.DefaultMMDBName)
if err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Dir(path), serverMMDBDirPerm); err != nil {
return "", err
}
if err := os.WriteFile(path, data, serverMMDBFilePerm); err != nil { //nolint:gosec // world-readable mmdb
return "", err
}
return path, nil
}
// ResetRuntimeForTest clears lazy-init state for unit tests.
func ResetRuntimeForTest() {
runtimeOnce = sync.Once{}
runtimeInitErr = nil
currentProviderMu.Lock()
currentProvider = ""
currentProviderMu.Unlock()
}
@@ -0,0 +1,42 @@
package geoip
import (
"context"
"testing"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
pkggeoip "github.com/Rain-kl/Wavelet/pkg/geoip"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestEnsureRuntimeProviderInitializesConfiguredProvider(t *testing.T) {
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{DisableForeignKeyConstraintWhenMigrating: true})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := sqliteDB.AutoMigrate(&model.OpenFlareOption{}); err != nil {
t.Fatalf("migrate: %v", err)
}
db.SetDB(sqliteDB)
t.Cleanup(func() {
db.SetDB(nil)
model.ResetOptionMapForTest()
ResetRuntimeForTest()
})
ctx := context.Background()
model.ResetOptionMapForTest()
ResetRuntimeForTest()
if err := model.UpdateOpenFlareOption(ctx, "GeoIPProvider", pkggeoip.ProviderIPInfo); err != nil {
t.Fatalf("update option: %v", err)
}
if err := EnsureRuntimeProvider(ctx); err != nil {
t.Fatalf("EnsureRuntimeProvider error = %v", err)
}
if pkggeoip.CurrentProvider == nil || pkggeoip.CurrentProvider.Name() == "EmptyProvider" {
t.Fatalf("expected ipinfo provider, got %#v", pkggeoip.CurrentProvider)
}
}