feat: implement custom proxy subscription management and enhance configuration

- Added support for importing Clash/V2ray subscriptions, including automatic format detection and integration with sing-box for protocol conversion.
- Introduced five proxy usage modes in the configuration, allowing flexible selection between mixed, custom-only, and free-only modes.
- Enhanced `.env.example` and `docker-compose.yml` to include new environment variables for custom proxy settings.
- Updated `CHANGELOG.md` to document new features and improvements related to subscription management.
- Improved WebUI for managing subscriptions and displaying proxy statistics.
- Implemented a background process for refreshing subscriptions and probing disabled proxies for reactivation.
This commit is contained in:
isboyjc
2026-04-04 22:25:54 +08:00
parent 2d118688a5
commit f03c3300b4
23 changed files with 5394 additions and 1488 deletions

View File

@@ -98,30 +98,76 @@ func (s *Server) checkAuth(r *http.Request) bool {
return usernameMatch && passwordMatch
}
// selectProxy 根据使用模式和选择策略获取代理
func (s *Server) selectProxy(tried []string, lowestLatency bool) (*storage.Proxy, error) {
cfg := config.Get()
sourceFilter := sourceFilterFromMode(cfg.CustomProxyMode)
// 混用 + 优先模式:先尝试优先源,无可用则 fallback 全部
if cfg.CustomProxyMode == "mixed" && (cfg.CustomPriority || cfg.CustomFreePriority) {
preferSource := "custom"
if cfg.CustomFreePriority {
preferSource = "free"
}
var p *storage.Proxy
var err error
if lowestLatency {
p, err = s.storage.GetLowestLatencyExcludeFiltered(tried, preferSource)
} else {
p, err = s.storage.GetRandomExcludeFiltered(tried, preferSource)
}
if err == nil {
return p, nil
}
// fallback 到全部
if lowestLatency {
return s.storage.GetLowestLatencyExcludeFiltered(tried, "")
}
return s.storage.GetRandomExcludeFiltered(tried, "")
}
if lowestLatency {
return s.storage.GetLowestLatencyExcludeFiltered(tried, sourceFilter)
}
return s.storage.GetRandomExcludeFiltered(tried, sourceFilter)
}
// removeOrDisableProxy 根据代理来源决定删除或禁用
func removeOrDisableProxy(store *storage.Storage, p *storage.Proxy) {
if p.Source == "custom" {
store.DisableProxy(p.Address)
} else {
store.Delete(p.Address)
}
}
// sourceFilterFromMode 根据使用模式返回来源过滤值
func sourceFilterFromMode(mode string) string {
switch mode {
case "custom_only":
return "custom"
case "free_only":
return "free"
default:
return "" // mixed
}
}
// handleHTTP 处理普通 HTTP 请求(带自动重试)
func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) {
var tried []string
for attempt := 0; attempt <= s.cfg.MaxRetry; attempt++ {
var p *storage.Proxy
var err error
// 根据模式选择代理
if s.mode == "lowest-latency" {
p, err = s.storage.GetLowestLatencyExclude(tried)
} else {
p, err = s.storage.GetRandomExclude(tried)
}
p, err := s.selectProxy(tried, s.mode == "lowest-latency")
if err != nil {
http.Error(w, "no available proxy", http.StatusServiceUnavailable)
return
}
tried = append(tried, p.Address)
client, err := s.buildClient(p)
if err != nil {
s.storage.Delete(p.Address)
removeOrDisableProxy(s.storage, p)
continue
}
@@ -136,7 +182,8 @@ func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) {
resp, err := client.Do(req)
if err != nil {
log.Printf("[proxy] %s via %s failed, removing", r.RequestURI, p.Address)
s.storage.Delete(p.Address)
s.storage.RecordProxyUse(p.Address, false)
removeOrDisableProxy(s.storage, p)
continue
}
defer resp.Body.Close()
@@ -149,6 +196,7 @@ func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) {
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
s.storage.RecordProxyUse(p.Address, true)
if resp.StatusCode == 429 {
log.Printf("[proxy] ⚠️ 429 %s via %s (protocol=%s)", r.RequestURI, p.Address, p.Protocol)
} else {
@@ -164,30 +212,24 @@ func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleTunnel(w http.ResponseWriter, r *http.Request) {
var tried []string
for attempt := 0; attempt <= s.cfg.MaxRetry; attempt++ {
var p *storage.Proxy
var err error
// 根据模式选择代理
if s.mode == "lowest-latency" {
p, err = s.storage.GetLowestLatencyExclude(tried)
} else {
p, err = s.storage.GetRandomExclude(tried)
}
p, err := s.selectProxy(tried, s.mode == "lowest-latency")
if err != nil {
http.Error(w, "no available proxy", http.StatusServiceUnavailable)
return
}
tried = append(tried, p.Address)
conn, err := s.dialViaProxy(p, r.Host)
if err != nil {
log.Printf("[tunnel] dial %s via %s failed, removing", r.Host, p.Address)
s.storage.Delete(p.Address)
s.storage.RecordProxyUse(p.Address, false)
removeOrDisableProxy(s.storage, p)
continue
}
s.storage.RecordProxyUse(p.Address, true)
// 告知客户端隧道建立
hijacker, ok := w.(http.Hijacker)
if !ok {

View File

@@ -80,16 +80,7 @@ func (s *SOCKS5Server) handleConnection(clientConn net.Conn) {
maxRetries := s.cfg.MaxRetry + 2 // 增加重试次数以应对质量差的代理
for attempt := 0; attempt <= maxRetries; attempt++ {
var p *storage.Proxy
var err error
// SOCKS5 服务只使用 SOCKS5 上游代理
if s.mode == "lowest-latency" {
p, err = s.storage.GetLowestLatencyByProtocolExclude("socks5", tried)
} else {
p, err = s.storage.GetRandomByProtocolExclude("socks5", tried)
}
p, err := s.selectSOCKS5Proxy(tried)
if err != nil {
log.Printf("[socks5] no available socks5 upstream proxy: %v", err)
s.sendSOCKS5Reply(clientConn, 0x01) // General failure
@@ -102,7 +93,8 @@ func (s *SOCKS5Server) handleConnection(clientConn net.Conn) {
upstreamConn, err := s.dialViaProxy(p, target)
if err != nil {
log.Printf("[socks5] dial %s via %s (%s) failed: %v, removing", target, p.Address, p.Protocol, err)
s.storage.Delete(p.Address)
s.storage.RecordProxyUse(p.Address, false)
removeOrDisableProxy(s.storage, p)
continue
}
@@ -112,6 +104,7 @@ func (s *SOCKS5Server) handleConnection(clientConn net.Conn) {
return
}
s.storage.RecordProxyUse(p.Address, true)
log.Printf("[socks5] %s via %s established", target, p.Address)
// 双向转发数据
@@ -128,6 +121,40 @@ func (s *SOCKS5Server) handleConnection(clientConn net.Conn) {
log.Printf("[socks5] all proxies failed for %s", target)
}
// selectSOCKS5Proxy 根据使用模式选择 SOCKS5 上游代理
func (s *SOCKS5Server) selectSOCKS5Proxy(tried []string) (*storage.Proxy, error) {
cfg := config.Get()
sourceFilter := sourceFilterFromMode(cfg.CustomProxyMode)
// 混用 + 优先模式
if cfg.CustomProxyMode == "mixed" && (cfg.CustomPriority || cfg.CustomFreePriority) {
preferSource := "custom"
if cfg.CustomFreePriority {
preferSource = "free"
}
var p *storage.Proxy
var err error
if s.mode == "lowest-latency" {
p, err = s.storage.GetLowestLatencyByProtocolExcludeFiltered("socks5", tried, preferSource)
} else {
p, err = s.storage.GetRandomByProtocolExcludeFiltered("socks5", tried, preferSource)
}
if err == nil {
return p, nil
}
// fallback
if s.mode == "lowest-latency" {
return s.storage.GetLowestLatencyByProtocolExcludeFiltered("socks5", tried, "")
}
return s.storage.GetRandomByProtocolExcludeFiltered("socks5", tried, "")
}
if s.mode == "lowest-latency" {
return s.storage.GetLowestLatencyByProtocolExcludeFiltered("socks5", tried, sourceFilter)
}
return s.storage.GetRandomByProtocolExcludeFiltered("socks5", tried, sourceFilter)
}
// socks5Handshake 处理 SOCKS5 握手
func (s *SOCKS5Server) socks5Handshake(conn net.Conn) error {
buf := make([]byte, 257)