mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
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:
@@ -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` 解构失败。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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, ", ")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user