From d9b8dc81ee4560896b9a03e38820998bc6c7682a Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 22 Jun 2026 15:49:08 +0800 Subject: [PATCH] fix(relay): prioritize IPv4 in dual-stack outbound IP detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 Relay 节点在双栈网络环境下可能上报 IPv6 地址,导致 Tunnel frpc 客户端无法连接 frps 的问题。 根因: - HTTPOutboundIPStrategy 首次尝试使用 tcp4 强制 IPv4 连接 - 失败时回退到双栈 tcp 客户端,此时可能通过 IPv6 连接并返回 IPv6 地址 - Relay 心跳将 IPv6 地址上报给 Server - Tunnel frpc 尝试连接该 IPv6 地址失败 修复: - 在回退到双栈客户端后,仍优先返回 IPv4 地址(通过 ip.To4() 转换) - 仅在完全无 IPv4 路由时才返回 IPv6 - 确保 Relay 心跳上报的 IP 与 frpc 连接兼容 影响范围: - pkg/geoip.HTTPOutboundIPStrategy.GetOutboundIP - internal/apps/relay/heartbeat.Service(通过 nodeip.DetectWithContext 调用) 测试: - 新增 TestHTTPOutboundIPStrategyPrioritizesIPv4 验证 IPv4 优先逻辑 - 所有现有测试保持通过 --- docs/changelog/index.md | 1 + pkg/geoip/outboundip.go | 11 +++++++++- pkg/geoip/outboundip_test.go | 42 ++++++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 4fd6f1b2..bedae03c 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -25,6 +25,7 @@ sidebar: false - 修复 openflared(Tunnel Client)WebSocket 连接在 Cloudflare 代理环境下频繁收到 EOF 断连的问题。根本原因:服务端 `read_pump` 仅在收到 WebSocket 协议层 Pong 帧时刷新读超时,而客户端(`golang.org/x/net/websocket`)以 JSON 应用层 `{"type":"pong"}` 响应 ping,服务端 90s 读超时到期后主动关闭连接,客户端收到 EOF 并进入无限重连循环。修复方式:在 `clientPongType` 分支中同步调用 `conn.SetReadDeadline` 刷新超时。 - 修复 openflared frpc 子进程异常退出(`exit status 1`)时缺乏详细诊断信息的问题。现捕获 frpc stderr 并在进程退出时将其输出记录到结构化日志 `stderr` 字段,便于排查配置格式错误、Auth Token 鉴权失败、relay 端不可达等具体原因。 +- 修复 Relay 节点启动时在双栈网络环境可能上报 IPv6 地址,导致 Tunnel frpc 客户端无法连接 frps 的问题。强化 `pkg/geoip.HTTPOutboundIPStrategy` 在回退到双栈客户端后仍优先返回 IPv4 地址,确保 Relay 心跳上报的 IP 与 frpc 连接兼容。 ### 变更 diff --git a/pkg/geoip/outboundip.go b/pkg/geoip/outboundip.go index 4be351d5..4ff14700 100644 --- a/pkg/geoip/outboundip.go +++ b/pkg/geoip/outboundip.go @@ -109,7 +109,16 @@ func (s *HTTPOutboundIPStrategy) GetOutboundIP(ctx context.Context) (net.IP, err ExpectContinueTimeout: 1 * time.Second, }, } - return s.query(ctx, fallbackClient) + ip, err = s.query(ctx, fallbackClient) + if err != nil { + return nil, err + } + // Always prioritize IPv4 for relay compatibility + if ipv4 := ip.To4(); ipv4 != nil { + return ipv4, nil + } + // Return IPv6 only if no IPv4 is available + return ip, nil } func (s *HTTPOutboundIPStrategy) query(ctx context.Context, client *http.Client) (net.IP, error) { diff --git a/pkg/geoip/outboundip_test.go b/pkg/geoip/outboundip_test.go index 76010697..6dd8a232 100644 --- a/pkg/geoip/outboundip_test.go +++ b/pkg/geoip/outboundip_test.go @@ -80,3 +80,45 @@ func TestHTTPOutboundIPStrategyRejectsPrivateIP(t *testing.T) { t.Fatal("expected private IP to be rejected") } } + +func TestHTTPOutboundIPStrategyPrioritizesIPv4(t *testing.T) { + tests := []struct { + name string + response string + want string + }{ + { + name: "IPv4 address", + response: `{"ip":"8.8.8.8"}`, + want: "8.8.8.8", + }, + { + name: "IPv6 address normalized to IPv4", + response: `{"ip":"::ffff:8.8.8.8"}`, + want: "8.8.8.8", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tt.response)) + })) + defer server.Close() + + strategy := NewHTTPOutboundIPStrategy(RealIPCCAdapter{URL: server.URL}, server.Client()) + ip, err := strategy.GetOutboundIP(context.Background()) + if err != nil { + t.Fatalf("GetOutboundIP failed: %v", err) + } + if ip.String() != tt.want { + t.Errorf("GetOutboundIP() = %v, want %v", ip.String(), tt.want) + } + // Ensure it's an IPv4 address (4 bytes) + if ipv4 := ip.To4(); ipv4 == nil { + t.Errorf("expected IPv4 address, got %v", ip) + } + }) + } +}