diff --git a/internal/apps/edge/nodeip/nodeip.go b/internal/apps/edge/nodeip/nodeip.go index 771348c4..6f6ac0ab 100644 --- a/internal/apps/edge/nodeip/nodeip.go +++ b/internal/apps/edge/nodeip/nodeip.go @@ -4,6 +4,7 @@ package nodeip import ( "context" "net" + "sync" "time" "github.com/Rain-kl/Wavelet/pkg/geoip" @@ -13,6 +14,7 @@ import ( const ( outboundIPLookupTimeout = 5 * time.Second publicIPPriorityScore = 2 // matches iputil.Score for public IPv4 addresses + ipCacheTTL = 10 * time.Minute ) // LookupOutboundIP and LookupLocalIP are the provider functions used to detect the node's outbound/local IP. @@ -20,22 +22,39 @@ const ( var ( LookupOutboundIP = geoip.GetOutboundIP LookupLocalIP = DetectLocal + + cacheMu sync.RWMutex + cachedIP string + lastDetected time.Time ) // Detect returns the best available outbound or local IPv4 address for this node. func Detect() string { - if ip := detectOutbound(context.Background()); ip != "" { - return ip - } - return LookupLocalIP() + return DetectWithContext(context.Background()) } // DetectWithContext returns the best available outbound or local IPv4 address, respecting ctx for cancellation. func DetectWithContext(ctx context.Context) string { - if ip := detectOutbound(ctx); ip != "" { + cacheMu.RLock() + if cachedIP != "" && time.Since(lastDetected) < ipCacheTTL { + ip := cachedIP + cacheMu.RUnlock() return ip } - return LookupLocalIP() + cacheMu.RUnlock() + + var ip string + if ip = detectOutbound(ctx); ip == "" { + ip = LookupLocalIP() + } + + if ip != "" { + cacheMu.Lock() + cachedIP = ip + lastDetected = time.Now() + cacheMu.Unlock() + } + return ip } func detectOutbound(ctx context.Context) string { diff --git a/pkg/geoip/outboundip.go b/pkg/geoip/outboundip.go index 4ff14700..6f7a82ff 100644 --- a/pkg/geoip/outboundip.go +++ b/pkg/geoip/outboundip.go @@ -187,10 +187,46 @@ func (a RealIPCCAdapter) DecodeIP(reader io.Reader) (net.IP, error) { return ip, nil } +// PlainTextIPAdapter decodes public IP responses from raw plain text endpoints. +type PlainTextIPAdapter struct { + ProviderName string + URL string +} + +// Name returns the provider name. +func (a PlainTextIPAdapter) Name() string { + return a.ProviderName +} + +// Endpoint returns the plain text API URL. +func (a PlainTextIPAdapter) Endpoint() string { + return a.URL +} + +// DecodeIP parses a plain text response into a public IP address. +func (a PlainTextIPAdapter) DecodeIP(reader io.Reader) (net.IP, error) { + body, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + ipStr := strings.TrimSpace(string(body)) + ip := net.ParseIP(ipStr) + if ip == nil { + return nil, fmt.Errorf("invalid plain text IP %q", ipStr) + } + if ipv4 := ip.To4(); ipv4 != nil { + return ipv4, nil + } + return ip, nil +} + // DefaultOutboundIPStrategies returns the built-in public egress IP lookup strategies. func DefaultOutboundIPStrategies() []OutboundIPStrategy { return []OutboundIPStrategy{ NewRealIPCCOutboundIPStrategy(), + NewHTTPOutboundIPStrategy(PlainTextIPAdapter{ProviderName: "ifconfig.me", URL: "https://ifconfig.me"}, nil), + NewHTTPOutboundIPStrategy(PlainTextIPAdapter{ProviderName: "ip.sb", URL: "https://api.ip.sb/ip"}, nil), + NewHTTPOutboundIPStrategy(PlainTextIPAdapter{ProviderName: "icanhazip.com", URL: "https://icanhazip.com"}, nil), } }