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