onesvm-browser-server/server/internal/scheduler/proxy_client.go
chii eb972dfa93 feat: 落地 browser-server 控制面并打通 mgr1 海外订阅
单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 15:05:12 +08:00

107 lines
3.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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()
}