diff --git a/backend/utils/ip/ip.go b/backend/utils/ip/ip.go index b67f280..b39a029 100644 --- a/backend/utils/ip/ip.go +++ b/backend/utils/ip/ip.go @@ -1,6 +1,7 @@ package ip import ( + "context" "errors" "io" "net" @@ -14,6 +15,8 @@ import ( const ( publicIPv4Endpoint = "https://api.ipify.org" publicIPv6Endpoint = "https://api6.ipify.org" + publicIPv4Fallback = "https://ipv4.icanhazip.com" + publicIPv6Fallback = "https://ipv6.icanhazip.com" maxIPResponseBytes = 64 ) @@ -33,14 +36,14 @@ func GetIp() string { } func GetIPv4() string { - return getPublicIP(publicIPv4Endpoint, false) + return getPublicIP([]string{publicIPv4Endpoint, publicIPv4Fallback}, false) } func GetIPv6() string { - return getPublicIP(publicIPv6Endpoint, true) + return getPublicIP([]string{publicIPv6Endpoint, publicIPv6Fallback}, true) } -func getPublicIP(endpoint string, wantIPv6 bool) string { +func getPublicIP(endpoints []string, wantIPv6 bool) string { cacheMu.Lock() if wantIPv6 && cachedIPv6 != "" { value := cachedIPv6 @@ -54,11 +57,9 @@ func getPublicIP(endpoint string, wantIPv6 bool) string { } cacheMu.Unlock() - request, err := http.NewRequest(http.MethodGet, endpoint, nil) - if err != nil { - return "" - } - value, err := fetchPublicIPForFamily(publicIPClient, request, wantIPv6) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + value, err := fetchPublicIPFromEndpoints(ctx, publicIPClient, endpoints, wantIPv6) if err != nil { return "" } @@ -77,6 +78,34 @@ func getPublicIP(endpoint string, wantIPv6 bool) string { return cachedIPv4 } +// 多个 HTTPS 探测共享总超时,先返回有效公网地址,并取消其余请求。 +func fetchPublicIPFromEndpoints(ctx context.Context, client *http.Client, endpoints []string, wantIPv6 bool) (string, error) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + results := make(chan string, len(endpoints)) + for _, endpoint := range endpoints { + go func(endpoint string) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + value := "" + if err == nil { + value, _ = fetchPublicIPForFamily(client, request, wantIPv6) + } + results <- value + }(endpoint) + } + for range endpoints { + select { + case value := <-results: + if value != "" { + return value, nil + } + case <-ctx.Done(): + return "", ctx.Err() + } + } + return "", errors.New("public IP services are unavailable") +} + func fetchPublicIPForFamily(client *http.Client, request *http.Request, wantIPv6 bool) (string, error) { value, err := fetchPublicIP(client, request) if err != nil { diff --git a/backend/utils/ip/ip_test.go b/backend/utils/ip/ip_test.go index 960b839..27b6571 100644 --- a/backend/utils/ip/ip_test.go +++ b/backend/utils/ip/ip_test.go @@ -1,6 +1,7 @@ package ip import ( + "context" "net/http" "net/http/httptest" "strings" @@ -8,6 +9,50 @@ import ( "time" ) +func TestPublicIPFallbackCancelsUnavailableService(t *testing.T) { + started := make(chan struct{}) + cancelled := make(chan struct{}) + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/slow" { + close(started) + <-r.Context().Done() + close(cancelled) + return + } + <-started + _, _ = w.Write([]byte("8.8.4.4\n")) + })) + defer server.Close() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + value, err := fetchPublicIPFromEndpoints(ctx, server.Client(), []string{server.URL + "/slow", server.URL + "/fallback"}, false) + if err != nil || value != "8.8.4.4" { + t.Fatalf("fallback address = %q, err = %v", value, err) + } + select { + case <-cancelled: + case <-ctx.Done(): + t.Fatal("unused public IP request was not cancelled") + } +} + +func TestPublicIPFallbackRejectsPrivateAddressAndHonorsDeadline(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/private" { + _, _ = w.Write([]byte("192.168.1.10")) + return + } + <-r.Context().Done() + })) + defer server.Close() + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + value, err := fetchPublicIPFromEndpoints(ctx, server.Client(), []string{server.URL + "/private", server.URL + "/slow"}, false) + if err == nil || value != "" { + t.Fatalf("invalid fallback address = %q, err = %v", value, err) + } +} + func TestFetchPublicIPAcceptsPublicAddress(t *testing.T) { server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("8.8.8.8\n"))