mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-03 11:13:50 +08:00
783 lines
22 KiB
Go
783 lines
22 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math/rand"
|
|
"net/http"
|
|
"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 := strings.ToLower(u.String())
|
|
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])
|
|
}
|
|
out = append(out, ProxyPoolEntry{ID: fmt.Sprintf("proxy-%d", time.Now().UnixNano()+int64(n)), URL: rawURL, Name: name, Remark: remark, Enabled: true, Health: "unhealthy-cooling"})
|
|
}
|
|
return out, errs
|
|
}
|
|
|
|
type proxyPoolState struct {
|
|
mu sync.Mutex
|
|
entries []proxyPoolEntry
|
|
active string
|
|
logs []ProxySwitchLog
|
|
lastTimed time.Time
|
|
}
|
|
|
|
func (m *Manager) initProxyPool() {
|
|
m.proxyPool = &proxyPoolState{}
|
|
if raw, ok, _ := m.pg.GetSetting(settingProxyPool); ok && strings.TrimSpace(raw) != "" {
|
|
var p proxyPoolPersist
|
|
if json.Unmarshal([]byte(raw), &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 && 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 m.proxyPool.active != "" {
|
|
for _, e := range m.proxyPool.entries {
|
|
if e.ID == m.proxyPool.active {
|
|
m.globalProxy = e.RawURL
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
if err := m.persistProxyPoolLocked(); err != nil {
|
|
return err
|
|
}
|
|
for _, e := range entries {
|
|
if e.ID == active && e.Enabled && e.Health == "healthy" {
|
|
m.mu.Lock()
|
|
m.globalProxy = e.RawURL
|
|
m.mu.Unlock()
|
|
if m.pg != nil {
|
|
_ = m.pg.SetSetting(settingGlobalProxy, e.RawURL)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if active == "" {
|
|
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 {
|
|
var next string
|
|
for _, e := range m.proxyPool.entries {
|
|
if e.ID == id && e.Enabled && e.Health == "healthy" {
|
|
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 == "healthy" && e.ID != exclude {
|
|
pool = append(pool, e.ID)
|
|
}
|
|
}
|
|
if len(pool) == 0 && exclude != "" {
|
|
for _, e := range m.proxyPool.entries {
|
|
if e.Enabled && e.Health == "healthy" {
|
|
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 == "healthy" && 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()
|
|
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 := 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 {
|
|
_, interval, _, _ := s.m.proxyRotateConfig()
|
|
_, _, healthInterval := s.m.proxyHealthConfig()
|
|
if healthInterval > 0 && healthInterval < interval {
|
|
interval = healthInterval
|
|
}
|
|
if interval < minProxyRotateInterval*time.Second {
|
|
interval = minProxyRotateInterval * time.Second
|
|
}
|
|
t := time.NewTimer(interval)
|
|
select {
|
|
case <-ctx.Done():
|
|
if !t.Stop() {
|
|
<-t.C
|
|
}
|
|
return
|
|
case <-t.C:
|
|
s.proxyPoolTick()
|
|
case <-s.proxyPoolWake:
|
|
if !t.Stop() {
|
|
<-t.C
|
|
}
|
|
s.proxyPoolTick()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) proxyPoolTick() {
|
|
if s == nil || s.m == nil || s.m.proxyPool == nil {
|
|
return
|
|
}
|
|
timed, rotateInterval, _, _ := s.m.proxyRotateConfig()
|
|
if timed {
|
|
s.m.proxyPool.mu.Lock()
|
|
due := s.m.proxyPool.lastTimed.IsZero() || time.Since(s.m.proxyPool.lastTimed) >= rotateInterval
|
|
if due {
|
|
s.m.proxyPool.lastTimed = time.Now()
|
|
}
|
|
s.m.proxyPool.mu.Unlock()
|
|
if due {
|
|
_ = s.m.rotateProxy("timed")
|
|
}
|
|
}
|
|
threshold, cooldown, _ := s.m.proxyHealthConfig()
|
|
entries := s.m.proxyPoolEntries()
|
|
sem := s.proxyPoolProbeSem
|
|
if sem == nil {
|
|
sem = make(chan struct{}, proxyPoolMaxProbeConcurrency)
|
|
}
|
|
var wg sync.WaitGroup
|
|
now := time.Now()
|
|
for _, e := range entries {
|
|
if !e.Enabled {
|
|
continue
|
|
}
|
|
if 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 <-s.ctx.Done():
|
|
return
|
|
}
|
|
defer func() { <-sem }()
|
|
res := s.runProxyProbe(s.ctx, e.RawURL, "", defaultEgressProbes, proxyPoolProbeTimeout)
|
|
s.m.markProxyProbe(e.ID, res)
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
// A successful probe can become the active proxy after the pool was down or
|
|
// after the previously active entry cooled out.
|
|
entries = s.m.proxyPoolEntries()
|
|
activeID := s.m.proxyPoolActiveID()
|
|
activeHealthy := false
|
|
for _, e := range entries {
|
|
if e.ID == activeID && e.Enabled && e.Health == "healthy" {
|
|
activeHealthy = true
|
|
break
|
|
}
|
|
}
|
|
if !activeHealthy {
|
|
for _, e := range entries {
|
|
if e.Enabled && e.Health == "healthy" {
|
|
_ = s.m.activateProxyID(e.ID, "reconnect")
|
|
s.proxyReconnectSuccess()
|
|
return
|
|
}
|
|
}
|
|
if activeID != "" {
|
|
_ = s.m.activateProxyID("", "reconnect")
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
return false
|
|
}
|
|
if !isGlobalProxyDialFailure(cause) || strings.TrimSpace(s.m.GlobalProxy()) == "" {
|
|
return false
|
|
}
|
|
cur := s.m.GlobalProxy()
|
|
res := s.runProxyProbe(context.Background(), cur, "", defaultEgressProbes, proxyPoolProbeTimeout)
|
|
if res.OK {
|
|
return false
|
|
}
|
|
for _, e := range s.m.proxyPoolEntries() {
|
|
if e.RawURL == cur {
|
|
s.m.markProxyProbe(e.ID, res)
|
|
break
|
|
}
|
|
}
|
|
_, _, onFailure, _ := s.m.proxyRotateConfig()
|
|
if onFailure && s.m.rotateProxy("failure") {
|
|
return true
|
|
}
|
|
select {
|
|
case s.proxyPoolWake <- struct{}{}:
|
|
default:
|
|
}
|
|
return false
|
|
}
|
|
|
|
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[e.RawURL] = true
|
|
}
|
|
for _, p := range parsed {
|
|
if seen[p.URL] {
|
|
continue
|
|
}
|
|
u, _ := traffic.ValidateProxyURL(p.URL)
|
|
entries = append(entries, proxyPoolEntry{ProxyPoolEntry: p, RawURL: u.String()})
|
|
seen[u.String()] = 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
|
|
}
|
|
select {
|
|
case s.proxyPoolWake <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
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 (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
|
|
}
|
|
s.m.proxyPool.mu.Lock()
|
|
defer s.m.proxyPool.mu.Unlock()
|
|
for i := range s.m.proxyPool.entries {
|
|
e := &s.m.proxyPool.entries[i]
|
|
if e.ID != id {
|
|
continue
|
|
}
|
|
if req.URL != nil {
|
|
raw := strings.TrimSpace(*req.URL)
|
|
if strings.Contains(raw, "***") || strings.Contains(raw, "******") {
|
|
raw = e.RawURL
|
|
}
|
|
if e.ID == s.m.proxyPool.active && e.Enabled && e.RawURL != "" {
|
|
if s.m.traffic != nil {
|
|
if err := s.m.traffic.SetUpstreamProxy(e.RawURL); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
}
|
|
s.m.mu.Lock()
|
|
s.m.globalProxy = e.RawURL
|
|
s.m.mu.Unlock()
|
|
if s.m.pg != nil {
|
|
_ = s.m.pg.SetSetting(settingGlobalProxy, e.RawURL)
|
|
}
|
|
}
|
|
if raw != "" {
|
|
u, err := traffic.ValidateProxyURL(raw)
|
|
if err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
e.RawURL = u.String()
|
|
e.URL = maskSecrets(e.RawURL)
|
|
} else {
|
|
e.RawURL, e.URL = "", ""
|
|
}
|
|
}
|
|
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 && s.m.proxyPool.active == id {
|
|
_ = s.m.applyActiveProxyLocked("", "manual")
|
|
}
|
|
}
|
|
if err := s.m.persistProxyPoolLocked(); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
health := e.Health
|
|
if !e.Enabled {
|
|
health = "disabled"
|
|
}
|
|
writeJSON(w, 200, map[string]any{"entry": ProxyPoolEntry{ID: e.ID, URL: maskSecrets(e.RawURL), Name: e.Name, Remark: e.Remark, Enabled: e.Enabled, Health: health}})
|
|
return
|
|
}
|
|
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
|
|
}
|
|
s.m.proxyPool.mu.Lock()
|
|
defer s.m.proxyPool.mu.Unlock()
|
|
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 {
|
|
writeErr(w, 404, "代理不存在")
|
|
return
|
|
}
|
|
s.m.proxyPool.entries = out
|
|
if s.m.proxyPool.active == id {
|
|
s.m.proxyPool.active = ""
|
|
if err := s.m.applyActiveProxyLocked("", "manual"); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
}
|
|
_ = s.m.persistProxyPoolLocked()
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
func (s *Server) activateProxyPool(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
if err := s.m.activateProxyID(id, "manual"); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
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) {
|
|
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.m.GlobalProxy() == "" {
|
|
_ = s.m.activateProxyID(id, "reconnect")
|
|
s.proxyReconnectSuccess()
|
|
}
|
|
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) {
|
|
select {
|
|
case s.proxyPoolWake <- struct{}{}:
|
|
default:
|
|
}
|
|
s.proxyPoolTick()
|
|
entries, active, logs := s.m.ProxyPoolSnapshot()
|
|
writeJSON(w, 200, map[string]any{"entries": entries, "active_id": active, "switch_log": logs})
|
|
}
|