[功能] 优化节点 IP 解析逻辑,优先使用公网地址并添加相关测试

This commit is contained in:
ryan
2026-03-30 13:09:30 +08:00
parent 39d54c2fe4
commit 894745d43a
13 changed files with 256 additions and 13 deletions
+2 -1
View File
@@ -131,7 +131,7 @@ Agent 当前支持:
| `agent_token` | 节点专属认证 Token | 与 `discovery_token` 二选一 | 空 | | `agent_token` | 节点专属认证 Token | 与 `discovery_token` 二选一 | 空 |
| `discovery_token` | 首次自动注册使用的全局 Token | 与 `agent_token` 二选一 | 空 | | `discovery_token` | 首次自动注册使用的全局 Token | 与 `agent_token` 二选一 | 空 |
| `node_name` | 节点名称 | 否 | 自动使用主机名 | | `node_name` | 节点名称 | 否 | 自动使用主机名 |
| `node_ip` | 节点 IP | 否 | 自动探测 | | `node_ip` | 节点 IP | 否 | 自动探测,优先选择公网 IPv4;仅无公网地址时退回可用内网地址 |
| `openresty_path` | 本机 OpenResty 路径 | 否 | 空,未设置时走 Docker 模式 | | `openresty_path` | 本机 OpenResty 路径 | 否 | 空,未设置时走 Docker 模式 |
| `openresty_container_name` | Docker 模式下的容器名 | 否 | `openflare-openresty` | | `openresty_container_name` | Docker 模式下的容器名 | 否 | `openflare-openresty` |
| `openresty_docker_image` | Docker 模式下的镜像 | 否 | `openresty/openresty:alpine` | | `openresty_docker_image` | Docker 模式下的镜像 | 否 | `openresty/openresty:alpine` |
@@ -155,6 +155,7 @@ Agent 当前支持:
* `agent_token` 与 `discovery_token` 不能同时为空 * `agent_token` 与 `discovery_token` 不能同时为空
* `heartbeat_interval` 与 `request_timeout` 支持毫秒整数或 Go duration 字符串 * `heartbeat_interval` 与 `request_timeout` 支持毫秒整数或 Go duration 字符串
* 未配置 `openresty_path` 时默认使用 Docker OpenResty 模式 * 未配置 `openresty_path` 时默认使用 Docker OpenResty 模式
* Agent 自动探测到私网 `node_ip` 时,Server 会在注册/心跳阶段优先保留 Agent 直连来源的公网地址,避免 NAT/多网卡场景误登记内网网卡地址
## 3. 维护要求 ## 3. 维护要求
+1 -1
View File
@@ -12,7 +12,7 @@
### 1.2 Agent ### 1.2 Agent
* Go 1.23+ * Go 1.24+
* 对 Agent 数据目录有写权限 * 对 Agent 数据目录有写权限
* 本机模式下可执行 `openresty -t` 与 `openresty -s reload` * 本机模式下可执行 `openresty -t` 与 `openresty -s reload`
* Docker 模式下具备 Docker 执行权限 * Docker 模式下具备 Docker 执行权限
+1 -1
View File
@@ -20,7 +20,7 @@
`openflare_agent` 继续作为 Go 单体程序: `openflare_agent` 继续作为 Go 单体程序:
* Go 1.23+ * Go 1.24+
* 单二进制 * 单二进制
* 节点本地执行 * 节点本地执行
* `openresty_path` 优先 * `openresty_path` 优先
+5 -1
View File
@@ -1,3 +1,7 @@
module openflare-agent module openflare-agent
go 1.23.0 go 1.24.0
require openflare v0.0.0
replace openflare => ../openflare_server
+23 -4
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"net" "net"
"openflare/utils/geoip/iputil"
"os" "os"
pathpkg "path" pathpkg "path"
"path/filepath" "path/filepath"
@@ -331,6 +332,8 @@ func detectNodeIP() string {
if err != nil { if err != nil {
return "" return ""
} }
bestIP := ""
bestPriority := -1
for _, iface := range interfaces { for _, iface := range interfaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue continue
@@ -344,11 +347,27 @@ func detectNodeIP() string {
if !ok || ipNet.IP == nil || ipNet.IP.IsLoopback() { if !ok || ipNet.IP == nil || ipNet.IP.IsLoopback() {
continue continue
} }
ipv4 := ipNet.IP.To4() ipv4 := normalizeIPv4(ipNet.IP)
if ipv4 != nil { priority := nodeIPPriority(ipv4)
return ipv4.String() 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)
} }
@@ -2,6 +2,7 @@ package config
import ( import (
"encoding/json" "encoding/json"
"net"
"os" "os"
"path/filepath" "path/filepath"
"testing" "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)
}
})
}
}
+2
View File
@@ -24,6 +24,7 @@ func AgentRegister(c *gin.Context) {
respondBadRequest(c, "") respondBadRequest(c, "")
return return
} }
payload.IP = service.ResolveReportedNodeIP(payload.IP, c.Request.RemoteAddr)
var ( var (
result *service.AgentRegistrationResponse result *service.AgentRegistrationResponse
@@ -57,6 +58,7 @@ func AgentHeartbeat(c *gin.Context) {
respondBadRequest(c, "") respondBadRequest(c, "")
return return
} }
payload.IP = service.ResolveReportedNodeIP(payload.IP, c.Request.RemoteAddr)
authNode, ok := c.Get("agent_node") authNode, ok := c.Get("agent_node")
if !ok { if !ok {
+1 -1
View File
@@ -1,6 +1,6 @@
module openflare module openflare
// +heroku goVersion go1.18 // +heroku goVersion go1.24
go 1.24.0 go 1.24.0
require ( require (
+15 -4
View File
@@ -212,10 +212,10 @@ func TestPhase2AgentLifecycle(t *testing.T) {
"current_version": "", "current_version": "",
"last_error": "", "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 var registeredNode model.Node
decodeResponseData(t, resp, &registeredNode) decodeResponseData(t, resp, &registeredNode)
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") t.Fatal("expected heartbeat to update node metadata")
} }
if registeredNode.OpenrestyStatus != service.OpenrestyStatusUnhealthy { 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 := httptest.NewRequest(http.MethodPost, "/api/agent/nodes/heartbeat", bytes.NewReader(rawHeartbeatPayload))
restartHeartbeatReq.Header.Set("Content-Type", "application/json") restartHeartbeatReq.Header.Set("Content-Type", "application/json")
restartHeartbeatReq.Header.Set("X-Agent-Token", createdNode.AgentToken) restartHeartbeatReq.Header.Set("X-Agent-Token", createdNode.AgentToken)
restartHeartbeatReq.RemoteAddr = "198.51.100.10:1234"
restartHeartbeatRecorder := httptest.NewRecorder() restartHeartbeatRecorder := httptest.NewRecorder()
engine.ServeHTTP(restartHeartbeatRecorder, restartHeartbeatReq) engine.ServeHTTP(restartHeartbeatRecorder, restartHeartbeatReq)
if restartHeartbeatRecorder.Code != http.StatusOK { if restartHeartbeatRecorder.Code != http.StatusOK {
@@ -533,7 +534,7 @@ func TestPhase2GlobalDiscoveryRegistration(t *testing.T) {
t.Fatal("expected global discovery token to be available") 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", "node_id": "local-node-id",
"name": "bulk-edge-1", "name": "bulk-edge-1",
"ip": "10.0.0.18", "ip": "10.0.0.18",
@@ -541,7 +542,7 @@ func TestPhase2GlobalDiscoveryRegistration(t *testing.T) {
"nginx_version": "1.25.5", "nginx_version": "1.25.5",
"current_version": "", "current_version": "",
"last_error": "", "last_error": "",
}) }, "203.0.113.18:4321")
var registration service.AgentRegistrationResponse var registration service.AgentRegistrationResponse
decodeResponseData(t, resp, &registration) decodeResponseData(t, resp, &registration)
if registration.AgentToken == "" || registration.NodeID == "" { 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 { 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") 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 { 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() t.Helper()
var payload []byte var payload []byte
var err error var err error
@@ -573,6 +581,9 @@ func performAgentJSONRequestWithToken(t *testing.T, engine http.Handler, token s
if body != nil { if body != nil {
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
} }
if remoteAddr != "" {
req.RemoteAddr = remoteAddr
}
req.Header.Set("X-Agent-Token", token) req.Header.Set("X-Agent-Token", token)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req) engine.ServeHTTP(recorder, req)
+27
View File
@@ -10,6 +10,7 @@ import (
"openflare/common" "openflare/common"
"openflare/model" "openflare/model"
"openflare/utils/geoip" "openflare/utils/geoip"
"openflare/utils/geoip/iputil"
"strings" "strings"
"time" "time"
) )
@@ -332,6 +333,29 @@ func cloneCoordinate(value *float64) *float64 {
return &cloned 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 { func buildNodeAgentReleaseView(node *model.Node, release *githubReleaseResponse, channel ReleaseChannel) *NodeAgentReleaseInfo {
currentVersion := strings.TrimSpace(node.AgentVersion) currentVersion := strings.TrimSpace(node.AgentVersion)
view := &NodeAgentReleaseInfo{ view := &NodeAgentReleaseInfo{
@@ -428,6 +452,9 @@ func validateAgentNodePayload(payload AgentNodePayload) error {
if payload.IP == "" { if payload.IP == "" {
return errors.New("ip 不能为空") return errors.New("ip 不能为空")
} }
if net.ParseIP(payload.IP) == nil {
return errors.New("ip 格式无效")
}
if payload.AgentVersion == "" { if payload.AgentVersion == "" {
return errors.New("agent_version 不能为空") return errors.New("agent_version 不能为空")
} }
@@ -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) { func TestHeartbeatNodeResolvesGeoMetadataFromIPWhenNotManuallyOverridden(t *testing.T) {
setupServiceTestDB(t) setupServiceTestDB(t)
withFakeGeoIPProvider(t, &geoip.GeoInfo{ withFakeGeoIPProvider(t, &geoip.GeoInfo{
@@ -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
}
@@ -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)
}
}