单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。 Co-authored-by: Cursor <cursoragent@cursor.com>
107 lines
3.1 KiB
Go
107 lines
3.1 KiB
Go
// 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()
|
||
}
|