mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-02 18:53:50 +08:00
1147 lines
33 KiB
Go
1147 lines
33 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math/rand"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/Autumn-27/artex/traffic"
|
|
)
|
|
|
|
const (
|
|
settingProxyPool = "proxy_pool_entries"
|
|
settingProxyPoolActive = "proxy_pool_active_id"
|
|
settingProxyRotateTimed = "proxy_rotate_timed_enabled"
|
|
settingProxyRotateInterval = "proxy_rotate_interval_seconds"
|
|
settingProxyRotateFailure = "proxy_rotate_on_failure"
|
|
settingProxyRotateStrategy = "proxy_rotate_strategy"
|
|
settingProxyHealthThreshold = "proxy_health_fail_threshold"
|
|
settingProxyHealthCooldown = "proxy_health_cooldown_seconds"
|
|
settingProxyHealthInterval = "proxy_health_check_interval_seconds"
|
|
defaultProxyRotateInterval = 30
|
|
minProxyRotateInterval = 3
|
|
defaultProxyHealthThreshold = 3
|
|
defaultProxyHealthCooldown = 60
|
|
defaultProxyHealthInterval = 30
|
|
proxyPoolProbeTimeout = 10 * time.Second
|
|
proxyPoolMaxProbeConcurrency = 4
|
|
)
|
|
|
|
// ProxyPoolEntry is the public shape. URL is always masked in API responses.
|
|
type ProxyPoolEntry struct {
|
|
ID string `json:"id"`
|
|
URL string `json:"url"`
|
|
Name string `json:"name,omitempty"`
|
|
Remark string `json:"remark,omitempty"`
|
|
Enabled bool `json:"enabled"`
|
|
EgressIP string `json:"egress_ip,omitempty"`
|
|
LatencyMS int64 `json:"latency_ms,omitempty"`
|
|
LastError string `json:"last_error,omitempty"`
|
|
LastCheckedAt time.Time `json:"last_checked_at,omitempty"`
|
|
ConsecutiveFailures int `json:"consecutive_failures"`
|
|
Health string `json:"health"`
|
|
}
|
|
|
|
type proxyPoolEntry struct {
|
|
ProxyPoolEntry
|
|
RawURL string `json:"raw_url"`
|
|
}
|
|
|
|
type ProxySwitchLog struct {
|
|
At time.Time `json:"at"`
|
|
From string `json:"from"`
|
|
To string `json:"to"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
type proxyPoolPersist struct {
|
|
Entries []proxyPoolEntry `json:"entries"`
|
|
Active string `json:"active"`
|
|
}
|
|
|
|
type ProxyImportError struct {
|
|
Line int `json:"line"`
|
|
Input string `json:"input"`
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
// ParseProxyBatch accepts one proxy URL per line. Optional name and remark may
|
|
// follow as URL|name|remark. Duplicates are reported once and skipped.
|
|
func ParseProxyBatch(raw string) ([]ProxyPoolEntry, []ProxyImportError) {
|
|
var out []ProxyPoolEntry
|
|
var errs []ProxyImportError
|
|
seen := map[string]bool{}
|
|
for n, line := range strings.Split(raw, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
parts := strings.SplitN(line, "|", 3)
|
|
rawURL := strings.TrimSpace(parts[0])
|
|
u, err := traffic.ValidateProxyURL(rawURL)
|
|
if err != nil {
|
|
errs = append(errs, ProxyImportError{Line: n + 1, Input: maskSecrets(line), Error: maskSecrets(err.Error())})
|
|
continue
|
|
}
|
|
key := proxyURLKey(u)
|
|
if seen[key] {
|
|
errs = append(errs, ProxyImportError{Line: n + 1, Input: maskSecrets(line), Error: "重复代理,已跳过"})
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
name, remark := "", ""
|
|
if len(parts) > 1 {
|
|
name = strings.TrimSpace(parts[1])
|
|
}
|
|
if len(parts) > 2 {
|
|
remark = strings.TrimSpace(parts[2])
|
|
}
|
|
// New entries have not failed yet. Keep them available until a probe
|
|
// establishes a consecutive-failure streak.
|
|
out = append(out, ProxyPoolEntry{ID: fmt.Sprintf("proxy-%d-%d", time.Now().UnixNano(), n+1), URL: rawURL, Name: name, Remark: remark, Enabled: true, Health: "unknown"})
|
|
}
|
|
return out, errs
|
|
}
|
|
|
|
func proxyURLKey(u *url.URL) string {
|
|
if u == nil {
|
|
return ""
|
|
}
|
|
v := *u
|
|
v.Host = strings.ToLower(v.Host)
|
|
v.Scheme = strings.ToLower(v.Scheme)
|
|
return v.String()
|
|
}
|
|
|
|
func proxyURLKeyRaw(raw string) string {
|
|
u, err := traffic.ValidateProxyURL(raw)
|
|
if err != nil {
|
|
return strings.ToLower(strings.TrimSpace(raw))
|
|
}
|
|
return proxyURLKey(u)
|
|
}
|
|
|
|
type proxyPoolState struct {
|
|
mu sync.Mutex
|
|
entries []proxyPoolEntry
|
|
active string
|
|
logs []ProxySwitchLog
|
|
lastTimed time.Time
|
|
lastHealth time.Time
|
|
}
|
|
|
|
type proxyPoolTickOutcome struct {
|
|
confirmed bool
|
|
switched bool
|
|
}
|
|
|
|
func (m *Manager) initProxyPool() {
|
|
m.proxyPool = &proxyPoolState{}
|
|
persistedPool, poolSettingPresent, _ := m.pg.GetSetting(settingProxyPool)
|
|
if poolSettingPresent && strings.TrimSpace(persistedPool) != "" {
|
|
var p proxyPoolPersist
|
|
if json.Unmarshal([]byte(persistedPool), &p) == nil {
|
|
m.proxyPool.entries, m.proxyPool.active = p.Entries, p.Active
|
|
}
|
|
}
|
|
if m.proxyPool.active == "" {
|
|
if v, ok, _ := m.pg.GetSetting(settingProxyPoolActive); ok {
|
|
m.proxyPool.active = strings.TrimSpace(v)
|
|
}
|
|
}
|
|
if len(m.proxyPool.entries) == 0 && !poolSettingPresent && strings.TrimSpace(m.globalProxy) != "" {
|
|
m.proxyPool.entries = []proxyPoolEntry{{ProxyPoolEntry: ProxyPoolEntry{ID: "proxy-legacy", URL: maskSecrets(m.globalProxy), Enabled: true, Health: "healthy"}, RawURL: m.globalProxy}}
|
|
m.proxyPool.active = "proxy-legacy"
|
|
_ = m.persistProxyPoolLocked()
|
|
}
|
|
if len(m.proxyPool.entries) == 0 && poolSettingPresent {
|
|
m.globalProxy = ""
|
|
m.proxyPool.active = ""
|
|
_ = m.pg.SetSetting(settingGlobalProxy, "")
|
|
_ = m.pg.SetSetting(settingProxyPoolActive, "")
|
|
} else if len(m.proxyPool.entries) > 0 {
|
|
for i := range m.proxyPool.entries {
|
|
if strings.TrimSpace(m.proxyPool.entries[i].RawURL) != "" && m.proxyPool.entries[i].Health == "" {
|
|
m.proxyPool.entries[i].Health = "unknown"
|
|
}
|
|
}
|
|
chosen := ""
|
|
for _, e := range m.proxyPool.entries {
|
|
if e.ID == m.proxyPool.active && e.Enabled {
|
|
chosen = e.ID
|
|
break
|
|
}
|
|
}
|
|
if chosen == "" {
|
|
for _, e := range m.proxyPool.entries {
|
|
if e.Enabled && e.RawURL != "" && e.Health != "unhealthy-cooling" {
|
|
chosen = e.ID
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if chosen == "" {
|
|
for _, e := range m.proxyPool.entries {
|
|
if e.Enabled && e.RawURL != "" {
|
|
chosen = e.ID
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if chosen == "" {
|
|
m.globalProxy, m.proxyPool.active = "", ""
|
|
_ = m.pg.SetSetting(settingGlobalProxy, "")
|
|
_ = m.pg.SetSetting(settingProxyPoolActive, "")
|
|
} else {
|
|
m.proxyPool.active = chosen
|
|
for _, e := range m.proxyPool.entries {
|
|
if e.ID == chosen {
|
|
m.globalProxy = e.RawURL
|
|
break
|
|
}
|
|
}
|
|
_ = m.pg.SetSetting(settingProxyPoolActive, chosen)
|
|
_ = m.pg.SetSetting(settingGlobalProxy, m.globalProxy)
|
|
}
|
|
}
|
|
m.proxyPoolConfigured.Store(len(m.proxyPool.entries) > 0)
|
|
}
|
|
|
|
func (m *Manager) persistProxyPoolLocked() error {
|
|
if m.pg == nil {
|
|
return nil
|
|
}
|
|
b, err := json.Marshal(proxyPoolPersist{Entries: m.proxyPool.entries, Active: m.proxyPool.active})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return m.pg.SetSetting(settingProxyPool, string(b))
|
|
}
|
|
|
|
func (m *Manager) ProxyPoolSnapshot() ([]ProxyPoolEntry, string, []ProxySwitchLog) {
|
|
if m.proxyPool == nil {
|
|
return nil, "", nil
|
|
}
|
|
m.proxyPool.mu.Lock()
|
|
defer m.proxyPool.mu.Unlock()
|
|
entries := make([]ProxyPoolEntry, 0, len(m.proxyPool.entries))
|
|
for _, e := range m.proxyPool.entries {
|
|
p := e.ProxyPoolEntry
|
|
p.URL = maskSecrets(e.RawURL)
|
|
p.LastError = maskSecrets(p.LastError)
|
|
if !p.Enabled {
|
|
p.Health = "disabled"
|
|
}
|
|
entries = append(entries, p)
|
|
}
|
|
logs := append([]ProxySwitchLog(nil), m.proxyPool.logs...)
|
|
for i := range logs {
|
|
logs[i].From, logs[i].To = maskSecrets(logs[i].From), maskSecrets(logs[i].To)
|
|
}
|
|
return entries, m.proxyPool.active, logs
|
|
}
|
|
|
|
func (m *Manager) ActiveProxy() string { return m.GlobalProxy() }
|
|
|
|
func (m *Manager) proxyPoolEntries() []proxyPoolEntry {
|
|
if m.proxyPool == nil {
|
|
return nil
|
|
}
|
|
m.proxyPool.mu.Lock()
|
|
defer m.proxyPool.mu.Unlock()
|
|
return append([]proxyPoolEntry(nil), m.proxyPool.entries...)
|
|
}
|
|
|
|
func (m *Manager) setProxyPoolEntries(entries []proxyPoolEntry, active string) error {
|
|
if m.proxyPool == nil {
|
|
m.proxyPool = &proxyPoolState{}
|
|
}
|
|
m.proxyPool.mu.Lock()
|
|
defer m.proxyPool.mu.Unlock()
|
|
m.proxyPool.entries, m.proxyPool.active = entries, active
|
|
m.proxyPoolConfigured.Store(len(entries) > 0)
|
|
if err := m.persistProxyPoolLocked(); err != nil {
|
|
return err
|
|
}
|
|
if active == "" {
|
|
for _, e := range entries {
|
|
if e.Enabled && e.RawURL != "" && e.Health != "unhealthy-cooling" {
|
|
return m.applyActiveProxyLocked(e.ID, "reconnect")
|
|
}
|
|
}
|
|
for _, e := range entries {
|
|
if e.Enabled && e.RawURL != "" {
|
|
return m.applyActiveProxyLockedWithHealth(e.ID, "reconnect", true)
|
|
}
|
|
}
|
|
if strings.TrimSpace(m.GlobalProxy()) != "" {
|
|
return m.applyActiveProxyLocked("", "reconnect")
|
|
}
|
|
return nil
|
|
}
|
|
for _, e := range entries {
|
|
if e.ID == active && e.Enabled && e.Health == "healthy" {
|
|
return m.applyActiveProxyLocked(active, "manual")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) applyActiveProxyLocked(id, reason string) error {
|
|
return m.applyActiveProxyLockedWithHealth(id, reason, false)
|
|
}
|
|
|
|
func (m *Manager) applyActiveProxyLockedWithHealth(id, reason string, allowUnhealthy bool) error {
|
|
var next string
|
|
for _, e := range m.proxyPool.entries {
|
|
if e.ID == id && e.Enabled && e.RawURL != "" && (allowUnhealthy || e.Health != "unhealthy-cooling") {
|
|
next = e.RawURL
|
|
break
|
|
}
|
|
}
|
|
if id == "" {
|
|
next = ""
|
|
}
|
|
if id != "" && next == "" {
|
|
return errors.New("代理未启用或不健康")
|
|
}
|
|
m.mu.RLock()
|
|
from := m.globalProxy
|
|
m.mu.RUnlock()
|
|
if m.traffic != nil {
|
|
if err := m.traffic.SetUpstreamProxy(next); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
m.mu.Lock()
|
|
m.globalProxy = next
|
|
m.mu.Unlock()
|
|
m.proxyPool.active = id
|
|
if m.pg != nil {
|
|
_ = m.pg.SetSetting(settingGlobalProxy, next)
|
|
_ = m.pg.SetSetting(settingProxyPoolActive, id)
|
|
}
|
|
if from != next {
|
|
m.proxyPool.logs = append(m.proxyPool.logs, ProxySwitchLog{At: time.Now(), From: maskSecrets(from), To: maskSecrets(next), Reason: reason})
|
|
if len(m.proxyPool.logs) > 20 {
|
|
m.proxyPool.logs = m.proxyPool.logs[len(m.proxyPool.logs)-20:]
|
|
}
|
|
}
|
|
// Browser MCP reads the current capture/upstream route on each reconciliation.
|
|
if m.pg != nil {
|
|
m.syncBrowserMCPProxy()
|
|
}
|
|
return m.persistProxyPoolLocked()
|
|
}
|
|
|
|
func (m *Manager) selectHealthyLocked(exclude string) string {
|
|
strategy := "sequential"
|
|
if m.pg != nil {
|
|
if v, ok, _ := m.pg.GetSetting(settingProxyRotateStrategy); ok && v == "random" {
|
|
strategy = v
|
|
}
|
|
}
|
|
pool := make([]string, 0)
|
|
for _, e := range m.proxyPool.entries {
|
|
if e.Enabled && e.Health != "unhealthy-cooling" && e.ID != exclude {
|
|
pool = append(pool, e.ID)
|
|
}
|
|
}
|
|
if len(pool) == 0 && exclude != "" {
|
|
for _, e := range m.proxyPool.entries {
|
|
if e.Enabled && e.Health != "unhealthy-cooling" {
|
|
pool = append(pool, e.ID)
|
|
}
|
|
}
|
|
}
|
|
if len(pool) == 0 {
|
|
return ""
|
|
}
|
|
if strategy == "random" {
|
|
return pool[rand.Intn(len(pool))]
|
|
}
|
|
start := 0
|
|
for i, e := range m.proxyPool.entries {
|
|
if e.ID == exclude {
|
|
start = i + 1
|
|
break
|
|
}
|
|
}
|
|
for n := 0; n < len(m.proxyPool.entries); n++ {
|
|
e := m.proxyPool.entries[(start+n)%len(m.proxyPool.entries)]
|
|
if e.Enabled && e.Health != "unhealthy-cooling" && e.ID != exclude {
|
|
return e.ID
|
|
}
|
|
}
|
|
return pool[0]
|
|
}
|
|
|
|
func (m *Manager) rotateProxy(reason string) bool {
|
|
if m.proxyPool == nil {
|
|
return false
|
|
}
|
|
m.proxyPool.mu.Lock()
|
|
defer m.proxyPool.mu.Unlock()
|
|
id := m.selectHealthyLocked(m.proxyPool.active)
|
|
if id == "" || id == m.proxyPool.active {
|
|
return false
|
|
}
|
|
return m.applyActiveProxyLocked(id, reason) == nil
|
|
}
|
|
|
|
func (m *Manager) markProxyProbe(id string, res egressProbeResult) {
|
|
if m.proxyPool == nil {
|
|
return
|
|
}
|
|
m.proxyPool.mu.Lock()
|
|
defer m.proxyPool.mu.Unlock()
|
|
m.markProxyProbeLocked(id, res)
|
|
}
|
|
|
|
func (m *Manager) markProxyProbeLocked(id string, res egressProbeResult) {
|
|
for i := range m.proxyPool.entries {
|
|
e := &m.proxyPool.entries[i]
|
|
if e.ID != id {
|
|
continue
|
|
}
|
|
e.LastCheckedAt, e.LatencyMS, e.EgressIP = time.Now(), res.LatencyMS, res.ExitIP
|
|
if res.OK {
|
|
e.ConsecutiveFailures, e.LastError, e.Health = 0, "", "healthy"
|
|
} else {
|
|
e.ConsecutiveFailures++
|
|
e.LastError = maskSecrets(res.Error)
|
|
threshold := defaultProxyHealthThreshold
|
|
if m.pg != nil {
|
|
threshold = proxyIntSetting(m.pg, settingProxyHealthThreshold, defaultProxyHealthThreshold)
|
|
}
|
|
if e.ConsecutiveFailures >= threshold {
|
|
e.Health = "unhealthy-cooling"
|
|
}
|
|
}
|
|
_ = m.persistProxyPoolLocked()
|
|
return
|
|
}
|
|
}
|
|
|
|
func proxyIntSetting(pg interface {
|
|
GetSetting(string) (string, bool, error)
|
|
}, key string, def int) int {
|
|
if pg == nil {
|
|
return def
|
|
}
|
|
if v, ok, _ := pg.GetSetting(key); ok {
|
|
var n int
|
|
if _, err := fmt.Sscanf(strings.TrimSpace(v), "%d", &n); err == nil && n > 0 {
|
|
return n
|
|
}
|
|
}
|
|
return def
|
|
}
|
|
|
|
func (m *Manager) proxyHealthConfig() (threshold int, cooldown, interval time.Duration) {
|
|
if m.pg == nil {
|
|
return defaultProxyHealthThreshold, defaultProxyHealthCooldown * time.Second, defaultProxyHealthInterval * time.Second
|
|
}
|
|
return proxyIntSetting(m.pg, settingProxyHealthThreshold, defaultProxyHealthThreshold), time.Duration(proxyIntSetting(m.pg, settingProxyHealthCooldown, defaultProxyHealthCooldown)) * time.Second, time.Duration(proxyIntSetting(m.pg, settingProxyHealthInterval, defaultProxyHealthInterval)) * time.Second
|
|
}
|
|
|
|
func (m *Manager) SetProxyPoolSettings(timed bool, interval int, onFailure bool, strategy string, threshold, cooldown, healthInterval int) error {
|
|
if interval < minProxyRotateInterval {
|
|
return fmt.Errorf("定时轮换间隔不能小于 %d 秒", minProxyRotateInterval)
|
|
}
|
|
if strategy != "sequential" && strategy != "random" {
|
|
return errors.New("proxy_rotate_strategy 必须是 sequential 或 random")
|
|
}
|
|
if threshold < 1 || cooldown < 1 || healthInterval < 1 {
|
|
return errors.New("健康检查参数必须大于 0")
|
|
}
|
|
return m.pg.SetSettingsAtomic(context.Background(), map[string]string{settingProxyRotateTimed: fmt.Sprint(timed), settingProxyRotateInterval: fmt.Sprint(interval), settingProxyRotateFailure: fmt.Sprint(onFailure), settingProxyRotateStrategy: strategy, settingProxyHealthThreshold: fmt.Sprint(threshold), settingProxyHealthCooldown: fmt.Sprint(cooldown), settingProxyHealthInterval: fmt.Sprint(healthInterval)})
|
|
}
|
|
|
|
func (m *Manager) proxyRotateConfig() (bool, time.Duration, bool, string) {
|
|
if m.pg == nil {
|
|
return false, defaultProxyRotateInterval * time.Second, false, "sequential"
|
|
}
|
|
return m.pg.GetBool(settingProxyRotateTimed, false), time.Duration(proxyIntSetting(m.pg, settingProxyRotateInterval, defaultProxyRotateInterval)) * time.Second, m.pg.GetBool(settingProxyRotateFailure, false), func() string {
|
|
v, ok, _ := m.pg.GetSetting(settingProxyRotateStrategy)
|
|
if ok && v == "random" {
|
|
return v
|
|
}
|
|
return "sequential"
|
|
}()
|
|
}
|
|
|
|
func (s *Server) runProxyPoolSupervisor(ctx context.Context) {
|
|
for {
|
|
_, rotateInterval, _, _ := s.m.proxyRotateConfig()
|
|
_, _, healthInterval := s.m.proxyHealthConfig()
|
|
if rotateInterval <= 0 {
|
|
rotateInterval = defaultProxyRotateInterval * time.Second
|
|
}
|
|
if healthInterval <= 0 {
|
|
healthInterval = defaultProxyHealthInterval * time.Second
|
|
}
|
|
d := healthInterval
|
|
if timed, _, _, _ := s.m.proxyRotateConfig(); timed && rotateInterval < d {
|
|
d = rotateInterval
|
|
}
|
|
if d <= 0 {
|
|
d = time.Second
|
|
}
|
|
t := time.NewTimer(d)
|
|
select {
|
|
case <-ctx.Done():
|
|
if !t.Stop() {
|
|
select {
|
|
case <-t.C:
|
|
default:
|
|
}
|
|
}
|
|
return
|
|
case <-t.C:
|
|
s.proxyPoolTick()
|
|
case <-s.proxyPoolWake:
|
|
if !t.Stop() {
|
|
select {
|
|
case <-t.C:
|
|
default:
|
|
}
|
|
}
|
|
s.proxyPoolTick()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) proxyPoolTick() proxyPoolTickOutcome {
|
|
if s == nil {
|
|
return proxyPoolTickOutcome{}
|
|
}
|
|
s.proxyPoolTickMu.Lock()
|
|
defer s.proxyPoolTickMu.Unlock()
|
|
return s.proxyPoolTickLocked(false)
|
|
}
|
|
|
|
func (s *Server) proxyPoolTickLocked(forceHealth bool) proxyPoolTickOutcome {
|
|
var outcome proxyPoolTickOutcome
|
|
if s.m == nil || s.m.proxyPool == nil {
|
|
return outcome
|
|
}
|
|
now := time.Now()
|
|
threshold, cooldown, healthInterval := s.m.proxyHealthConfig()
|
|
s.m.proxyPool.mu.Lock()
|
|
healthDue := s.m.proxyPool.lastHealth.IsZero() || now.Sub(s.m.proxyPool.lastHealth) >= healthInterval
|
|
if !healthDue {
|
|
// A cooling member becomes probe-eligible at its own cooldown deadline;
|
|
// do not make it wait for the next full health-check interval.
|
|
for _, e := range s.m.proxyPool.entries {
|
|
if e.Enabled && e.Health == "unhealthy-cooling" && (e.LastCheckedAt.IsZero() || now.Sub(e.LastCheckedAt) >= cooldown) {
|
|
healthDue = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if healthDue {
|
|
s.m.proxyPool.lastHealth = now
|
|
}
|
|
s.m.proxyPool.mu.Unlock()
|
|
if healthDue || forceHealth {
|
|
entries := s.m.proxyPoolEntries()
|
|
sem := s.proxyPoolProbeSem
|
|
if sem == nil {
|
|
sem = make(chan struct{}, proxyPoolMaxProbeConcurrency)
|
|
}
|
|
activeAtProbe := s.m.proxyPoolActiveID()
|
|
probeResults := make(map[string]bool, len(entries))
|
|
var probeMu sync.Mutex
|
|
var wg sync.WaitGroup
|
|
for _, e := range entries {
|
|
if !e.Enabled {
|
|
continue
|
|
}
|
|
if !forceHealth && e.Health == "unhealthy-cooling" && e.ConsecutiveFailures >= threshold && !e.LastCheckedAt.IsZero() && now.Sub(e.LastCheckedAt) < cooldown {
|
|
continue
|
|
}
|
|
e := e
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
select {
|
|
case sem <- struct{}{}:
|
|
case <-serverContext(s).Done():
|
|
return
|
|
}
|
|
defer func() { <-sem }()
|
|
res := s.runProxyProbe(serverContext(s), e.RawURL, "", defaultEgressProbes, proxyPoolProbeTimeout)
|
|
s.m.markProxyProbe(e.ID, res)
|
|
probeMu.Lock()
|
|
probeResults[e.ID] = res.OK
|
|
probeMu.Unlock()
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
probeMu.Lock()
|
|
confirmed := probeResults[activeAtProbe]
|
|
probeMu.Unlock()
|
|
if confirmed {
|
|
outcome.confirmed = true
|
|
}
|
|
if s.reconcileProxyPoolAfterProbe(confirmed) {
|
|
outcome.switched = true
|
|
}
|
|
}
|
|
|
|
// Health reconciliation runs before timed rotation. This matters when an
|
|
// import starts a fresh probe round: tasks are not paused or rerouted until
|
|
// the new members have been checked. A successful recovery switch already
|
|
// selected the best available member for this round, so avoid immediately
|
|
// rotating away from it.
|
|
timed, rotateInterval, _, _ := s.m.proxyRotateConfig()
|
|
if timed && !outcome.switched {
|
|
s.m.proxyPool.mu.Lock()
|
|
due := s.m.proxyPool.lastTimed.IsZero() || now.Sub(s.m.proxyPool.lastTimed) >= rotateInterval
|
|
if due {
|
|
s.m.proxyPool.lastTimed = now
|
|
}
|
|
s.m.proxyPool.mu.Unlock()
|
|
if due {
|
|
before := s.m.proxyPoolActiveID()
|
|
if s.m.rotateProxy("timed") && before != s.m.proxyPoolActiveID() {
|
|
outcome.switched = true
|
|
s.proxyReconnectSuccess()
|
|
}
|
|
}
|
|
}
|
|
return outcome
|
|
}
|
|
|
|
func serverContext(s *Server) context.Context {
|
|
if s != nil && s.ctx != nil {
|
|
return s.ctx
|
|
}
|
|
return context.Background()
|
|
}
|
|
|
|
func (s *Server) reconcileProxyPoolAfterProbe(activeConfirmed bool) bool {
|
|
entries := s.m.proxyPoolEntries()
|
|
activeID := s.m.proxyPoolActiveID()
|
|
activeAvailable := false
|
|
for _, e := range entries {
|
|
if e.ID == activeID && e.Enabled && e.RawURL != "" && e.Health != "unhealthy-cooling" {
|
|
activeAvailable = true
|
|
break
|
|
}
|
|
}
|
|
if activeAvailable && activeConfirmed {
|
|
s.proxyReconnectSuccess()
|
|
return false
|
|
}
|
|
if activeAvailable {
|
|
return false
|
|
}
|
|
s.m.proxyPool.mu.Lock()
|
|
id := s.m.selectHealthyLocked(activeID)
|
|
var err error
|
|
if id != "" {
|
|
err = s.m.applyActiveProxyLocked(id, "reconnect")
|
|
}
|
|
s.m.proxyPool.mu.Unlock()
|
|
if err == nil && id != "" {
|
|
s.proxyReconnectSuccess()
|
|
return true
|
|
}
|
|
allCooling := false
|
|
enabled := 0
|
|
for _, e := range entries {
|
|
if e.Enabled && e.RawURL != "" {
|
|
enabled++
|
|
if e.Health == "unhealthy-cooling" {
|
|
allCooling = true
|
|
} else {
|
|
allCooling = false
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if id == "" && enabled > 0 && allCooling && (activeID != "" || strings.TrimSpace(s.m.GlobalProxy()) != "") {
|
|
s.pauseAllProxyTasks(errors.New("代理池暂无健康成员"))
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (m *Manager) activateProxyID(id, reason string) error {
|
|
if m.proxyPool == nil {
|
|
return errors.New("代理池未初始化")
|
|
}
|
|
m.proxyPool.mu.Lock()
|
|
defer m.proxyPool.mu.Unlock()
|
|
if err := m.applyActiveProxyLocked(id, reason); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleProxyPoolFailure(cause error) bool {
|
|
if s == nil || s.m == nil || s.m.proxyPool == nil || !isGlobalProxyDialFailure(cause) {
|
|
return false
|
|
}
|
|
cur := strings.TrimSpace(s.m.GlobalProxy())
|
|
if cur == "" {
|
|
return false
|
|
}
|
|
res := s.runProxyProbe(serverContext(s), cur, "", defaultEgressProbes, proxyPoolProbeTimeout)
|
|
if res.OK {
|
|
return false
|
|
}
|
|
_, _, onFailure, _ := s.m.proxyRotateConfig()
|
|
s.proxyPoolTickMu.Lock()
|
|
s.m.proxyPool.mu.Lock()
|
|
active := s.m.proxyPool.active
|
|
currentChanged := s.m.GlobalProxy() != cur
|
|
for _, e := range s.m.proxyPool.entries {
|
|
if e.RawURL == cur {
|
|
s.m.markProxyProbeLocked(e.ID, res)
|
|
break
|
|
}
|
|
}
|
|
if currentChanged {
|
|
s.m.proxyPool.mu.Unlock()
|
|
s.proxyPoolTickMu.Unlock()
|
|
return true
|
|
}
|
|
if onFailure {
|
|
if id := s.m.selectHealthyLocked(active); id != "" && id != active {
|
|
if err := s.m.applyActiveProxyLocked(id, "failure"); err == nil {
|
|
s.m.proxyPool.mu.Unlock()
|
|
s.proxyPoolTickMu.Unlock()
|
|
s.proxyReconnectSuccess()
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
select {
|
|
case s.proxyPoolWake <- struct{}{}:
|
|
default:
|
|
}
|
|
s.m.proxyPool.mu.Unlock()
|
|
s.proxyPoolTickMu.Unlock()
|
|
return false
|
|
}
|
|
|
|
func (s *Server) pauseAllProxyTasks(cause error) {
|
|
for _, t := range s.m.List() {
|
|
st := t.lifecycleSnapshot()
|
|
if isTerminalStatus(st.Status) || st.Paused || (s.engine != nil && s.engine.IsDeleting(t.ID)) {
|
|
continue
|
|
}
|
|
s.pauseProxyTask(t.ID, cause)
|
|
}
|
|
}
|
|
|
|
func (s *Server) getProxyPool(w http.ResponseWriter, r *http.Request) {
|
|
entries, active, logs := s.m.ProxyPoolSnapshot()
|
|
timed, interval, onFailure, strategy := s.m.proxyRotateConfig()
|
|
threshold, cooldown, healthInterval := s.m.proxyHealthConfig()
|
|
writeJSON(w, 200, map[string]any{"entries": entries, "active_id": active, "active_proxy": maskSecrets(s.m.GlobalProxy()), "switch_log": logs, "timed_enabled": timed, "interval_seconds": int(interval / time.Second), "on_failure": onFailure, "strategy": strategy, "health_fail_threshold": threshold, "health_cooldown_seconds": int(cooldown / time.Second), "health_check_interval_seconds": int(healthInterval / time.Second)})
|
|
}
|
|
|
|
func (s *Server) importProxyPool(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Text string `json:"text"`
|
|
TimedEnabled *bool `json:"timed_enabled"`
|
|
Interval *int `json:"interval_seconds"`
|
|
OnFailure *bool `json:"on_failure"`
|
|
Strategy *string `json:"strategy"`
|
|
Threshold *int `json:"health_fail_threshold"`
|
|
Cooldown *int `json:"health_cooldown_seconds"`
|
|
HealthInterval *int `json:"health_check_interval_seconds"`
|
|
}
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
parsed, errs := ParseProxyBatch(req.Text)
|
|
entries := s.m.proxyPoolEntries()
|
|
seen := map[string]bool{}
|
|
for _, e := range entries {
|
|
seen[proxyURLKeyRaw(e.RawURL)] = true
|
|
}
|
|
for _, p := range parsed {
|
|
key := proxyURLKeyRaw(p.URL)
|
|
if seen[key] {
|
|
line := 0
|
|
if i := strings.LastIndex(p.ID, "-"); i >= 0 {
|
|
_, _ = fmt.Sscanf(p.ID[i+1:], "%d", &line)
|
|
}
|
|
errs = append(errs, ProxyImportError{Line: line, Input: maskSecrets(p.URL), Error: "重复代理,已跳过"})
|
|
continue
|
|
}
|
|
u, _ := traffic.ValidateProxyURL(p.URL)
|
|
entries = append(entries, proxyPoolEntry{ProxyPoolEntry: p, RawURL: u.String()})
|
|
seen[proxyURLKey(u)] = true
|
|
}
|
|
active := s.m.proxyPoolActiveID()
|
|
if active != "" {
|
|
foundActive := false
|
|
for _, e := range entries {
|
|
if e.ID == active {
|
|
foundActive = true
|
|
break
|
|
}
|
|
}
|
|
if !foundActive {
|
|
active = ""
|
|
}
|
|
}
|
|
if err := s.m.setProxyPoolEntries(entries, active); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if req.Interval != nil || req.TimedEnabled != nil || req.OnFailure != nil || req.Strategy != nil || req.Threshold != nil || req.Cooldown != nil || req.HealthInterval != nil {
|
|
timed, interval, onFailure, strategy := s.m.proxyRotateConfig()
|
|
threshold, cooldown, healthInterval := s.m.proxyHealthConfig()
|
|
if req.TimedEnabled != nil {
|
|
timed = *req.TimedEnabled
|
|
}
|
|
if req.Interval != nil {
|
|
interval = time.Duration(*req.Interval) * time.Second
|
|
}
|
|
if req.OnFailure != nil {
|
|
onFailure = *req.OnFailure
|
|
}
|
|
if req.Strategy != nil {
|
|
strategy = *req.Strategy
|
|
}
|
|
if req.Threshold != nil {
|
|
threshold = *req.Threshold
|
|
}
|
|
if req.Cooldown != nil {
|
|
cooldown = time.Duration(*req.Cooldown) * time.Second
|
|
}
|
|
if req.HealthInterval != nil {
|
|
healthInterval = time.Duration(*req.HealthInterval) * time.Second
|
|
}
|
|
if err := s.m.SetProxyPoolSettings(timed, int(interval/time.Second), onFailure, strategy, threshold, int(cooldown/time.Second), int(healthInterval/time.Second)); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
}
|
|
// Probe imported/unchecked entries before any pool-wide pause decision. The
|
|
// task state is untouched until this round has completed.
|
|
s.proxyPoolTick()
|
|
entriesOut, activeOut, logs := s.m.ProxyPoolSnapshot()
|
|
writeJSON(w, 200, map[string]any{"entries": entriesOut, "active_id": activeOut, "switch_log": logs, "errors": errs})
|
|
}
|
|
|
|
func (m *Manager) proxyPoolActiveID() string {
|
|
if m.proxyPool == nil {
|
|
return ""
|
|
}
|
|
m.proxyPool.mu.Lock()
|
|
defer m.proxyPool.mu.Unlock()
|
|
return m.proxyPool.active
|
|
}
|
|
|
|
func mergeMaskedProxyURL(raw, old string) (string, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return "", nil
|
|
}
|
|
nu, err := traffic.ValidateProxyURL(raw)
|
|
if err != nil {
|
|
return "", fmt.Errorf("%s", maskSecrets(err.Error()))
|
|
}
|
|
if ou, _ := traffic.ValidateProxyURL(old); ou != nil && nu.User != nil && ou.User != nil {
|
|
newUser := nu.User.Username()
|
|
newPW, hasNewPW := nu.User.Password()
|
|
maskedUser := newUser == "***" || newUser == "******"
|
|
maskedPW := hasNewPW && (strings.Contains(newPW, "***") || strings.Contains(newPW, "******"))
|
|
if maskedUser {
|
|
oldPW, hasOldPW := ou.User.Password()
|
|
if hasOldPW {
|
|
nu.User = url.UserPassword(ou.User.Username(), oldPW)
|
|
} else {
|
|
nu.User = url.User(ou.User.Username())
|
|
}
|
|
} else if maskedPW {
|
|
if oldPW, hasOldPW := ou.User.Password(); hasOldPW {
|
|
nu.User = url.UserPassword(newUser, oldPW)
|
|
} else {
|
|
nu.User = url.User(newUser)
|
|
}
|
|
}
|
|
}
|
|
return nu.String(), nil
|
|
}
|
|
|
|
func (s *Server) updateProxyPool(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
var req struct {
|
|
URL *string `json:"url"`
|
|
Name *string `json:"name"`
|
|
Remark *string `json:"remark"`
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
if s.m.proxyPool == nil {
|
|
writeErr(w, 404, "代理池为空")
|
|
return
|
|
}
|
|
needPause := false
|
|
switched := false
|
|
routed := false
|
|
s.m.proxyPool.mu.Lock()
|
|
for i := range s.m.proxyPool.entries {
|
|
e := &s.m.proxyPool.entries[i]
|
|
if e.ID != id {
|
|
continue
|
|
}
|
|
oldEntry := *e
|
|
oldRaw := e.RawURL
|
|
if req.URL != nil {
|
|
raw, err := mergeMaskedProxyURL(*req.URL, oldRaw)
|
|
if err != nil {
|
|
s.m.proxyPool.mu.Unlock()
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
e.RawURL, e.URL = raw, maskSecrets(raw)
|
|
if e.ID == s.m.proxyPool.active && e.Enabled && raw != "" {
|
|
// A validated edit replaces the endpoint and starts a fresh health round,
|
|
// including when the old endpoint was cooling down.
|
|
e.Health, e.ConsecutiveFailures, e.LastError = "healthy", 0, ""
|
|
}
|
|
}
|
|
if req.Name != nil {
|
|
e.Name = strings.TrimSpace(*req.Name)
|
|
}
|
|
if req.Remark != nil {
|
|
e.Remark = strings.TrimSpace(*req.Remark)
|
|
}
|
|
if req.Enabled != nil {
|
|
e.Enabled = *req.Enabled
|
|
if !e.Enabled {
|
|
e.Health = "disabled"
|
|
} else if e.Health == "disabled" {
|
|
e.Health = "healthy"
|
|
}
|
|
}
|
|
if s.m.proxyPool.active == id {
|
|
if req.URL != nil || req.Enabled != nil {
|
|
if e.Enabled && e.RawURL != "" {
|
|
if err := s.m.applyActiveProxyLockedWithHealth(id, "manual", req.URL != nil); err != nil {
|
|
*e = oldEntry
|
|
s.m.proxyPool.mu.Unlock()
|
|
writeErr(w, 400, maskSecrets(err.Error()))
|
|
return
|
|
}
|
|
routed = true
|
|
} else if next := s.m.selectHealthyLocked(id); next != "" {
|
|
for _, candidate := range s.m.proxyPool.entries {
|
|
if candidate.ID == next && candidate.RawURL == "" {
|
|
next = ""
|
|
break
|
|
}
|
|
}
|
|
if next == "" {
|
|
// Fall through to the enabled-member fallback below.
|
|
} else {
|
|
if err := s.m.applyActiveProxyLocked(next, "manual"); err != nil {
|
|
s.m.proxyPool.mu.Unlock()
|
|
writeErr(w, 400, maskSecrets(err.Error()))
|
|
return
|
|
}
|
|
switched = true
|
|
routed = true
|
|
}
|
|
}
|
|
if !routed {
|
|
// A disabled or cleared active member must never remain the
|
|
// runtime route. Keep traffic on any surviving enabled member,
|
|
// including a cooling one, until the normal reconnect loop can
|
|
// establish a healthy route.
|
|
next := ""
|
|
for _, candidate := range s.m.proxyPool.entries {
|
|
if candidate.ID != id && candidate.Enabled && candidate.RawURL != "" {
|
|
next = candidate.ID
|
|
break
|
|
}
|
|
}
|
|
if next != "" {
|
|
if err := s.m.applyActiveProxyLockedWithHealth(next, "manual", true); err != nil {
|
|
s.m.proxyPool.mu.Unlock()
|
|
writeErr(w, 400, maskSecrets(err.Error()))
|
|
return
|
|
}
|
|
needPause = true
|
|
} else {
|
|
if err := s.m.applyActiveProxyLocked("", "manual"); err != nil {
|
|
s.m.proxyPool.mu.Unlock()
|
|
writeErr(w, 400, maskSecrets(err.Error()))
|
|
return
|
|
}
|
|
needPause = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if err := s.m.persistProxyPoolLocked(); err != nil {
|
|
s.m.proxyPool.mu.Unlock()
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
out := e.ProxyPoolEntry
|
|
out.URL = maskSecrets(e.RawURL)
|
|
if !out.Enabled {
|
|
out.Health = "disabled"
|
|
}
|
|
s.m.proxyPool.mu.Unlock()
|
|
if needPause {
|
|
s.pauseAllProxyTasks(errors.New("代理池暂无健康成员"))
|
|
}
|
|
if switched {
|
|
s.proxyReconnectSuccess()
|
|
}
|
|
writeJSON(w, 200, map[string]any{"entry": out})
|
|
return
|
|
}
|
|
s.m.proxyPool.mu.Unlock()
|
|
writeErr(w, 404, "代理不存在")
|
|
}
|
|
|
|
func (s *Server) deleteProxyPool(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
if s.m.proxyPool == nil {
|
|
writeErr(w, 404, "代理不存在")
|
|
return
|
|
}
|
|
needPause := false
|
|
switched := false
|
|
s.m.proxyPool.mu.Lock()
|
|
found := false
|
|
out := s.m.proxyPool.entries[:0]
|
|
for _, e := range s.m.proxyPool.entries {
|
|
if e.ID == id {
|
|
found = true
|
|
continue
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
if !found {
|
|
s.m.proxyPool.mu.Unlock()
|
|
writeErr(w, 404, "代理不存在")
|
|
return
|
|
}
|
|
s.m.proxyPool.entries = out
|
|
s.m.proxyPoolConfigured.Store(len(out) > 0)
|
|
if s.m.proxyPool.active == id {
|
|
if next := s.m.selectHealthyLocked(id); next != "" {
|
|
if err := s.m.applyActiveProxyLocked(next, "manual"); err != nil {
|
|
s.m.proxyPool.mu.Unlock()
|
|
writeErr(w, 500, maskSecrets(err.Error()))
|
|
return
|
|
}
|
|
switched = true
|
|
} else {
|
|
// Keep runtime traffic attached to a surviving pool member even when
|
|
// every remaining member is cooling down; never retain the deleted URL.
|
|
next := ""
|
|
for _, e := range s.m.proxyPool.entries {
|
|
if e.Enabled && e.RawURL != "" {
|
|
next = e.ID
|
|
break
|
|
}
|
|
}
|
|
if next != "" {
|
|
if err := s.m.applyActiveProxyLockedWithHealth(next, "manual", true); err != nil {
|
|
s.m.proxyPool.mu.Unlock()
|
|
writeErr(w, 500, maskSecrets(err.Error()))
|
|
return
|
|
}
|
|
needPause = true
|
|
} else {
|
|
if err := s.m.applyActiveProxyLocked("", "manual"); err != nil {
|
|
s.m.proxyPool.mu.Unlock()
|
|
writeErr(w, 500, maskSecrets(err.Error()))
|
|
return
|
|
}
|
|
// Clearing the last usable route leaves no proxy to recover.
|
|
// Only an enabled member that is actually cooling warrants the
|
|
// pool-wide proxy pause barrier.
|
|
for _, candidate := range s.m.proxyPool.entries {
|
|
if candidate.Enabled && candidate.RawURL != "" && candidate.Health == "unhealthy-cooling" {
|
|
needPause = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if err := s.m.persistProxyPoolLocked(); err != nil {
|
|
s.m.proxyPool.mu.Unlock()
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
s.m.proxyPool.mu.Unlock()
|
|
if needPause {
|
|
s.pauseAllProxyTasks(errors.New("代理池暂无健康成员"))
|
|
}
|
|
if switched {
|
|
s.proxyReconnectSuccess()
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
func (s *Server) activateProxyPool(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
before := s.m.proxyPoolActiveID()
|
|
if err := s.m.activateProxyID(id, "manual"); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
if before != id {
|
|
s.proxyReconnectSuccess()
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true, "active_id": id, "active_proxy": maskSecrets(s.m.GlobalProxy())})
|
|
}
|
|
|
|
func (s *Server) testProxyPoolEntry(w http.ResponseWriter, r *http.Request) {
|
|
s.proxyPoolTickMu.Lock()
|
|
defer s.proxyPoolTickMu.Unlock()
|
|
id := r.PathValue("id")
|
|
for _, e := range s.m.proxyPoolEntries() {
|
|
if e.ID == id {
|
|
res := s.runProxyProbe(r.Context(), e.RawURL, "", defaultEgressProbes, proxyPoolProbeTimeout)
|
|
s.m.markProxyProbe(id, res)
|
|
if res.OK {
|
|
s.reconcileProxyPoolAfterProbe(id == s.m.proxyPoolActiveID())
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": res.OK, "result": res})
|
|
return
|
|
}
|
|
}
|
|
writeErr(w, 404, "代理不存在")
|
|
}
|
|
|
|
func (s *Server) testAllProxyPool(w http.ResponseWriter, r *http.Request) {
|
|
s.proxyPoolTickMu.Lock()
|
|
if s.m != nil && s.m.proxyPool != nil {
|
|
s.m.proxyPool.mu.Lock()
|
|
s.m.proxyPool.lastHealth = time.Time{}
|
|
s.m.proxyPool.mu.Unlock()
|
|
}
|
|
s.proxyPoolTickLocked(true)
|
|
s.proxyPoolTickMu.Unlock()
|
|
entries, active, logs := s.m.ProxyPoolSnapshot()
|
|
writeJSON(w, 200, map[string]any{"entries": entries, "active_id": active, "switch_log": logs})
|
|
}
|