Files

400 lines
8.4 KiB
Go

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
}
}