// 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(), }) }