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

110 lines
3.8 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.

// api.go:ProxyManager overlay HTTP API(D3:仅 overlay :8642)。
//
// 路由(brief A4):
// - GET /healthz {ok, pool_alive, active_exit, last_switch}
// - GET /api/proxies [{name,type,region,alive,delay_ms,ewma}](脱敏无凭据)
// - GET /api/exit?domain=&session= P2C+sticky → ExitDecision(deny 域 blocked:true)
// - POST /api/rules/reload 从 store rules 表重载域名路由
//
// 入参边界(T7):domain/session 长度上限 + 字符白名单,拒绝异常超长输入。
package proxymanager
import (
"encoding/json"
"net/http"
"regexp"
"strings"
)
// 入参边界(T7:Pydantic→Go 等价校验)。
const (
maxDomainLen = 253 // DNS 域名硬上限
maxSessionLen = 128
)
// domainRE 域名字符白名单(字母数字点连字符;不做 DNS 解析)。
var domainRE = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$`)
// Routes 挂载 API 路由(供 main.go serveHealth mux 使用)。
func (m *Manager) Routes(mux *http.ServeMux) {
mux.HandleFunc("/healthz", m.handleHealthz)
mux.HandleFunc("/api/proxies", m.handleProxies)
mux.HandleFunc("/api/exit", m.handleExit)
mux.HandleFunc("/api/rules/reload", m.handleRulesReload)
}
// writeJSON 统一 JSON 响应。
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
_ = enc.Encode(v)
}
// handleHealthz GET /healthz。
func (m *Manager) handleHealthz(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, m.Healthz())
}
// handleProxies GET /api/proxies(探活状态,脱敏无凭据)。
func (m *Manager) handleProxies(w http.ResponseWriter, _ *http.Request) {
ewma := m.health.RegionEWMA()
writeJSON(w, http.StatusOK, map[string]any{
"proxies": m.Proxies(),
"region_ewma": ewma,
"pool_alive": m.health.AliveCount(),
"pool_total": m.health.TotalCount(),
})
}
// handleExit GET /api/exit?domain=x&session=y。
func (m *Manager) handleExit(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "仅 GET"})
return
}
domain := strings.TrimSpace(r.URL.Query().Get("domain"))
session := strings.TrimSpace(r.URL.Query().Get("session"))
if domain == "" && session == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "domain 与 session 至少其一必填"})
return
}
if len(domain) > maxDomainLen {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "domain 超长(>253)"})
return
}
if len(session) > maxSessionLen {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "session 超长(>128)"})
return
}
if domain != "" && !domainRE.MatchString(domain) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "domain 字符非法"})
return
}
// deny 域即时判定(决策在 GetExit 内亦做一次;此处提前短路便于测试观察)。
if domain != "" {
if action, ok := m.rules.Lookup(domain); ok && action == "deny" {
writeJSON(w, http.StatusOK, ExitDecision{Proxy: mixedProxyURL, Blocked: true, Reason: "deny_rule"})
return
}
}
d := m.GetExit(domain, session)
writeJSON(w, http.StatusOK, d)
}
// handleRulesReload POST /api/rules/reload。
func (m *Manager) handleRulesReload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "仅 POST"})
return
}
if err := m.ReloadRules(); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"generation": m.rules.Generation(),
})
}