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
+2
View File
@@ -18,6 +18,8 @@ sidebar: false
### 修复
- 修复 Agent 心跳上报公网 IP 后节点地理位置未自动更新:进程启动时按 `GeoIPProvider` 初始化 `pkg/geoip`,`mmdb` 模式从内置 GeoLite2 种子到 `data/`,并在 Relay 心跳同步地理位置。
- 修复 Agent 启动时 Pages 部署包下载失败:Pages 部署包统一下载走 upload 文件存储框架,部署记录持久化 `upload_id`,legacy `artifact_path` 仅用于一次性回填 upload。
- 修复登录 Cap 人机验证:前端 `cap-solver` 与 Cap 路由测试对齐 `b3a55d4` 之后的统一 API 信封 `{ error_msg, data }`,避免 `challenge` 解构失败。
+2 -26
View File
@@ -82,7 +82,7 @@ func (e payloadError) Error() string { return string(e) }
func errPayload(message string) error { return payloadError(message) }
func applyNodeRuntime(node *model.OpenFlareNode, payload NodePayload, preserveName bool) {
func applyNodeRuntime(ctx context.Context, node *model.OpenFlareNode, payload NodePayload, preserveName bool) {
if !preserveName || strings.TrimSpace(node.Name) == "" {
if strings.TrimSpace(payload.Name) != "" {
node.Name = strings.TrimSpace(payload.Name)
@@ -101,31 +101,7 @@ func applyNodeRuntime(node *model.OpenFlareNode, payload NodePayload, preserveNa
node.LastSeenAt = &now
node.LastError = truncateForDatabase(payload.LastError, maxDatabaseTextLength)
if !node.GeoManualOverride {
applyGeoInfoFromIP(node, node.IP)
}
}
func applyGeoInfoFromIP(node *model.OpenFlareNode, rawIP string) {
if node == nil {
return
}
node.GeoName = ""
node.GeoLatitude = nil
node.GeoLongitude = nil
ip := net.ParseIP(strings.TrimSpace(rawIP))
if ip == nil {
return
}
info, err := ofgeoip.GeoInfoFromIP(ip)
if err != nil || 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)
ofgeoip.ApplyNodeGeoFromIP(ctx, node, node.IP)
}
}
@@ -4,9 +4,11 @@
package agent
import (
"context"
"net"
"testing"
ofgeoip "github.com/Rain-kl/Wavelet/internal/apps/openflare/geoip"
"github.com/Rain-kl/Wavelet/internal/model"
pkggeoip "github.com/Rain-kl/Wavelet/pkg/geoip"
)
@@ -48,7 +50,7 @@ func TestApplyGeoInfoFromIP(t *testing.T) {
})
node := &model.OpenFlareNode{IP: "203.0.113.10"}
applyGeoInfoFromIP(node, node.IP)
ofgeoip.ApplyNodeGeoFromIP(context.Background(), node, node.IP)
if node.GeoName != "Shanghai" {
t.Fatalf("expected geo_name Shanghai, got %q", node.GeoName)
@@ -70,7 +72,7 @@ func TestApplyGeoInfoFromIPSkipsInvalidIP(t *testing.T) {
GeoLatitude: geoipFloat(1),
GeoLongitude: geoipFloat(2),
}
applyGeoInfoFromIP(node, "not-an-ip")
ofgeoip.ApplyNodeGeoFromIP(context.Background(), node, "not-an-ip")
if node.GeoName != "" || node.GeoLatitude != nil || node.GeoLongitude != nil {
t.Fatalf("expected geo fields to be cleared on invalid IP, got %+v", node)
@@ -90,7 +92,7 @@ func TestApplyNodeRuntimeRespectsGeoManualOverride(t *testing.T) {
GeoLatitude: geoipFloat(10),
GeoLongitude: geoipFloat(20),
}
applyNodeRuntime(node, NodePayload{
applyNodeRuntime(context.Background(), node, NodePayload{
IP: "203.0.113.10",
Version: "1.0.0",
}, true)
+7 -3
View File
@@ -9,6 +9,7 @@ import (
"strings"
"time"
ofgeoip "github.com/Rain-kl/Wavelet/internal/apps/openflare/geoip"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/node"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
@@ -17,6 +18,7 @@ import (
// RegisterWithAccessToken registers an agent on a reserved node token.
func RegisterWithAccessToken(ctx context.Context, authNode *model.OpenFlareNode, payload NodePayload) (*RegistrationResponse, error) {
_ = ofgeoip.EnsureRuntimeProvider(ctx)
payload = normalizeNodePayload(payload)
if authNode == nil {
return nil, errors.New(errNodeNotFound)
@@ -24,7 +26,7 @@ func RegisterWithAccessToken(ctx context.Context, authNode *model.OpenFlareNode,
if err := validateNodePayload(payload); err != nil {
return nil, err
}
applyNodeRuntime(authNode, payload, true)
applyNodeRuntime(ctx, authNode, payload, true)
if err := model.SaveOpenFlareNode(ctx, authNode); err != nil {
return nil, err
}
@@ -38,6 +40,7 @@ func RegisterWithAccessToken(ctx context.Context, authNode *model.OpenFlareNode,
// RegisterWithDiscovery registers a new node using the global discovery token.
func RegisterWithDiscovery(ctx context.Context, payload NodePayload) (*RegistrationResponse, error) {
_ = ofgeoip.EnsureRuntimeProvider(ctx)
payload = normalizeNodePayload(payload)
if err := validateNodePayload(payload); err != nil {
return nil, err
@@ -66,7 +69,7 @@ func RegisterWithDiscovery(ctx context.Context, payload NodePayload) (*Registrat
CapabilitiesJSON: "[]",
UpdateChannel: releaseChannelStable,
}
applyNodeRuntime(record, payload, false)
applyNodeRuntime(ctx, record, payload, false)
if err = model.CreateOpenFlareNode(ctx, record); err != nil {
if isUniqueConstraintError(err) {
@@ -84,6 +87,7 @@ func RegisterWithDiscovery(ctx context.Context, payload NodePayload) (*Registrat
// HeartbeatNode updates runtime state and returns agent settings.
func HeartbeatNode(ctx context.Context, authNode *model.OpenFlareNode, payload NodePayload) (*HeartbeatResponse, error) {
_ = ofgeoip.EnsureRuntimeProvider(ctx)
if authNode == nil {
return nil, errors.New(errNodeNotFound)
}
@@ -99,7 +103,7 @@ func HeartbeatNode(ctx context.Context, authNode *model.OpenFlareNode, payload N
updateChannel := strings.TrimSpace(authNode.UpdateChannel)
updateTag := strings.TrimSpace(authNode.UpdateTag)
applyNodeRuntime(authNode, payload, true)
applyNodeRuntime(ctx, authNode, payload, true)
authNode.UpdateRequested = false
authNode.UpdateChannel = releaseChannelStable
authNode.UpdateTag = ""
+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)
}
}
+9 -1
View File
@@ -136,7 +136,15 @@ func updateOptions(ctx context.Context, options []model.OpenFlareOption) error {
if err := validateOptions(options); err != nil {
return err
}
return model.UpdateOpenFlareOptions(ctx, options)
if err := model.UpdateOpenFlareOptions(ctx, options); err != nil {
return err
}
for _, item := range options {
if item.Key == "GeoIPProvider" {
return geoip.RefreshRuntimeProvider(ctx)
}
}
return nil
}
func getNotice(ctx context.Context) (string, error) {
+23
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/agent"
ofgeoip "github.com/Rain-kl/Wavelet/internal/apps/openflare/geoip"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
)
@@ -52,6 +53,21 @@ func Heartbeat(ctx context.Context, node *model.OpenFlareNode, payload Heartbeat
changes["ip"] = payload.IP
node.IP = payload.IP
}
if !node.GeoManualOverride {
beforeGeo := node.GeoName
beforeLat := node.GeoLatitude
beforeLon := node.GeoLongitude
ofgeoip.ApplyNodeGeoFromIP(ctx, node, node.IP)
if node.GeoName != beforeGeo {
changes["geo_name"] = node.GeoName
}
if !coordinatesEqual(beforeLat, node.GeoLatitude) {
changes["geo_latitude"] = node.GeoLatitude
}
if !coordinatesEqual(beforeLon, node.GeoLongitude) {
changes["geo_longitude"] = node.GeoLongitude
}
}
if !previous.UpdateRequested {
delete(changes, "update_requested")
}
@@ -86,3 +102,10 @@ func Heartbeat(ctx context.Context, node *model.OpenFlareNode, payload Heartbeat
RelaySettings: BuildSettings(node, updateNow, updateChannel, updateTag),
}, nil
}
func coordinatesEqual(before *float64, after *float64) bool {
if before == nil || after == nil {
return before == after
}
return *before == *after
}
+5 -1
View File
@@ -12,6 +12,7 @@ import (
admin_push "github.com/Rain-kl/Wavelet/internal/apps/admin/push"
"github.com/Rain-kl/Wavelet/internal/apps/admin/push/custom_events"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/chwriter"
ofgeoip "github.com/Rain-kl/Wavelet/internal/apps/openflare/geoip"
"github.com/Rain-kl/Wavelet/internal/apps/risk_control"
"github.com/Rain-kl/Wavelet/internal/lifecycle"
taskhandlers "github.com/Rain-kl/Wavelet/internal/task/handlers"
@@ -80,6 +81,9 @@ func RegisterAll() {
// Call from cmd entry points after wiring registration and database migration, not from router.
func Init(ctx context.Context, opts Options) {
initRuntimeOnce.Do(func() {
if err := ofgeoip.EnsureRuntimeProvider(ctx); err != nil {
logger.ErrorF(ctx, "[Bootstrap] init GeoIP provider failed: %v", err)
}
if err := admin_push.SyncEvents(ctx); err != nil {
logger.ErrorF(ctx, "[Bootstrap] sync push events failed: %v", err)
}
@@ -98,4 +102,4 @@ func Stop(ctx context.Context) {
// ResetInitRuntimeOnceForTest clears initRuntimeOnce so Init can run again in unit tests.
func ResetInitRuntimeOnceForTest() {
initRuntimeOnce = sync.Once{}
}
}
+19 -1
View File
@@ -81,9 +81,10 @@ func (s *IPInfoService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
// 实际上,IPinfo 的 'country' 字段就是 ISO 2-letter code。
// 如果需要完整的国家名称,可能需要一个本地的 ISO 代码到名称的映射。
// 为了与 GetRegionUnicodeEmoji 函数兼容,我们直接使用 country 作为 ISOCode。
name := formatIPInfoLocation(apiResp)
return &GeoInfo{
ISOCode: apiResp.Country,
Name: apiResp.Country,
Name: name,
Latitude: latitude,
Longitude: longitude,
}, nil
@@ -115,3 +116,20 @@ func parseIPInfoCoordinates(value string) (*float64, *float64) {
return float64Pointer(latitudeValue), float64Pointer(longitudeValue)
}
func formatIPInfoLocation(resp ipInfoResponse) string {
parts := make([]string, 0, 3)
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, ", ")
}