proxy_probe_service.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. package repository
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "io"
  7. "log"
  8. "net/http"
  9. "net/url"
  10. "strings"
  11. "time"
  12. "github.com/Wei-Shaw/sub2api/internal/config"
  13. "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
  14. "github.com/Wei-Shaw/sub2api/internal/pkg/proxyurl"
  15. "github.com/Wei-Shaw/sub2api/internal/service"
  16. )
  17. func NewProxyExitInfoProber(cfg *config.Config) service.ProxyExitInfoProber {
  18. insecure := false
  19. allowPrivate := false
  20. validateResolvedIP := true
  21. maxResponseBytes := defaultProxyProbeResponseMaxBytes
  22. if cfg != nil {
  23. insecure = cfg.Security.ProxyProbe.InsecureSkipVerify
  24. allowPrivate = cfg.Security.URLAllowlist.AllowPrivateHosts
  25. validateResolvedIP = cfg.Security.URLAllowlist.Enabled
  26. if cfg.Gateway.ProxyProbeResponseReadMaxBytes > 0 {
  27. maxResponseBytes = cfg.Gateway.ProxyProbeResponseReadMaxBytes
  28. }
  29. }
  30. if insecure {
  31. log.Printf("[ProxyProbe] Warning: insecure_skip_verify is not allowed and will cause probe failure.")
  32. }
  33. // 构建探测 URL 列表:配置存在时覆盖内置默认列表。
  34. var configuredTargets []configuredProbeTarget
  35. if cfg != nil && len(cfg.Security.ProxyProbe.URLs) > 0 {
  36. configuredTargets = make([]configuredProbeTarget, 0, len(cfg.Security.ProxyProbe.URLs))
  37. for _, u := range cfg.Security.ProxyProbe.URLs {
  38. configuredTargets = append(configuredTargets, configuredProbeTarget{
  39. url: u.URL,
  40. parser: u.Parser,
  41. })
  42. }
  43. }
  44. return &proxyProbeService{
  45. insecureSkipVerify: insecure,
  46. allowPrivateHosts: allowPrivate,
  47. validateResolvedIP: validateResolvedIP,
  48. maxResponseBytes: maxResponseBytes,
  49. configuredProbeURLs: configuredTargets,
  50. }
  51. }
  52. const (
  53. defaultProxyProbeTimeout = 15 * time.Second // 放宽至15s以兼容慢速代理(如geo.miyaip 8s延迟)
  54. defaultProxyProbeResponseMaxBytes = int64(1024 * 1024)
  55. )
  56. // probeURLs 按优先级排列的内置探测 URL 列表。
  57. // 某些 AI API 专用代理只允许访问特定域名,因此需要多个备选。
  58. var probeURLs = []struct {
  59. url string
  60. parser string
  61. }{
  62. {"http://ip-api.com/json/?lang=zh-CN", "ip-api"},
  63. {"http://api64.ipify.org?format=json", "ipify"},
  64. }
  65. type configuredProbeTarget struct {
  66. url string
  67. parser string
  68. }
  69. type proxyProbeService struct {
  70. insecureSkipVerify bool
  71. allowPrivateHosts bool
  72. validateResolvedIP bool
  73. maxResponseBytes int64
  74. configuredProbeURLs []configuredProbeTarget
  75. }
  76. func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*service.ProxyExitInfo, int64, error) {
  77. // 兼容多格式输入,规范化后再探测
  78. normalized := strings.TrimSpace(proxyURL)
  79. if _, _, err := proxyurl.Parse(normalized); err != nil {
  80. if t, p, ferr := proxyurl.ParseFlexible(normalized, "http"); ferr == nil && p != nil {
  81. normalized = t
  82. }
  83. }
  84. candidates := buildProxyCandidates(normalized)
  85. var lastErr error
  86. for idx, cand := range candidates {
  87. client, err := httpclient.GetClient(httpclient.Options{
  88. ProxyURL: cand,
  89. Timeout: defaultProxyProbeTimeout,
  90. InsecureSkipVerify: s.insecureSkipVerify,
  91. ValidateResolvedIP: s.validateResolvedIP,
  92. AllowPrivateHosts: s.allowPrivateHosts,
  93. })
  94. if err != nil {
  95. // 客户端构造失败只与配置有关(不支持的协议/非法参数),与候选
  96. // 协议无关,重试其他候选无意义,直接失败。
  97. lastErr = fmt.Errorf("failed to create proxy client for %s: %w", urlRedacted(cand), err)
  98. break
  99. }
  100. var probeErr error
  101. if len(s.configuredProbeURLs) > 0 {
  102. for _, probe := range s.configuredProbeURLs {
  103. exitInfo, latencyMs, err := s.probeWithURL(ctx, client, probe.url, probe.parser)
  104. if err == nil {
  105. return exitInfo, latencyMs, nil
  106. }
  107. probeErr = err
  108. }
  109. } else {
  110. for _, probe := range probeURLs {
  111. exitInfo, latencyMs, err := s.probeWithURL(ctx, client, probe.url, probe.parser)
  112. if err == nil {
  113. return exitInfo, latencyMs, nil
  114. }
  115. probeErr = err
  116. }
  117. }
  118. lastErr = probeErr
  119. // 仅当原始协议为连接层失败时才尝试回退协议;业务层失败(503、JSON)说明代理已连通,无需换协议
  120. if idx == 0 {
  121. if !isProxyConnectError(lastErr) {
  122. break
  123. }
  124. }
  125. }
  126. if lastErr == nil {
  127. lastErr = fmt.Errorf("no proxy candidates")
  128. }
  129. return nil, 0, fmt.Errorf("all probe URLs failed, last error: %w", lastErr)
  130. }
  131. // buildProxyCandidates 基于原始代理URL生成多协议候选,顺序:原始优先,然后 http/socks5h/https 互备
  132. func buildProxyCandidates(raw string) []string {
  133. raw = strings.TrimSpace(raw)
  134. if raw == "" {
  135. return []string{""}
  136. }
  137. _, parsed, err := proxyurl.Parse(raw)
  138. if err != nil {
  139. // 若严格解析失败,尝试柔性解析后重建
  140. if t, p, ferr := proxyurl.ParseFlexible(raw, "http"); ferr == nil && p != nil {
  141. parsed = p
  142. raw = t
  143. } else {
  144. return []string{raw}
  145. }
  146. }
  147. origScheme := strings.ToLower(parsed.Scheme)
  148. // 首候选使用归一化形式(Parse 已将 socks5 升级为 socks5h,确保远端 DNS 解析)。
  149. // 直接用 raw 会导致 socks5:// 输入首试弱语义(客户端本地解析 DNS)。
  150. raw = parsed.String()
  151. // 已去重
  152. seen := map[string]bool{raw: true}
  153. cands := []string{raw}
  154. // 定义回退顺序:http<->socks5h, https<->socks5h, socks5h->http
  155. var alts []string
  156. switch origScheme {
  157. case "http":
  158. alts = []string{"socks5h", "https"}
  159. case "https":
  160. alts = []string{"http", "socks5h"}
  161. case "socks5", "socks5h":
  162. alts = []string{"http", "https"}
  163. default:
  164. alts = []string{"http", "socks5h"}
  165. }
  166. for _, alt := range alts {
  167. dup := *parsed
  168. dup.Scheme = alt
  169. s := dup.String()
  170. if !seen[s] {
  171. seen[s] = true
  172. cands = append(cands, s)
  173. }
  174. }
  175. return cands
  176. }
  177. func urlRedacted(raw string) string {
  178. if u, err := url.Parse(raw); err == nil && u != nil {
  179. return u.Redacted()
  180. }
  181. if len(raw) > 12 {
  182. return raw[:3] + "***" + raw[len(raw)-3:]
  183. }
  184. return "***"
  185. }
  186. func isProxyConnectError(err error) bool {
  187. if err == nil {
  188. return false
  189. }
  190. msg := err.Error()
  191. // 传输层统一包装为 "proxy connection failed: ..."(probeWithURL),直接判定。
  192. if strings.HasPrefix(msg, "proxy connection failed") {
  193. return true
  194. }
  195. // 业务层失败(状态码/解析)即使正文恰好含有连接关键字也不换协议。
  196. if strings.HasPrefix(msg, "request failed with status") ||
  197. strings.HasPrefix(msg, "failed to parse") ||
  198. strings.HasPrefix(msg, "failed to read response") ||
  199. strings.Contains(msg, "ip-api request failed") ||
  200. strings.Contains(msg, "ipify") ||
  201. strings.Contains(msg, "chatgpt-trace") {
  202. return false
  203. }
  204. lower := strings.ToLower(msg)
  205. return strings.Contains(lower, "proxyconnect") ||
  206. strings.Contains(lower, "connection refused") ||
  207. strings.Contains(lower, "connection reset") ||
  208. strings.Contains(lower, "timeout") ||
  209. strings.Contains(lower, "deadline exceeded") ||
  210. strings.Contains(lower, "no such host") ||
  211. strings.Contains(lower, "tls:") ||
  212. strings.Contains(lower, "certificate") ||
  213. strings.Contains(lower, "eof")
  214. }
  215. func (s *proxyProbeService) probeWithURL(ctx context.Context, client *http.Client, url string, parser string) (*service.ProxyExitInfo, int64, error) {
  216. startTime := time.Now()
  217. req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
  218. if err != nil {
  219. return nil, 0, fmt.Errorf("failed to create request: %w", err)
  220. }
  221. resp, err := client.Do(req)
  222. if err != nil {
  223. return nil, 0, fmt.Errorf("proxy connection failed: %w", err)
  224. }
  225. defer func() { _ = resp.Body.Close() }()
  226. latencyMs := time.Since(startTime).Milliseconds()
  227. if resp.StatusCode != http.StatusOK {
  228. return nil, latencyMs, fmt.Errorf("request failed with status: %d", resp.StatusCode)
  229. }
  230. maxResponseBytes := s.maxResponseBytes
  231. if maxResponseBytes <= 0 {
  232. maxResponseBytes = defaultProxyProbeResponseMaxBytes
  233. }
  234. body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
  235. if err != nil {
  236. return nil, latencyMs, fmt.Errorf("failed to read response: %w", err)
  237. }
  238. if int64(len(body)) > maxResponseBytes {
  239. return nil, latencyMs, fmt.Errorf("proxy probe response exceeds limit: %d", maxResponseBytes)
  240. }
  241. switch parser {
  242. case "ip-api":
  243. return s.parseIPAPI(body, latencyMs)
  244. case "ipify":
  245. return s.parseIPify(body, latencyMs)
  246. case "chatgpt-trace":
  247. return s.parseChatGPTTrace(body, latencyMs)
  248. default:
  249. return nil, latencyMs, fmt.Errorf("unknown parser: %s", parser)
  250. }
  251. }
  252. func (s *proxyProbeService) parseIPAPI(body []byte, latencyMs int64) (*service.ProxyExitInfo, int64, error) {
  253. var ipInfo struct {
  254. Status string `json:"status"`
  255. Message string `json:"message"`
  256. Query string `json:"query"`
  257. City string `json:"city"`
  258. Region string `json:"region"`
  259. RegionName string `json:"regionName"`
  260. Country string `json:"country"`
  261. CountryCode string `json:"countryCode"`
  262. }
  263. if err := json.Unmarshal(body, &ipInfo); err != nil {
  264. preview := string(body)
  265. if len(preview) > 200 {
  266. preview = preview[:200] + "..."
  267. }
  268. return nil, latencyMs, fmt.Errorf("failed to parse response: %w (body: %s)", err, preview)
  269. }
  270. if strings.ToLower(ipInfo.Status) != "success" {
  271. if ipInfo.Message == "" {
  272. ipInfo.Message = "ip-api request failed"
  273. }
  274. return nil, latencyMs, fmt.Errorf("ip-api request failed: %s", ipInfo.Message)
  275. }
  276. region := ipInfo.RegionName
  277. if region == "" {
  278. region = ipInfo.Region
  279. }
  280. return &service.ProxyExitInfo{
  281. IP: ipInfo.Query,
  282. City: ipInfo.City,
  283. Region: region,
  284. Country: ipInfo.Country,
  285. CountryCode: ipInfo.CountryCode,
  286. }, latencyMs, nil
  287. }
  288. func (s *proxyProbeService) parseIPify(body []byte, latencyMs int64) (*service.ProxyExitInfo, int64, error) {
  289. var result struct {
  290. IP string `json:"ip"`
  291. }
  292. if err := json.Unmarshal(body, &result); err != nil {
  293. return nil, latencyMs, fmt.Errorf("failed to parse ipify response: %w", err)
  294. }
  295. if result.IP == "" {
  296. return nil, latencyMs, fmt.Errorf("ipify: no IP found in response")
  297. }
  298. return &service.ProxyExitInfo{
  299. IP: result.IP,
  300. }, latencyMs, nil
  301. }
  302. // parseChatGPTTrace 解析 Cloudflare trace 端点(如 chatgpt.com/cdn-cgi/trace)的纯文本响应。
  303. // 响应按行给出键值对,其中 ip= 为出口 IP,loc= 为国家代码。
  304. func (s *proxyProbeService) parseChatGPTTrace(body []byte, latencyMs int64) (*service.ProxyExitInfo, int64, error) {
  305. var ip, loc string
  306. for _, line := range strings.Split(string(body), "\n") {
  307. key, value, found := strings.Cut(strings.TrimSpace(line), "=")
  308. if !found {
  309. continue
  310. }
  311. switch key {
  312. case "ip":
  313. ip = strings.TrimSpace(value)
  314. case "loc":
  315. loc = strings.TrimSpace(value)
  316. }
  317. }
  318. if ip == "" {
  319. preview := string(body)
  320. if len(preview) > 200 {
  321. preview = preview[:200] + "..."
  322. }
  323. return nil, latencyMs, fmt.Errorf("chatgpt-trace: no ip= found in response (body: %s)", preview)
  324. }
  325. info := &service.ProxyExitInfo{
  326. IP: ip,
  327. }
  328. if loc != "" {
  329. info.CountryCode = loc
  330. }
  331. return info, latencyMs, nil
  332. }