// 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 ), // 属下游契约允许 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) }