package egressutil import ( "context" "encoding/json" "fmt" "net" "net/netip" "net/url" "os/exec" "strconv" "strings" "sync" "time" ) var ( curlPathOnce sync.Once curlPath string wgetPathOnce sync.Once wgetPath string ) func ParseIPFromBody(raw string) (string, error) { s := strings.TrimSpace(raw) if s == "" { return "", fmt.Errorf("empty response") } var obj map[string]any if strings.HasPrefix(s, "{") && strings.HasSuffix(s, "}") { if err := json.Unmarshal([]byte(s), &obj); err == nil && obj != nil { keys := []string{ "ip", "origin", "query", "your_ip", "client_ip", "ip_addr", "ip_address", "address", } for _, key := range keys { if v := strings.TrimSpace(AnyToString(obj[key])); v != "" { if i := strings.Index(v, ","); i >= 0 { v = strings.TrimSpace(v[:i]) } if addr, err := netip.ParseAddr(v); err == nil { return addr.String(), nil } } } } } parts := strings.FieldsFunc(s, func(r rune) bool { switch r { case '\n', '\r', '\t', ' ', ',', ';', '[', ']', '"', '\'': return true default: return false } }) for _, part := range parts { v := strings.TrimSpace(strings.TrimPrefix(part, "ip=")) if v == "" { continue } if addr, err := netip.ParseAddr(v); err == nil { return addr.String(), nil } } return "", fmt.Errorf("cannot parse egress ip from response: %q", s) } func ParseGeoResponse(raw string) (string, string, error) { var obj map[string]any if err := json.Unmarshal([]byte(raw), &obj); err != nil { return "", "", err } if v, ok := obj["success"]; ok { if b, ok := v.(bool); ok && !b { msg := strings.TrimSpace(AnyToString(obj["message"])) if msg == "" { msg = "geo lookup reported success=false" } return "", "", fmt.Errorf("%s", msg) } } if status := strings.ToLower(strings.TrimSpace(AnyToString(obj["status"]))); status == "fail" { msg := strings.TrimSpace(AnyToString(obj["message"])) if msg == "" { msg = "geo lookup status=fail" } return "", "", fmt.Errorf("%s", msg) } code := NormalizeCountryCode(FirstNonEmptyAny(obj, "country_code", "countryCode", "cc")) name := strings.TrimSpace(FirstNonEmptyAny(obj, "country_name", "country", "countryName")) if code == "" && name == "" { return "", "", fmt.Errorf("geo response does not contain country fields") } return code, name, nil } func NormalizeCountryCode(raw string) string { cc := strings.ToUpper(strings.TrimSpace(raw)) if len(cc) != 2 { return "" } for _, ch := range cc { if ch < 'A' || ch > 'Z' { return "" } } return cc } func IPEndpoints(envRaw string) []string { raw := strings.TrimSpace(strings.ReplaceAll(envRaw, ";", ",")) if raw == "" { return []string{ "https://api64.ipify.org", "https://api.ipify.org", "https://ifconfig.me/ip", } } return ParseURLList(raw) } func GeoEndpointsForIP(envRaw, ip string) []string { raw := strings.TrimSpace(strings.ReplaceAll(envRaw, ";", ",")) if raw == "" { raw = "https://ipwho.is/%s,http://ip-api.com/json/%s?fields=status,country,countryCode,query,message" } base := ParseURLList(raw) out := make([]string, 0, len(base)) for _, item := range base { if strings.Contains(item, "%s") { out = append(out, fmt.Sprintf(item, ip)) continue } out = append(out, strings.TrimRight(item, "/")+"/"+ip) } return out } func ParseURLList(raw string) []string { parts := strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == '\n' || r == '\r' || r == '\t' || r == ' ' }) out := make([]string, 0, len(parts)) for _, part := range parts { v := strings.TrimSpace(part) if v == "" { continue } if !strings.Contains(v, "://") { v = "https://" + v } out = append(out, v) } return dedupeStrings(out) } func LimitEndpoints(in []string, maxN int) []string { if len(in) == 0 { return nil } if maxN <= 0 || maxN >= len(in) { out := make([]string, 0, len(in)) out = append(out, in...) return out } out := make([]string, 0, maxN) out = append(out, in[:maxN]...) return out } func JoinErrorsCompact(errs []string) string { if len(errs) == 0 { return "probe failed" } first := strings.TrimSpace(errs[0]) if first == "" { first = "probe failed" } if len(errs) == 1 { return first } return fmt.Sprintf("%s; +%d more", first, len(errs)-1) } func ParseSingBoxSOCKSProxyURL(root map[string]any) string { if root == nil { return "" } rawInbounds, ok := root["inbounds"].([]any) if !ok || len(rawInbounds) == 0 { return "" } for _, raw := range rawInbounds { inb, ok := raw.(map[string]any) if !ok { continue } typ := strings.ToLower(strings.TrimSpace(AnyToString(inb["type"]))) if typ != "socks" && typ != "mixed" { continue } port, ok := parseIntAny(inb["listen_port"]) if !ok || port <= 0 || port > 65535 { continue } host := strings.TrimSpace(AnyToString(inb["listen"])) switch host { case "", "::", "::1", "0.0.0.0", "[::]": host = "127.0.0.1" } if strings.TrimSpace(host) == "" { host = "127.0.0.1" } return fmt.Sprintf("socks5h://%s:%d", host, port) } return "" } func ResolvedHostForURL(rawURL string) (string, int, string) { u, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil { return "", 0, "" } host := strings.TrimSpace(u.Hostname()) if host == "" { return "", 0, "" } if _, err := netip.ParseAddr(host); err == nil { return "", 0, "" } port := 0 if p := strings.TrimSpace(u.Port()); p != "" { n, err := strconv.Atoi(p) if err == nil && n > 0 && n <= 65535 { port = n } } if port == 0 { switch strings.ToLower(strings.TrimSpace(u.Scheme)) { case "http": port = 80 default: port = 443 } } ip, err := ResolveHostIPv4(host, 2*time.Second) if err != nil || ip == "" { return "", 0, "" } return host, port, ip } func ResolveHostIPv4(host string, timeout time.Duration) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() addrs, err := net.DefaultResolver.LookupIPAddr(ctx, strings.TrimSpace(host)) if err != nil { return "", err } for _, a := range addrs { if ip4 := a.IP.To4(); ip4 != nil { return ip4.String(), nil } } return "", fmt.Errorf("no ipv4 address for host %q", host) } func ResolveCurlPath() string { curlPathOnce.Do(func() { if p, err := exec.LookPath("curl"); err == nil { curlPath = strings.TrimSpace(p) return } for _, cand := range []string{"/usr/bin/curl", "/bin/curl"} { if _, err := exec.LookPath(cand); err == nil { curlPath = strings.TrimSpace(cand) return } } curlPath = "" }) return curlPath } func ResolveWgetPath() string { wgetPathOnce.Do(func() { if p, err := exec.LookPath("wget"); err == nil { wgetPath = strings.TrimSpace(p) return } for _, cand := range []string{"/usr/bin/wget", "/bin/wget"} { if _, err := exec.LookPath(cand); err == nil { wgetPath = strings.TrimSpace(cand) return } } wgetPath = "" }) return wgetPath } func TimeoutSec(timeout time.Duration) int { sec := int(timeout.Seconds()) if sec < 1 { sec = 1 } return sec } func FirstNonEmptyAny(obj map[string]any, keys ...string) string { for _, key := range keys { if v := strings.TrimSpace(AnyToString(obj[key])); v != "" { return v } } return "" } func AnyToString(v any) string { switch x := v.(type) { case string: return x case fmt.Stringer: return x.String() case int: return strconv.Itoa(x) case int64: return strconv.FormatInt(x, 10) case float64: return strconv.FormatFloat(x, 'f', -1, 64) case bool: if x { return "true" } return "false" default: return "" } } func dedupeStrings(in []string) []string { seen := map[string]struct{}{} out := make([]string, 0, len(in)) for _, raw := range in { v := strings.TrimSpace(raw) if v == "" { continue } if _, ok := seen[v]; ok { continue } seen[v] = struct{}{} out = append(out, v) } return out } func parseIntAny(v any) (int, bool) { switch x := v.(type) { case int: return x, true case int8: return int(x), true case int16: return int(x), true case int32: return int(x), true case int64: return int(x), true case uint: return int(x), true case uint8: return int(x), true case uint16: return int(x), true case uint32: return int(x), true case uint64: return int(x), true case float64: return int(x), true case string: n, err := strconv.Atoi(strings.TrimSpace(x)) if err != nil { return 0, false } return n, true default: return 0, false } }