| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355 |
- package repository
- import (
- "context"
- "encoding/json"
- "fmt"
- "io"
- "log"
- "net/http"
- "net/url"
- "strings"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
- "github.com/Wei-Shaw/sub2api/internal/pkg/proxyurl"
- "github.com/Wei-Shaw/sub2api/internal/service"
- )
- func NewProxyExitInfoProber(cfg *config.Config) service.ProxyExitInfoProber {
- insecure := false
- allowPrivate := false
- validateResolvedIP := true
- maxResponseBytes := defaultProxyProbeResponseMaxBytes
- if cfg != nil {
- insecure = cfg.Security.ProxyProbe.InsecureSkipVerify
- allowPrivate = cfg.Security.URLAllowlist.AllowPrivateHosts
- validateResolvedIP = cfg.Security.URLAllowlist.Enabled
- if cfg.Gateway.ProxyProbeResponseReadMaxBytes > 0 {
- maxResponseBytes = cfg.Gateway.ProxyProbeResponseReadMaxBytes
- }
- }
- if insecure {
- log.Printf("[ProxyProbe] Warning: insecure_skip_verify is not allowed and will cause probe failure.")
- }
- // 构建探测 URL 列表:配置存在时覆盖内置默认列表。
- var configuredTargets []configuredProbeTarget
- if cfg != nil && len(cfg.Security.ProxyProbe.URLs) > 0 {
- configuredTargets = make([]configuredProbeTarget, 0, len(cfg.Security.ProxyProbe.URLs))
- for _, u := range cfg.Security.ProxyProbe.URLs {
- configuredTargets = append(configuredTargets, configuredProbeTarget{
- url: u.URL,
- parser: u.Parser,
- })
- }
- }
- return &proxyProbeService{
- insecureSkipVerify: insecure,
- allowPrivateHosts: allowPrivate,
- validateResolvedIP: validateResolvedIP,
- maxResponseBytes: maxResponseBytes,
- configuredProbeURLs: configuredTargets,
- }
- }
- const (
- defaultProxyProbeTimeout = 15 * time.Second // 放宽至15s以兼容慢速代理(如geo.miyaip 8s延迟)
- defaultProxyProbeResponseMaxBytes = int64(1024 * 1024)
- )
- // probeURLs 按优先级排列的内置探测 URL 列表。
- // 某些 AI API 专用代理只允许访问特定域名,因此需要多个备选。
- var probeURLs = []struct {
- url string
- parser string
- }{
- {"http://ip-api.com/json/?lang=zh-CN", "ip-api"},
- {"http://api64.ipify.org?format=json", "ipify"},
- }
- type configuredProbeTarget struct {
- url string
- parser string
- }
- type proxyProbeService struct {
- insecureSkipVerify bool
- allowPrivateHosts bool
- validateResolvedIP bool
- maxResponseBytes int64
- configuredProbeURLs []configuredProbeTarget
- }
- func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*service.ProxyExitInfo, int64, error) {
- // 兼容多格式输入,规范化后再探测
- normalized := strings.TrimSpace(proxyURL)
- if _, _, err := proxyurl.Parse(normalized); err != nil {
- if t, p, ferr := proxyurl.ParseFlexible(normalized, "http"); ferr == nil && p != nil {
- normalized = t
- }
- }
- candidates := buildProxyCandidates(normalized)
- var lastErr error
- for idx, cand := range candidates {
- client, err := httpclient.GetClient(httpclient.Options{
- ProxyURL: cand,
- Timeout: defaultProxyProbeTimeout,
- InsecureSkipVerify: s.insecureSkipVerify,
- ValidateResolvedIP: s.validateResolvedIP,
- AllowPrivateHosts: s.allowPrivateHosts,
- })
- if err != nil {
- // 客户端构造失败只与配置有关(不支持的协议/非法参数),与候选
- // 协议无关,重试其他候选无意义,直接失败。
- lastErr = fmt.Errorf("failed to create proxy client for %s: %w", urlRedacted(cand), err)
- break
- }
- var probeErr error
- if len(s.configuredProbeURLs) > 0 {
- for _, probe := range s.configuredProbeURLs {
- exitInfo, latencyMs, err := s.probeWithURL(ctx, client, probe.url, probe.parser)
- if err == nil {
- return exitInfo, latencyMs, nil
- }
- probeErr = err
- }
- } else {
- for _, probe := range probeURLs {
- exitInfo, latencyMs, err := s.probeWithURL(ctx, client, probe.url, probe.parser)
- if err == nil {
- return exitInfo, latencyMs, nil
- }
- probeErr = err
- }
- }
- lastErr = probeErr
- // 仅当原始协议为连接层失败时才尝试回退协议;业务层失败(503、JSON)说明代理已连通,无需换协议
- if idx == 0 {
- if !isProxyConnectError(lastErr) {
- break
- }
- }
- }
- if lastErr == nil {
- lastErr = fmt.Errorf("no proxy candidates")
- }
- return nil, 0, fmt.Errorf("all probe URLs failed, last error: %w", lastErr)
- }
- // buildProxyCandidates 基于原始代理URL生成多协议候选,顺序:原始优先,然后 http/socks5h/https 互备
- func buildProxyCandidates(raw string) []string {
- raw = strings.TrimSpace(raw)
- if raw == "" {
- return []string{""}
- }
- _, parsed, err := proxyurl.Parse(raw)
- if err != nil {
- // 若严格解析失败,尝试柔性解析后重建
- if t, p, ferr := proxyurl.ParseFlexible(raw, "http"); ferr == nil && p != nil {
- parsed = p
- raw = t
- } else {
- return []string{raw}
- }
- }
- origScheme := strings.ToLower(parsed.Scheme)
- // 首候选使用归一化形式(Parse 已将 socks5 升级为 socks5h,确保远端 DNS 解析)。
- // 直接用 raw 会导致 socks5:// 输入首试弱语义(客户端本地解析 DNS)。
- raw = parsed.String()
- // 已去重
- seen := map[string]bool{raw: true}
- cands := []string{raw}
- // 定义回退顺序:http<->socks5h, https<->socks5h, socks5h->http
- var alts []string
- switch origScheme {
- case "http":
- alts = []string{"socks5h", "https"}
- case "https":
- alts = []string{"http", "socks5h"}
- case "socks5", "socks5h":
- alts = []string{"http", "https"}
- default:
- alts = []string{"http", "socks5h"}
- }
- for _, alt := range alts {
- dup := *parsed
- dup.Scheme = alt
- s := dup.String()
- if !seen[s] {
- seen[s] = true
- cands = append(cands, s)
- }
- }
- return cands
- }
- func urlRedacted(raw string) string {
- if u, err := url.Parse(raw); err == nil && u != nil {
- return u.Redacted()
- }
- if len(raw) > 12 {
- return raw[:3] + "***" + raw[len(raw)-3:]
- }
- return "***"
- }
- func isProxyConnectError(err error) bool {
- if err == nil {
- return false
- }
- msg := err.Error()
- // 传输层统一包装为 "proxy connection failed: ..."(probeWithURL),直接判定。
- if strings.HasPrefix(msg, "proxy connection failed") {
- return true
- }
- // 业务层失败(状态码/解析)即使正文恰好含有连接关键字也不换协议。
- if strings.HasPrefix(msg, "request failed with status") ||
- strings.HasPrefix(msg, "failed to parse") ||
- strings.HasPrefix(msg, "failed to read response") ||
- strings.Contains(msg, "ip-api request failed") ||
- strings.Contains(msg, "ipify") ||
- strings.Contains(msg, "chatgpt-trace") {
- return false
- }
- lower := strings.ToLower(msg)
- return strings.Contains(lower, "proxyconnect") ||
- strings.Contains(lower, "connection refused") ||
- strings.Contains(lower, "connection reset") ||
- strings.Contains(lower, "timeout") ||
- strings.Contains(lower, "deadline exceeded") ||
- strings.Contains(lower, "no such host") ||
- strings.Contains(lower, "tls:") ||
- strings.Contains(lower, "certificate") ||
- strings.Contains(lower, "eof")
- }
- func (s *proxyProbeService) probeWithURL(ctx context.Context, client *http.Client, url string, parser string) (*service.ProxyExitInfo, int64, error) {
- startTime := time.Now()
- req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
- if err != nil {
- return nil, 0, fmt.Errorf("failed to create request: %w", err)
- }
- resp, err := client.Do(req)
- if err != nil {
- return nil, 0, fmt.Errorf("proxy connection failed: %w", err)
- }
- defer func() { _ = resp.Body.Close() }()
- latencyMs := time.Since(startTime).Milliseconds()
- if resp.StatusCode != http.StatusOK {
- return nil, latencyMs, fmt.Errorf("request failed with status: %d", resp.StatusCode)
- }
- maxResponseBytes := s.maxResponseBytes
- if maxResponseBytes <= 0 {
- maxResponseBytes = defaultProxyProbeResponseMaxBytes
- }
- body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
- if err != nil {
- return nil, latencyMs, fmt.Errorf("failed to read response: %w", err)
- }
- if int64(len(body)) > maxResponseBytes {
- return nil, latencyMs, fmt.Errorf("proxy probe response exceeds limit: %d", maxResponseBytes)
- }
- switch parser {
- case "ip-api":
- return s.parseIPAPI(body, latencyMs)
- case "ipify":
- return s.parseIPify(body, latencyMs)
- case "chatgpt-trace":
- return s.parseChatGPTTrace(body, latencyMs)
- default:
- return nil, latencyMs, fmt.Errorf("unknown parser: %s", parser)
- }
- }
- func (s *proxyProbeService) parseIPAPI(body []byte, latencyMs int64) (*service.ProxyExitInfo, int64, error) {
- var ipInfo struct {
- Status string `json:"status"`
- Message string `json:"message"`
- Query string `json:"query"`
- City string `json:"city"`
- Region string `json:"region"`
- RegionName string `json:"regionName"`
- Country string `json:"country"`
- CountryCode string `json:"countryCode"`
- }
- if err := json.Unmarshal(body, &ipInfo); err != nil {
- preview := string(body)
- if len(preview) > 200 {
- preview = preview[:200] + "..."
- }
- return nil, latencyMs, fmt.Errorf("failed to parse response: %w (body: %s)", err, preview)
- }
- if strings.ToLower(ipInfo.Status) != "success" {
- if ipInfo.Message == "" {
- ipInfo.Message = "ip-api request failed"
- }
- return nil, latencyMs, fmt.Errorf("ip-api request failed: %s", ipInfo.Message)
- }
- region := ipInfo.RegionName
- if region == "" {
- region = ipInfo.Region
- }
- return &service.ProxyExitInfo{
- IP: ipInfo.Query,
- City: ipInfo.City,
- Region: region,
- Country: ipInfo.Country,
- CountryCode: ipInfo.CountryCode,
- }, latencyMs, nil
- }
- func (s *proxyProbeService) parseIPify(body []byte, latencyMs int64) (*service.ProxyExitInfo, int64, error) {
- var result struct {
- IP string `json:"ip"`
- }
- if err := json.Unmarshal(body, &result); err != nil {
- return nil, latencyMs, fmt.Errorf("failed to parse ipify response: %w", err)
- }
- if result.IP == "" {
- return nil, latencyMs, fmt.Errorf("ipify: no IP found in response")
- }
- return &service.ProxyExitInfo{
- IP: result.IP,
- }, latencyMs, nil
- }
- // parseChatGPTTrace 解析 Cloudflare trace 端点(如 chatgpt.com/cdn-cgi/trace)的纯文本响应。
- // 响应按行给出键值对,其中 ip= 为出口 IP,loc= 为国家代码。
- func (s *proxyProbeService) parseChatGPTTrace(body []byte, latencyMs int64) (*service.ProxyExitInfo, int64, error) {
- var ip, loc string
- for _, line := range strings.Split(string(body), "\n") {
- key, value, found := strings.Cut(strings.TrimSpace(line), "=")
- if !found {
- continue
- }
- switch key {
- case "ip":
- ip = strings.TrimSpace(value)
- case "loc":
- loc = strings.TrimSpace(value)
- }
- }
- if ip == "" {
- preview := string(body)
- if len(preview) > 200 {
- preview = preview[:200] + "..."
- }
- return nil, latencyMs, fmt.Errorf("chatgpt-trace: no ip= found in response (body: %s)", preview)
- }
- info := &service.ProxyExitInfo{
- IP: ip,
- }
- if loc != "" {
- info.CountryCode = loc
- }
- return info, latencyMs, nil
- }
|