diff --git a/docs/app-config.md b/docs/app-config.md index d1073cbc..3f334e51 100644 --- a/docs/app-config.md +++ b/docs/app-config.md @@ -131,7 +131,7 @@ Agent 当前支持: | `agent_token` | 节点专属认证 Token | 与 `discovery_token` 二选一 | 空 | | `discovery_token` | 首次自动注册使用的全局 Token | 与 `agent_token` 二选一 | 空 | | `node_name` | 节点名称 | 否 | 自动使用主机名 | -| `node_ip` | 节点 IP | 否 | 自动探测 | +| `node_ip` | 节点 IP | 否 | 自动探测,优先选择公网 IPv4;仅无公网地址时退回可用内网地址 | | `openresty_path` | 本机 OpenResty 路径 | 否 | 空,未设置时走 Docker 模式 | | `openresty_container_name` | Docker 模式下的容器名 | 否 | `openflare-openresty` | | `openresty_docker_image` | Docker 模式下的镜像 | 否 | `openresty/openresty:alpine` | @@ -155,6 +155,7 @@ Agent 当前支持: * `agent_token` 与 `discovery_token` 不能同时为空 * `heartbeat_interval` 与 `request_timeout` 支持毫秒整数或 Go duration 字符串 * 未配置 `openresty_path` 时默认使用 Docker OpenResty 模式 +* Agent 自动探测到私网 `node_ip` 时,Server 会在注册/心跳阶段优先保留 Agent 直连来源的公网地址,避免 NAT/多网卡场景误登记内网网卡地址 ## 3. 维护要求 diff --git a/docs/deployment.md b/docs/deployment.md index 2e8dd064..8e0cb633 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -12,7 +12,7 @@ ### 1.2 Agent -* Go 1.23+ +* Go 1.24+ * 对 Agent 数据目录有写权限 * 本机模式下可执行 `openresty -t` 与 `openresty -s reload` * Docker 模式下具备 Docker 执行权限 diff --git a/docs/development-guidelines.md b/docs/development-guidelines.md index ef74e577..0fafe879 100644 --- a/docs/development-guidelines.md +++ b/docs/development-guidelines.md @@ -20,7 +20,7 @@ `openflare_agent` 继续作为 Go 单体程序: -* Go 1.23+ +* Go 1.24+ * 单二进制 * 节点本地执行 * `openresty_path` 优先 diff --git a/openflare_agent/go.mod b/openflare_agent/go.mod index 4a232075..264152ac 100644 --- a/openflare_agent/go.mod +++ b/openflare_agent/go.mod @@ -1,3 +1,7 @@ module openflare-agent -go 1.23.0 +go 1.24.0 + +require openflare v0.0.0 + +replace openflare => ../openflare_server diff --git a/openflare_agent/internal/config/config.go b/openflare_agent/internal/config/config.go index 37e939ca..0397e055 100644 --- a/openflare_agent/internal/config/config.go +++ b/openflare_agent/internal/config/config.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "net" + "openflare/utils/geoip/iputil" "os" pathpkg "path" "path/filepath" @@ -331,6 +332,8 @@ func detectNodeIP() string { if err != nil { return "" } + bestIP := "" + bestPriority := -1 for _, iface := range interfaces { if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { continue @@ -344,11 +347,27 @@ func detectNodeIP() string { if !ok || ipNet.IP == nil || ipNet.IP.IsLoopback() { continue } - ipv4 := ipNet.IP.To4() - if ipv4 != nil { - return ipv4.String() + ipv4 := normalizeIPv4(ipNet.IP) + priority := nodeIPPriority(ipv4) + if priority > bestPriority { + bestIP = ipv4.String() + bestPriority = priority + } + if bestPriority == 2 { + return bestIP } } } - return "" + return bestIP +} + +func normalizeIPv4(ip net.IP) net.IP { + if ip == nil { + return nil + } + return ip.To4() +} + +func nodeIPPriority(ip net.IP) int { + return iputil.Score(ip) } diff --git a/openflare_agent/internal/config/config_test.go b/openflare_agent/internal/config/config_test.go index d67ff231..1bba6cb4 100644 --- a/openflare_agent/internal/config/config_test.go +++ b/openflare_agent/internal/config/config_test.go @@ -2,6 +2,7 @@ package config import ( "encoding/json" + "net" "os" "path/filepath" "testing" @@ -326,3 +327,49 @@ func TestInitialAuthToken(t *testing.T) { }) } } + +func TestNodeIPPriority(t *testing.T) { + tests := []struct { + name string + ip string + expected int + }{ + { + name: "public ipv4 preferred", + ip: "8.8.8.8", + expected: 2, + }, + { + name: "private ipv4 fallback", + ip: "10.0.0.8", + expected: 1, + }, + { + name: "link local ignored", + ip: "169.254.1.10", + expected: -1, + }, + { + name: "loopback ignored", + ip: "127.0.0.1", + expected: -1, + }, + { + name: "nil ignored", + ip: "", + expected: -1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var parsed net.IP + if tt.ip != "" { + parsed = net.ParseIP(tt.ip) + } + if got := nodeIPPriority(parsed); got != tt.expected { + t.Fatalf("unexpected priority for %q: got %d want %d", tt.ip, got, tt.expected) + } + }) + } +} diff --git a/openflare_server/controller/agent.go b/openflare_server/controller/agent.go index c342e3a7..f00203d6 100644 --- a/openflare_server/controller/agent.go +++ b/openflare_server/controller/agent.go @@ -24,6 +24,7 @@ func AgentRegister(c *gin.Context) { respondBadRequest(c, "") return } + payload.IP = service.ResolveReportedNodeIP(payload.IP, c.Request.RemoteAddr) var ( result *service.AgentRegistrationResponse @@ -57,6 +58,7 @@ func AgentHeartbeat(c *gin.Context) { respondBadRequest(c, "") return } + payload.IP = service.ResolveReportedNodeIP(payload.IP, c.Request.RemoteAddr) authNode, ok := c.Get("agent_node") if !ok { diff --git a/openflare_server/go.mod b/openflare_server/go.mod index 1b2f989e..b4ce448f 100644 --- a/openflare_server/go.mod +++ b/openflare_server/go.mod @@ -1,6 +1,6 @@ module openflare -// +heroku goVersion go1.18 +// +heroku goVersion go1.24 go 1.24.0 require ( diff --git a/openflare_server/router/api_phase2_test.go b/openflare_server/router/api_phase2_test.go index 1064ac99..3e36cbbb 100644 --- a/openflare_server/router/api_phase2_test.go +++ b/openflare_server/router/api_phase2_test.go @@ -212,10 +212,10 @@ func TestPhase2AgentLifecycle(t *testing.T) { "current_version": "", "last_error": "", } - resp := performAgentJSONRequestWithToken(t, engine, createdNode.AgentToken, http.MethodPost, "/api/agent/nodes/heartbeat", heartbeatPayload) + resp := performAgentJSONRequestWithTokenAndRemote(t, engine, createdNode.AgentToken, http.MethodPost, "/api/agent/nodes/heartbeat", heartbeatPayload, "198.51.100.10:1234") var registeredNode model.Node decodeResponseData(t, resp, ®isteredNode) - if registeredNode.IP != "10.0.0.9" || registeredNode.AgentVersion != "0.1.1" || registeredNode.NodeID != createdNode.NodeID { + if registeredNode.IP != "198.51.100.10" || registeredNode.AgentVersion != "0.1.1" || registeredNode.NodeID != createdNode.NodeID { t.Fatal("expected heartbeat to update node metadata") } if registeredNode.OpenrestyStatus != service.OpenrestyStatusUnhealthy { @@ -330,6 +330,7 @@ func TestPhase2AgentLifecycle(t *testing.T) { restartHeartbeatReq := httptest.NewRequest(http.MethodPost, "/api/agent/nodes/heartbeat", bytes.NewReader(rawHeartbeatPayload)) restartHeartbeatReq.Header.Set("Content-Type", "application/json") restartHeartbeatReq.Header.Set("X-Agent-Token", createdNode.AgentToken) + restartHeartbeatReq.RemoteAddr = "198.51.100.10:1234" restartHeartbeatRecorder := httptest.NewRecorder() engine.ServeHTTP(restartHeartbeatRecorder, restartHeartbeatReq) if restartHeartbeatRecorder.Code != http.StatusOK { @@ -533,7 +534,7 @@ func TestPhase2GlobalDiscoveryRegistration(t *testing.T) { t.Fatal("expected global discovery token to be available") } - resp := performAgentJSONRequestWithToken(t, engine, bootstrap.DiscoveryToken, http.MethodPost, "/api/agent/nodes/register", map[string]any{ + resp := performAgentJSONRequestWithTokenAndRemote(t, engine, bootstrap.DiscoveryToken, http.MethodPost, "/api/agent/nodes/register", map[string]any{ "node_id": "local-node-id", "name": "bulk-edge-1", "ip": "10.0.0.18", @@ -541,7 +542,7 @@ func TestPhase2GlobalDiscoveryRegistration(t *testing.T) { "nginx_version": "1.25.5", "current_version": "", "last_error": "", - }) + }, "203.0.113.18:4321") var registration service.AgentRegistrationResponse decodeResponseData(t, resp, ®istration) if registration.AgentToken == "" || registration.NodeID == "" { @@ -557,9 +558,16 @@ func TestPhase2GlobalDiscoveryRegistration(t *testing.T) { if nodes[0].Name != "bulk-edge-1" || nodes[0].AgentToken != registration.AgentToken || nodes[0].Status != service.NodeStatusOnline { t.Fatal("expected discovered node to be created online with issued agent token") } + if nodes[0].IP != "203.0.113.18" { + t.Fatalf("expected discovered node to keep public source ip, got %s", nodes[0].IP) + } } func performAgentJSONRequestWithToken(t *testing.T, engine http.Handler, token string, method string, path string, body any) apiResponse { + return performAgentJSONRequestWithTokenAndRemote(t, engine, token, method, path, body, "") +} + +func performAgentJSONRequestWithTokenAndRemote(t *testing.T, engine http.Handler, token string, method string, path string, body any, remoteAddr string) apiResponse { t.Helper() var payload []byte var err error @@ -573,6 +581,9 @@ func performAgentJSONRequestWithToken(t *testing.T, engine http.Handler, token s if body != nil { req.Header.Set("Content-Type", "application/json") } + if remoteAddr != "" { + req.RemoteAddr = remoteAddr + } req.Header.Set("X-Agent-Token", token) recorder := httptest.NewRecorder() engine.ServeHTTP(recorder, req) diff --git a/openflare_server/service/node.go b/openflare_server/service/node.go index 67ee647b..9290869f 100644 --- a/openflare_server/service/node.go +++ b/openflare_server/service/node.go @@ -10,6 +10,7 @@ import ( "openflare/common" "openflare/model" "openflare/utils/geoip" + "openflare/utils/geoip/iputil" "strings" "time" ) @@ -332,6 +333,29 @@ func cloneCoordinate(value *float64) *float64 { return &cloned } +func ResolveReportedNodeIP(reportedIP string, remoteAddr string) string { + reported := iputil.NormalizeIP(reportedIP) + remote := iputil.NormalizeRemoteAddr(remoteAddr) + if reported == "" { + return remote + } + if !shouldPreferRemoteNodeIP(reported) { + return reported + } + if isPublicNodeIP(remote) { + return remote + } + return reported +} + +func shouldPreferRemoteNodeIP(ip string) bool { + return !isPublicNodeIP(ip) +} + +func isPublicNodeIP(raw string) bool { + return iputil.IsPublicString(raw) +} + func buildNodeAgentReleaseView(node *model.Node, release *githubReleaseResponse, channel ReleaseChannel) *NodeAgentReleaseInfo { currentVersion := strings.TrimSpace(node.AgentVersion) view := &NodeAgentReleaseInfo{ @@ -428,6 +452,9 @@ func validateAgentNodePayload(payload AgentNodePayload) error { if payload.IP == "" { return errors.New("ip 不能为空") } + if net.ParseIP(payload.IP) == nil { + return errors.New("ip 格式无效") + } if payload.AgentVersion == "" { return errors.New("agent_version 不能为空") } diff --git a/openflare_server/service/node_update_test.go b/openflare_server/service/node_update_test.go index 344310f9..2f4396ce 100644 --- a/openflare_server/service/node_update_test.go +++ b/openflare_server/service/node_update_test.go @@ -328,6 +328,27 @@ func TestUpdateNodeCanChangeIPAndAutoResolveGeo(t *testing.T) { } } +func TestResolveReportedNodeIPPrefersPublicRemoteAddr(t *testing.T) { + resolved := ResolveReportedNodeIP("10.0.0.8", "198.51.100.20:9000") + if resolved != "198.51.100.20" { + t.Fatalf("expected public remote ip to override private reported ip, got %q", resolved) + } +} + +func TestResolveReportedNodeIPKeepsPublicReportedAddr(t *testing.T) { + resolved := ResolveReportedNodeIP("8.8.8.8", "198.51.100.20:9000") + if resolved != "8.8.8.8" { + t.Fatalf("expected reported public ip to be preserved, got %q", resolved) + } +} + +func TestResolveReportedNodeIPKeepsPrivateReportedAddrWhenRemoteIsPrivate(t *testing.T) { + resolved := ResolveReportedNodeIP("10.0.0.8", "172.16.1.10:9000") + if resolved != "10.0.0.8" { + t.Fatalf("expected private reported ip to be preserved when remote is also private, got %q", resolved) + } +} + func TestHeartbeatNodeResolvesGeoMetadataFromIPWhenNotManuallyOverridden(t *testing.T) { setupServiceTestDB(t) withFakeGeoIPProvider(t, &geoip.GeoInfo{ diff --git a/openflare_server/utils/geoip/iputil/iputil.go b/openflare_server/utils/geoip/iputil/iputil.go new file mode 100644 index 00000000..a5f2b1b2 --- /dev/null +++ b/openflare_server/utils/geoip/iputil/iputil.go @@ -0,0 +1,66 @@ +package iputil + +import ( + "net" + "strings" +) + +func NormalizeIP(raw string) string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "" + } + ip := net.ParseIP(trimmed) + if ip == nil { + return "" + } + if ipv4 := ip.To4(); ipv4 != nil { + return ipv4.String() + } + return ip.String() +} + +func NormalizeRemoteAddr(remoteAddr string) string { + trimmed := strings.TrimSpace(remoteAddr) + if trimmed == "" { + return "" + } + if host, _, err := net.SplitHostPort(trimmed); err == nil { + return NormalizeIP(host) + } + return NormalizeIP(trimmed) +} + +func IsPublic(ip net.IP) bool { + if ip == nil { + return false + } + if ipv4 := ip.To4(); ipv4 != nil { + ip = ipv4 + } + if !ip.IsGlobalUnicast() || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsMulticast() || ip.IsUnspecified() { + return false + } + return true +} + +func IsPublicString(raw string) bool { + ip := net.ParseIP(strings.TrimSpace(raw)) + return IsPublic(ip) +} + +func Score(ip net.IP) int { + if ip == nil { + return -1 + } + if ipv4 := ip.To4(); ipv4 != nil { + ip = ipv4 + } + if !ip.IsGlobalUnicast() || ip.IsLoopback() || ip.IsMulticast() || ip.IsUnspecified() { + return -1 + } + if IsPublic(ip) { + return 2 + } + return 1 +} diff --git a/openflare_server/utils/geoip/iputil/iputil_test.go b/openflare_server/utils/geoip/iputil/iputil_test.go new file mode 100644 index 00000000..c05a5724 --- /dev/null +++ b/openflare_server/utils/geoip/iputil/iputil_test.go @@ -0,0 +1,45 @@ +package iputil + +import ( + "net" + "testing" +) + +func TestNormalizeIP(t *testing.T) { + if got := NormalizeIP(" 8.8.8.8 "); got != "8.8.8.8" { + t.Fatalf("unexpected normalized ipv4: %q", got) + } + if got := NormalizeIP("[::1]"); got != "" { + t.Fatalf("expected invalid bracketed host to be rejected, got %q", got) + } +} + +func TestNormalizeRemoteAddr(t *testing.T) { + if got := NormalizeRemoteAddr("203.0.113.10:8443"); got != "203.0.113.10" { + t.Fatalf("unexpected remote addr normalization: %q", got) + } +} + +func TestIsPublic(t *testing.T) { + if !IsPublic(net.ParseIP("8.8.8.8")) { + t.Fatal("expected public ip to be detected") + } + if IsPublic(net.ParseIP("10.0.0.8")) { + t.Fatal("expected private ip to be rejected") + } + if IsPublic(net.ParseIP("127.0.0.1")) { + t.Fatal("expected loopback ip to be rejected") + } +} + +func TestScore(t *testing.T) { + if got := Score(net.ParseIP("8.8.8.8")); got != 2 { + t.Fatalf("unexpected score for public ip: %d", got) + } + if got := Score(net.ParseIP("10.0.0.8")); got != 1 { + t.Fatalf("unexpected score for private ip: %d", got) + } + if got := Score(net.ParseIP("127.0.0.1")); got != -1 { + t.Fatalf("unexpected score for loopback ip: %d", got) + } +}