package ipfetcher import ( "context" "fmt" "io" "log/slog" "net" "net/http" "strings" "time" ) var defaultProviders = []string{ "https://api.ipify.org", "https://ifconfig.me/ip", "https://icanhazip.com", } var httpClient = &http.Client{Timeout: 5 * time.Second} // Result holds the outcome of a successful public IP fetch. type Result struct { IP string // The detected public IP. Source string // The URL that returned the IP (e.g. https://api.ipify.org). } // FetchPublicIPFrom fetches the public IP by trying each URL in order. // If urls is nil or empty, the built-in default providers are used. func FetchPublicIPFrom(ctx context.Context, urls []string) (*Result, error) { slog.Debug("fetching public IP...") if len(urls) == 0 { urls = defaultProviders } var lastErr error for _, url := range urls { ip, err := fetchFrom(ctx, url) if err != nil { lastErr = err continue } slog.Default().Debug("public IP fetched", "ip", ip, "source", url) return &Result{IP: ip, Source: url}, nil } return nil, fmt.Errorf("all IP providers failed, last error: %w", lastErr) } func fetchFrom(ctx context.Context, url string) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return "", err } resp, err := httpClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("%s returned status %d", url, resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, 256)) if err != nil { return "", err } ip := strings.TrimSpace(string(body)) if net.ParseIP(ip) == nil { return "", fmt.Errorf("%s returned invalid IP: %q", url, ip) } return ip, nil }