onesvm-browser-server/server/internal/proxymanager/controller.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

175 lines
5.8 KiB
Go
Raw Permalink 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.

// controller.go:mihomo external-controller REST 客户端(controller 19090)。
//
// 探活方案(brief A4 锁定):逐节点经 mihomo 原生 delay API 测
// `GET /proxies/{name}/delay?timeout=8000&url=https://www.google.com/generate_204`,
// 比外部拉代理链简单且为 mihomo 官方能力。
//
// 认证分轨说明(service-secret-protocol):BROWSER_SERVER_MIHOMO_SECRET 是
// mihomo controller 自身的 REST 认证(mihomo 规范即 Authorization: Bearer <secret>),
// 属下游契约允许 Bearer 的场景;与本项目消费者侧 X-Service-Token 分轨不冲突。
package proxymanager
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// controller mihomo REST 客户端。
type controller struct {
base string // 如 http://mihomo:19090
secret string // 可选 bearer;空则不带认证头
hc *http.Client
timeout time.Duration
}
func newController(base, secret string, timeout time.Duration) *controller {
if timeout <= 0 {
timeout = 10 * time.Second
}
return &controller{
base: strings.TrimRight(base, "/"),
secret: secret,
hc: &http.Client{Timeout: timeout},
timeout: timeout,
}
}
// do 执行 REST 调用(带可选 bearer)。
func (c *controller) do(ctx context.Context, method, path string, body io.Reader) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, method, c.base+path, body)
if err != nil {
return nil, fmt.Errorf("mihomo: 请求构造失败: %w", err)
}
if c.secret != "" {
req.Header.Set("Authorization", "Bearer "+c.secret)
}
resp, err := c.hc.Do(req)
if err != nil {
return nil, fmt.Errorf("mihomo: %s %s 失败: %w", method, path, err)
}
defer resp.Body.Close()
respBody, rerr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if rerr != nil {
return nil, fmt.Errorf("mihomo: 读响应失败: %w", rerr)
}
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("mihomo: %s %s HTTP %d: %s", method, path, resp.StatusCode, truncate(respBody, 200))
}
return respBody, nil
}
func truncate(b []byte, n int) string {
s := strings.TrimSpace(string(b))
if len(s) > n {
s = s[:n]
}
return s
}
// Version 探测 controller 可达性(up.sh 等待 /version 同语义)。
func (c *controller) Version(ctx context.Context) error {
_, err := c.do(ctx, http.MethodGet, "/version", nil)
return err
}
// ProbeURL 探针地址(proxy-probe §6.2:禁 cp.cloudflare.com HEAD,
// 必须 google generate_204 HTTPS GET)。
const ProbeURL = "https://www.google.com/generate_204"
// probeTimeout 单节点探活超时(design §5.2:8s)。
const probeTimeout = 8 * time.Second
// ProbeNode 测单节点延迟(mihomo delay API)。节点名 URL quote(api_quote 移植)。
// 返回毫秒延迟;失败返回 error(调用方计入连续失败计数)。
func (c *controller) ProbeNode(ctx context.Context, name string) (int, error) {
q := url.Values{}
q.Set("timeout", fmt.Sprintf("%d", probeTimeout.Milliseconds()))
q.Set("url", ProbeURL)
p := "/proxies/" + url.PathEscape(name) + "/delay?" + q.Encode()
ctx2, cancel := context.WithTimeout(ctx, probeTimeout+2*time.Second)
defer cancel()
body, err := c.do(ctx2, http.MethodGet, p, nil)
if err != nil {
return 0, err
}
// 形状:{"delay": 123}(mihomo 官方)。
var out struct {
Delay int `json:"delay"`
}
if err := json.Unmarshal(body, &out); err != nil {
return 0, fmt.Errorf("mihomo: delay 响应解析失败: %w", err)
}
if out.Delay <= 0 {
return 0, fmt.Errorf("mihomo: delay 响应异常值 %s", truncate(body, 100))
}
return out.Delay, nil
}
// Reload 热载配置(PUT /configs?force=true,payload path=provider.yaml 全路径)。
// controller 不可达仅告警不 panic(调用方处理错误)。
func (c *controller) Reload(ctx context.Context, configPath string) error {
payload := `{"path": ` + jsonString(configPath) + `}`
req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.base+"/configs?force=true", strings.NewReader(payload))
if err != nil {
return fmt.Errorf("mihomo: reload 请求构造失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if c.secret != "" {
req.Header.Set("Authorization", "Bearer "+c.secret)
}
resp, err := c.hc.Do(req)
if err != nil {
return fmt.Errorf("mihomo: reload 失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
return fmt.Errorf("mihomo: reload HTTP %d: %s", resp.StatusCode, truncate(b, 200))
}
return nil
}
// SelectInGroup 切换选择组当前节点(PUT /proxies/{group},body {"name": node})。
// 用于把主出口钉到指定节点(主备切换)。
func (c *controller) SelectInGroup(ctx context.Context, group, node string) error {
payload := `{"name": ` + jsonString(node) + `}`
req, err := http.NewRequestWithContext(ctx, http.MethodPut,
"/proxies/"+url.PathEscape(group), strings.NewReader(payload))
if err != nil {
return fmt.Errorf("mihomo: select 请求构造失败: %w", err)
}
req.URL, err = url.Parse(c.base + "/proxies/" + url.PathEscape(group))
if err != nil {
return fmt.Errorf("mihomo: select URL 失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if c.secret != "" {
req.Header.Set("Authorization", "Bearer "+c.secret)
}
resp, err := c.hc.Do(req.WithContext(ctx))
if err != nil {
return fmt.Errorf("mihomo: select 失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
return fmt.Errorf("mihomo: select HTTP %d: %s", resp.StatusCode, truncate(b, 200))
}
return nil
}
// jsonString 最小 JSON 字符串编码(防注入)。
func jsonString(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
// 便于测试注入的毫秒格式化。
func ms(v int) string { return strconv.Itoa(v) }