[功能] 优化节点 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` 二选一 | 空 |
| `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. 维护要求
+1 -1
View File
@@ -12,7 +12,7 @@
### 1.2 Agent
* Go 1.23+
* Go 1.24+
* 对 Agent 数据目录有写权限
* 本机模式下可执行 `openresty -t` 与 `openresty -s reload`
* Docker 模式下具备 Docker 执行权限
+1 -1
View File
@@ -20,7 +20,7 @@
`openflare_agent` 继续作为 Go 单体程序:
* Go 1.23+
* Go 1.24+
* 单二进制
* 节点本地执行
* `openresty_path` 优先
+5 -1
View File
@@ -1,3 +1,7 @@
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"
"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)
}
@@ -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)
}
})
}
}
+2
View File
@@ -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 {
+1 -1
View File
@@ -1,6 +1,6 @@
module openflare
// +heroku goVersion go1.18
// +heroku goVersion go1.24
go 1.24.0
require (
+15 -4
View File
@@ -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, &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")
}
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, &registration)
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)
+27
View File
@@ -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 不能为空")
}
@@ -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{
@@ -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)
}
}