// proxy_client.go:ProxyManager 出口客户端(overseas 任务统一走 mihomo mixed 出口)。 // // 契约(A3.2):HTTP GET {PROXYMANAGER_URL}/api/exit?domain= → {proxy:"http://mihomo:17890", node:"..."}。 // O3 决策:proxymanager 不可达 → overseas 任务 fail-closed 排队等待(不降级直连, // 回队+告警)——Core.proxyWatchLoop 维护可达位,overseas 任务在不可达期被 // Execute 路由拒绝为 503 重试。 package scheduler import ( "context" "encoding/json" "fmt" "net/http" "net/url" "sync" "time" ) // ProxyClient proxymanager 客户端。 type ProxyClient struct { baseURL string hc *http.Client mu sync.Mutex cache map[string]proxyExit // 域名 sticky 缓存(进程内短 TTL) cacheT map[string]time.Time ttl time.Duration } // proxyExit /api/exit 响应形状。 type proxyExit struct { Proxy string `json:"proxy"` // "http://mihomo:17890" Node string `json:"node"` } // NewProxyClient 构造(baseURL 默认 http://proxymanager:8642)。 func NewProxyClient(baseURL string) *ProxyClient { return &ProxyClient{ baseURL: baseURL, hc: &http.Client{Timeout: 3 * time.Second}, cache: map[string]proxyExit{}, cacheT: map[string]time.Time{}, ttl: 5 * time.Minute, } } // BaseURL 端点。 func (p *ProxyClient) BaseURL() string { return p.baseURL } // ExitForDomain 查询域名出口(sticky 由 proxymanager 侧维护;此处短缓存减少调用)。 func (p *ProxyClient) ExitForDomain(ctx context.Context, rawURL string) (proxyExit, error) { host := hostOf(rawURL) if host == "" { return proxyExit{}, fmt.Errorf("proxy: URL 无 host: %s", rawURL) } p.mu.Lock() if e, ok := p.cache[host]; ok && time.Since(p.cacheT[host]) < p.ttl { p.mu.Unlock() return e, nil } p.mu.Unlock() req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.baseURL+"/api/exit?"+url.Values{"domain": {host}}.Encode(), nil) if err != nil { return proxyExit{}, err } resp, err := p.hc.Do(req) if err != nil { return proxyExit{}, fmt.Errorf("proxy: /api/exit: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return proxyExit{}, fmt.Errorf("proxy: /api/exit HTTP %d", resp.StatusCode) } var e proxyExit if err := json.NewDecoder(resp.Body).Decode(&e); err != nil { return proxyExit{}, fmt.Errorf("proxy: /api/exit 解码: %w", err) } p.mu.Lock() p.cache[host] = e p.cacheT[host] = time.Now() p.mu.Unlock() return e, nil } // Ping 探活(Core.proxyWatchLoop 周期调用;不可达 → overseas fail-closed)。 func (p *ProxyClient) Ping(ctx context.Context) bool { hc := &http.Client{Timeout: 2 * time.Second} req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.baseURL+"/healthz", nil) if err != nil { return false } resp, err := hc.Do(req) if err != nil { return false } defer resp.Body.Close() return resp.StatusCode == http.StatusOK } // hostOf 取 URL host(容错非法 URL 返回空串)。 func hostOf(rawURL string) string { u, err := url.Parse(rawURL) if err != nil || u.Hostname() == "" { return "" } return u.Hostname() }