diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 1805e6dd..30658c9d 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -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` 解构失败。 diff --git a/internal/apps/openflare/agent/helpers.go b/internal/apps/openflare/agent/helpers.go index 194d2afa..7554f801 100644 --- a/internal/apps/openflare/agent/helpers.go +++ b/internal/apps/openflare/agent/helpers.go @@ -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) } } diff --git a/internal/apps/openflare/agent/helpers_test.go b/internal/apps/openflare/agent/helpers_test.go index 98e00c88..28006533 100644 --- a/internal/apps/openflare/agent/helpers_test.go +++ b/internal/apps/openflare/agent/helpers_test.go @@ -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) diff --git a/internal/apps/openflare/agent/logics.go b/internal/apps/openflare/agent/logics.go index 2f20610e..71ed1252 100644 --- a/internal/apps/openflare/agent/logics.go +++ b/internal/apps/openflare/agent/logics.go @@ -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 = "" diff --git a/internal/apps/openflare/geoip/node.go b/internal/apps/openflare/geoip/node.go new file mode 100644 index 00000000..b0607d08 --- /dev/null +++ b/internal/apps/openflare/geoip/node.go @@ -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 +} diff --git a/internal/apps/openflare/geoip/runtime.go b/internal/apps/openflare/geoip/runtime.go new file mode 100644 index 00000000..47bdcc39 --- /dev/null +++ b/internal/apps/openflare/geoip/runtime.go @@ -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() +} diff --git a/internal/apps/openflare/geoip/runtime_test.go b/internal/apps/openflare/geoip/runtime_test.go new file mode 100644 index 00000000..9d11303d --- /dev/null +++ b/internal/apps/openflare/geoip/runtime_test.go @@ -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) + } +} diff --git a/internal/apps/openflare/option/logics.go b/internal/apps/openflare/option/logics.go index 05d7b0ed..db61ab4d 100644 --- a/internal/apps/openflare/option/logics.go +++ b/internal/apps/openflare/option/logics.go @@ -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) { diff --git a/internal/apps/openflare/relay/logics.go b/internal/apps/openflare/relay/logics.go index 0b728eb7..d3744b28 100644 --- a/internal/apps/openflare/relay/logics.go +++ b/internal/apps/openflare/relay/logics.go @@ -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 +} diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index a18a9c55..e464ece5 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -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{} -} \ No newline at end of file +} diff --git a/pkg/geoip/ipinfo.go b/pkg/geoip/ipinfo.go index f9efade0..b0dc062d 100644 --- a/pkg/geoip/ipinfo.go +++ b/pkg/geoip/ipinfo.go @@ -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, ", ") +}