Files
nyoung ed51efd8bc fix: 网关与控制台代码审查修复批次(含回归测试)
- Chat 接口拒绝 n>1(此前静默丢弃多余 choice),返回 400 结构化错误
- Responses 接口透传 text.format(json_schema / json_object),三种接口协议对齐
- TLS 指纹统一升级到 Chrome_150(internal/httpx.DefaultProfile),与 UA 匹配
- metrics 落盘失败不再静默:Save() 返回错误并打日志,临时文件失败会清理
- GET /api/config 脱敏 admin.password 与 turnstile.api_key(控制台从不展示,
  不应随 5s 轮询下发);PUT 改走 ReplaceJSON,先校验后替换、失败回滚
- set_limits / set_turnstile 改指针部分更新:缺字段保持现状,不会把并发、
  限流或求解开关按零值清掉
- 读请求体统一 64MB 上限;配置写盘原子替换 + 0600 权限
- main.go 改为 run() 返回退出码:日志落盘、指标落盘、xray 子进程回收全部
  挂 defer,任何退出路径(含启动失败)都会执行,杜绝孤儿 xray
- proxypool / turnstile / upstream / openai / config / httpx / logbuf 等
  逐项补红绿回归测试,新增 14 个测试文件,race 下全绿
2026-10-08 15:13:23 +08:00

760 lines
18 KiB
Go

