mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-04 23:16:37 +08:00
feat: add geo_manual_override to NodeItem and NodeMutationPayload
fix: update default GeoIPProvider to ipinfo in settings feat: implement WorldStageMap component for visualizing node health on a world map feat: create NodeEditorModal for editing node details with manual geo location options docs: update app-config documentation to reflect changes in GeoIPProvider default value
This commit is contained in:
@@ -89,6 +89,7 @@ type NodeView struct {
|
||||
GeoName string `json:"geo_name"`
|
||||
GeoLatitude *float64 `json:"geo_latitude"`
|
||||
GeoLongitude *float64 `json:"geo_longitude"`
|
||||
GeoManualOverride bool `json:"geo_manual_override"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
||||
UpdateRequested bool `json:"update_requested"`
|
||||
@@ -324,6 +325,7 @@ func collectNodeHeartbeatChanges(previous *model.Node, current *model.Node) map[
|
||||
}
|
||||
appendIfChanged("name", previous.Name, current.Name)
|
||||
appendIfChanged("ip", previous.IP, current.IP)
|
||||
appendIfChanged("geo_name", previous.GeoName, current.GeoName)
|
||||
appendIfChanged("agent_version", previous.AgentVersion, current.AgentVersion)
|
||||
appendIfChanged("nginx_version", previous.NginxVersion, current.NginxVersion)
|
||||
appendIfChanged("openresty_status", previous.OpenrestyStatus, current.OpenrestyStatus)
|
||||
@@ -335,8 +337,21 @@ func collectNodeHeartbeatChanges(previous *model.Node, current *model.Node) map[
|
||||
appendIfChanged("update_channel", previous.UpdateChannel, current.UpdateChannel)
|
||||
appendIfChanged("update_tag", previous.UpdateTag, current.UpdateTag)
|
||||
appendIfChanged("restart_openresty_requested", previous.RestartOpenrestyRequested, current.RestartOpenrestyRequested)
|
||||
if !coordinatesEqual(previous.GeoLatitude, current.GeoLatitude) {
|
||||
changes["geo_latitude"] = current.GeoLatitude
|
||||
}
|
||||
if !coordinatesEqual(previous.GeoLongitude, current.GeoLongitude) {
|
||||
changes["geo_longitude"] = current.GeoLongitude
|
||||
}
|
||||
if !previous.LastSeenAt.Equal(current.LastSeenAt) {
|
||||
changes["last_seen_at"] = current.LastSeenAt
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
func coordinatesEqual(before *float64, after *float64) bool {
|
||||
if before == nil || after == nil {
|
||||
return before == after
|
||||
}
|
||||
return *before == *after
|
||||
}
|
||||
|
||||
@@ -3,21 +3,25 @@ package service
|
||||
import (
|
||||
"atsflare/common"
|
||||
"atsflare/model"
|
||||
"atsflare/utils/geoip"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type NodeInput struct {
|
||||
Name string `json:"name"`
|
||||
IP string `json:"ip"`
|
||||
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
||||
GeoName string `json:"geo_name"`
|
||||
GeoLatitude *float64 `json:"geo_latitude"`
|
||||
GeoLongitude *float64 `json:"geo_longitude"`
|
||||
GeoManualOverride bool `json:"geo_manual_override"`
|
||||
}
|
||||
|
||||
type NodeAgentUpdateInput struct {
|
||||
@@ -50,16 +54,17 @@ type AgentRegistrationResponse struct {
|
||||
}
|
||||
|
||||
func CreateNode(input NodeInput) (*NodeView, error) {
|
||||
name, geoName, geoLatitude, geoLongitude, err := normalizeNodeInput(input)
|
||||
name, ip, geoName, geoLatitude, geoLongitude, geoManualOverride, err := normalizeNodeInput(input)
|
||||
if name == "" {
|
||||
return nil, errors.New("节点名不能为空")
|
||||
}
|
||||
node := &model.Node{
|
||||
Name: name,
|
||||
IP: "",
|
||||
IP: ip,
|
||||
GeoName: geoName,
|
||||
GeoLatitude: geoLatitude,
|
||||
GeoLongitude: geoLongitude,
|
||||
GeoManualOverride: geoManualOverride,
|
||||
AgentVersion: "",
|
||||
NginxVersion: "",
|
||||
Status: NodeStatusPending,
|
||||
@@ -73,6 +78,9 @@ func CreateNode(input NodeInput) (*NodeView, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !node.GeoManualOverride {
|
||||
applyGeoInfoFromIP(node, node.IP)
|
||||
}
|
||||
if err := node.Insert(); err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
return nil, errors.New("节点标识生成冲突,请重试")
|
||||
@@ -84,7 +92,7 @@ func CreateNode(input NodeInput) (*NodeView, error) {
|
||||
}
|
||||
|
||||
func UpdateNode(id uint, input NodeInput) (*NodeView, error) {
|
||||
name, geoName, geoLatitude, geoLongitude, err := normalizeNodeInput(input)
|
||||
name, ip, geoName, geoLatitude, geoLongitude, geoManualOverride, err := normalizeNodeInput(input)
|
||||
if name == "" {
|
||||
return nil, errors.New("节点名不能为空")
|
||||
}
|
||||
@@ -93,10 +101,15 @@ func UpdateNode(id uint, input NodeInput) (*NodeView, error) {
|
||||
return nil, err
|
||||
}
|
||||
node.Name = name
|
||||
node.IP = ip
|
||||
node.GeoName = geoName
|
||||
node.GeoLatitude = geoLatitude
|
||||
node.GeoLongitude = geoLongitude
|
||||
node.GeoManualOverride = geoManualOverride
|
||||
node.AutoUpdateEnabled = input.AutoUpdateEnabled
|
||||
if !node.GeoManualOverride {
|
||||
applyGeoInfoFromIP(node, strings.TrimSpace(node.IP))
|
||||
}
|
||||
if err = node.Update(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -242,6 +255,7 @@ func buildNodeView(node *model.Node) *NodeView {
|
||||
GeoName: strings.TrimSpace(node.GeoName),
|
||||
GeoLatitude: node.GeoLatitude,
|
||||
GeoLongitude: node.GeoLongitude,
|
||||
GeoManualOverride: node.GeoManualOverride,
|
||||
AgentToken: node.AgentToken,
|
||||
UpdateChannel: strings.TrimSpace(node.UpdateChannel),
|
||||
UpdateTag: strings.TrimSpace(node.UpdateTag),
|
||||
@@ -265,26 +279,41 @@ func buildNodeView(node *model.Node) *NodeView {
|
||||
return view
|
||||
}
|
||||
|
||||
func normalizeNodeInput(input NodeInput) (string, string, *float64, *float64, error) {
|
||||
func normalizeNodeInput(input NodeInput) (string, string, string, *float64, *float64, bool, error) {
|
||||
name := strings.TrimSpace(input.Name)
|
||||
ip := strings.TrimSpace(input.IP)
|
||||
geoName := strings.TrimSpace(input.GeoName)
|
||||
manualOverride := input.GeoManualOverride || geoName != "" || input.GeoLatitude != nil || input.GeoLongitude != nil
|
||||
if len(ip) > 64 {
|
||||
return "", "", "", nil, nil, false, errors.New("节点 IP 不能超过 64 个字符")
|
||||
}
|
||||
if ip != "" && net.ParseIP(ip) == nil {
|
||||
return "", "", "", nil, nil, false, errors.New("节点 IP 格式无效")
|
||||
}
|
||||
if len(geoName) > 128 {
|
||||
return "", "", nil, nil, errors.New("节点位置名不能超过 128 个字符")
|
||||
return "", "", "", nil, nil, false, errors.New("节点位置名不能超过 128 个字符")
|
||||
}
|
||||
|
||||
geoLatitude := cloneCoordinate(input.GeoLatitude)
|
||||
geoLongitude := cloneCoordinate(input.GeoLongitude)
|
||||
if (geoLatitude == nil) != (geoLongitude == nil) {
|
||||
return "", "", nil, nil, errors.New("地图坐标必须同时填写纬度和经度")
|
||||
return "", "", "", nil, nil, false, errors.New("地图坐标必须同时填写纬度和经度")
|
||||
}
|
||||
if geoLatitude != nil && (*geoLatitude < -90 || *geoLatitude > 90) {
|
||||
return "", "", nil, nil, errors.New("纬度必须在 -90 到 90 之间")
|
||||
return "", "", "", nil, nil, false, errors.New("纬度必须在 -90 到 90 之间")
|
||||
}
|
||||
if geoLongitude != nil && (*geoLongitude < -180 || *geoLongitude > 180) {
|
||||
return "", "", nil, nil, errors.New("经度必须在 -180 到 180 之间")
|
||||
return "", "", "", nil, nil, false, errors.New("经度必须在 -180 到 180 之间")
|
||||
}
|
||||
|
||||
return name, geoName, geoLatitude, geoLongitude, nil
|
||||
if !manualOverride {
|
||||
return name, ip, "", nil, nil, false, nil
|
||||
}
|
||||
if geoLatitude == nil && geoLongitude == nil && geoName == "" {
|
||||
return name, ip, "", nil, nil, false, nil
|
||||
}
|
||||
|
||||
return name, ip, geoName, geoLatitude, geoLongitude, true, nil
|
||||
}
|
||||
|
||||
func cloneCoordinate(value *float64) *float64 {
|
||||
@@ -410,6 +439,33 @@ func applyNodeRuntime(node *model.Node, payload AgentNodePayload, preserveName b
|
||||
node.CurrentVersion = strings.TrimSpace(payload.CurrentVersion)
|
||||
node.LastSeenAt = time.Now()
|
||||
node.LastError = strings.TrimSpace(payload.LastError)
|
||||
if !node.GeoManualOverride {
|
||||
applyGeoInfoFromIP(node, node.IP)
|
||||
}
|
||||
}
|
||||
|
||||
func applyGeoInfoFromIP(node *model.Node, 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 := geoip.GetGeoInfo(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)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeOpenrestyStatus(status string) string {
|
||||
|
||||
@@ -3,7 +3,9 @@ package service
|
||||
import (
|
||||
"atsflare/common"
|
||||
"atsflare/model"
|
||||
"atsflare/utils/geoip"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -13,20 +15,54 @@ import (
|
||||
|
||||
type roundTripFunc func(req *http.Request) (*http.Response, error)
|
||||
|
||||
type fakeGeoIPProvider struct {
|
||||
info *geoip.GeoInfo
|
||||
}
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func (f *fakeGeoIPProvider) Name() string {
|
||||
return "fake-geoip"
|
||||
}
|
||||
|
||||
func (f *fakeGeoIPProvider) GetGeoInfo(ip net.IP) (*geoip.GeoInfo, error) {
|
||||
return f.info, nil
|
||||
}
|
||||
|
||||
func (f *fakeGeoIPProvider) UpdateDatabase() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeGeoIPProvider) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func withFakeGeoIPProvider(t *testing.T, info *geoip.GeoInfo) {
|
||||
t.Helper()
|
||||
previous := geoip.CurrentProvider
|
||||
geoip.CurrentProvider = &fakeGeoIPProvider{info: info}
|
||||
t.Cleanup(func() {
|
||||
geoip.CurrentProvider = previous
|
||||
})
|
||||
}
|
||||
|
||||
func geoipFloat(value float64) *float64 {
|
||||
return &value
|
||||
}
|
||||
|
||||
func TestRequestNodeAgentPreviewUpdate(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
latitude := 31.2304
|
||||
longitude := 121.4737
|
||||
node, err := CreateNode(NodeInput{
|
||||
Name: "preview-edge-1",
|
||||
GeoName: "Shanghai",
|
||||
GeoLatitude: &latitude,
|
||||
GeoLongitude: &longitude,
|
||||
Name: "preview-edge-1",
|
||||
GeoManualOverride: true,
|
||||
GeoName: "Shanghai",
|
||||
GeoLatitude: &latitude,
|
||||
GeoLongitude: &longitude,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create node: %v", err)
|
||||
@@ -173,6 +209,7 @@ func TestUpdateNodeValidatesAndPersistsGeoMetadata(t *testing.T) {
|
||||
updated, err := UpdateNode(node.ID, NodeInput{
|
||||
Name: "geo-edge-updated",
|
||||
AutoUpdateEnabled: true,
|
||||
GeoManualOverride: true,
|
||||
GeoName: "San Francisco",
|
||||
GeoLatitude: &latitude,
|
||||
GeoLongitude: &longitude,
|
||||
@@ -203,13 +240,148 @@ func TestUpdateNodeRejectsPartialGeoMetadata(t *testing.T) {
|
||||
|
||||
latitude := 37.7749
|
||||
if _, err = UpdateNode(node.ID, NodeInput{
|
||||
Name: "geo-edge-invalid",
|
||||
GeoLatitude: &latitude,
|
||||
Name: "geo-edge-invalid",
|
||||
GeoManualOverride: true,
|
||||
GeoLatitude: &latitude,
|
||||
}); err == nil {
|
||||
t.Fatal("expected partial geo metadata to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNodeRejectsInvalidIP(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
node, err := CreateNode(NodeInput{Name: "geo-edge-invalid-ip"})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create node: %v", err)
|
||||
}
|
||||
|
||||
if _, err = UpdateNode(node.ID, NodeInput{
|
||||
Name: "geo-edge-invalid-ip",
|
||||
IP: "not-an-ip",
|
||||
}); err == nil {
|
||||
t.Fatal("expected invalid IP to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNodeCanChangeIPAndAutoResolveGeo(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
withFakeGeoIPProvider(t, &geoip.GeoInfo{
|
||||
ISOCode: "US",
|
||||
Name: "United States",
|
||||
Latitude: geoipFloat(37.7749),
|
||||
Longitude: geoipFloat(-122.4194),
|
||||
})
|
||||
|
||||
node, err := CreateNode(NodeInput{Name: "geo-edge-auto-ip"})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create node: %v", err)
|
||||
}
|
||||
|
||||
updated, err := UpdateNode(node.ID, NodeInput{
|
||||
Name: "geo-edge-auto-ip",
|
||||
IP: "8.8.8.8",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected node update to succeed: %v", err)
|
||||
}
|
||||
if updated.IP != "8.8.8.8" {
|
||||
t.Fatalf("expected updated IP to be persisted, got %+v", updated.IP)
|
||||
}
|
||||
if updated.GeoName != "United States" {
|
||||
t.Fatalf("expected geo name to be auto resolved, got %+v", updated)
|
||||
}
|
||||
if updated.GeoLatitude == nil || updated.GeoLongitude == nil {
|
||||
t.Fatalf("expected geo coordinates to be auto resolved, got %+v", updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatNodeResolvesGeoMetadataFromIPWhenNotManuallyOverridden(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
withFakeGeoIPProvider(t, &geoip.GeoInfo{
|
||||
ISOCode: "US",
|
||||
Name: "United States",
|
||||
Latitude: geoipFloat(37.7749),
|
||||
Longitude: geoipFloat(-122.4194),
|
||||
})
|
||||
|
||||
node := &model.Node{
|
||||
NodeID: "node-geo-auto",
|
||||
Name: "geo-auto",
|
||||
IP: "10.0.0.8",
|
||||
AgentToken: "agent-token",
|
||||
AgentVersion: "v0.4.0",
|
||||
NginxVersion: "1.27.1.2",
|
||||
Status: NodeStatusOnline,
|
||||
}
|
||||
if err := node.Insert(); err != nil {
|
||||
t.Fatalf("failed to seed node: %v", err)
|
||||
}
|
||||
|
||||
resp, err := HeartbeatNode(node, AgentNodePayload{
|
||||
NodeID: node.NodeID,
|
||||
Name: node.Name,
|
||||
IP: "8.8.8.8",
|
||||
AgentVersion: node.AgentVersion,
|
||||
NginxVersion: node.NginxVersion,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected heartbeat to succeed: %v", err)
|
||||
}
|
||||
if resp.Node.GeoName != "United States" {
|
||||
t.Fatalf("expected auto geo name, got %+v", resp.Node)
|
||||
}
|
||||
if resp.Node.GeoLatitude == nil || resp.Node.GeoLongitude == nil {
|
||||
t.Fatalf("expected auto geo coordinates, got %+v", resp.Node)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatNodePreservesManualGeoOverride(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
withFakeGeoIPProvider(t, &geoip.GeoInfo{
|
||||
ISOCode: "US",
|
||||
Name: "United States",
|
||||
Latitude: geoipFloat(37.7749),
|
||||
Longitude: geoipFloat(-122.4194),
|
||||
})
|
||||
|
||||
latitude := 31.2304
|
||||
longitude := 121.4737
|
||||
node := &model.Node{
|
||||
NodeID: "node-geo-manual",
|
||||
Name: "geo-manual",
|
||||
IP: "10.0.0.8",
|
||||
GeoName: "Shanghai",
|
||||
GeoLatitude: &latitude,
|
||||
GeoLongitude: &longitude,
|
||||
GeoManualOverride: true,
|
||||
AgentToken: "agent-token",
|
||||
AgentVersion: "v0.4.0",
|
||||
NginxVersion: "1.27.1.2",
|
||||
Status: NodeStatusOnline,
|
||||
}
|
||||
if err := node.Insert(); err != nil {
|
||||
t.Fatalf("failed to seed node: %v", err)
|
||||
}
|
||||
|
||||
resp, err := HeartbeatNode(node, AgentNodePayload{
|
||||
NodeID: node.NodeID,
|
||||
Name: node.Name,
|
||||
IP: "8.8.8.8",
|
||||
AgentVersion: node.AgentVersion,
|
||||
NginxVersion: node.NginxVersion,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected heartbeat to succeed: %v", err)
|
||||
}
|
||||
if resp.Node.GeoName != "Shanghai" {
|
||||
t.Fatalf("expected manual geo name to be preserved, got %+v", resp.Node)
|
||||
}
|
||||
if resp.Node.GeoLatitude == nil || *resp.Node.GeoLatitude != latitude {
|
||||
t.Fatalf("expected manual latitude to be preserved, got %+v", resp.Node.GeoLatitude)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestNodeOpenrestyRestart(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user