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