package proxypool
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"sort"
"strings"
"sync"
"time"
"github.com/nyoungo/dsfree2api/internal/config"
"github.com/nyoungo/dsfree2api/internal/upstream"
)
// endpoint is one runtime pool slot.
type endpoint struct {
name string
kind string // "xray" | "url" | "invalid"
scheme string
display string
link string
source string // "" = manual entry, else subscription name
enabled bool
parseErr string
outbound map[string]any
url string
proxyURL string // resolved egress URL (socks5://127.0.0.1:port or plain)
checked bool
healthy bool
latencyMs int64
lastErr string
checkedAt time.Time
}
type subState struct {
name string
nodes []string
lastErr string
fetchedAt time.Time
}
// Manager owns the proxy pool: endpoints, health, subscriptions and sticky
// per-site selection. It is safe for concurrent use.
type Manager struct {
cfg *config.Config
log *slog.Logger
dir string
xray *xrayCore
reloadMu sync.Mutex
mu sync.Mutex
endpoints map[string]*endpoint
subs map[string]*subState
sticky map[string]string
fetching map[string]bool
started bool
lastCheck time.Time
lastErr string
// 关闭协调:stopCh 通知循环退出,doneCh 在循环真正退出后关闭,
// loopCancel 取消 Start 传入的 ctx(让进行中的 cycle 立刻收尾)。
stopCh chan struct{}
doneCh chan struct{}
loopCancel context.CancelFunc
closeOnce sync.Once
}
func New(cfg *config.Config, dataDir string, log *slog.Logger) *Manager {
if log == nil {
log = slog.Default()
}
return &Manager{
cfg: cfg,
log: log,
dir: dataDir,
xray: newXrayCore(dataDir, log),
endpoints: map[string]*endpoint{},
subs: map[string]*subState{},
sticky: map[string]string{},
fetching: map[string]bool{},
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
}
}
// Start runs the background loop: reload from config, refresh due
// subscriptions, probe endpoint health.
func (m *Manager) Start(ctx context.Context) {
m.mu.Lock()
if m.started {
m.mu.Unlock()
return
}
m.started = true
ctx, m.loopCancel = context.WithCancel(ctx)
m.mu.Unlock()
go m.loop(ctx)
}
// Close 停掉后台循环并同步回收 xray 子进程,阻塞到两件事都完成为止。
// 进程退出路径必须调用它:仅靠 ctx 取消是异步的,主 goroutine 退出时
// 循环可能还没跑到 xray.stop(),子进程会变成孤儿(Windows 上父进程退出
// 不会带走子进程)。
func (m *Manager) Close() {
m.closeOnce.Do(func() {
m.mu.Lock()
cancel := m.loopCancel
started := m.started
m.mu.Unlock()
if cancel != nil {
cancel()
}
close(m.stopCh)
if !started {
// 循环没启动过,没有 doneCh 可等,直接回收。
m.xray.stop()
return
}
<-m.doneCh
})
}
func (m *Manager) loop(ctx context.Context) {
defer close(m.doneCh)
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
m.xray.stop()
return
case <-m.stopCh:
m.xray.stop()
return
case <-ticker.C:
}
m.cycle(ctx)
}
}
func (m *Manager) cycle(ctx context.Context) {
m.Reload()
m.cfg.RLock()
enabled := m.cfg.ProxyPool.Enabled
interval := time.Duration(m.cfg.ProxyPool.CheckIntervalSeconds) * time.Second
subs := make(map[string]config.ProxySubscription, len(m.cfg.ProxyPool.Subscriptions))
for name, sc := range m.cfg.ProxyPool.Subscriptions {
if sc != nil {
subs[name] = *sc
}
}
m.cfg.RUnlock()
if !enabled {
return
}
if interval <= 0 {
interval = 120 * time.Second
}
m.mu.Lock()
due := m.lastCheck.IsZero() || time.Since(m.lastCheck) >= interval
m.mu.Unlock()
if due {
m.checkAll(ctx)
}
now := time.Now()
for name, sc := range subs {
if !sc.Enabled || strings.TrimSpace(sc.URL) == "" {
continue
}
m.mu.Lock()
st := m.subs[name]
busy := m.fetching[name]
m.mu.Unlock()
if busy {
continue
}
iv := time.Duration(sc.IntervalMinutes) * time.Minute
if iv <= 0 {
iv = time.Hour
}
if st != nil && !st.fetchedAt.IsZero() && now.Sub(st.fetchedAt) < iv {
continue
}
go m.fetchSub(ctx, name, sc)
}
}
// Reload rebuilds the runtime endpoint table from the live config plus the
// cached subscription nodes, (re)configures the Xray core and prunes sticky
// picks that no longer resolve.
func (m *Manager) Reload() {
m.reloadMu.Lock()
defer m.reloadMu.Unlock()
m.cfg.RLock()
poolCfg := m.cfg.ProxyPool
entries := make(map[string]config.ProxyEntry, len(poolCfg.Entries))
for name, e := range poolCfg.Entries {
if e != nil {
entries[name] = *e
}
}
subsCfg := make(map[string]config.ProxySubscription, len(poolCfg.Subscriptions))
for name, sc := range poolCfg.Subscriptions {
if sc != nil {
subsCfg[name] = *sc
}
}
m.cfg.RUnlock()
m.mu.Lock()
subNodes := make(map[string][]string, len(m.subs))
for name, st := range m.subs {
subNodes[name] = append([]string(nil), st.nodes...)
}
old := m.endpoints
m.mu.Unlock()
desired := map[string]*endpoint{}
for name, e := range entries {
if strings.TrimSpace(e.Link) == "" {
continue
}
ep := newEndpoint(name, e.Link, poolCfg.DefaultScheme, "")
ep.enabled = e.Enabled
desired[name] = ep
}
for subName, nodes := range subNodes {
sc, ok := subsCfg[subName]
if !ok {
continue
}
for _, line := range nodes {
name := subName + "#" + shortHash(line)
if _, dup := desired[name]; dup {
continue
}
ep := newEndpoint(name, line, poolCfg.DefaultScheme, subName)
ep.enabled = sc.Enabled
desired[name] = ep
}
}
for name, ep := range desired {
if o := old[name]; o != nil && o.link == ep.link {
ep.checked = o.checked
ep.healthy = o.healthy
ep.latencyMs = o.latencyMs
ep.lastErr = o.lastErr
ep.checkedAt = o.checkedAt
}
}
nodes := make([]xrayNode, 0, len(desired))
for _, ep := range desired {
if ep.kind == "xray" && ep.enabled && ep.parseErr == "" && ep.outbound != nil {
nodes = append(nodes, xrayNode{Name: ep.name, Outbound: ep.outbound})
}
}
sort.Slice(nodes, func(i, j int) bool { return nodes[i].Name < nodes[j].Name })
if len(nodes) > 0 {
if err := m.xray.ensure(poolCfg.XrayPath, poolCfg.XrayAutoDownload, poolCfg.XrayVersion); err != nil {
m.setErr(err)
} else if err := m.xray.configure(nodes); err != nil {
m.setErr(err)
} else {
m.setErr(nil)
}
} else {
_ = m.xray.configure(nil)
m.setErr(nil)
}
xrayUp := m.xray.status().Running
for _, ep := range desired {
switch ep.kind {
case "url":
ep.proxyURL = ep.url
case "xray":
if ep.parseErr == "" && xrayUp {
if port := m.xray.port(ep.name); port > 0 {
ep.proxyURL = fmt.Sprintf("socks5://127.0.0.1:%d", port)
}
}
}
}
m.mu.Lock()
m.endpoints = desired
for site, name := range m.sticky {
ep := desired[name]
if ep == nil || !ep.enabled || ep.proxyURL == "" {
delete(m.sticky, site)
}
}
m.mu.Unlock()
}
// RefreshNow refreshes every subscription and probes every endpoint, then
// reloads. Used by the console button.
func (m *Manager) RefreshNow(ctx context.Context) {
go func() {
m.Reload()
m.cfg.RLock()
enabled := m.cfg.ProxyPool.Enabled
subs := make(map[string]config.ProxySubscription, len(m.cfg.ProxyPool.Subscriptions))
for name, sc := range m.cfg.ProxyPool.Subscriptions {
if sc != nil {
subs[name] = *sc
}
}
m.cfg.RUnlock()
for name, sc := range subs {
if sc.Enabled && strings.TrimSpace(sc.URL) != "" {
m.fetchSub(ctx, name, sc)
}
}
if enabled {
m.checkAll(ctx)
}
m.Reload()
}()
}
func (m *Manager) fetchSub(ctx context.Context, name string, sc config.ProxySubscription) {
m.mu.Lock()
m.fetching[name] = true
m.mu.Unlock()
defer func() {
m.mu.Lock()
delete(m.fetching, name)
m.mu.Unlock()
}()
client, err := m.fetchClient()
if err == nil {
var req *http.Request
req, err = http.NewRequestWithContext(ctx, http.MethodGet, sc.URL, nil)
if err == nil {
var resp *http.Response
resp, err = client.Do(req)
if err == nil {
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
err = fmt.Errorf("subscription http %d", resp.StatusCode)
} else {
var raw []byte
raw, err = io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err == nil {
lines := ParseSubscriptionBody(raw)
if len(lines) == 0 {
err = fmt.Errorf("subscription returned no nodes")
} else {
err = nil
m.subResult(name, lines, nil)
m.log.Info("subscription refreshed", "name", name, "nodes", len(lines))
m.Reload()
return
}
}
}
}
}
}
m.subResult(name, nil, err)
m.log.Warn("subscription refresh failed", "name", name, "error", err)
}
func (m *Manager) subResult(name string, nodes []string, err error) {
m.mu.Lock()
defer m.mu.Unlock()
st := m.subs[name]
if st == nil {
st = &subState{name: name}
m.subs[name] = st
}
if err != nil {
st.lastErr = err.Error()
return
}
st.nodes = nodes
st.lastErr = ""
st.fetchedAt = time.Now()
}
func (m *Manager) fetchClient() (*http.Client, error) {
m.cfg.RLock()
proxy := strings.TrimSpace(m.cfg.Proxy.URL)
m.cfg.RUnlock()
tr := &http.Transport{
TLSHandshakeTimeout: 20 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
}
if proxy != "" {
u, err := url.Parse(proxy)
if err != nil {
return nil, fmt.Errorf("proxy url: %w", err)
}
tr.Proxy = http.ProxyURL(u)
}
return &http.Client{Transport: tr, Timeout: 45 * time.Second}, nil
}
func (m *Manager) checkAll(ctx context.Context) {
m.mu.Lock()
list := make([]*endpoint, 0, len(m.endpoints))
for _, ep := range m.endpoints {
list = append(list, ep)
}
m.mu.Unlock()
m.cfg.RLock()
timeout := time.Duration(m.cfg.ProxyPool.CheckTimeoutSeconds) * time.Second
checkURL := strings.TrimSpace(m.cfg.ProxyPool.CheckURL)
m.cfg.RUnlock()
if timeout <= 0 {
timeout = 10 * time.Second
}
if checkURL == "" {
checkURL = "https://www.gstatic.com/generate_204"
}
sem := make(chan struct{}, 4)
var wg sync.WaitGroup
for _, ep := range list {
if !ep.enabled || ep.proxyURL == "" {
continue
}
wg.Add(1)
sem <- struct{}{}
go func(ep *endpoint) {
defer wg.Done()
defer func() { <-sem }()
if ctx.Err() != nil {
return
}
ok, ms, errMsg := probeURL(ctx, ep.proxyURL, checkURL, timeout)
m.mu.Lock()
ep.checked = true
ep.checkedAt = time.Now()
ep.healthy = ok
ep.latencyMs = ms
ep.lastErr = errMsg
m.mu.Unlock()
}(ep)
}
wg.Wait()
m.mu.Lock()
m.lastCheck = time.Now()
m.mu.Unlock()
}
func probeURL(ctx context.Context, proxyURL, checkURL string, timeout time.Duration) (bool, int64, string) {
u, err := url.Parse(proxyURL)
if err != nil {
return false, 0, "bad proxy url"
}
tr := &http.Transport{
Proxy: http.ProxyURL(u),
TLSHandshakeTimeout: timeout,
ResponseHeaderTimeout: timeout,
DisableKeepAlives: true,
}
client := &http.Client{Transport: tr, Timeout: timeout}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, checkURL, nil)
if err != nil {
return false, 0, err.Error()
}
start := time.Now()
resp, err := client.Do(req)
ms := time.Since(start).Milliseconds()
if err != nil {
return false, ms, trimErr(err)
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
if resp.StatusCode >= 400 {
return false, ms, fmt.Sprintf("http %d", resp.StatusCode)
}
return true, ms, ""
}
// Candidates resolves the ordered route candidates for one site: its bound
// entries (or subscriptions) filtered to healthy ones, sticky pick first.
// It implements upstream.RouteSource.
func (m *Manager) Candidates(site string) []upstream.RouteCandidate {
m.cfg.RLock()
enabled := m.cfg.ProxyPool.Enabled
var items []string
if st, ok := m.cfg.Sites[site]; ok && st != nil {
items = append(items, st.Proxies...)
}
m.cfg.RUnlock()
if !enabled || len(items) == 0 {
return nil
}
m.mu.Lock()
defer m.mu.Unlock()
var all []*endpoint
for _, item := range items {
all = append(all, m.resolveItemLocked(item)...)
}
usable := make([]*endpoint, 0, len(all))
for _, ep := range all {
if ep.enabled && ep.proxyURL != "" && ep.parseErr == "" {
usable = append(usable, ep)
}
}
pick := make([]*endpoint, 0, len(usable))
for _, ep := range usable {
if !ep.checked || ep.healthy {
pick = append(pick, ep)
}
}
if len(pick) == 0 {
pick = usable
}
if len(pick) == 0 {
return nil
}
if chosen := m.sticky[site]; chosen != "" {
for i, ep := range pick {
if ep.name == chosen && i > 0 {
pick = append([]*endpoint{ep}, append(pick[:i], pick[i+1:]...)...)
break
}
}
}
m.sticky[site] = pick[0].name
out := make([]upstream.RouteCandidate, 0, len(pick))
for _, ep := range pick {
out = append(out, upstream.RouteCandidate{Name: ep.name, Proxy: ep.proxyURL})
}
return out
}
func (m *Manager) resolveItemLocked(item string) []*endpoint {
item = strings.TrimSpace(item)
if item == "" {
return nil
}
if name, ok := strings.CutPrefix(item, "sub:"); ok {
var out []*endpoint
for _, ep := range m.endpoints {
if ep.source == name {
out = append(out, ep)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].name < out[j].name })
return out
}
if ep := m.endpoints[item]; ep != nil {
return []*endpoint{ep}
}
return nil
}
// Report records how a request through `proxy` went, so a failing endpoint
// loses its sticky pick immediately and a healthy one is trusted again.
func (m *Manager) Report(proxy string, ok bool) {
if m == nil || proxy == "" {
return
}
m.mu.Lock()
defer m.mu.Unlock()
for _, ep := range m.endpoints {
if ep.proxyURL != proxy {
continue
}
ep.checked = true
ep.checkedAt = time.Now()
if ok {
ep.healthy = true
ep.lastErr = ""
} else {
ep.healthy = false
ep.lastErr = "request failed"
for site, name := range m.sticky {
if name == ep.name {
delete(m.sticky, site)
}
}
}
return
}
}
// Status is the console snapshot.
type Status struct {
Enabled bool `json:"enabled"`
CheckIntervalS int `json:"check_interval_seconds"`
CheckTimeoutS int `json:"check_timeout_seconds"`
CheckURL string `json:"check_url"`
DefaultScheme string `json:"default_scheme"`
XrayPath string `json:"xray_path"`
XrayVersion string `json:"xray_version"`
XrayAutoDownload bool `json:"xray_auto_download"`
Xray XrayStatus `json:"xray"`
Entries []EntryStatus `json:"entries"`
Subscriptions []SubStatus `json:"subscriptions"`
Sticky map[string]string `json:"sticky"`
LastError string `json:"last_error,omitempty"`
}
type EntryStatus struct {
Name string `json:"name"`
Kind string `json:"kind"`
Scheme string `json:"scheme"`
Target string `json:"target"`
Source string `json:"source,omitempty"`
Enabled bool `json:"enabled"`
Healthy bool `json:"healthy"`
Checked bool `json:"checked"`
LatencyMs int64 `json:"latency_ms"`
LastErr string `json:"last_error,omitempty"`
ParseError string `json:"parse_error,omitempty"`
CheckedAt int64 `json:"checked_at"`
}
type SubStatus struct {
Name string `json:"name"`
URL string `json:"url"`
Enabled bool `json:"enabled"`
IntervalMinutes int `json:"interval_minutes"`
Nodes int `json:"nodes"`
FetchedAt int64 `json:"fetched_at"`
LastErr string `json:"last_error,omitempty"`
}
func (m *Manager) Status() Status {
m.cfg.RLock()
poolCfg := m.cfg.ProxyPool
subsCfg := make(map[string]config.ProxySubscription, len(poolCfg.Subscriptions))
for name, sc := range poolCfg.Subscriptions {
if sc != nil {
subsCfg[name] = *sc
}
}
m.cfg.RUnlock()
m.mu.Lock()
// 在锁内把 endpoint 快照成值类型:检查循环/Report 会在 m.mu 下改
// ep.healthy/lastErr/latencyMs,锁外再读这些字段就是数据竞争。
entries := make([]EntryStatus, 0, len(m.endpoints))
for _, ep := range m.endpoints {
es := EntryStatus{
Name: ep.name, Kind: ep.kind, Scheme: ep.scheme, Target: ep.display,
Source: ep.source, Enabled: ep.enabled, Healthy: ep.healthy,
Checked: ep.checked, LatencyMs: ep.latencyMs, LastErr: ep.lastErr,
ParseError: ep.parseErr,
}
if !ep.checkedAt.IsZero() {
es.CheckedAt = ep.checkedAt.Unix()
}
entries = append(entries, es)
}
sticky := make(map[string]string, len(m.sticky))
for k, v := range m.sticky {
sticky[k] = v
}
subs := make(map[string]*subState, len(m.subs))
for name, st := range m.subs {
cp := *st
subs[name] = &cp
}
lastErr := m.lastErr
m.mu.Unlock()
st := Status{
Enabled: poolCfg.Enabled,
CheckIntervalS: poolCfg.CheckIntervalSeconds,
CheckTimeoutS: poolCfg.CheckTimeoutSeconds,
CheckURL: poolCfg.CheckURL,
DefaultScheme: poolCfg.DefaultScheme,
XrayPath: poolCfg.XrayPath,
XrayVersion: poolCfg.XrayVersion,
XrayAutoDownload: poolCfg.XrayAutoDownload,
Xray: m.xray.status(),
Entries: entries,
Sticky: sticky,
LastError: lastErr,
}
sort.Slice(st.Entries, func(i, j int) bool {
a, b := st.Entries[i], st.Entries[j]
if (a.Source == "") != (b.Source == "") {
return a.Source == ""
}
if a.Source != b.Source {
return a.Source < b.Source
}
return a.Name < b.Name
})
for name, sc := range subsCfg {
ss := SubStatus{Name: name, URL: sc.URL, Enabled: sc.Enabled, IntervalMinutes: sc.IntervalMinutes}
if stt := subs[name]; stt != nil {
ss.Nodes = len(stt.nodes)
ss.LastErr = stt.lastErr
if !stt.fetchedAt.IsZero() {
ss.FetchedAt = stt.fetchedAt.Unix()
}
}
st.Subscriptions = append(st.Subscriptions, ss)
}
sort.Slice(st.Subscriptions, func(i, j int) bool { return st.Subscriptions[i].Name < st.Subscriptions[j].Name })
return st
}
func newEndpoint(name, link, defaultScheme, source string) *endpoint {
ep := &endpoint{name: name, source: source, link: strings.TrimSpace(link)}
node, err := ParseNode(ep.link, defaultScheme)
if err != nil {
ep.kind = "invalid"
ep.parseErr = err.Error()
return ep
}
ep.kind = node.Kind
ep.scheme = node.Scheme
ep.display = node.Display
ep.outbound = node.Outbound
ep.url = node.URL
return ep
}
func (m *Manager) setErr(err error) {
m.mu.Lock()
defer m.mu.Unlock()
if err == nil {
m.lastErr = ""
return
}
m.lastErr = err.Error()
}
func shortHash(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:4])
}
func trimErr(err error) string {
s := err.Error()
if len(s) > 160 {
return s[:160] + "..."
}
return s
}