| 1 | package utils |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "io" |
| 8 | "net" |
| 9 | "net/http" |
| 10 | "strings" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/gosuda/portal-tunnel/v2/types" |
| 14 | ) |
| 15 | |
| 16 | var ( |
| 17 | publicIPEndpoints = []string{ |
| 18 | "https://api.ipify.org", |
| 19 | "https://ifconfig.me/ip", |
| 20 | "https://icanhazip.com", |
| 21 | "https://checkip.amazonaws.com", |
| 22 | } |
| 23 | publicIPv4Endpoints = []string{ |
| 24 | "https://api4.ipify.org", |
| 25 | "https://ipv4.icanhazip.com", |
| 26 | "https://v4.ident.me", |
| 27 | "https://checkip.amazonaws.com", |
| 28 | } |
| 29 | ) |
| 30 | |
| 31 | // ResolvePublicIP attempts to determine the caller's public IP address |
| 32 | // using well-known external services. Returns empty string on failure. |
| 33 | // Best-effort with a short timeout to avoid blocking registration. |
| 34 | func ResolvePublicIP(ctx context.Context) string { |
| 35 | endpoints := append(append([]string{}, publicIPEndpoints...), publicIPv4Endpoints...) |
| 36 | ip, err := resolvePublicIP(ctx, 5*time.Second, 1500*time.Millisecond, false, endpoints...) |
| 37 | if err != nil { |
| 38 | return "" |
| 39 | } |
| 40 | return ip |
| 41 | } |
| 42 | |
| 43 | func ResolvePublicIPv4(ctx context.Context) (string, error) { |
| 44 | endpoints := append(append([]string{}, publicIPv4Endpoints...), publicIPEndpoints...) |
| 45 | return resolvePublicIP(ctx, 15*time.Second, 3*time.Second, true, endpoints...) |
| 46 | } |
| 47 | |
| 48 | func resolvePublicIP(ctx context.Context, totalTimeout, attemptTimeout time.Duration, requireIPv4 bool, endpoints ...string) (string, error) { |
| 49 | ctx, cancel := context.WithTimeout(ctx, totalTimeout) |
| 50 | defer cancel() |
| 51 | |
| 52 | client := DefaultHTTPClient |
| 53 | headers := http.Header{"User-Agent": []string{"portal-tunnel"}} |
| 54 | var lastErr error |
| 55 | |
| 56 | for _, endpoint := range endpoints { |
| 57 | if err := ctx.Err(); err != nil { |
| 58 | lastErr = err |
| 59 | break |
| 60 | } |
| 61 | |
| 62 | requestTimeout := attemptTimeout |
| 63 | if deadline, ok := ctx.Deadline(); ok { |
| 64 | remaining := time.Until(deadline) |
| 65 | if remaining <= 0 { |
| 66 | lastErr = context.DeadlineExceeded |
| 67 | break |
| 68 | } |
| 69 | if requestTimeout <= 0 || requestTimeout > remaining { |
| 70 | requestTimeout = remaining |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | requestCtx, cancelRequest := context.WithTimeout(ctx, requestTimeout) |
| 75 | resp, err := httpDo(requestCtx, client, http.MethodGet, endpoint, nil, headers) |
| 76 | cancelRequest() |
| 77 | if err != nil { |
| 78 | lastErr = err |
| 79 | continue |
| 80 | } |
| 81 | |
| 82 | limitedBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 256)) |
| 83 | _ = resp.Body.Close() |
| 84 | if resp.StatusCode != http.StatusOK { |
| 85 | lastErr = errors.New(resp.Status) |
| 86 | continue |
| 87 | } |
| 88 | if readErr != nil { |
| 89 | lastErr = readErr |
| 90 | continue |
| 91 | } |
| 92 | |
| 93 | candidate := SanitizeReportedIP(string(limitedBody)) |
| 94 | if candidate == "" { |
| 95 | lastErr = errors.New("invalid public ip response") |
| 96 | continue |
| 97 | } |
| 98 | if requireIPv4 { |
| 99 | parsed := net.ParseIP(candidate) |
| 100 | if parsed == nil || parsed.To4() == nil { |
| 101 | lastErr = errors.New("public ip is not ipv4") |
| 102 | continue |
| 103 | } |
| 104 | } |
| 105 | return candidate, nil |
| 106 | } |
| 107 | |
| 108 | if lastErr == nil { |
| 109 | lastErr = errors.New("resolve public ip failed") |
| 110 | } |
| 111 | return "", lastErr |
| 112 | } |
| 113 | |
| 114 | func SanitizeReportedIP(raw string) string { |
| 115 | candidate := strings.TrimSpace(raw) |
| 116 | if candidate == "" { |
| 117 | return "" |
| 118 | } |
| 119 | if net.ParseIP(candidate) == nil { |
| 120 | return "" |
| 121 | } |
| 122 | return candidate |
| 123 | } |
| 124 | |
| 125 | // FetchRelayVersion calls GET /sdk/domain on a relay and returns its release version. |
| 126 | // Returns an empty string on any error (timeout, unreachable, bad response). |
| 127 | func FetchRelayVersion(ctx context.Context, relayURL string) string { |
| 128 | client := NewHTTPClient(WithHTTPTimeout(3 * time.Second)) |
| 129 | resp, err := httpDo(ctx, client, http.MethodGet, relayURL+types.PathSDKDomain, nil, nil) |
| 130 | if err != nil { |
| 131 | return "" |
| 132 | } |
| 133 | defer resp.Body.Close() |
| 134 | if resp.StatusCode != http.StatusOK { |
| 135 | return "" |
| 136 | } |
| 137 | var envelope types.APIEnvelope[types.DomainResponse] |
| 138 | if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil || !envelope.OK { |
| 139 | return "" |
| 140 | } |
| 141 | return envelope.Data.ReleaseVersion |
| 142 | } |