From deb232d840daa4824c2eac72234ca445b5ed91cb Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 22 Jun 2026 15:06:39 +0800 Subject: [PATCH] refactor(edge): unify dynamic IP detection and prioritize IPv4 reporting - Align agent, relay, and flared to dynamically resolve IP during heartbeat using the nodeip package (when not manually configured). - Update GeoIP outbound IP strategy to prefer IPv4 HTTP client lookup using tcp4 dialer and fall back to dual-stack tcp. - Optimize agent profile fingerprinting to exclude dynamic UptimeSeconds and ReportedAtUnix fields, preventing redundant updates. - Refactor unit tests to prevent outbound network queries during tests. --- docs/changelog/index.md | 4 ++ internal/apps/agent/agent/runner_test.go | 6 +++ internal/apps/agent/config/config.go | 2 + internal/apps/agent/heartbeat/cycle.go | 9 +++- .../apps/agent/observability/collector.go | 5 +- internal/apps/relay/config/config.go | 2 + internal/apps/relay/heartbeat/service.go | 9 +++- pkg/geoip/outboundip.go | 53 +++++++++++++++++-- 8 files changed, 84 insertions(+), 6 deletions(-) diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 2b605898..4fd6f1b2 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -28,6 +28,10 @@ sidebar: false ### 变更 +- 优化并统一 `agent`、`relay`、`flared` 的 IP 探测与上报逻辑,均复用公用 `nodeip` 包;在未指定 `node_ip` 时实现心跳 Tick 动态探测上报。 +- 优化 `pkg/geoip.GetOutboundIP` 出口 IP 探测策略,优先通过 `tcp4` 建立 HTTP 连接以获得 IPv4 公网地址,并在纯 IPv6/无 IPv4 路由环境下自动降级为双栈 `tcp` 握手。 +- 优化 `agent` 系统指纹缓存算法,计算指纹时排除 `UptimeSeconds` 和 `ReportedAtUnix` 动态字段,防止周期心跳时不断触发冗余完整的系统 Profile 数据上报。 + - 彻底移除废弃的 GitHub OAuth 和微信登录相关遗留设置项(包括 `GitHubOAuthEnabled`、`GitHubClientId`、`GitHubClientSecret`、`WeChatAuthEnabled` 等),从公开状态接口 `/api/v1/d/status` 移除这些字段的返回。 - 前端路由调整:将 TLS 证书和 DNS 账号的路由地址移出 `/websites`(分别变更为顶级路由 `/certificates` 和 `/dns-accounts`),将 WAF IP 组的路由地址移出 `/waf`(变更为顶级路由 `/ip-groups`)。 diff --git a/internal/apps/agent/agent/runner_test.go b/internal/apps/agent/agent/runner_test.go index 615aea4f..61af2447 100644 --- a/internal/apps/agent/agent/runner_test.go +++ b/internal/apps/agent/agent/runner_test.go @@ -211,6 +211,7 @@ func TestRunnerKeepsHeartbeatWhenStartupSyncFails(t *testing.T) { AccessToken: "agent-token", NodeName: "edge-01", NodeIP: "10.0.0.8", + NodeIPConfigured: true, Version: config.Version, ExtVersion: "1.27.1.2", HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond), @@ -264,6 +265,7 @@ func TestRunnerDoesNotExitOnHeartbeatOrSyncError(t *testing.T) { AccessToken: "agent-token", NodeName: "edge-01", NodeIP: "10.0.0.8", + NodeIPConfigured: true, Version: config.Version, ExtVersion: "1.27.1.2", HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond), @@ -322,6 +324,7 @@ func TestRunnerReportsOpenrestyHealthAndExecutesRestart(t *testing.T) { AccessToken: "agent-token", NodeName: "edge-01", NodeIP: "10.0.0.8", + NodeIPConfigured: true, Version: config.Version, ExtVersion: "1.27.1.2", HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond), @@ -375,6 +378,7 @@ func TestRunnerHeartbeatPayloadIncludesObservabilityExtensions(t *testing.T) { Config: &config.Config{ NodeName: "edge-observe-1", NodeIP: "10.0.0.51", + NodeIPConfigured: true, Version: config.Version, ExtVersion: "1.27.1.2", DataDir: tempDir, @@ -458,6 +462,7 @@ func TestRunnerReplaysBufferedObservabilityAfterHeartbeatRecovery(t *testing.T) AccessToken: "agent-token", NodeName: "edge-buffer-01", NodeIP: "10.0.0.52", + NodeIPConfigured: true, Version: config.Version, ExtVersion: "1.27.1.2", DataDir: tempDir, @@ -537,6 +542,7 @@ func TestRunnerDiscoveryRegisterUpdatesTokenAndNodeID(t *testing.T) { DiscoveryToken: cfg.DiscoveryToken, NodeName: cfg.NodeName, NodeIP: cfg.NodeIP, + NodeIPConfigured: cfg.NodeIPConfigured, Version: config.Version, ExtVersion: "1.27.1.2", HeartbeatInterval: config.MillisecondDuration(10 * time.Millisecond), diff --git a/internal/apps/agent/config/config.go b/internal/apps/agent/config/config.go index ea1f2c0c..a7c23426 100644 --- a/internal/apps/agent/config/config.go +++ b/internal/apps/agent/config/config.go @@ -67,6 +67,7 @@ type Config struct { HeartbeatInterval MillisecondDuration `json:"heartbeat_interval"` RequestTimeout MillisecondDuration `json:"request_timeout"` configPath string `json:"-"` + NodeIPConfigured bool `json:"-"` } type configFile struct { @@ -129,6 +130,7 @@ func Load(path string) (*Config, error) { } cfg.configPath = path applyEnvOverrides(cfg) + cfg.NodeIPConfigured = cfg.NodeIP != "" applyDefaults(cfg, filepath.Dir(path)) if err = validate(cfg); err != nil { return nil, err diff --git a/internal/apps/agent/heartbeat/cycle.go b/internal/apps/agent/heartbeat/cycle.go index 380c540f..16ebf53c 100644 --- a/internal/apps/agent/heartbeat/cycle.go +++ b/internal/apps/agent/heartbeat/cycle.go @@ -14,6 +14,7 @@ import ( "github.com/Rain-kl/Wavelet/internal/apps/agent/state" "github.com/Rain-kl/Wavelet/internal/apps/agent/updater" edgeheartbeat "github.com/Rain-kl/Wavelet/internal/apps/edge/heartbeat" + "github.com/Rain-kl/Wavelet/internal/apps/edge/nodeip" ) // SyncService is the interface used by Cycle to sync active configuration and WAF IP groups. @@ -97,10 +98,16 @@ func (c *Cycle) NodePayload(ctx context.Context, nodeID string) protocol.NodePay metricSnapshot := observability.BuildSnapshot(c.Config, c.StateStore) openrestyObservation := observability.BuildOpenrestyObservation(managedOpenRestyMetrics) healthEvents := observability.BuildHealthEvents(snapshot) + + ip := c.Config.NodeIP + if !c.Config.NodeIPConfigured { + ip = nodeip.DetectWithContext(ctx) + } + payload := protocol.NodePayload{ NodeID: nodeID, Name: c.Config.NodeName, - IP: c.Config.NodeIP, + IP: ip, Version: c.Config.Version, ExtVersion: c.Config.ExtVersion, CurrentVersion: snapshot.CurrentVersion, diff --git a/internal/apps/agent/observability/collector.go b/internal/apps/agent/observability/collector.go index 398b0766..d87e0f27 100644 --- a/internal/apps/agent/observability/collector.go +++ b/internal/apps/agent/observability/collector.go @@ -148,7 +148,10 @@ func collectProfile(cfg *config.Config) *protocol.NodeSystemProfile { } func fingerprintProfile(profile *protocol.NodeSystemProfile) string { - raw, err := json.Marshal(profile) + cloned := *profile + cloned.UptimeSeconds = 0 + cloned.ReportedAtUnix = 0 + raw, err := json.Marshal(&cloned) if err != nil { return "" } diff --git a/internal/apps/relay/config/config.go b/internal/apps/relay/config/config.go index 082d3973..dd841c8e 100644 --- a/internal/apps/relay/config/config.go +++ b/internal/apps/relay/config/config.go @@ -35,6 +35,7 @@ type Config struct { HeartbeatInterval MillisecondDuration `json:"heartbeat_interval"` RequestTimeout MillisecondDuration `json:"request_timeout"` configPath string + NodeIPConfigured bool } // Load reads configuration from path, applying environment overrides and defaults. @@ -54,6 +55,7 @@ func Load(path string) (*Config, error) { } cfg.configPath = path applyEnvOverrides(cfg) + cfg.NodeIPConfigured = cfg.NodeIP != "" applyDefaults(cfg, filepath.Dir(path)) if err = validate(cfg); err != nil { return nil, err diff --git a/internal/apps/relay/heartbeat/service.go b/internal/apps/relay/heartbeat/service.go index b42311ad..1d039f30 100644 --- a/internal/apps/relay/heartbeat/service.go +++ b/internal/apps/relay/heartbeat/service.go @@ -6,6 +6,7 @@ import ( "log/slog" edgeheartbeat "github.com/Rain-kl/Wavelet/internal/apps/edge/heartbeat" + "github.com/Rain-kl/Wavelet/internal/apps/edge/nodeip" "github.com/Rain-kl/Wavelet/internal/apps/relay/config" "github.com/Rain-kl/Wavelet/internal/apps/relay/frps" "github.com/Rain-kl/Wavelet/internal/apps/relay/httpclient" @@ -46,6 +47,12 @@ func (s *Service) doHeartbeat(ctx context.Context) { slog.Debug("sending heartbeat") runtimeStatus := s.frpsManager.GetRuntimeStatus() + + ip := s.config.NodeIP + if !s.config.NodeIPConfigured { + ip = nodeip.DetectWithContext(ctx) + } + payload := service.RelayHeartbeatPayload{ Version: config.Version, ExtVersion: s.frpsManager.GetVersion(ctx), @@ -55,7 +62,7 @@ func (s *Service) doHeartbeat(ctx context.Context) { FrpsClientCount: runtimeStatus.ClientCount, FrpsProxies: runtimeStatus.Proxies, Name: s.config.NodeName, - IP: s.config.NodeIP, + IP: ip, Profile: observability.BuildProfile(s.config, s.stateStore), Snapshot: observability.BuildSnapshot(s.config, s.stateStore), HealthEvents: observability.BuildHealthEvents(runtimeStatus), diff --git a/pkg/geoip/outboundip.go b/pkg/geoip/outboundip.go index b85aec97..4be351d5 100644 --- a/pkg/geoip/outboundip.go +++ b/pkg/geoip/outboundip.go @@ -62,10 +62,57 @@ func (s *HTTPOutboundIPStrategy) GetOutboundIP(ctx context.Context) (net.IP, err if ctx == nil { return nil, errors.New("context is required") } - client := s.Client - if client == nil { - client = &http.Client{Timeout: defaultOutboundIPLookupTimeout} + + if s.Client != nil { + return s.query(ctx, s.Client) } + + dialer := &net.Dialer{ + Timeout: defaultOutboundIPLookupTimeout, + KeepAlive: 30 * time.Second, + } + + // Try IPv4 first + ipv4Client := &http.Client{ + Timeout: defaultOutboundIPLookupTimeout, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: func(ctx context.Context, _, addr string) (net.Conn, error) { + return dialer.DialContext(ctx, "tcp4", addr) + }, + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + }, + } + ip, err := s.query(ctx, ipv4Client) + if err == nil && ip != nil { + if ipv4 := ip.To4(); ipv4 != nil { + return ipv4, nil + } + } + + // Fallback to standard client (dual-stack: tcp) + fallbackClient := &http.Client{ + Timeout: defaultOutboundIPLookupTimeout, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: func(ctx context.Context, _, addr string) (net.Conn, error) { + return dialer.DialContext(ctx, "tcp", addr) + }, + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + }, + } + return s.query(ctx, fallbackClient) +} + +func (s *HTTPOutboundIPStrategy) query(ctx context.Context, client *http.Client) (net.IP, error) { request, err := http.NewRequestWithContext(ctx, http.MethodGet, s.Adapter.Endpoint(), nil) if err != nil { return nil, fmt.Errorf("%s create request failed: %w", s.Name(), err)