chore: 准备发布 v1.0.0 版本
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"easy_proxies/internal/boxmgr"
|
||||
"easy_proxies/internal/config"
|
||||
"easy_proxies/internal/monitor"
|
||||
"easy_proxies/internal/store"
|
||||
"easy_proxies/internal/subscription"
|
||||
)
|
||||
|
||||
// Run builds the runtime components from config and blocks until shutdown.
|
||||
func Run(ctx context.Context, cfg *config.Config) error {
|
||||
// ── 1. Open SQLite store ──
|
||||
dbPath := cfg.DatabasePath
|
||||
if dbPath == "" {
|
||||
dbPath = filepath.Join(filepath.Dir(cfg.FilePath()), "data", "data.db")
|
||||
}
|
||||
|
||||
// Ensure the directory for the database file exists
|
||||
if dir := filepath.Dir(dbPath); dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create database directory %q: %w", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
dataStore, err := store.Open(dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
defer dataStore.Close()
|
||||
|
||||
// ── 2. Load nodes from Store into config (if any exist) ──
|
||||
if err := loadNodesFromStore(ctx, cfg, dataStore); err != nil {
|
||||
log.Printf("⚠️ Failed to load nodes from store: %v", err)
|
||||
}
|
||||
|
||||
// ── 3. Build monitor config ──
|
||||
proxyUsername := cfg.Listener.Username
|
||||
proxyPassword := cfg.Listener.Password
|
||||
if cfg.Mode == "multi-port" || cfg.Mode == "hybrid" {
|
||||
proxyUsername = cfg.MultiPort.Username
|
||||
proxyPassword = cfg.MultiPort.Password
|
||||
}
|
||||
|
||||
monitorCfg := monitor.Config{
|
||||
Enabled: cfg.ManagementEnabled(),
|
||||
Listen: cfg.Management.Listen,
|
||||
ProbeTarget: cfg.Management.ProbeTarget,
|
||||
Password: cfg.Management.Password,
|
||||
ProxyUsername: proxyUsername,
|
||||
ProxyPassword: proxyPassword,
|
||||
ExternalIP: cfg.ExternalIP,
|
||||
}
|
||||
|
||||
// ── 4. Create and start BoxManager ──
|
||||
boxMgr := boxmgr.New(cfg, monitorCfg, boxmgr.WithStore(dataStore))
|
||||
if err := boxMgr.Start(ctx); err != nil {
|
||||
return fmt.Errorf("start box manager: %w", err)
|
||||
}
|
||||
defer boxMgr.Close()
|
||||
|
||||
// Wire up config and store to monitor server for settings API
|
||||
if server := boxMgr.MonitorServer(); server != nil {
|
||||
server.SetConfig(cfg)
|
||||
server.SetStore(dataStore)
|
||||
}
|
||||
|
||||
// ── 5. Create and start SubscriptionManager ──
|
||||
// Always created so it can dynamically respond to config changes
|
||||
// (e.g., user enables subscriptions via WebUI). The manager's internal
|
||||
// refresh loop checks config state to decide when to actually refresh.
|
||||
subMgr := subscription.New(cfg, boxMgr, subscription.WithStore(dataStore))
|
||||
subMgr.Start()
|
||||
defer subMgr.Stop()
|
||||
|
||||
// Register as config update listener so baseCfg stays in sync after reloads
|
||||
boxMgr.AddConfigListener(subMgr)
|
||||
|
||||
if server := boxMgr.MonitorServer(); server != nil {
|
||||
server.SetSubscriptionRefresher(subMgr)
|
||||
}
|
||||
|
||||
// ── 6. Start periodic stats flush ──
|
||||
statsCtx, statsCancel := context.WithCancel(ctx)
|
||||
defer statsCancel()
|
||||
go periodicStatsFlush(statsCtx, boxMgr, dataStore)
|
||||
|
||||
// ── 7. Wait for shutdown signal ──
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
defer signal.Stop(sigCh)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
fmt.Println("Context cancelled, initiating graceful shutdown...")
|
||||
case sig := <-sigCh:
|
||||
fmt.Printf("Received %s, initiating graceful shutdown...\n", sig)
|
||||
}
|
||||
|
||||
// ── 8. Graceful shutdown ──
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer shutdownCancel()
|
||||
|
||||
// Flush stats one final time before shutdown
|
||||
flushStatsToStore(context.Background(), boxMgr, dataStore)
|
||||
|
||||
fmt.Println("Stopping subscription manager...")
|
||||
if subMgr != nil {
|
||||
subMgr.Stop()
|
||||
}
|
||||
|
||||
fmt.Println("Stopping box manager...")
|
||||
if err := boxMgr.Close(); err != nil {
|
||||
fmt.Printf("Error closing box manager: %v\n", err)
|
||||
}
|
||||
|
||||
fmt.Println("Waiting for connections to drain...")
|
||||
select {
|
||||
case <-time.After(2 * time.Second):
|
||||
fmt.Println("Graceful shutdown completed")
|
||||
case <-shutdownCtx.Done():
|
||||
fmt.Println("Shutdown timeout exceeded, forcing exit")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadNodesFromStore replaces config.Nodes with nodes from the Store.
|
||||
// If the Store is empty, seeds it with whatever nodes config already has
|
||||
// (from config.yaml inline nodes or subscription fetch), then returns.
|
||||
func loadNodesFromStore(ctx context.Context, cfg *config.Config, s store.Store) error {
|
||||
nodes, err := s.ListNodes(ctx, store.NodeFilter{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list nodes from store: %w", err)
|
||||
}
|
||||
|
||||
if len(nodes) == 0 {
|
||||
// Store is empty — seed it with the nodes that config already loaded
|
||||
// (inline nodes, subscription fetch, manual nodes, etc.)
|
||||
if len(cfg.Nodes) > 0 {
|
||||
log.Printf("[app] store is empty, seeding %d nodes from config", len(cfg.Nodes))
|
||||
if err := seedStoreFromConfig(ctx, cfg, s); err != nil {
|
||||
log.Printf("⚠️ Failed to seed store from config: %v", err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[app] no nodes in store and no nodes in config")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Convert store.Node to config.NodeConfig
|
||||
var configNodes []config.NodeConfig
|
||||
for _, n := range nodes {
|
||||
if !n.Enabled {
|
||||
continue
|
||||
}
|
||||
configNodes = append(configNodes, config.NodeConfig{
|
||||
Name: n.Name,
|
||||
URI: n.URI,
|
||||
Port: n.Port,
|
||||
Username: n.Username,
|
||||
Password: n.Password,
|
||||
Source: config.NodeSource(n.Source),
|
||||
})
|
||||
}
|
||||
|
||||
if len(configNodes) > 0 {
|
||||
cfg.Nodes = configNodes
|
||||
log.Printf("[app] loaded %d nodes from store", len(configNodes))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedStoreFromConfig writes all cfg.Nodes into the Store as a bulk upsert.
|
||||
// This is called once on first startup when the database is empty.
|
||||
func seedStoreFromConfig(ctx context.Context, cfg *config.Config, s store.Store) error {
|
||||
var storeNodes []store.Node
|
||||
for _, n := range cfg.Nodes {
|
||||
source := string(n.Source)
|
||||
if source == "" {
|
||||
source = store.NodeSourceSubscription
|
||||
}
|
||||
storeNodes = append(storeNodes, store.Node{
|
||||
URI: n.URI,
|
||||
Name: n.Name,
|
||||
Source: source,
|
||||
Port: n.Port,
|
||||
Username: n.Username,
|
||||
Password: n.Password,
|
||||
Enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
if err := s.BulkUpsertNodes(ctx, storeNodes); err != nil {
|
||||
return fmt.Errorf("bulk upsert seed nodes: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[app] seeded %d nodes into store", len(storeNodes))
|
||||
return nil
|
||||
}
|
||||
|
||||
// periodicStatsFlush periodically writes in-memory node stats to the Store.
|
||||
func periodicStatsFlush(ctx context.Context, boxMgr *boxmgr.Manager, s store.Store) {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
flushStatsToStore(ctx, boxMgr, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flushStatsToStore writes current node stats from monitor to the Store.
|
||||
func flushStatsToStore(ctx context.Context, boxMgr *boxmgr.Manager, s store.Store) {
|
||||
mgr := boxMgr.MonitorManager()
|
||||
if mgr == nil {
|
||||
return
|
||||
}
|
||||
|
||||
snapshots := mgr.Snapshot()
|
||||
if len(snapshots) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Build URI/name -> node ID lookup from store
|
||||
storeNodes, err := s.ListNodes(ctx, store.NodeFilter{})
|
||||
if err != nil {
|
||||
log.Printf("[app] stats flush: failed to list store nodes: %v", err)
|
||||
return
|
||||
}
|
||||
uriToID := make(map[string]int64, len(storeNodes))
|
||||
nameToID := make(map[string]int64, len(storeNodes))
|
||||
for _, n := range storeNodes {
|
||||
uriToID[n.URI] = n.ID
|
||||
nameToID[n.Name] = n.ID
|
||||
}
|
||||
|
||||
var updates []store.StatsUpdate
|
||||
for _, snap := range snapshots {
|
||||
nodeID, ok := uriToID[snap.URI]
|
||||
if !ok {
|
||||
nodeID, ok = nameToID[snap.Name]
|
||||
}
|
||||
if !ok || nodeID == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
updates = append(updates, store.StatsUpdate{
|
||||
NodeID: nodeID,
|
||||
FailureCount: snap.FailureCount,
|
||||
SuccessCount: snap.SuccessCount,
|
||||
Blacklisted: snap.Blacklisted,
|
||||
BlacklistedUntil: snap.BlacklistedUntil,
|
||||
LastError: snap.LastError,
|
||||
LastFailureAt: snap.LastFailure,
|
||||
LastSuccessAt: snap.LastSuccess,
|
||||
LastLatencyMs: snap.LastLatencyMs,
|
||||
Available: snap.Available,
|
||||
InitialCheckDone: snap.InitialCheckDone,
|
||||
TotalUploadBytes: snap.TotalUpload,
|
||||
TotalDownloadBytes: snap.TotalDownload,
|
||||
})
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
if err := s.BatchUpdateStats(ctx, updates); err != nil {
|
||||
log.Printf("[app] stats flush failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,934 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"easy_proxies/internal/config"
|
||||
"easy_proxies/internal/geoip"
|
||||
poolout "easy_proxies/internal/outbound/pool"
|
||||
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing/common/auth"
|
||||
"github.com/sagernet/sing/common/json/badoption"
|
||||
)
|
||||
|
||||
// Build converts high level config into sing-box Options tree.
|
||||
func Build(cfg *config.Config) (option.Options, error) {
|
||||
baseOutbounds := make([]option.Outbound, 0, len(cfg.Nodes))
|
||||
memberTags := make([]string, 0, len(cfg.Nodes))
|
||||
metadata := make(map[string]poolout.MemberMeta)
|
||||
var failedNodes []string
|
||||
usedTags := make(map[string]int) // Track tag usage for uniqueness
|
||||
|
||||
// Initialize GeoIP lookup if enabled
|
||||
var geoLookup *geoip.Lookup
|
||||
if cfg.GeoIP.Enabled && cfg.GeoIP.DatabasePath != "" {
|
||||
var err error
|
||||
// Use auto-update if enabled
|
||||
if cfg.GeoIP.AutoUpdateEnabled {
|
||||
interval := cfg.GeoIP.AutoUpdateInterval
|
||||
if interval == 0 {
|
||||
interval = 24 * time.Hour // Default to 24 hours
|
||||
}
|
||||
geoLookup, err = geoip.NewWithAutoUpdate(cfg.GeoIP.DatabasePath, interval)
|
||||
} else {
|
||||
geoLookup, err = geoip.New(cfg.GeoIP.DatabasePath)
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("⚠️ GeoIP database load failed: %v (region routing disabled)", err)
|
||||
} else {
|
||||
log.Printf("✅ GeoIP database loaded: %s", cfg.GeoIP.DatabasePath)
|
||||
}
|
||||
}
|
||||
|
||||
// Track nodes by region for GeoIP routing
|
||||
regionMembers := make(map[string][]string)
|
||||
for _, region := range geoip.AllRegions() {
|
||||
regionMembers[region] = []string{}
|
||||
}
|
||||
|
||||
for _, node := range cfg.Nodes {
|
||||
baseTag := sanitizeTag(node.Name)
|
||||
if baseTag == "" {
|
||||
baseTag = fmt.Sprintf("node-%d", len(memberTags)+1)
|
||||
}
|
||||
|
||||
// Ensure tag uniqueness by appending a counter if needed
|
||||
tag := baseTag
|
||||
if count, exists := usedTags[baseTag]; exists {
|
||||
usedTags[baseTag] = count + 1
|
||||
tag = fmt.Sprintf("%s-%d", baseTag, count+1)
|
||||
} else {
|
||||
usedTags[baseTag] = 1
|
||||
}
|
||||
|
||||
outbound, err := buildNodeOutbound(tag, node.URI, cfg.SkipCertVerify)
|
||||
if err != nil {
|
||||
log.Printf("❌ Failed to build node '%s': %v (skipping)", node.Name, err)
|
||||
failedNodes = append(failedNodes, node.Name)
|
||||
continue
|
||||
}
|
||||
memberTags = append(memberTags, tag)
|
||||
baseOutbounds = append(baseOutbounds, outbound)
|
||||
meta := poolout.MemberMeta{
|
||||
Name: node.Name,
|
||||
URI: node.URI,
|
||||
Mode: cfg.Mode,
|
||||
}
|
||||
// For multi-port and hybrid modes, use per-node port
|
||||
if cfg.Mode == "multi-port" || cfg.Mode == "hybrid" {
|
||||
meta.ListenAddress = cfg.MultiPort.Address
|
||||
meta.Port = node.Port
|
||||
} else {
|
||||
meta.ListenAddress = cfg.Listener.Address
|
||||
meta.Port = cfg.Listener.Port
|
||||
}
|
||||
|
||||
// GeoIP lookup for region classification
|
||||
if geoLookup != nil && geoLookup.IsEnabled() {
|
||||
regionInfo := geoLookup.LookupURI(node.URI)
|
||||
meta.Region = regionInfo.Code
|
||||
meta.Country = regionInfo.Country
|
||||
regionMembers[regionInfo.Code] = append(regionMembers[regionInfo.Code], tag)
|
||||
} else {
|
||||
meta.Region = geoip.RegionOther
|
||||
meta.Country = "Unknown"
|
||||
regionMembers[geoip.RegionOther] = append(regionMembers[geoip.RegionOther], tag)
|
||||
}
|
||||
|
||||
metadata[tag] = meta
|
||||
}
|
||||
|
||||
// Close GeoIP database after lookup
|
||||
if geoLookup != nil {
|
||||
geoLookup.Close()
|
||||
}
|
||||
|
||||
// Check if we have at least one valid node
|
||||
if len(baseOutbounds) == 0 {
|
||||
return option.Options{}, fmt.Errorf("no valid nodes available (all %d nodes failed to build)", len(cfg.Nodes))
|
||||
}
|
||||
|
||||
// Log summary
|
||||
if len(failedNodes) > 0 {
|
||||
log.Printf("⚠️ %d/%d nodes failed and were skipped: %v", len(failedNodes), len(cfg.Nodes), failedNodes)
|
||||
}
|
||||
log.Printf("✅ Successfully built %d/%d nodes", len(baseOutbounds), len(cfg.Nodes))
|
||||
|
||||
// Log GeoIP region distribution
|
||||
if cfg.GeoIP.Enabled {
|
||||
log.Println("🌍 GeoIP Region Distribution:")
|
||||
for _, region := range geoip.AllRegions() {
|
||||
count := len(regionMembers[region])
|
||||
if count > 0 {
|
||||
log.Printf(" %s %s: %d nodes", geoip.RegionEmoji(region), geoip.RegionName(region), count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Print proxy links for each node
|
||||
printProxyLinks(cfg, metadata)
|
||||
|
||||
var (
|
||||
inbounds []option.Inbound
|
||||
outbounds = make([]option.Outbound, len(baseOutbounds))
|
||||
route option.RouteOptions
|
||||
)
|
||||
copy(outbounds, baseOutbounds)
|
||||
|
||||
// Determine which components to enable based on mode
|
||||
enablePoolInbound := cfg.Mode == "pool" || cfg.Mode == "hybrid"
|
||||
enableMultiPort := cfg.Mode == "multi-port" || cfg.Mode == "hybrid"
|
||||
|
||||
if !enablePoolInbound && !enableMultiPort {
|
||||
return option.Options{}, fmt.Errorf("unsupported mode %s", cfg.Mode)
|
||||
}
|
||||
|
||||
// Build pool inbound (single entry point for all nodes)
|
||||
if enablePoolInbound {
|
||||
inbound, err := buildPoolInbound(cfg)
|
||||
if err != nil {
|
||||
return option.Options{}, err
|
||||
}
|
||||
inbounds = append(inbounds, inbound)
|
||||
poolOptions := poolout.Options{
|
||||
Mode: cfg.Pool.Mode,
|
||||
Members: memberTags,
|
||||
FailureThreshold: cfg.Pool.FailureThreshold,
|
||||
BlacklistDuration: cfg.Pool.BlacklistDuration,
|
||||
Metadata: metadata,
|
||||
}
|
||||
outbounds = append(outbounds, option.Outbound{
|
||||
Type: poolout.Type,
|
||||
Tag: poolout.Tag,
|
||||
Options: &poolOptions,
|
||||
})
|
||||
route.Final = poolout.Tag
|
||||
}
|
||||
|
||||
// Build multi-port inbounds (one port per node)
|
||||
if enableMultiPort {
|
||||
addr, err := parseAddr(cfg.MultiPort.Address)
|
||||
if err != nil {
|
||||
return option.Options{}, fmt.Errorf("parse multi-port address: %w", err)
|
||||
}
|
||||
for _, tag := range memberTags {
|
||||
meta := metadata[tag]
|
||||
perMeta := map[string]poolout.MemberMeta{tag: meta}
|
||||
poolTag := fmt.Sprintf("%s-%s", poolout.Tag, tag)
|
||||
perOptions := poolout.Options{
|
||||
Mode: "sequential",
|
||||
Members: []string{tag},
|
||||
FailureThreshold: cfg.Pool.FailureThreshold,
|
||||
BlacklistDuration: cfg.Pool.BlacklistDuration,
|
||||
Metadata: perMeta,
|
||||
}
|
||||
perPool := option.Outbound{
|
||||
Type: poolout.Type,
|
||||
Tag: poolTag,
|
||||
Options: &perOptions,
|
||||
}
|
||||
outbounds = append(outbounds, perPool)
|
||||
inboundOptions := &option.HTTPMixedInboundOptions{
|
||||
ListenOptions: option.ListenOptions{
|
||||
Listen: addr,
|
||||
ListenPort: meta.Port,
|
||||
},
|
||||
}
|
||||
username := cfg.MultiPort.Username
|
||||
password := cfg.MultiPort.Password
|
||||
if username != "" {
|
||||
inboundOptions.Users = []auth.User{{Username: username, Password: password}}
|
||||
}
|
||||
inboundTag := fmt.Sprintf("in-%s", tag)
|
||||
inbounds = append(inbounds, option.Inbound{
|
||||
Type: C.TypeHTTP,
|
||||
Tag: inboundTag,
|
||||
Options: inboundOptions,
|
||||
})
|
||||
route.Rules = append(route.Rules, option.Rule{
|
||||
Type: C.RuleTypeDefault,
|
||||
DefaultOptions: option.DefaultRule{
|
||||
RawDefaultRule: option.RawDefaultRule{
|
||||
Inbound: badoption.Listable[string]{inboundTag},
|
||||
},
|
||||
RuleAction: option.RuleAction{
|
||||
Action: C.RuleActionTypeRoute,
|
||||
RouteOptions: option.RouteActionOptions{
|
||||
Outbound: poolTag,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Build GeoIP region-based pool outbounds and routing
|
||||
if cfg.GeoIP.Enabled && enablePoolInbound {
|
||||
// Create pool outbound for each region that has nodes
|
||||
for _, region := range geoip.AllRegions() {
|
||||
members := regionMembers[region]
|
||||
if len(members) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Build metadata for this region's members
|
||||
regionMeta := make(map[string]poolout.MemberMeta)
|
||||
for _, tag := range members {
|
||||
regionMeta[tag] = metadata[tag]
|
||||
}
|
||||
|
||||
regionPoolTag := fmt.Sprintf("pool-%s", region)
|
||||
regionPoolOptions := poolout.Options{
|
||||
Mode: cfg.Pool.Mode,
|
||||
Members: members,
|
||||
FailureThreshold: cfg.Pool.FailureThreshold,
|
||||
BlacklistDuration: cfg.Pool.BlacklistDuration,
|
||||
Metadata: regionMeta,
|
||||
}
|
||||
outbounds = append(outbounds, option.Outbound{
|
||||
Type: poolout.Type,
|
||||
Tag: regionPoolTag,
|
||||
Options: ®ionPoolOptions,
|
||||
})
|
||||
}
|
||||
|
||||
// Log GeoIP routing info
|
||||
geoipPort := cfg.GeoIP.Port
|
||||
if geoipPort == 0 {
|
||||
geoipPort = cfg.Listener.Port
|
||||
}
|
||||
geoipListen := cfg.GeoIP.Listen
|
||||
if geoipListen == "" {
|
||||
geoipListen = cfg.Listener.Address
|
||||
}
|
||||
log.Println("🌐 GeoIP Region Routing Enabled:")
|
||||
log.Printf(" Access via: http://%s:%d/{region}", geoipListen, geoipPort)
|
||||
log.Println(" Available regions: /jp, /kr, /us, /hk, /tw, /other")
|
||||
log.Println(" Default (no path): all nodes pool")
|
||||
}
|
||||
|
||||
opts := option.Options{
|
||||
Log: &option.LogOptions{Level: strings.ToLower(cfg.LogLevel)},
|
||||
Inbounds: inbounds,
|
||||
Outbounds: outbounds,
|
||||
Route: &route,
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func buildPoolInbound(cfg *config.Config) (option.Inbound, error) {
|
||||
listenAddr, err := parseAddr(cfg.Listener.Address)
|
||||
if err != nil {
|
||||
return option.Inbound{}, fmt.Errorf("parse listener address: %w", err)
|
||||
}
|
||||
inboundOptions := &option.HTTPMixedInboundOptions{
|
||||
ListenOptions: option.ListenOptions{
|
||||
Listen: listenAddr,
|
||||
ListenPort: cfg.Listener.Port,
|
||||
},
|
||||
}
|
||||
if cfg.Listener.Username != "" {
|
||||
inboundOptions.Users = []auth.User{{
|
||||
Username: cfg.Listener.Username,
|
||||
Password: cfg.Listener.Password,
|
||||
}}
|
||||
}
|
||||
inbound := option.Inbound{
|
||||
Type: C.TypeHTTP,
|
||||
Tag: "http-in",
|
||||
Options: inboundOptions,
|
||||
}
|
||||
return inbound, nil
|
||||
}
|
||||
|
||||
func buildNodeOutbound(tag, rawURI string, skipCertVerify bool) (option.Outbound, error) {
|
||||
parsed, err := url.Parse(rawURI)
|
||||
if err != nil {
|
||||
return option.Outbound{}, fmt.Errorf("parse uri: %w", err)
|
||||
}
|
||||
switch strings.ToLower(parsed.Scheme) {
|
||||
case "vless":
|
||||
opts, err := buildVLESSOptions(parsed, skipCertVerify)
|
||||
if err != nil {
|
||||
return option.Outbound{}, err
|
||||
}
|
||||
return option.Outbound{Type: C.TypeVLESS, Tag: tag, Options: &opts}, nil
|
||||
case "hysteria2", "hy2":
|
||||
opts, err := buildHysteria2Options(parsed, skipCertVerify)
|
||||
if err != nil {
|
||||
return option.Outbound{}, err
|
||||
}
|
||||
return option.Outbound{Type: C.TypeHysteria2, Tag: tag, Options: &opts}, nil
|
||||
case "ss", "shadowsocks":
|
||||
opts, err := buildShadowsocksOptions(parsed)
|
||||
if err != nil {
|
||||
return option.Outbound{}, err
|
||||
}
|
||||
return option.Outbound{Type: C.TypeShadowsocks, Tag: tag, Options: &opts}, nil
|
||||
case "trojan":
|
||||
opts, err := buildTrojanOptions(parsed, skipCertVerify)
|
||||
if err != nil {
|
||||
return option.Outbound{}, err
|
||||
}
|
||||
return option.Outbound{Type: C.TypeTrojan, Tag: tag, Options: &opts}, nil
|
||||
case "vmess":
|
||||
opts, err := buildVMessOptions(rawURI, skipCertVerify)
|
||||
if err != nil {
|
||||
return option.Outbound{}, err
|
||||
}
|
||||
return option.Outbound{Type: C.TypeVMess, Tag: tag, Options: &opts}, nil
|
||||
default:
|
||||
return option.Outbound{}, fmt.Errorf("unsupported scheme %q", parsed.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
func buildVLESSOptions(u *url.URL, skipCertVerify bool) (option.VLESSOutboundOptions, error) {
|
||||
uuid := u.User.Username()
|
||||
if uuid == "" {
|
||||
return option.VLESSOutboundOptions{}, errors.New("vless uri missing uuid in userinfo")
|
||||
}
|
||||
server, port, err := hostPort(u, 443)
|
||||
if err != nil {
|
||||
return option.VLESSOutboundOptions{}, err
|
||||
}
|
||||
query := u.Query()
|
||||
opts := option.VLESSOutboundOptions{
|
||||
UUID: uuid,
|
||||
ServerOptions: option.ServerOptions{Server: server, ServerPort: uint16(port)},
|
||||
Network: option.NetworkList(""),
|
||||
}
|
||||
if flow := query.Get("flow"); flow != "" {
|
||||
opts.Flow = flow
|
||||
}
|
||||
if packetEncoding := query.Get("packetEncoding"); packetEncoding != "" {
|
||||
opts.PacketEncoding = &packetEncoding
|
||||
}
|
||||
if transport, err := buildV2RayTransport(query); err != nil {
|
||||
return option.VLESSOutboundOptions{}, err
|
||||
} else if transport != nil {
|
||||
opts.Transport = transport
|
||||
}
|
||||
if tlsOptions, err := buildTLSOptions(query, skipCertVerify); err != nil {
|
||||
return option.VLESSOutboundOptions{}, err
|
||||
} else if tlsOptions != nil {
|
||||
opts.OutboundTLSOptionsContainer = option.OutboundTLSOptionsContainer{TLS: tlsOptions}
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func buildHysteria2Options(u *url.URL, skipCertVerify bool) (option.Hysteria2OutboundOptions, error) {
|
||||
password := u.User.String()
|
||||
server, port, err := hostPort(u, 443)
|
||||
if err != nil {
|
||||
return option.Hysteria2OutboundOptions{}, err
|
||||
}
|
||||
query := u.Query()
|
||||
opts := option.Hysteria2OutboundOptions{
|
||||
ServerOptions: option.ServerOptions{Server: server, ServerPort: uint16(port)},
|
||||
Password: password,
|
||||
}
|
||||
if up := query.Get("upMbps"); up != "" {
|
||||
opts.UpMbps = atoiDefault(up)
|
||||
}
|
||||
if down := query.Get("downMbps"); down != "" {
|
||||
opts.DownMbps = atoiDefault(down)
|
||||
}
|
||||
if obfs := query.Get("obfs"); obfs != "" {
|
||||
opts.Obfs = &option.Hysteria2Obfs{Type: obfs, Password: query.Get("obfs-password")}
|
||||
}
|
||||
opts.OutboundTLSOptionsContainer = option.OutboundTLSOptionsContainer{TLS: hysteriaTLSOptions(server, query, skipCertVerify)}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func hysteriaTLSOptions(host string, query url.Values, skipCertVerify bool) *option.OutboundTLSOptions {
|
||||
tlsOptions := &option.OutboundTLSOptions{
|
||||
Enabled: true,
|
||||
ServerName: host,
|
||||
Insecure: skipCertVerify,
|
||||
}
|
||||
if sni := query.Get("sni"); sni != "" {
|
||||
tlsOptions.ServerName = sni
|
||||
}
|
||||
insecure := query.Get("insecure")
|
||||
if insecure == "" {
|
||||
insecure = query.Get("allowInsecure")
|
||||
}
|
||||
if insecure != "" {
|
||||
tlsOptions.Insecure = insecure == "1" || strings.EqualFold(insecure, "true")
|
||||
}
|
||||
if alpn := query.Get("alpn"); alpn != "" {
|
||||
tlsOptions.ALPN = badoption.Listable[string](strings.Split(alpn, ","))
|
||||
}
|
||||
return tlsOptions
|
||||
}
|
||||
|
||||
func buildTLSOptions(query url.Values, skipCertVerify bool) (*option.OutboundTLSOptions, error) {
|
||||
security := strings.ToLower(query.Get("security"))
|
||||
if security == "" || security == "none" {
|
||||
return nil, nil
|
||||
}
|
||||
tlsOptions := &option.OutboundTLSOptions{Enabled: true, Insecure: skipCertVerify}
|
||||
if sni := query.Get("sni"); sni != "" {
|
||||
tlsOptions.ServerName = sni
|
||||
}
|
||||
insecure := query.Get("allowInsecure")
|
||||
if insecure == "" {
|
||||
insecure = query.Get("insecure")
|
||||
}
|
||||
if insecure != "" {
|
||||
tlsOptions.Insecure = insecure == "1" || strings.EqualFold(insecure, "true")
|
||||
}
|
||||
if alpn := query.Get("alpn"); alpn != "" {
|
||||
tlsOptions.ALPN = badoption.Listable[string](strings.Split(alpn, ","))
|
||||
}
|
||||
fp := query.Get("fp")
|
||||
if fp != "" {
|
||||
tlsOptions.UTLS = &option.OutboundUTLSOptions{Enabled: true, Fingerprint: fp}
|
||||
}
|
||||
if security == "reality" {
|
||||
tlsOptions.Reality = &option.OutboundRealityOptions{Enabled: true, PublicKey: query.Get("pbk"), ShortID: query.Get("sid")}
|
||||
// Reality requires uTLS; use default fingerprint if not specified
|
||||
if tlsOptions.UTLS == nil {
|
||||
if fp == "" {
|
||||
fp = "chrome"
|
||||
}
|
||||
tlsOptions.UTLS = &option.OutboundUTLSOptions{Enabled: true, Fingerprint: fp}
|
||||
}
|
||||
}
|
||||
return tlsOptions, nil
|
||||
}
|
||||
|
||||
func buildV2RayTransport(query url.Values) (*option.V2RayTransportOptions, error) {
|
||||
transportType := strings.ToLower(query.Get("type"))
|
||||
if transportType == "" || transportType == "tcp" {
|
||||
return nil, nil
|
||||
}
|
||||
options := &option.V2RayTransportOptions{Type: transportType}
|
||||
switch transportType {
|
||||
case C.V2RayTransportTypeWebsocket:
|
||||
wsPath := query.Get("path")
|
||||
// 解析 path 中的 early data 参数,如 /path?ed=2048
|
||||
if idx := strings.Index(wsPath, "?ed="); idx != -1 {
|
||||
edPart := wsPath[idx+4:]
|
||||
wsPath = wsPath[:idx]
|
||||
// 解析 ed 值
|
||||
edValue := edPart
|
||||
if ampIdx := strings.Index(edPart, "&"); ampIdx != -1 {
|
||||
edValue = edPart[:ampIdx]
|
||||
}
|
||||
if ed, err := strconv.Atoi(edValue); err == nil && ed > 0 {
|
||||
options.WebsocketOptions.MaxEarlyData = uint32(ed)
|
||||
options.WebsocketOptions.EarlyDataHeaderName = "Sec-WebSocket-Protocol"
|
||||
}
|
||||
}
|
||||
options.WebsocketOptions.Path = wsPath
|
||||
if host := query.Get("host"); host != "" {
|
||||
options.WebsocketOptions.Headers = badoption.HTTPHeader{"Host": {host}}
|
||||
}
|
||||
case C.V2RayTransportTypeHTTP:
|
||||
options.HTTPOptions.Path = query.Get("path")
|
||||
if host := query.Get("host"); host != "" {
|
||||
options.HTTPOptions.Host = badoption.Listable[string]{host}
|
||||
}
|
||||
case C.V2RayTransportTypeGRPC:
|
||||
options.GRPCOptions.ServiceName = query.Get("serviceName")
|
||||
case C.V2RayTransportTypeHTTPUpgrade:
|
||||
options.HTTPUpgradeOptions.Path = query.Get("path")
|
||||
case "xhttp":
|
||||
// XHTTP is not supported by sing-box, fallback to HTTPUpgrade
|
||||
log.Printf("⚠️ XHTTP transport not supported by sing-box, falling back to HTTPUpgrade")
|
||||
options.Type = C.V2RayTransportTypeHTTPUpgrade
|
||||
options.HTTPUpgradeOptions.Path = query.Get("path")
|
||||
if host := query.Get("host"); host != "" {
|
||||
options.HTTPUpgradeOptions.Headers = badoption.HTTPHeader{"Host": {host}}
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported transport type %q", transportType)
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
func buildShadowsocksOptions(u *url.URL) (option.ShadowsocksOutboundOptions, error) {
|
||||
server, port, err := hostPort(u, 8388)
|
||||
if err != nil {
|
||||
return option.ShadowsocksOutboundOptions{}, err
|
||||
}
|
||||
|
||||
// Decode userinfo (base64 encoded method:password)
|
||||
userInfo := u.User.String()
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(userInfo)
|
||||
if err != nil {
|
||||
// Try standard base64
|
||||
decoded, err = base64.StdEncoding.DecodeString(userInfo)
|
||||
if err != nil {
|
||||
return option.ShadowsocksOutboundOptions{}, fmt.Errorf("decode shadowsocks userinfo: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
parts := strings.SplitN(string(decoded), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return option.ShadowsocksOutboundOptions{}, errors.New("shadowsocks userinfo format must be method:password")
|
||||
}
|
||||
|
||||
method := parts[0]
|
||||
password := parts[1]
|
||||
|
||||
opts := option.ShadowsocksOutboundOptions{
|
||||
ServerOptions: option.ServerOptions{Server: server, ServerPort: uint16(port)},
|
||||
Method: method,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
query := u.Query()
|
||||
if plugin := query.Get("plugin"); plugin != "" {
|
||||
opts.Plugin = plugin
|
||||
opts.PluginOptions = query.Get("plugin-opts")
|
||||
}
|
||||
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func buildTrojanOptions(u *url.URL, skipCertVerify bool) (option.TrojanOutboundOptions, error) {
|
||||
password := u.User.Username()
|
||||
if password == "" {
|
||||
return option.TrojanOutboundOptions{}, errors.New("trojan uri missing password in userinfo")
|
||||
}
|
||||
|
||||
server, port, err := hostPort(u, 443)
|
||||
if err != nil {
|
||||
return option.TrojanOutboundOptions{}, err
|
||||
}
|
||||
|
||||
query := u.Query()
|
||||
opts := option.TrojanOutboundOptions{
|
||||
ServerOptions: option.ServerOptions{Server: server, ServerPort: uint16(port)},
|
||||
Password: password,
|
||||
Network: option.NetworkList(""),
|
||||
}
|
||||
|
||||
// Parse TLS options
|
||||
if tlsOptions, err := buildTrojanTLSOptions(query, skipCertVerify); err != nil {
|
||||
return option.TrojanOutboundOptions{}, err
|
||||
} else if tlsOptions != nil {
|
||||
opts.OutboundTLSOptionsContainer = option.OutboundTLSOptionsContainer{TLS: tlsOptions}
|
||||
}
|
||||
|
||||
// Parse transport options
|
||||
if transport, err := buildV2RayTransport(query); err != nil {
|
||||
return option.TrojanOutboundOptions{}, err
|
||||
} else if transport != nil {
|
||||
opts.Transport = transport
|
||||
}
|
||||
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
// vmessJSON represents the JSON structure of a VMess URI
|
||||
type vmessJSON struct {
|
||||
V interface{} `json:"v"` // Version, can be string or int
|
||||
PS string `json:"ps"` // Remarks/name
|
||||
Add string `json:"add"` // Server address
|
||||
Port interface{} `json:"port"` // Server port, can be string or int
|
||||
ID string `json:"id"` // UUID
|
||||
Aid interface{} `json:"aid"` // Alter ID, can be string or int
|
||||
Scy string `json:"scy"` // Security/cipher
|
||||
Net string `json:"net"` // Network type (tcp, ws, etc.)
|
||||
Type string `json:"type"` // Header type
|
||||
Host string `json:"host"` // Host header
|
||||
Path string `json:"path"` // Path
|
||||
TLS string `json:"tls"` // TLS (tls or empty)
|
||||
SNI string `json:"sni"` // SNI
|
||||
ALPN string `json:"alpn"` // ALPN
|
||||
FP string `json:"fp"` // Fingerprint
|
||||
}
|
||||
|
||||
func (v *vmessJSON) GetPort() int {
|
||||
switch p := v.Port.(type) {
|
||||
case float64:
|
||||
return int(p)
|
||||
case int:
|
||||
return p
|
||||
case string:
|
||||
port, _ := strconv.Atoi(p)
|
||||
return port
|
||||
}
|
||||
return 443
|
||||
}
|
||||
|
||||
func (v *vmessJSON) GetAlterId() int {
|
||||
switch a := v.Aid.(type) {
|
||||
case float64:
|
||||
return int(a)
|
||||
case int:
|
||||
return a
|
||||
case string:
|
||||
aid, _ := strconv.Atoi(a)
|
||||
return aid
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func buildVMessOptions(rawURI string, skipCertVerify bool) (option.VMessOutboundOptions, error) {
|
||||
// Remove vmess:// prefix
|
||||
encoded := strings.TrimPrefix(rawURI, "vmess://")
|
||||
|
||||
// Try to decode as base64 JSON (standard format)
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
// Try URL-safe base64
|
||||
decoded, err = base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
// Try as URL format: vmess://uuid@server:port?...
|
||||
return buildVMessOptionsFromURL(rawURI, skipCertVerify)
|
||||
}
|
||||
}
|
||||
|
||||
var vmess vmessJSON
|
||||
if err := json.Unmarshal(decoded, &vmess); err != nil {
|
||||
return option.VMessOutboundOptions{}, fmt.Errorf("parse vmess json: %w", err)
|
||||
}
|
||||
|
||||
if vmess.Add == "" {
|
||||
return option.VMessOutboundOptions{}, errors.New("vmess missing server address")
|
||||
}
|
||||
if vmess.ID == "" {
|
||||
return option.VMessOutboundOptions{}, errors.New("vmess missing uuid")
|
||||
}
|
||||
|
||||
port := vmess.GetPort()
|
||||
if port == 0 {
|
||||
port = 443
|
||||
}
|
||||
|
||||
opts := option.VMessOutboundOptions{
|
||||
ServerOptions: option.ServerOptions{
|
||||
Server: vmess.Add,
|
||||
ServerPort: uint16(port),
|
||||
},
|
||||
UUID: vmess.ID,
|
||||
AlterId: vmess.GetAlterId(),
|
||||
Security: vmess.Scy,
|
||||
}
|
||||
|
||||
// Default security
|
||||
if opts.Security == "" {
|
||||
opts.Security = "auto"
|
||||
}
|
||||
|
||||
// Build transport options
|
||||
if vmess.Net != "" && vmess.Net != "tcp" {
|
||||
transport := &option.V2RayTransportOptions{}
|
||||
switch vmess.Net {
|
||||
case "ws":
|
||||
transport.Type = C.V2RayTransportTypeWebsocket
|
||||
wsPath := vmess.Path
|
||||
// Handle early data in path
|
||||
if idx := strings.Index(wsPath, "?ed="); idx != -1 {
|
||||
edPart := wsPath[idx+4:]
|
||||
wsPath = wsPath[:idx]
|
||||
edValue := edPart
|
||||
if ampIdx := strings.Index(edPart, "&"); ampIdx != -1 {
|
||||
edValue = edPart[:ampIdx]
|
||||
}
|
||||
if ed, err := strconv.Atoi(edValue); err == nil && ed > 0 {
|
||||
transport.WebsocketOptions.MaxEarlyData = uint32(ed)
|
||||
transport.WebsocketOptions.EarlyDataHeaderName = "Sec-WebSocket-Protocol"
|
||||
}
|
||||
}
|
||||
transport.WebsocketOptions.Path = wsPath
|
||||
if vmess.Host != "" {
|
||||
transport.WebsocketOptions.Headers = badoption.HTTPHeader{"Host": {vmess.Host}}
|
||||
}
|
||||
case "h2":
|
||||
transport.Type = C.V2RayTransportTypeHTTP
|
||||
transport.HTTPOptions.Path = vmess.Path
|
||||
if vmess.Host != "" {
|
||||
transport.HTTPOptions.Host = badoption.Listable[string]{vmess.Host}
|
||||
}
|
||||
case "grpc":
|
||||
transport.Type = C.V2RayTransportTypeGRPC
|
||||
transport.GRPCOptions.ServiceName = vmess.Path
|
||||
default:
|
||||
transport.Type = vmess.Net
|
||||
}
|
||||
opts.Transport = transport
|
||||
}
|
||||
|
||||
// Build TLS options
|
||||
if vmess.TLS == "tls" {
|
||||
tlsOptions := &option.OutboundTLSOptions{Enabled: true, Insecure: skipCertVerify}
|
||||
if vmess.SNI != "" {
|
||||
tlsOptions.ServerName = vmess.SNI
|
||||
} else if vmess.Host != "" {
|
||||
tlsOptions.ServerName = vmess.Host
|
||||
}
|
||||
if vmess.ALPN != "" {
|
||||
tlsOptions.ALPN = badoption.Listable[string](strings.Split(vmess.ALPN, ","))
|
||||
}
|
||||
if vmess.FP != "" {
|
||||
tlsOptions.UTLS = &option.OutboundUTLSOptions{Enabled: true, Fingerprint: vmess.FP}
|
||||
}
|
||||
opts.OutboundTLSOptionsContainer = option.OutboundTLSOptionsContainer{TLS: tlsOptions}
|
||||
}
|
||||
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func buildVMessOptionsFromURL(rawURI string, skipCertVerify bool) (option.VMessOutboundOptions, error) {
|
||||
parsed, err := url.Parse(rawURI)
|
||||
if err != nil {
|
||||
return option.VMessOutboundOptions{}, fmt.Errorf("parse vmess url: %w", err)
|
||||
}
|
||||
|
||||
uuid := parsed.User.Username()
|
||||
if uuid == "" {
|
||||
return option.VMessOutboundOptions{}, errors.New("vmess uri missing uuid")
|
||||
}
|
||||
|
||||
server, port, err := hostPort(parsed, 443)
|
||||
if err != nil {
|
||||
return option.VMessOutboundOptions{}, err
|
||||
}
|
||||
|
||||
query := parsed.Query()
|
||||
opts := option.VMessOutboundOptions{
|
||||
ServerOptions: option.ServerOptions{
|
||||
Server: server,
|
||||
ServerPort: uint16(port),
|
||||
},
|
||||
UUID: uuid,
|
||||
Security: query.Get("encryption"),
|
||||
}
|
||||
|
||||
if opts.Security == "" {
|
||||
opts.Security = "auto"
|
||||
}
|
||||
|
||||
if aid := query.Get("alterId"); aid != "" {
|
||||
opts.AlterId, _ = strconv.Atoi(aid)
|
||||
}
|
||||
|
||||
// Build transport
|
||||
if transport, err := buildV2RayTransport(query); err != nil {
|
||||
return option.VMessOutboundOptions{}, err
|
||||
} else if transport != nil {
|
||||
opts.Transport = transport
|
||||
}
|
||||
|
||||
// Build TLS
|
||||
if tlsOptions, err := buildTLSOptions(query, skipCertVerify); err != nil {
|
||||
return option.VMessOutboundOptions{}, err
|
||||
} else if tlsOptions != nil {
|
||||
opts.OutboundTLSOptionsContainer = option.OutboundTLSOptionsContainer{TLS: tlsOptions}
|
||||
}
|
||||
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func buildTrojanTLSOptions(query url.Values, skipCertVerify bool) (*option.OutboundTLSOptions, error) {
|
||||
// Trojan always uses TLS by default
|
||||
tlsOptions := &option.OutboundTLSOptions{Enabled: true, Insecure: skipCertVerify}
|
||||
|
||||
if sni := query.Get("sni"); sni != "" {
|
||||
tlsOptions.ServerName = sni
|
||||
}
|
||||
if peer := query.Get("peer"); peer != "" && tlsOptions.ServerName == "" {
|
||||
tlsOptions.ServerName = peer
|
||||
}
|
||||
|
||||
insecure := query.Get("allowInsecure")
|
||||
if insecure == "" {
|
||||
insecure = query.Get("insecure")
|
||||
}
|
||||
if insecure != "" {
|
||||
tlsOptions.Insecure = insecure == "1" || strings.EqualFold(insecure, "true")
|
||||
}
|
||||
|
||||
if alpn := query.Get("alpn"); alpn != "" {
|
||||
tlsOptions.ALPN = badoption.Listable[string](strings.Split(alpn, ","))
|
||||
}
|
||||
|
||||
if fp := query.Get("fp"); fp != "" {
|
||||
tlsOptions.UTLS = &option.OutboundUTLSOptions{Enabled: true, Fingerprint: fp}
|
||||
}
|
||||
|
||||
return tlsOptions, nil
|
||||
}
|
||||
|
||||
func hostPort(u *url.URL, defaultPort int) (string, int, error) {
|
||||
host := u.Hostname()
|
||||
if host == "" {
|
||||
return "", 0, errors.New("missing host")
|
||||
}
|
||||
portStr := u.Port()
|
||||
if portStr == "" {
|
||||
portStr = strconv.Itoa(defaultPort)
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("invalid port %q", portStr)
|
||||
}
|
||||
return host, port, nil
|
||||
}
|
||||
|
||||
func parseAddr(value string) (*badoption.Addr, error) {
|
||||
addr := strings.TrimSpace(value)
|
||||
if addr == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parsed, err := netip.ParseAddr(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bad := badoption.Addr(parsed)
|
||||
return &bad, nil
|
||||
}
|
||||
|
||||
func sanitizeTag(name string) string {
|
||||
lower := strings.ToLower(name)
|
||||
lower = strings.TrimSpace(lower)
|
||||
if lower == "" {
|
||||
return ""
|
||||
}
|
||||
segments := strings.FieldsFunc(lower, func(r rune) bool {
|
||||
return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9')
|
||||
})
|
||||
result := strings.Join(segments, "-")
|
||||
result = strings.Trim(result, "-")
|
||||
return result
|
||||
}
|
||||
|
||||
func atoiDefault(value string) int {
|
||||
if strings.HasSuffix(value, "mbps") {
|
||||
value = strings.TrimSuffix(value, "mbps")
|
||||
}
|
||||
if strings.HasSuffix(value, "Mbps") {
|
||||
value = strings.TrimSuffix(value, "Mbps")
|
||||
}
|
||||
v, _ := strconv.Atoi(value)
|
||||
return v
|
||||
}
|
||||
|
||||
// printProxyLinks prints all proxy connection information at startup
|
||||
func printProxyLinks(cfg *config.Config, metadata map[string]poolout.MemberMeta) {
|
||||
log.Println("")
|
||||
log.Println("📡 Proxy Links:")
|
||||
log.Println("═══════════════════════════════════════════════════════════════")
|
||||
|
||||
showPoolEntry := cfg.Mode == "pool" || cfg.Mode == "hybrid"
|
||||
showMultiPort := cfg.Mode == "multi-port" || cfg.Mode == "hybrid"
|
||||
|
||||
if showPoolEntry {
|
||||
// Pool mode: single entry point for all nodes
|
||||
var auth string
|
||||
if cfg.Listener.Username != "" {
|
||||
auth = fmt.Sprintf("%s:%s@", cfg.Listener.Username, cfg.Listener.Password)
|
||||
}
|
||||
proxyURL := fmt.Sprintf("http://%s%s:%d", auth, cfg.Listener.Address, cfg.Listener.Port)
|
||||
log.Printf("🌐 Pool Entry Point:")
|
||||
log.Printf(" %s", proxyURL)
|
||||
log.Println("")
|
||||
log.Printf(" Nodes in pool (%d):", len(metadata))
|
||||
for _, meta := range metadata {
|
||||
log.Printf(" • %s", meta.Name)
|
||||
}
|
||||
if showMultiPort {
|
||||
log.Println("")
|
||||
}
|
||||
}
|
||||
|
||||
if showMultiPort {
|
||||
// Multi-port mode: each node has its own port
|
||||
log.Printf("🔌 Multi-Port Entry Points (%d nodes):", len(cfg.Nodes))
|
||||
log.Println("")
|
||||
for _, node := range cfg.Nodes {
|
||||
var auth string
|
||||
username := node.Username
|
||||
password := node.Password
|
||||
if username == "" {
|
||||
username = cfg.MultiPort.Username
|
||||
password = cfg.MultiPort.Password
|
||||
}
|
||||
if username != "" {
|
||||
auth = fmt.Sprintf("%s:%s@", username, password)
|
||||
}
|
||||
proxyURL := fmt.Sprintf("http://%s%s:%d", auth, cfg.MultiPort.Address, node.Port)
|
||||
log.Printf(" [%d] %s", node.Port, node.Name)
|
||||
log.Printf(" %s", proxyURL)
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("═══════════════════════════════════════════════════════════════")
|
||||
log.Println("")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,615 @@
|
||||
package geoip
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/oschwald/geoip2-golang"
|
||||
)
|
||||
|
||||
// Region codes
|
||||
const (
|
||||
RegionJP = "jp"
|
||||
RegionKR = "kr"
|
||||
RegionUS = "us"
|
||||
RegionHK = "hk"
|
||||
RegionTW = "tw"
|
||||
RegionOther = "other"
|
||||
)
|
||||
|
||||
// Default GeoIP database download URL
|
||||
const (
|
||||
DefaultGeoIPURL = "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb"
|
||||
)
|
||||
|
||||
// RegionInfo contains region details
|
||||
type RegionInfo struct {
|
||||
Code string // "jp", "kr", "us", "hk", "tw", "other"
|
||||
Country string // Full country name
|
||||
ISOCode string // ISO country code
|
||||
}
|
||||
|
||||
// Lookup provides GeoIP lookup functionality
|
||||
type Lookup struct {
|
||||
db *geoip2.Reader
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
updateInterval time.Duration
|
||||
stopChan chan struct{}
|
||||
updateOnce sync.Once
|
||||
}
|
||||
|
||||
// EnsureDatabase checks if the GeoIP database exists, and downloads it if not
|
||||
func EnsureDatabase(dbPath string) error {
|
||||
if dbPath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if file already exists and is valid
|
||||
info, err := os.Stat(dbPath)
|
||||
if err == nil {
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("geoip database path is not a file: %s", dbPath)
|
||||
}
|
||||
if info.Size() > 0 {
|
||||
return nil // File exists and has content
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("stat geoip database: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("📥 GeoIP database not found at %s, downloading...", dbPath)
|
||||
|
||||
// Create parent directory if needed
|
||||
dir := filepath.Dir(dbPath)
|
||||
if dir != "." {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("create directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Download with timeout
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
req, err := http.NewRequest(http.MethodGet, DefaultGeoIPURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create download request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download failed: unexpected status %s", resp.Status)
|
||||
}
|
||||
|
||||
// Download to temporary file
|
||||
tempFile, err := os.CreateTemp(dir, ".geoip-*.mmdb")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
tempPath := tempFile.Name()
|
||||
cleanup := true
|
||||
defer func() {
|
||||
if tempFile != nil {
|
||||
tempFile.Close()
|
||||
}
|
||||
if cleanup {
|
||||
os.Remove(tempPath)
|
||||
}
|
||||
}()
|
||||
|
||||
// Copy with progress tracking
|
||||
progress := &progressWriter{total: resp.ContentLength}
|
||||
reader := io.TeeReader(resp.Body, progress)
|
||||
written, err := io.Copy(tempFile, reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
// Verify download completeness
|
||||
if resp.ContentLength > 0 && written < resp.ContentLength {
|
||||
return fmt.Errorf("incomplete download (%d/%d bytes)", written, resp.ContentLength)
|
||||
}
|
||||
|
||||
// Sync and close temp file
|
||||
if err := tempFile.Sync(); err != nil {
|
||||
return fmt.Errorf("sync temp file: %w", err)
|
||||
}
|
||||
if err := tempFile.Close(); err != nil {
|
||||
return fmt.Errorf("close temp file: %w", err)
|
||||
}
|
||||
tempFile = nil
|
||||
|
||||
// Validate MMDB format
|
||||
if err := validateMMDB(tempPath); err != nil {
|
||||
return fmt.Errorf("validation failed: %w", err)
|
||||
}
|
||||
|
||||
// Atomic rename
|
||||
if err := os.Rename(tempPath, dbPath); err != nil {
|
||||
return fmt.Errorf("rename failed: %w", err)
|
||||
}
|
||||
cleanup = false
|
||||
|
||||
log.Printf("✅ GeoIP database downloaded successfully to %s", dbPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// progressWriter tracks download progress
|
||||
type progressWriter struct {
|
||||
total int64
|
||||
downloaded int64
|
||||
lastPercent int64
|
||||
lastLog time.Time
|
||||
}
|
||||
|
||||
func (p *progressWriter) Write(b []byte) (int, error) {
|
||||
n := len(b)
|
||||
p.downloaded += int64(n)
|
||||
|
||||
now := time.Now()
|
||||
if p.total > 0 {
|
||||
percent := p.downloaded * 100 / p.total
|
||||
if percent >= 100 || percent >= p.lastPercent+10 || now.Sub(p.lastLog) >= 3*time.Second {
|
||||
log.Printf(" Progress: %d%% (%d/%d bytes)", percent, p.downloaded, p.total)
|
||||
p.lastPercent = percent
|
||||
p.lastLog = now
|
||||
}
|
||||
} else if now.Sub(p.lastLog) >= 3*time.Second {
|
||||
log.Printf(" Downloaded: %d bytes", p.downloaded)
|
||||
p.lastLog = now
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// validateMMDB performs basic validation of MMDB file format
|
||||
func validateMMDB(path string) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Size() < 1024 {
|
||||
return fmt.Errorf("file too small (%d bytes)", info.Size())
|
||||
}
|
||||
|
||||
// Check for MaxMind metadata in the last 8KB
|
||||
const tailSize int64 = 8192
|
||||
readSize := tailSize
|
||||
if info.Size() < readSize {
|
||||
readSize = info.Size()
|
||||
}
|
||||
if _, err := file.Seek(-readSize, io.SeekEnd); err != nil {
|
||||
return err
|
||||
}
|
||||
buf := make([]byte, readSize)
|
||||
if _, err := io.ReadFull(file, buf); err != nil && err != io.ErrUnexpectedEOF {
|
||||
return err
|
||||
}
|
||||
if !bytes.Contains(buf, []byte("MaxMind.com")) {
|
||||
return fmt.Errorf("missing MaxMind metadata")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadFile downloads a file from URL to the specified path
|
||||
func downloadFile(filepath string, url string) error {
|
||||
// Create the file
|
||||
out, err := os.Create(filepath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Get the data
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check server response
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("bad status: %s", resp.Status)
|
||||
}
|
||||
|
||||
// Writer the body to file
|
||||
size, err := io.Copy(out, resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf(" Downloaded %d bytes", size)
|
||||
return nil
|
||||
}
|
||||
|
||||
// New creates a new GeoIP lookup instance
|
||||
func New(dbPath string) (*Lookup, error) {
|
||||
return NewWithAutoUpdate(dbPath, 0)
|
||||
}
|
||||
|
||||
// NewWithAutoUpdate creates a new GeoIP lookup instance with auto-update support
|
||||
func NewWithAutoUpdate(dbPath string, updateInterval time.Duration) (*Lookup, error) {
|
||||
if dbPath == "" {
|
||||
return &Lookup{}, nil
|
||||
}
|
||||
|
||||
// Ensure database exists (download if needed)
|
||||
if err := EnsureDatabase(dbPath); err != nil {
|
||||
return nil, fmt.Errorf("ensure database: %w", err)
|
||||
}
|
||||
|
||||
db, err := geoip2.Open(dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lookup := &Lookup{
|
||||
db: db,
|
||||
path: dbPath,
|
||||
updateInterval: updateInterval,
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Start auto-update goroutine if interval is set
|
||||
if updateInterval > 0 {
|
||||
go lookup.autoUpdateLoop()
|
||||
log.Printf("🔄 GeoIP auto-update enabled (interval: %v)", updateInterval)
|
||||
}
|
||||
|
||||
return lookup, nil
|
||||
}
|
||||
|
||||
// autoUpdateLoop periodically updates the GeoIP database
|
||||
func (l *Lookup) autoUpdateLoop() {
|
||||
ticker := time.NewTicker(l.updateInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if err := l.Update(); err != nil {
|
||||
log.Printf("⚠️ GeoIP auto-update failed: %v", err)
|
||||
}
|
||||
case <-l.stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update downloads and reloads the GeoIP database
|
||||
func (l *Lookup) Update() error {
|
||||
log.Printf("🔄 Updating GeoIP database...")
|
||||
|
||||
// Download to temporary file
|
||||
tempPath := l.path + ".update"
|
||||
if err := downloadDatabase(tempPath); err != nil {
|
||||
return fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
defer os.Remove(tempPath) // Clean up temp file
|
||||
|
||||
// Validate the downloaded database
|
||||
if err := validateMMDB(tempPath); err != nil {
|
||||
return fmt.Errorf("validation failed: %w", err)
|
||||
}
|
||||
|
||||
// Open new database
|
||||
newDB, err := geoip2.Open(tempPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open new database: %w", err)
|
||||
}
|
||||
|
||||
// Hot-swap the database
|
||||
l.mu.Lock()
|
||||
oldDB := l.db
|
||||
l.db = newDB
|
||||
l.mu.Unlock()
|
||||
|
||||
// Close old database
|
||||
if oldDB != nil {
|
||||
oldDB.Close()
|
||||
}
|
||||
|
||||
// Replace the old file with new one
|
||||
if err := os.Rename(tempPath, l.path); err != nil {
|
||||
log.Printf("⚠️ Failed to replace database file: %v (using in-memory version)", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ GeoIP database updated successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadDatabase downloads the GeoIP database to the specified path
|
||||
func downloadDatabase(filepath string) error {
|
||||
// Create parent directory if needed
|
||||
dir := filepath[:strings.LastIndex(filepath, "/")]
|
||||
if dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("create directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Download with timeout
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
req, err := http.NewRequest(http.MethodGet, DefaultGeoIPURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create download request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status %s", resp.Status)
|
||||
}
|
||||
|
||||
// Create temp file
|
||||
tempFile, err := os.CreateTemp(dir, ".geoip-download-*.mmdb")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
tempPath := tempFile.Name()
|
||||
cleanup := true
|
||||
defer func() {
|
||||
if tempFile != nil {
|
||||
tempFile.Close()
|
||||
}
|
||||
if cleanup {
|
||||
os.Remove(tempPath)
|
||||
}
|
||||
}()
|
||||
|
||||
// Copy with progress tracking
|
||||
progress := &progressWriter{total: resp.ContentLength}
|
||||
reader := io.TeeReader(resp.Body, progress)
|
||||
written, err := io.Copy(tempFile, reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Verify download completeness
|
||||
if resp.ContentLength > 0 && written < resp.ContentLength {
|
||||
return fmt.Errorf("incomplete download (%d/%d bytes)", written, resp.ContentLength)
|
||||
}
|
||||
|
||||
// Sync and close
|
||||
if err := tempFile.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tempFile.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
tempFile = nil
|
||||
|
||||
// Rename to target path
|
||||
if err := os.Rename(tempPath, filepath); err != nil {
|
||||
return err
|
||||
}
|
||||
cleanup = false
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the GeoIP database and stops auto-update
|
||||
func (l *Lookup) Close() error {
|
||||
// Stop auto-update goroutine
|
||||
l.updateOnce.Do(func() {
|
||||
if l.stopChan != nil {
|
||||
close(l.stopChan)
|
||||
}
|
||||
})
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.db != nil {
|
||||
return l.db.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsEnabled returns true if GeoIP lookup is available
|
||||
func (l *Lookup) IsEnabled() bool {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
return l.db != nil
|
||||
}
|
||||
|
||||
// LookupIP returns region info for an IP address
|
||||
func (l *Lookup) LookupIP(ipStr string) RegionInfo {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
|
||||
if l.db == nil {
|
||||
return RegionInfo{Code: RegionOther, Country: "Unknown", ISOCode: ""}
|
||||
}
|
||||
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
return RegionInfo{Code: RegionOther, Country: "Unknown", ISOCode: ""}
|
||||
}
|
||||
|
||||
record, err := l.db.Country(ip)
|
||||
if err != nil {
|
||||
return RegionInfo{Code: RegionOther, Country: "Unknown", ISOCode: ""}
|
||||
}
|
||||
|
||||
isoCode := record.Country.IsoCode
|
||||
country := record.Country.Names["en"]
|
||||
if country == "" {
|
||||
country = isoCode
|
||||
}
|
||||
|
||||
return RegionInfo{
|
||||
Code: isoCodeToRegion(isoCode),
|
||||
Country: country,
|
||||
ISOCode: isoCode,
|
||||
}
|
||||
}
|
||||
|
||||
// LookupURI extracts server from URI and returns region info
|
||||
func (l *Lookup) LookupURI(uri string) RegionInfo {
|
||||
host := extractHostFromURI(uri)
|
||||
if host == "" {
|
||||
return RegionInfo{Code: RegionOther, Country: "Unknown", ISOCode: ""}
|
||||
}
|
||||
|
||||
// Resolve hostname to IP if needed
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
// It's a hostname, try to resolve
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil || len(ips) == 0 {
|
||||
return RegionInfo{Code: RegionOther, Country: "Unknown", ISOCode: ""}
|
||||
}
|
||||
host = ips[0].String()
|
||||
}
|
||||
|
||||
return l.LookupIP(host)
|
||||
}
|
||||
|
||||
// extractHostFromURI extracts the host/IP from various proxy URI formats
|
||||
func extractHostFromURI(uri string) string {
|
||||
// Handle different URI schemes
|
||||
lowerURI := strings.ToLower(uri)
|
||||
|
||||
// vmess:// vless:// trojan:// ss:// ssr:// hysteria:// hysteria2:// hy2://
|
||||
if strings.HasPrefix(lowerURI, "vmess://") ||
|
||||
strings.HasPrefix(lowerURI, "vless://") ||
|
||||
strings.HasPrefix(lowerURI, "trojan://") ||
|
||||
strings.HasPrefix(lowerURI, "hysteria://") ||
|
||||
strings.HasPrefix(lowerURI, "hysteria2://") ||
|
||||
strings.HasPrefix(lowerURI, "hy2://") {
|
||||
// Standard URL format: scheme://user@host:port?params#fragment
|
||||
parsed, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return parsed.Hostname()
|
||||
}
|
||||
|
||||
if strings.HasPrefix(lowerURI, "ss://") {
|
||||
// Shadowsocks format: ss://base64(method:password)@host:port#name
|
||||
// or ss://base64@host:port#name
|
||||
return extractSSHost(uri)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(lowerURI, "ssr://") {
|
||||
// SSR format is base64 encoded
|
||||
return extractSSRHost(uri)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractSSHost(uri string) string {
|
||||
// Remove ss:// prefix
|
||||
content := strings.TrimPrefix(uri, "ss://")
|
||||
|
||||
// Remove fragment (#name)
|
||||
if idx := strings.Index(content, "#"); idx != -1 {
|
||||
content = content[:idx]
|
||||
}
|
||||
|
||||
// Check if it's the new format: base64@host:port
|
||||
if atIdx := strings.LastIndex(content, "@"); atIdx != -1 {
|
||||
hostPort := content[atIdx+1:]
|
||||
if colonIdx := strings.LastIndex(hostPort, ":"); colonIdx != -1 {
|
||||
return hostPort[:colonIdx]
|
||||
}
|
||||
return hostPort
|
||||
}
|
||||
|
||||
// Old format: entire content is base64
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractSSRHost(uri string) string {
|
||||
// SSR is complex, skip for now - will be marked as "other"
|
||||
return ""
|
||||
}
|
||||
|
||||
// isoCodeToRegion maps ISO country codes to our region codes
|
||||
func isoCodeToRegion(isoCode string) string {
|
||||
switch strings.ToUpper(isoCode) {
|
||||
case "JP":
|
||||
return RegionJP
|
||||
case "KR":
|
||||
return RegionKR
|
||||
case "US":
|
||||
return RegionUS
|
||||
case "HK":
|
||||
return RegionHK
|
||||
case "TW":
|
||||
return RegionTW
|
||||
default:
|
||||
return RegionOther
|
||||
}
|
||||
}
|
||||
|
||||
// AllRegions returns all supported region codes
|
||||
func AllRegions() []string {
|
||||
return []string{RegionJP, RegionKR, RegionUS, RegionHK, RegionTW, RegionOther}
|
||||
}
|
||||
|
||||
// RegionName returns the display name for a region code
|
||||
func RegionName(code string) string {
|
||||
switch code {
|
||||
case RegionJP:
|
||||
return "Japan"
|
||||
case RegionKR:
|
||||
return "Korea"
|
||||
case RegionUS:
|
||||
return "USA"
|
||||
case RegionHK:
|
||||
return "Hong Kong"
|
||||
case RegionTW:
|
||||
return "Taiwan"
|
||||
case RegionOther:
|
||||
return "Other"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// RegionEmoji returns the flag emoji for a region code
|
||||
func RegionEmoji(code string) string {
|
||||
switch code {
|
||||
case RegionJP:
|
||||
return "🇯🇵"
|
||||
case RegionKR:
|
||||
return "🇰🇷"
|
||||
case RegionUS:
|
||||
return "🇺🇸"
|
||||
case RegionHK:
|
||||
return "🇭🇰"
|
||||
case RegionTW:
|
||||
return "🇹🇼"
|
||||
case RegionOther:
|
||||
return "🌍"
|
||||
default:
|
||||
return "❓"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package geoip
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RouterConfig holds configuration for the GeoIP router
|
||||
type RouterConfig struct {
|
||||
Listen string
|
||||
Port uint16
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
// PoolDialer is an interface for dialing through a specific pool
|
||||
type PoolDialer interface {
|
||||
DialContext(ctx context.Context, network, address string) (net.Conn, error)
|
||||
}
|
||||
|
||||
// Router handles HTTP proxy requests with path-based region routing
|
||||
type Router struct {
|
||||
cfg RouterConfig
|
||||
pools map[string]PoolDialer // region -> dialer
|
||||
global PoolDialer // default pool for requests without region path
|
||||
server *http.Server
|
||||
mu sync.RWMutex
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
// NewRouter creates a new GeoIP router
|
||||
func NewRouter(cfg RouterConfig, logger *log.Logger) *Router {
|
||||
if logger == nil {
|
||||
logger = log.Default()
|
||||
}
|
||||
return &Router{
|
||||
cfg: cfg,
|
||||
pools: make(map[string]PoolDialer),
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// SetPool registers a pool dialer for a specific region
|
||||
func (r *Router) SetPool(region string, dialer PoolDialer) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.pools[region] = dialer
|
||||
}
|
||||
|
||||
// SetGlobalPool sets the default pool for requests without region path
|
||||
func (r *Router) SetGlobalPool(dialer PoolDialer) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.global = dialer
|
||||
}
|
||||
|
||||
// Start starts the GeoIP router HTTP server
|
||||
func (r *Router) Start(ctx context.Context) error {
|
||||
addr := fmt.Sprintf("%s:%d", r.cfg.Listen, r.cfg.Port)
|
||||
|
||||
r.server = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
go func() {
|
||||
r.logger.Printf("🌐 GeoIP Router started on %s", addr)
|
||||
r.logger.Println(" Routes: /jp, /kr, /us, /hk, /tw, /other (default: all nodes)")
|
||||
if err := r.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
r.logger.Printf("GeoIP router error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
r.Stop()
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops the GeoIP router
|
||||
func (r *Router) Stop() error {
|
||||
if r.server != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return r.server.Shutdown(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ServeHTTP handles incoming HTTP proxy requests
|
||||
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
// Check authentication if configured
|
||||
if r.cfg.Username != "" {
|
||||
user, pass, ok := req.BasicAuth()
|
||||
if !ok || user != r.cfg.Username || pass != r.cfg.Password {
|
||||
w.Header().Set("Proxy-Authenticate", `Basic realm="Proxy"`)
|
||||
http.Error(w, "Proxy authentication required", http.StatusProxyAuthRequired)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Extract region from path
|
||||
region, targetHost := r.parseRequest(req)
|
||||
|
||||
// Get the appropriate pool
|
||||
r.mu.RLock()
|
||||
var dialer PoolDialer
|
||||
if region != "" {
|
||||
dialer = r.pools[region]
|
||||
}
|
||||
if dialer == nil {
|
||||
dialer = r.global
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
|
||||
if dialer == nil {
|
||||
http.Error(w, "No proxy pool available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Method == http.MethodConnect {
|
||||
r.handleConnect(w, req, dialer, targetHost)
|
||||
} else {
|
||||
r.handleHTTP(w, req, dialer, targetHost)
|
||||
}
|
||||
}
|
||||
|
||||
// parseRequest extracts region and target host from the request
|
||||
func (r *Router) parseRequest(req *http.Request) (region, targetHost string) {
|
||||
// For CONNECT requests, the host is in req.Host
|
||||
// For regular requests, check the path prefix
|
||||
|
||||
if req.Method == http.MethodConnect {
|
||||
// CONNECT requests: check if host starts with region prefix
|
||||
// e.g., CONNECT jp/example.com:443 or just example.com:443
|
||||
host := req.Host
|
||||
for _, reg := range AllRegions() {
|
||||
prefix := reg + "/"
|
||||
if strings.HasPrefix(host, prefix) {
|
||||
return reg, strings.TrimPrefix(host, prefix)
|
||||
}
|
||||
}
|
||||
return "", host
|
||||
}
|
||||
|
||||
// For regular HTTP requests, check URL path
|
||||
path := req.URL.Path
|
||||
for _, reg := range AllRegions() {
|
||||
prefix := "/" + reg + "/"
|
||||
if strings.HasPrefix(path, prefix) {
|
||||
// Rewrite the path
|
||||
req.URL.Path = "/" + strings.TrimPrefix(path, prefix)
|
||||
return reg, req.Host
|
||||
}
|
||||
// Also check for exact match like /jp
|
||||
if path == "/"+reg {
|
||||
req.URL.Path = "/"
|
||||
return reg, req.Host
|
||||
}
|
||||
}
|
||||
|
||||
return "", req.Host
|
||||
}
|
||||
|
||||
// handleConnect handles HTTPS CONNECT tunneling
|
||||
func (r *Router) handleConnect(w http.ResponseWriter, req *http.Request, dialer PoolDialer, targetHost string) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
targetConn, err := dialer.DialContext(ctx, "tcp", targetHost)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to connect: %v", err), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer targetConn.Close()
|
||||
|
||||
hijacker, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "Hijacking not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
clientConn, _, err := hijacker.Hijack()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Hijack failed: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer clientConn.Close()
|
||||
|
||||
// Send 200 Connection Established
|
||||
clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
|
||||
|
||||
// Bidirectional copy
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
io.Copy(targetConn, clientConn)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
io.Copy(clientConn, targetConn)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// handleHTTP handles regular HTTP requests
|
||||
func (r *Router) handleHTTP(w http.ResponseWriter, req *http.Request, dialer PoolDialer, targetHost string) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Create a new request to the target
|
||||
targetURL := req.URL
|
||||
if targetURL.Host == "" {
|
||||
targetURL.Host = targetHost
|
||||
}
|
||||
if targetURL.Scheme == "" {
|
||||
targetURL.Scheme = "http"
|
||||
}
|
||||
|
||||
outReq, err := http.NewRequestWithContext(ctx, req.Method, targetURL.String(), req.Body)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to create request: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Copy headers
|
||||
for key, values := range req.Header {
|
||||
for _, value := range values {
|
||||
outReq.Header.Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove hop-by-hop headers
|
||||
outReq.Header.Del("Proxy-Connection")
|
||||
outReq.Header.Del("Proxy-Authorization")
|
||||
|
||||
// Create transport with custom dialer
|
||||
transport := &http.Transport{
|
||||
DialContext: dialer.DialContext,
|
||||
}
|
||||
|
||||
resp, err := transport.RoundTrip(outReq)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Request failed: %v", err), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Copy response headers
|
||||
for key, values := range resp.Header {
|
||||
for _, value := range values {
|
||||
w.Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
io.Copy(w, resp.Body)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Easy Proxies</title>
|
||||
<script type="module" crossorigin src="/assets/index-BkdNNtGR.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CZdhzSdd.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,21 @@
|
||||
package monitor
|
||||
|
||||
import "context"
|
||||
|
||||
type contextKey string
|
||||
|
||||
const managerKey contextKey = "easy_proxies.monitor"
|
||||
|
||||
// ContextWith attaches the manager into context so downstream components can reuse it.
|
||||
func ContextWith(ctx context.Context, mgr *Manager) context.Context {
|
||||
if mgr == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, managerKey, mgr)
|
||||
}
|
||||
|
||||
// FromContext extracts a manager if present.
|
||||
func FromContext(ctx context.Context) *Manager {
|
||||
mgr, _ := ctx.Value(managerKey).(*Manager)
|
||||
return mgr
|
||||
}
|
||||
@@ -0,0 +1,868 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
)
|
||||
|
||||
// Config mirrors user settings needed by the monitoring server.
|
||||
type Config struct {
|
||||
Enabled bool
|
||||
Listen string
|
||||
ProbeTarget string
|
||||
Password string
|
||||
ProxyUsername string // 代理池的用户名(用于导出)
|
||||
ProxyPassword string // 代理池的密码(用于导出)
|
||||
ExternalIP string // 外部 IP 地址,用于导出时替换 0.0.0.0
|
||||
SkipCertVerify bool // 全局跳过 SSL 证书验证
|
||||
}
|
||||
|
||||
// NodeInfo is static metadata about a proxy entry.
|
||||
type NodeInfo struct {
|
||||
Tag string `json:"tag"`
|
||||
Name string `json:"name"`
|
||||
URI string `json:"uri"`
|
||||
Mode string `json:"mode"`
|
||||
ListenAddress string `json:"listen_address,omitempty"`
|
||||
Port uint16 `json:"port,omitempty"`
|
||||
Region string `json:"region,omitempty"` // GeoIP region code: "jp", "kr", "us", "hk", "tw", "other"
|
||||
Country string `json:"country,omitempty"` // Full country name from GeoIP
|
||||
}
|
||||
|
||||
// TimelineEvent represents a single usage event for debug tracking.
|
||||
type TimelineEvent struct {
|
||||
Time time.Time `json:"time"`
|
||||
Success bool `json:"success"`
|
||||
LatencyMs int64 `json:"latency_ms"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Destination string `json:"destination,omitempty"`
|
||||
}
|
||||
|
||||
const maxTimelineSize = 20
|
||||
|
||||
// Snapshot is a runtime view of a proxy node.
|
||||
type Snapshot struct {
|
||||
NodeInfo
|
||||
FailureCount int `json:"failure_count"`
|
||||
SuccessCount int64 `json:"success_count"`
|
||||
Blacklisted bool `json:"blacklisted"`
|
||||
BlacklistedUntil time.Time `json:"blacklisted_until"`
|
||||
ActiveConnections int32 `json:"active_connections"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
LastFailure time.Time `json:"last_failure,omitempty"`
|
||||
LastSuccess time.Time `json:"last_success,omitempty"`
|
||||
LastProbeLatency time.Duration `json:"last_probe_latency,omitempty"`
|
||||
LastLatencyMs int64 `json:"last_latency_ms"`
|
||||
Available bool `json:"available"`
|
||||
InitialCheckDone bool `json:"initial_check_done"`
|
||||
TotalUpload int64 `json:"total_upload"`
|
||||
TotalDownload int64 `json:"total_download"`
|
||||
UploadSpeed int64 `json:"upload_speed"` // bytes/sec
|
||||
DownloadSpeed int64 `json:"download_speed"` // bytes/sec
|
||||
Timeline []TimelineEvent `json:"timeline,omitempty"`
|
||||
}
|
||||
|
||||
type NodeTrafficSpeed struct {
|
||||
Tag string `json:"tag"`
|
||||
UploadSpeed int64 `json:"upload_speed"` // bytes/sec
|
||||
DownloadSpeed int64 `json:"download_speed"` // bytes/sec
|
||||
TotalUpload int64 `json:"total_upload"`
|
||||
TotalDownload int64 `json:"total_download"`
|
||||
}
|
||||
|
||||
type TrafficSummary struct {
|
||||
NodeCount int `json:"node_count"`
|
||||
TotalUpload int64 `json:"total_upload"`
|
||||
TotalDownload int64 `json:"total_download"`
|
||||
UploadSpeed int64 `json:"upload_speed"` // bytes/sec
|
||||
DownloadSpeed int64 `json:"download_speed"` // bytes/sec
|
||||
Nodes []NodeTrafficSpeed `json:"nodes,omitempty"`
|
||||
SampledAt time.Time `json:"sampled_at"`
|
||||
}
|
||||
|
||||
type probeFunc func(ctx context.Context) (time.Duration, error)
|
||||
type releaseFunc func()
|
||||
|
||||
type EntryHandle struct {
|
||||
ref *entry
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
info NodeInfo
|
||||
failure int
|
||||
success int64
|
||||
timeline []TimelineEvent
|
||||
blacklist bool
|
||||
until time.Time
|
||||
lastError string
|
||||
lastFail time.Time
|
||||
lastOK time.Time
|
||||
lastProbe time.Duration
|
||||
active atomic.Int32
|
||||
totalUpload atomic.Int64
|
||||
totalDownload atomic.Int64
|
||||
uploadSpeed int64
|
||||
downloadSpeed int64
|
||||
lastSpeedUpload int64
|
||||
lastSpeedDown int64
|
||||
lastSpeedAt time.Time
|
||||
probe probeFunc
|
||||
release releaseFunc
|
||||
initialCheckDone bool
|
||||
available bool
|
||||
reloadGen uint64 // generation counter to track active registrations
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// Manager aggregates all node states for the UI/API.
|
||||
type Manager struct {
|
||||
cfg Config
|
||||
reloadGen uint64 // current reload generation
|
||||
probeDst M.Socksaddr
|
||||
probeReady bool
|
||||
mu sync.RWMutex
|
||||
nodes map[string]*entry
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// Logger interface for logging
|
||||
type Logger interface {
|
||||
Info(args ...any)
|
||||
Warn(args ...any)
|
||||
}
|
||||
|
||||
// NewManager constructs a manager and pre-validates the probe target.
|
||||
func NewManager(cfg Config) (*Manager, error) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
m := &Manager{
|
||||
cfg: cfg,
|
||||
nodes: make(map[string]*entry),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
if cfg.ProbeTarget != "" {
|
||||
target := cfg.ProbeTarget
|
||||
// Strip URL scheme if present (e.g., "https://www.google.com:443" -> "www.google.com:443")
|
||||
if strings.HasPrefix(target, "https://") {
|
||||
target = strings.TrimPrefix(target, "https://")
|
||||
} else if strings.HasPrefix(target, "http://") {
|
||||
target = strings.TrimPrefix(target, "http://")
|
||||
}
|
||||
// Remove trailing path if present
|
||||
if idx := strings.Index(target, "/"); idx != -1 {
|
||||
target = target[:idx]
|
||||
}
|
||||
host, port, err := net.SplitHostPort(target)
|
||||
if err != nil {
|
||||
// If no port specified, use default based on original scheme
|
||||
if strings.HasPrefix(cfg.ProbeTarget, "https://") {
|
||||
host = target
|
||||
port = "443"
|
||||
} else {
|
||||
host = target
|
||||
port = "80"
|
||||
}
|
||||
}
|
||||
parsed := M.ParseSocksaddrHostPort(host, parsePort(port))
|
||||
m.probeDst = parsed
|
||||
m.probeReady = true
|
||||
}
|
||||
go m.startTrafficSpeedSampler()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// SetLogger sets the logger for the manager.
|
||||
func (m *Manager) SetLogger(logger Logger) {
|
||||
m.logger = logger
|
||||
}
|
||||
|
||||
// StartPeriodicHealthCheck starts a background goroutine that periodically checks all nodes.
|
||||
// interval: how often to check (e.g., 30 * time.Second)
|
||||
// timeout: timeout for each probe (e.g., 10 * time.Second)
|
||||
func (m *Manager) StartPeriodicHealthCheck(interval, timeout time.Duration) {
|
||||
if !m.probeReady {
|
||||
if m.logger != nil {
|
||||
m.logger.Warn("probe target not configured, periodic health check disabled")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
// 启动后立即进行一次检查
|
||||
m.probeAllNodes(timeout)
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-m.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
m.probeAllNodes(timeout)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if m.logger != nil {
|
||||
m.logger.Info("periodic health check started, interval: ", interval)
|
||||
}
|
||||
}
|
||||
|
||||
// probeAllNodes checks all registered nodes concurrently.
|
||||
func (m *Manager) probeAllNodes(timeout time.Duration) {
|
||||
m.mu.RLock()
|
||||
entries := make([]*entry, 0, len(m.nodes))
|
||||
for _, e := range m.nodes {
|
||||
entries = append(entries, e)
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
if len(entries) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if m.logger != nil {
|
||||
m.logger.Info("starting health check for ", len(entries), " nodes")
|
||||
}
|
||||
|
||||
workerLimit := runtime.NumCPU() * 2
|
||||
if workerLimit < 8 {
|
||||
workerLimit = 8
|
||||
}
|
||||
sem := make(chan struct{}, workerLimit)
|
||||
var wg sync.WaitGroup
|
||||
var availableCount atomic.Int32
|
||||
var failedCount atomic.Int32
|
||||
|
||||
for _, e := range entries {
|
||||
e.mu.RLock()
|
||||
probeFn := e.probe
|
||||
tag := e.info.Tag
|
||||
e.mu.RUnlock()
|
||||
|
||||
if probeFn == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
sem <- struct{}{}
|
||||
wg.Add(1)
|
||||
go func(entry *entry, probe probeFunc, tag string) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
|
||||
ctx, cancel := context.WithTimeout(m.ctx, timeout)
|
||||
latency, err := probe(ctx)
|
||||
cancel()
|
||||
|
||||
entry.mu.Lock()
|
||||
if err != nil {
|
||||
failedCount.Add(1)
|
||||
entry.lastError = err.Error()
|
||||
entry.lastFail = time.Now()
|
||||
entry.lastProbe = 0
|
||||
entry.available = false
|
||||
entry.initialCheckDone = true
|
||||
} else {
|
||||
availableCount.Add(1)
|
||||
entry.lastOK = time.Now()
|
||||
entry.lastProbe = latency
|
||||
entry.available = true
|
||||
entry.initialCheckDone = true
|
||||
}
|
||||
entry.mu.Unlock()
|
||||
|
||||
if err != nil && m.logger != nil {
|
||||
m.logger.Warn("probe failed for ", tag, ": ", err)
|
||||
}
|
||||
}(e, probeFn, tag)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if m.logger != nil {
|
||||
m.logger.Info("health check completed: ", availableCount.Load(), " available, ", failedCount.Load(), " failed")
|
||||
}
|
||||
}
|
||||
|
||||
// Stop stops the periodic health check.
|
||||
func (m *Manager) Stop() {
|
||||
if m.cancel != nil {
|
||||
m.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) startTrafficSpeedSampler() {
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-m.ctx.Done():
|
||||
return
|
||||
case now := <-ticker.C:
|
||||
m.sampleTrafficSpeeds(now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) sampleTrafficSpeeds(now time.Time) {
|
||||
m.mu.RLock()
|
||||
entries := make([]*entry, 0, len(m.nodes))
|
||||
for _, e := range m.nodes {
|
||||
entries = append(entries, e)
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
for _, e := range entries {
|
||||
e.updateTrafficSpeed(now)
|
||||
}
|
||||
}
|
||||
|
||||
func parsePort(value string) uint16 {
|
||||
p, err := strconv.Atoi(value)
|
||||
if err != nil || p <= 0 || p > 65535 {
|
||||
return 80
|
||||
}
|
||||
return uint16(p)
|
||||
}
|
||||
|
||||
// BeginReload bumps the generation counter. Nodes registered after this call
|
||||
// will be marked with the new generation. Call SweepStaleNodes after reload
|
||||
// to remove nodes that were not re-registered (disabled/deleted nodes).
|
||||
func (m *Manager) BeginReload() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.reloadGen++
|
||||
}
|
||||
|
||||
// SweepStaleNodes removes nodes that were not re-registered during the current
|
||||
// reload cycle. This preserves monitoring data (latency, success/failure counts)
|
||||
// for nodes that are still active, while cleaning up disabled/removed nodes.
|
||||
func (m *Manager) SweepStaleNodes() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for tag, e := range m.nodes {
|
||||
if e.reloadGen != m.reloadGen {
|
||||
delete(m.nodes, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ClearNodes removes all registered nodes. Use BeginReload + SweepStaleNodes
|
||||
// for reload scenarios to preserve data for active nodes.
|
||||
func (m *Manager) ClearNodes() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.nodes = make(map[string]*entry)
|
||||
}
|
||||
|
||||
// Register ensures a node is tracked and returns its entry.
|
||||
// If the node already exists, its info is updated but monitoring stats
|
||||
// (latency, success/failure counts, etc.) are preserved.
|
||||
func (m *Manager) Register(info NodeInfo) *EntryHandle {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
e, ok := m.nodes[info.Tag]
|
||||
if !ok {
|
||||
e = &entry{
|
||||
info: info,
|
||||
timeline: make([]TimelineEvent, 0, maxTimelineSize),
|
||||
reloadGen: m.reloadGen,
|
||||
}
|
||||
m.nodes[info.Tag] = e
|
||||
} else {
|
||||
e.info = info
|
||||
e.reloadGen = m.reloadGen
|
||||
}
|
||||
return &EntryHandle{ref: e}
|
||||
}
|
||||
|
||||
// DestinationForProbe exposes the configured destination for health checks.
|
||||
func (m *Manager) DestinationForProbe() (M.Socksaddr, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if !m.probeReady {
|
||||
return M.Socksaddr{}, false
|
||||
}
|
||||
return m.probeDst, true
|
||||
}
|
||||
|
||||
// UpdateProbeTarget dynamically updates the probe destination at runtime.
|
||||
func (m *Manager) UpdateProbeTarget(target string) error {
|
||||
if target == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Strip URL scheme if present
|
||||
if strings.HasPrefix(target, "https://") {
|
||||
target = strings.TrimPrefix(target, "https://")
|
||||
} else if strings.HasPrefix(target, "http://") {
|
||||
target = strings.TrimPrefix(target, "http://")
|
||||
}
|
||||
// Remove trailing path if present
|
||||
if idx := strings.Index(target, "/"); idx != -1 {
|
||||
target = target[:idx]
|
||||
}
|
||||
|
||||
host, port, err := net.SplitHostPort(target)
|
||||
if err != nil {
|
||||
// If no port specified, use default port 80
|
||||
host = target
|
||||
port = "80"
|
||||
}
|
||||
|
||||
parsed := M.ParseSocksaddrHostPort(host, parsePort(port))
|
||||
|
||||
m.mu.Lock()
|
||||
m.probeDst = parsed
|
||||
m.probeReady = true
|
||||
m.cfg.ProbeTarget = target
|
||||
m.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Snapshot returns a sorted copy of current node states.
|
||||
// If onlyAvailable is true, only returns nodes that passed initial health check.
|
||||
func (m *Manager) Snapshot() []Snapshot {
|
||||
return m.SnapshotFiltered(false)
|
||||
}
|
||||
|
||||
// SnapshotFiltered returns a sorted copy of current node states.
|
||||
// If onlyAvailable is true, only returns nodes that passed initial health check.
|
||||
// Nodes that haven't been checked yet are also included (they will be checked on first use).
|
||||
func (m *Manager) SnapshotFiltered(onlyAvailable bool) []Snapshot {
|
||||
m.mu.RLock()
|
||||
list := make([]*entry, 0, len(m.nodes))
|
||||
for _, e := range m.nodes {
|
||||
list = append(list, e)
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
snapshots := make([]Snapshot, 0, len(list))
|
||||
for _, e := range list {
|
||||
snap := e.snapshot()
|
||||
// 如果只要可用节点:
|
||||
// - 跳过已完成检查但不可用的节点
|
||||
// - 保留未完成检查的节点(它们会在首次使用时被检查)
|
||||
if onlyAvailable && snap.InitialCheckDone && !snap.Available {
|
||||
continue
|
||||
}
|
||||
snapshots = append(snapshots, snap)
|
||||
}
|
||||
// 按延迟排序(延迟小的在前面,未测试的排在最后)
|
||||
sort.Slice(snapshots, func(i, j int) bool {
|
||||
latencyI := snapshots[i].LastLatencyMs
|
||||
latencyJ := snapshots[j].LastLatencyMs
|
||||
// -1 表示未测试,排在最后
|
||||
if latencyI < 0 && latencyJ < 0 {
|
||||
return snapshots[i].Name < snapshots[j].Name // 都未测试时按名称排序
|
||||
}
|
||||
if latencyI < 0 {
|
||||
return false // i 未测试,排在后面
|
||||
}
|
||||
if latencyJ < 0 {
|
||||
return true // j 未测试,i 排在前面
|
||||
}
|
||||
if latencyI == latencyJ {
|
||||
return snapshots[i].Name < snapshots[j].Name // 延迟相同时按名称排序
|
||||
}
|
||||
return latencyI < latencyJ
|
||||
})
|
||||
return snapshots
|
||||
}
|
||||
|
||||
// TrafficSummary returns aggregated traffic totals/speeds and per-node speeds.
|
||||
// includeNodes controls whether per-node details are returned.
|
||||
func (m *Manager) TrafficSummary(includeNodes bool) TrafficSummary {
|
||||
m.mu.RLock()
|
||||
list := make([]*entry, 0, len(m.nodes))
|
||||
for _, e := range m.nodes {
|
||||
list = append(list, e)
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
summary := TrafficSummary{
|
||||
NodeCount: len(list),
|
||||
SampledAt: time.Now(),
|
||||
}
|
||||
if includeNodes {
|
||||
summary.Nodes = make([]NodeTrafficSpeed, 0, len(list))
|
||||
}
|
||||
|
||||
for _, e := range list {
|
||||
totalUp := e.totalUpload.Load()
|
||||
totalDown := e.totalDownload.Load()
|
||||
|
||||
e.mu.RLock()
|
||||
upSpeed := e.uploadSpeed
|
||||
downSpeed := e.downloadSpeed
|
||||
tag := e.info.Tag
|
||||
e.mu.RUnlock()
|
||||
|
||||
summary.TotalUpload += totalUp
|
||||
summary.TotalDownload += totalDown
|
||||
summary.UploadSpeed += upSpeed
|
||||
summary.DownloadSpeed += downSpeed
|
||||
|
||||
if includeNodes {
|
||||
summary.Nodes = append(summary.Nodes, NodeTrafficSpeed{
|
||||
Tag: tag,
|
||||
UploadSpeed: upSpeed,
|
||||
DownloadSpeed: downSpeed,
|
||||
TotalUpload: totalUp,
|
||||
TotalDownload: totalDown,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
// Probe triggers a manual health check.
|
||||
func (m *Manager) Probe(ctx context.Context, tag string) (time.Duration, error) {
|
||||
e, err := m.entry(tag)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if e.probe == nil {
|
||||
return 0, errors.New("probe not available for this node")
|
||||
}
|
||||
latency, err := e.probe(ctx)
|
||||
if err != nil {
|
||||
// 探测失败:标记节点为不可用,清除旧延迟数据
|
||||
e.mu.Lock()
|
||||
e.available = false
|
||||
e.initialCheckDone = true
|
||||
e.lastProbe = 0 // 清除延迟,前端显示"未测试"
|
||||
e.lastError = err.Error()
|
||||
e.lastFail = time.Now()
|
||||
e.mu.Unlock()
|
||||
return 0, err
|
||||
}
|
||||
// 探测成功:recordSuccessWithLatency 已在 probe 函数内更新 available=true
|
||||
e.recordProbeLatency(latency)
|
||||
return latency, nil
|
||||
}
|
||||
|
||||
// Release clears blacklist state for the given node.
|
||||
func (m *Manager) Release(tag string) error {
|
||||
e, err := m.entry(tag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if e.release == nil {
|
||||
return errors.New("release not available for this node")
|
||||
}
|
||||
e.release()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) entry(tag string) (*entry, error) {
|
||||
m.mu.RLock()
|
||||
e, ok := m.nodes[tag]
|
||||
m.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("node %s not found", tag)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (e *entry) snapshot() Snapshot {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
|
||||
latencyMs := int64(-1)
|
||||
if e.lastProbe > 0 {
|
||||
latencyMs = e.lastProbe.Milliseconds()
|
||||
if latencyMs == 0 {
|
||||
latencyMs = 1
|
||||
}
|
||||
}
|
||||
|
||||
var timelineCopy []TimelineEvent
|
||||
if len(e.timeline) > 0 {
|
||||
timelineCopy = make([]TimelineEvent, len(e.timeline))
|
||||
copy(timelineCopy, e.timeline)
|
||||
}
|
||||
|
||||
return Snapshot{
|
||||
NodeInfo: e.info,
|
||||
FailureCount: e.failure,
|
||||
SuccessCount: e.success,
|
||||
Blacklisted: e.blacklist,
|
||||
BlacklistedUntil: e.until,
|
||||
ActiveConnections: e.active.Load(),
|
||||
LastError: e.lastError,
|
||||
LastFailure: e.lastFail,
|
||||
LastSuccess: e.lastOK,
|
||||
LastProbeLatency: e.lastProbe,
|
||||
LastLatencyMs: latencyMs,
|
||||
Available: e.available,
|
||||
InitialCheckDone: e.initialCheckDone,
|
||||
TotalUpload: e.totalUpload.Load(),
|
||||
TotalDownload: e.totalDownload.Load(),
|
||||
UploadSpeed: e.uploadSpeed,
|
||||
DownloadSpeed: e.downloadSpeed,
|
||||
Timeline: timelineCopy,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *entry) recordFailure(err error, destination string) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
errStr := err.Error()
|
||||
e.failure++
|
||||
e.lastError = errStr
|
||||
e.lastFail = time.Now()
|
||||
// 注意:不修改 available/initialCheckDone
|
||||
// 流量失败不代表节点不可用(可能是目标网站的问题)
|
||||
// available 只由探测操作控制
|
||||
e.appendTimelineLocked(false, 0, errStr, destination)
|
||||
}
|
||||
|
||||
func (e *entry) recordSuccess(destination string) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.success++
|
||||
e.lastOK = time.Now()
|
||||
// 注意:不修改 available/initialCheckDone
|
||||
// 流量成功不代表需要更新探测状态
|
||||
// available 只由探测操作控制
|
||||
e.appendTimelineLocked(true, 0, "", destination)
|
||||
}
|
||||
|
||||
func (e *entry) recordSuccessWithLatency(latency time.Duration) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.success++
|
||||
e.lastOK = time.Now()
|
||||
e.lastProbe = latency
|
||||
e.available = true
|
||||
e.initialCheckDone = true
|
||||
latencyMs := latency.Milliseconds()
|
||||
if latencyMs == 0 && latency > 0 {
|
||||
latencyMs = 1
|
||||
}
|
||||
e.appendTimelineLocked(true, latencyMs, "", "")
|
||||
}
|
||||
|
||||
func (e *entry) appendTimelineLocked(success bool, latencyMs int64, errStr string, destination string) {
|
||||
evt := TimelineEvent{
|
||||
Time: time.Now(),
|
||||
Success: success,
|
||||
LatencyMs: latencyMs,
|
||||
Error: errStr,
|
||||
Destination: destination,
|
||||
}
|
||||
if len(e.timeline) >= maxTimelineSize {
|
||||
copy(e.timeline, e.timeline[1:])
|
||||
e.timeline[len(e.timeline)-1] = evt
|
||||
} else {
|
||||
e.timeline = append(e.timeline, evt)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *entry) blacklistUntil(until time.Time) {
|
||||
e.mu.Lock()
|
||||
e.blacklist = true
|
||||
e.until = until
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
func (e *entry) clearBlacklist() {
|
||||
e.mu.Lock()
|
||||
e.blacklist = false
|
||||
e.until = time.Time{}
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
func (e *entry) incActive() {
|
||||
e.active.Add(1)
|
||||
}
|
||||
|
||||
func (e *entry) decActive() {
|
||||
e.active.Add(-1)
|
||||
}
|
||||
|
||||
func (e *entry) setProbe(fn probeFunc) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.probe = fn
|
||||
}
|
||||
|
||||
func (e *entry) setRelease(fn releaseFunc) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.release = fn
|
||||
}
|
||||
|
||||
func (e *entry) recordProbeLatency(d time.Duration) {
|
||||
e.mu.Lock()
|
||||
e.lastProbe = d
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
func (e *entry) updateTrafficSpeed(now time.Time) {
|
||||
curUp := e.totalUpload.Load()
|
||||
curDown := e.totalDownload.Load()
|
||||
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
if e.lastSpeedAt.IsZero() {
|
||||
e.lastSpeedAt = now
|
||||
e.lastSpeedUpload = curUp
|
||||
e.lastSpeedDown = curDown
|
||||
e.uploadSpeed = 0
|
||||
e.downloadSpeed = 0
|
||||
return
|
||||
}
|
||||
|
||||
elapsed := now.Sub(e.lastSpeedAt).Seconds()
|
||||
if elapsed <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
deltaUp := curUp - e.lastSpeedUpload
|
||||
deltaDown := curDown - e.lastSpeedDown
|
||||
if deltaUp < 0 {
|
||||
deltaUp = 0
|
||||
}
|
||||
if deltaDown < 0 {
|
||||
deltaDown = 0
|
||||
}
|
||||
|
||||
e.uploadSpeed = int64(float64(deltaUp) / elapsed)
|
||||
e.downloadSpeed = int64(float64(deltaDown) / elapsed)
|
||||
e.lastSpeedUpload = curUp
|
||||
e.lastSpeedDown = curDown
|
||||
e.lastSpeedAt = now
|
||||
}
|
||||
|
||||
// RecordFailure updates failure counters with destination info.
|
||||
func (h *EntryHandle) RecordFailure(err error, destination string) {
|
||||
if h == nil || h.ref == nil {
|
||||
return
|
||||
}
|
||||
h.ref.recordFailure(err, destination)
|
||||
}
|
||||
|
||||
// RecordSuccess updates the last success timestamp with destination info.
|
||||
func (h *EntryHandle) RecordSuccess(destination string) {
|
||||
if h == nil || h.ref == nil {
|
||||
return
|
||||
}
|
||||
h.ref.recordSuccess(destination)
|
||||
}
|
||||
|
||||
// RecordSuccessWithLatency updates the last success timestamp and latency.
|
||||
func (h *EntryHandle) RecordSuccessWithLatency(latency time.Duration) {
|
||||
if h == nil || h.ref == nil {
|
||||
return
|
||||
}
|
||||
h.ref.recordSuccessWithLatency(latency)
|
||||
}
|
||||
|
||||
// Blacklist marks the node unavailable until the given deadline.
|
||||
func (h *EntryHandle) Blacklist(until time.Time) {
|
||||
if h == nil || h.ref == nil {
|
||||
return
|
||||
}
|
||||
h.ref.blacklistUntil(until)
|
||||
}
|
||||
|
||||
// ClearBlacklist removes the blacklist flag.
|
||||
func (h *EntryHandle) ClearBlacklist() {
|
||||
if h == nil || h.ref == nil {
|
||||
return
|
||||
}
|
||||
h.ref.clearBlacklist()
|
||||
}
|
||||
|
||||
// IncActive increments the active connection counter.
|
||||
func (h *EntryHandle) IncActive() {
|
||||
if h == nil || h.ref == nil {
|
||||
return
|
||||
}
|
||||
h.ref.incActive()
|
||||
}
|
||||
|
||||
// DecActive decrements the active connection counter.
|
||||
func (h *EntryHandle) DecActive() {
|
||||
if h == nil || h.ref == nil {
|
||||
return
|
||||
}
|
||||
h.ref.decActive()
|
||||
}
|
||||
|
||||
// SetProbe assigns a probe function.
|
||||
func (h *EntryHandle) SetProbe(fn func(ctx context.Context) (time.Duration, error)) {
|
||||
if h == nil || h.ref == nil {
|
||||
return
|
||||
}
|
||||
h.ref.setProbe(fn)
|
||||
}
|
||||
|
||||
// SetRelease assigns a release function.
|
||||
func (h *EntryHandle) SetRelease(fn func()) {
|
||||
if h == nil || h.ref == nil {
|
||||
return
|
||||
}
|
||||
h.ref.setRelease(fn)
|
||||
}
|
||||
|
||||
// MarkInitialCheckDone marks the initial health check as completed.
|
||||
func (h *EntryHandle) MarkInitialCheckDone(available bool) {
|
||||
if h == nil || h.ref == nil {
|
||||
return
|
||||
}
|
||||
h.ref.mu.Lock()
|
||||
h.ref.initialCheckDone = true
|
||||
h.ref.available = available
|
||||
h.ref.mu.Unlock()
|
||||
}
|
||||
|
||||
// MarkAvailable updates the availability status.
|
||||
func (h *EntryHandle) MarkAvailable(available bool) {
|
||||
if h == nil || h.ref == nil {
|
||||
return
|
||||
}
|
||||
h.ref.mu.Lock()
|
||||
h.ref.available = available
|
||||
h.ref.mu.Unlock()
|
||||
}
|
||||
|
||||
// AddTraffic adds upload and download byte counts to the node's traffic counters.
|
||||
func (h *EntryHandle) AddTraffic(upload, download int64) {
|
||||
if h == nil || h.ref == nil {
|
||||
return
|
||||
}
|
||||
if upload > 0 {
|
||||
h.ref.totalUpload.Add(upload)
|
||||
}
|
||||
if download > 0 {
|
||||
h.ref.totalDownload.Add(download)
|
||||
}
|
||||
}
|
||||
|
||||
// SetTraffic sets the traffic counters to specific values (used for restoring from store).
|
||||
func (h *EntryHandle) SetTraffic(upload, download int64) {
|
||||
if h == nil || h.ref == nil {
|
||||
return
|
||||
}
|
||||
h.ref.totalUpload.Store(upload)
|
||||
h.ref.totalDownload.Store(download)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,749 @@
|
||||
package pool
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"easy_proxies/internal/monitor"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/adapter/outbound"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing/common"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
"github.com/sagernet/sing/service"
|
||||
)
|
||||
|
||||
const (
|
||||
// Type is the outbound type name exposed to sing-box.
|
||||
Type = "pool"
|
||||
// Tag is the default outbound tag used by builder.
|
||||
Tag = "proxy-pool"
|
||||
|
||||
modeSequential = "sequential"
|
||||
modeRandom = "random"
|
||||
modeBalance = "balance"
|
||||
)
|
||||
|
||||
// Options controls pool outbound behaviour.
|
||||
type Options struct {
|
||||
Mode string
|
||||
Members []string
|
||||
FailureThreshold int
|
||||
BlacklistDuration time.Duration
|
||||
Metadata map[string]MemberMeta
|
||||
}
|
||||
|
||||
// MemberMeta carries optional descriptive information for monitoring UI.
|
||||
type MemberMeta struct {
|
||||
Name string
|
||||
URI string
|
||||
Mode string
|
||||
ListenAddress string
|
||||
Port uint16
|
||||
Region string // GeoIP region code: "jp", "kr", "us", "hk", "tw", "other"
|
||||
Country string // Full country name from GeoIP
|
||||
}
|
||||
|
||||
// Register wires the pool outbound into the registry.
|
||||
func Register(registry *outbound.Registry) {
|
||||
outbound.Register[Options](registry, Type, newPool)
|
||||
}
|
||||
|
||||
type memberState struct {
|
||||
outbound adapter.Outbound
|
||||
tag string
|
||||
entry *monitor.EntryHandle
|
||||
shared *sharedMemberState
|
||||
}
|
||||
|
||||
type poolOutbound struct {
|
||||
outbound.Adapter
|
||||
ctx context.Context
|
||||
logger log.ContextLogger
|
||||
manager adapter.OutboundManager
|
||||
options Options
|
||||
mode string
|
||||
members []*memberState
|
||||
mu sync.Mutex
|
||||
rrCounter atomic.Uint32
|
||||
rng *rand.Rand
|
||||
rngMu sync.Mutex // protects rng for random mode
|
||||
monitor *monitor.Manager
|
||||
candidatesPool sync.Pool
|
||||
}
|
||||
|
||||
func newPool(ctx context.Context, _ adapter.Router, logger log.ContextLogger, tag string, options Options) (adapter.Outbound, error) {
|
||||
if len(options.Members) == 0 {
|
||||
return nil, E.New("pool requires at least one member")
|
||||
}
|
||||
manager := service.FromContext[adapter.OutboundManager](ctx)
|
||||
if manager == nil {
|
||||
return nil, E.New("missing outbound manager in context")
|
||||
}
|
||||
monitorMgr := monitor.FromContext(ctx)
|
||||
normalized := normalizeOptions(options)
|
||||
memberCount := len(normalized.Members)
|
||||
p := &poolOutbound{
|
||||
Adapter: outbound.NewAdapter(Type, tag, []string{N.NetworkTCP, N.NetworkUDP}, normalized.Members),
|
||||
ctx: ctx,
|
||||
logger: logger,
|
||||
manager: manager,
|
||||
options: normalized,
|
||||
mode: normalized.Mode,
|
||||
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
monitor: monitorMgr,
|
||||
candidatesPool: sync.Pool{
|
||||
New: func() any {
|
||||
return make([]*memberState, 0, memberCount)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Register nodes immediately if monitor is available
|
||||
if monitorMgr != nil {
|
||||
logger.Info("registering ", len(normalized.Members), " nodes to monitor")
|
||||
for _, memberTag := range normalized.Members {
|
||||
// Acquire shared state for this tag (creates if not exists)
|
||||
state := acquireSharedState(memberTag)
|
||||
|
||||
meta := normalized.Metadata[memberTag]
|
||||
info := monitor.NodeInfo{
|
||||
Tag: memberTag,
|
||||
Name: meta.Name,
|
||||
URI: meta.URI,
|
||||
Mode: meta.Mode,
|
||||
ListenAddress: meta.ListenAddress,
|
||||
Port: meta.Port,
|
||||
Region: meta.Region,
|
||||
Country: meta.Country,
|
||||
}
|
||||
entry := monitorMgr.Register(info)
|
||||
if entry != nil {
|
||||
// Attach entry to shared state so all pool instances share it
|
||||
state.attachEntry(entry)
|
||||
logger.Info("registered node: ", memberTag)
|
||||
// Set probe and release functions immediately
|
||||
entry.SetRelease(p.makeReleaseByTagFunc(memberTag))
|
||||
if probeFn := p.makeProbeByTagFunc(memberTag); probeFn != nil {
|
||||
entry.SetProbe(probeFn)
|
||||
}
|
||||
} else {
|
||||
logger.Warn("failed to register node: ", memberTag)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.Warn("monitor manager is nil, skipping node registration")
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func normalizeOptions(options Options) Options {
|
||||
if options.FailureThreshold <= 0 {
|
||||
options.FailureThreshold = 3
|
||||
}
|
||||
if options.BlacklistDuration <= 0 {
|
||||
options.BlacklistDuration = 24 * time.Hour
|
||||
}
|
||||
if options.Metadata == nil {
|
||||
options.Metadata = make(map[string]MemberMeta)
|
||||
}
|
||||
switch strings.ToLower(options.Mode) {
|
||||
case modeRandom:
|
||||
options.Mode = modeRandom
|
||||
case modeBalance:
|
||||
options.Mode = modeBalance
|
||||
default:
|
||||
options.Mode = modeSequential
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func (p *poolOutbound) Start(stage adapter.StartStage) error {
|
||||
if stage != adapter.StartStateStart {
|
||||
return nil
|
||||
}
|
||||
p.mu.Lock()
|
||||
err := p.initializeMembersLocked()
|
||||
p.mu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 在初始化完成后,立即在后台触发健康检查
|
||||
if p.monitor != nil {
|
||||
go p.probeAllMembersOnStartup()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// initializeMembersLocked must be called with p.mu held
|
||||
func (p *poolOutbound) initializeMembersLocked() error {
|
||||
if len(p.members) > 0 {
|
||||
return nil // Already initialized
|
||||
}
|
||||
|
||||
members := make([]*memberState, 0, len(p.options.Members))
|
||||
for _, tag := range p.options.Members {
|
||||
detour, loaded := p.manager.Outbound(tag)
|
||||
if !loaded {
|
||||
return E.New("pool member not found: ", tag)
|
||||
}
|
||||
|
||||
// Acquire shared state (creates if not exists, reuses if already created)
|
||||
state := acquireSharedState(tag)
|
||||
|
||||
member := &memberState{
|
||||
outbound: detour,
|
||||
tag: tag,
|
||||
shared: state,
|
||||
entry: state.entryHandle(),
|
||||
}
|
||||
|
||||
// Connect to existing monitor entry if available
|
||||
if p.monitor != nil {
|
||||
meta := p.options.Metadata[tag]
|
||||
info := monitor.NodeInfo{
|
||||
Tag: tag,
|
||||
Name: meta.Name,
|
||||
URI: meta.URI,
|
||||
Mode: meta.Mode,
|
||||
ListenAddress: meta.ListenAddress,
|
||||
Port: meta.Port,
|
||||
Region: meta.Region,
|
||||
Country: meta.Country,
|
||||
}
|
||||
entry := p.monitor.Register(info)
|
||||
if entry != nil {
|
||||
state.attachEntry(entry)
|
||||
member.entry = entry
|
||||
entry.SetRelease(p.makeReleaseFunc(member))
|
||||
if probe := p.makeProbeFunc(member); probe != nil {
|
||||
entry.SetProbe(probe)
|
||||
}
|
||||
}
|
||||
}
|
||||
members = append(members, member)
|
||||
}
|
||||
p.members = members
|
||||
p.logger.Info("pool initialized with ", len(members), " members")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// probeAllMembersOnStartup performs initial health checks on all members
|
||||
func (p *poolOutbound) probeAllMembersOnStartup() {
|
||||
destination, ok := p.monitor.DestinationForProbe()
|
||||
if !ok {
|
||||
p.logger.Warn("probe target not configured, skipping initial health check")
|
||||
// 没有配置探测目标时,标记所有节点为可用
|
||||
p.mu.Lock()
|
||||
for _, member := range p.members {
|
||||
if member.entry != nil {
|
||||
member.entry.MarkInitialCheckDone(true)
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
p.logger.Info("starting initial health check for all nodes")
|
||||
|
||||
p.mu.Lock()
|
||||
members := make([]*memberState, len(p.members))
|
||||
copy(members, p.members)
|
||||
p.mu.Unlock()
|
||||
|
||||
availableCount := 0
|
||||
failedCount := 0
|
||||
|
||||
for _, member := range members {
|
||||
// Create a timeout context for each probe
|
||||
ctx, cancel := context.WithTimeout(p.ctx, 15*time.Second)
|
||||
|
||||
start := time.Now()
|
||||
conn, err := member.outbound.DialContext(ctx, N.NetworkTCP, destination)
|
||||
|
||||
probeDst := destination.String()
|
||||
if err != nil {
|
||||
p.logger.Warn("initial probe failed for ", member.tag, ": ", err)
|
||||
failedCount++
|
||||
if member.entry != nil {
|
||||
member.entry.RecordFailure(err, probeDst)
|
||||
member.entry.MarkInitialCheckDone(false) // 标记为不可用
|
||||
}
|
||||
cancel()
|
||||
continue
|
||||
}
|
||||
|
||||
// Perform HTTP probe to measure actual latency (TTFB)
|
||||
_, err = httpProbe(conn, destination.AddrString())
|
||||
conn.Close()
|
||||
|
||||
if err != nil {
|
||||
p.logger.Warn("initial HTTP probe failed for ", member.tag, ": ", err)
|
||||
failedCount++
|
||||
if member.entry != nil {
|
||||
member.entry.RecordFailure(err, probeDst)
|
||||
member.entry.MarkInitialCheckDone(false)
|
||||
}
|
||||
cancel()
|
||||
continue
|
||||
}
|
||||
|
||||
// Total latency = dial + HTTP probe
|
||||
latency := time.Since(start)
|
||||
latencyMs := latency.Milliseconds()
|
||||
p.logger.Info("initial probe success for ", member.tag, ", latency: ", latencyMs, "ms")
|
||||
availableCount++
|
||||
if member.entry != nil {
|
||||
member.entry.RecordSuccessWithLatency(latency)
|
||||
member.entry.MarkInitialCheckDone(true)
|
||||
}
|
||||
|
||||
cancel()
|
||||
}
|
||||
|
||||
p.logger.Info("initial health check completed: ", availableCount, " available, ", failedCount, " failed")
|
||||
}
|
||||
|
||||
func (p *poolOutbound) memberName(member *memberState) string {
|
||||
if meta, ok := p.options.Metadata[member.tag]; ok && meta.Name != "" {
|
||||
return meta.Name
|
||||
}
|
||||
return member.tag
|
||||
}
|
||||
|
||||
func (p *poolOutbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
|
||||
member, err := p.pickMember(network)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dst := destination.String()
|
||||
p.logger.Info("→ ", dst, " ⇒ ", p.memberName(member), " [", network, "]")
|
||||
p.incActive(member)
|
||||
conn, err := member.outbound.DialContext(ctx, network, destination)
|
||||
if err != nil {
|
||||
p.decActive(member)
|
||||
p.recordFailure(member, err, dst)
|
||||
return nil, err
|
||||
}
|
||||
p.recordSuccess(member, dst)
|
||||
return p.wrapConn(conn, member), nil
|
||||
}
|
||||
|
||||
func (p *poolOutbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
|
||||
member, err := p.pickMember(N.NetworkUDP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dst := destination.String()
|
||||
p.logger.Info("→ ", dst, " ⇒ ", p.memberName(member), " [udp]")
|
||||
p.incActive(member)
|
||||
conn, err := member.outbound.ListenPacket(ctx, destination)
|
||||
if err != nil {
|
||||
p.decActive(member)
|
||||
p.recordFailure(member, err, dst)
|
||||
return nil, err
|
||||
}
|
||||
p.recordSuccess(member, dst)
|
||||
return p.wrapPacketConn(conn, member), nil
|
||||
}
|
||||
|
||||
func (p *poolOutbound) pickMember(network string) (*memberState, error) {
|
||||
now := time.Now()
|
||||
candidates := p.getCandidateBuffer()
|
||||
|
||||
p.mu.Lock()
|
||||
if len(p.members) == 0 {
|
||||
if err := p.initializeMembersLocked(); err != nil {
|
||||
p.mu.Unlock()
|
||||
p.putCandidateBuffer(candidates)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
candidates = p.availableMembersLocked(now, network, candidates)
|
||||
p.mu.Unlock()
|
||||
|
||||
if len(candidates) == 0 {
|
||||
p.mu.Lock()
|
||||
if p.releaseIfAllBlacklistedLocked(now) {
|
||||
candidates = p.availableMembersLocked(now, network, candidates)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
p.putCandidateBuffer(candidates)
|
||||
return nil, E.New("no healthy proxy available")
|
||||
}
|
||||
|
||||
member := p.selectMember(candidates)
|
||||
p.putCandidateBuffer(candidates)
|
||||
return member, nil
|
||||
}
|
||||
|
||||
func (p *poolOutbound) availableMembersLocked(now time.Time, network string, buf []*memberState) []*memberState {
|
||||
result := buf[:0]
|
||||
for _, member := range p.members {
|
||||
// Check blacklist via shared state (auto-clears if expired)
|
||||
if member.shared != nil && member.shared.isBlacklisted(now) {
|
||||
continue
|
||||
}
|
||||
if network != "" && !common.Contains(member.outbound.Network(), network) {
|
||||
continue
|
||||
}
|
||||
result = append(result, member)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (p *poolOutbound) releaseIfAllBlacklistedLocked(now time.Time) bool {
|
||||
if len(p.members) == 0 {
|
||||
return false
|
||||
}
|
||||
// Check if all members are blacklisted
|
||||
for _, member := range p.members {
|
||||
if member.shared == nil || !member.shared.isBlacklisted(now) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// All blacklisted, force release all
|
||||
for _, member := range p.members {
|
||||
if member.shared != nil {
|
||||
member.shared.forceRelease()
|
||||
}
|
||||
}
|
||||
p.logger.Warn("all upstream proxies were blacklisted, releasing them for retry")
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *poolOutbound) selectMember(candidates []*memberState) *memberState {
|
||||
switch p.mode {
|
||||
case modeRandom:
|
||||
p.rngMu.Lock()
|
||||
idx := p.rng.Intn(len(candidates))
|
||||
p.rngMu.Unlock()
|
||||
return candidates[idx]
|
||||
case modeBalance:
|
||||
var selected *memberState
|
||||
var minActive int32
|
||||
for _, member := range candidates {
|
||||
var active int32
|
||||
if member.shared != nil {
|
||||
active = member.shared.activeCount()
|
||||
}
|
||||
if selected == nil || active < minActive {
|
||||
selected = member
|
||||
minActive = active
|
||||
}
|
||||
}
|
||||
return selected
|
||||
default:
|
||||
idx := int(p.rrCounter.Add(1)-1) % len(candidates)
|
||||
return candidates[idx]
|
||||
}
|
||||
}
|
||||
|
||||
func (p *poolOutbound) recordFailure(member *memberState, cause error, destination string) {
|
||||
if member.shared == nil {
|
||||
p.logger.Warn("proxy ", member.tag, " failure (no shared state): ", cause)
|
||||
return
|
||||
}
|
||||
failures, blacklisted, _ := member.shared.recordFailure(cause, p.options.FailureThreshold, p.options.BlacklistDuration, destination)
|
||||
if blacklisted {
|
||||
p.logger.Warn("proxy ", member.tag, " blacklisted for ", p.options.BlacklistDuration, ": ", cause)
|
||||
} else {
|
||||
p.logger.Warn("proxy ", member.tag, " failure ", failures, "/", p.options.FailureThreshold, ": ", cause)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *poolOutbound) recordSuccess(member *memberState, destination string) {
|
||||
if member.shared != nil {
|
||||
member.shared.recordSuccess(destination)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *poolOutbound) wrapConn(conn net.Conn, member *memberState) net.Conn {
|
||||
return &trackedConn{
|
||||
Conn: conn,
|
||||
release: func() {
|
||||
p.decActive(member)
|
||||
},
|
||||
onTraffic: func(upload, download int64) {
|
||||
if member.shared != nil {
|
||||
member.shared.addTraffic(upload, download)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *poolOutbound) wrapPacketConn(conn net.PacketConn, member *memberState) net.PacketConn {
|
||||
return &trackedPacketConn{
|
||||
PacketConn: conn,
|
||||
release: func() {
|
||||
p.decActive(member)
|
||||
},
|
||||
onTraffic: func(upload, download int64) {
|
||||
if member.shared != nil {
|
||||
member.shared.addTraffic(upload, download)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *poolOutbound) makeReleaseFunc(member *memberState) func() {
|
||||
return func() {
|
||||
if member.shared != nil {
|
||||
member.shared.forceRelease()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// httpProbe performs an HTTP probe through the connection and measures TTFB.
|
||||
// It sends a minimal HTTP request and waits for the first byte of response.
|
||||
func httpProbe(conn net.Conn, host string) (time.Duration, error) {
|
||||
// Build HTTP request
|
||||
req := fmt.Sprintf("GET /generate_204 HTTP/1.1\r\nHost: %s\r\nConnection: close\r\nUser-Agent: Mozilla/5.0\r\n\r\n", host)
|
||||
|
||||
// Try to set write deadline (ignore errors for connections that don't support it)
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
||||
|
||||
// Record time just before sending request
|
||||
start := time.Now()
|
||||
|
||||
// Send HTTP request
|
||||
if _, err := conn.Write([]byte(req)); err != nil {
|
||||
return 0, fmt.Errorf("write request: %w", err)
|
||||
}
|
||||
|
||||
// Try to set read deadline (ignore errors for connections that don't support it)
|
||||
_ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||
|
||||
// Read first byte (TTFB - Time To First Byte)
|
||||
reader := bufio.NewReader(conn)
|
||||
_, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
// Calculate TTFB
|
||||
ttfb := time.Since(start)
|
||||
return ttfb, nil
|
||||
}
|
||||
|
||||
func (p *poolOutbound) makeProbeFunc(member *memberState) func(ctx context.Context) (time.Duration, error) {
|
||||
if p.monitor == nil {
|
||||
return nil
|
||||
}
|
||||
// 仅在创建时检查是否有探测目标,实际目标在执行时动态获取
|
||||
if _, ok := p.monitor.DestinationForProbe(); !ok {
|
||||
return nil
|
||||
}
|
||||
return func(ctx context.Context) (time.Duration, error) {
|
||||
// 每次执行时动态获取最新的探测目标
|
||||
destination, ok := p.monitor.DestinationForProbe()
|
||||
if !ok {
|
||||
return 0, E.New("probe target not configured")
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
probeDst := destination.String()
|
||||
conn, err := member.outbound.DialContext(ctx, N.NetworkTCP, destination)
|
||||
if err != nil {
|
||||
if member.entry != nil {
|
||||
member.entry.RecordFailure(err, probeDst)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Perform HTTP probe to measure actual latency (TTFB)
|
||||
_, err = httpProbe(conn, destination.AddrString())
|
||||
if err != nil {
|
||||
if member.entry != nil {
|
||||
member.entry.RecordFailure(err, probeDst)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Total duration = dial time + HTTP probe
|
||||
duration := time.Since(start)
|
||||
if member.entry != nil {
|
||||
member.entry.RecordSuccessWithLatency(duration)
|
||||
}
|
||||
return duration, nil
|
||||
}
|
||||
}
|
||||
|
||||
// makeProbeByTagFunc creates a probe function that works before member initialization
|
||||
func (p *poolOutbound) makeProbeByTagFunc(tag string) func(ctx context.Context) (time.Duration, error) {
|
||||
if p.monitor == nil {
|
||||
return nil
|
||||
}
|
||||
// 仅在创建时检查是否有探测目标,实际目标在执行时动态获取
|
||||
if _, ok := p.monitor.DestinationForProbe(); !ok {
|
||||
return nil
|
||||
}
|
||||
return func(ctx context.Context) (time.Duration, error) {
|
||||
// 每次执行时动态获取最新的探测目标
|
||||
destination, ok := p.monitor.DestinationForProbe()
|
||||
if !ok {
|
||||
return 0, E.New("probe target not configured")
|
||||
}
|
||||
|
||||
// Ensure members are initialized
|
||||
p.mu.Lock()
|
||||
if len(p.members) == 0 {
|
||||
if err := p.initializeMembersLocked(); err != nil {
|
||||
p.mu.Unlock()
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
// Find the member by tag
|
||||
var member *memberState
|
||||
for _, m := range p.members {
|
||||
if m.tag == tag {
|
||||
member = m
|
||||
break
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
if member == nil {
|
||||
return 0, E.New("member not found: ", tag)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
probeDst := destination.String()
|
||||
conn, err := member.outbound.DialContext(ctx, N.NetworkTCP, destination)
|
||||
if err != nil {
|
||||
if member.entry != nil {
|
||||
member.entry.RecordFailure(err, probeDst)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Perform HTTP probe to measure actual latency (TTFB)
|
||||
_, err = httpProbe(conn, destination.AddrString())
|
||||
if err != nil {
|
||||
if member.entry != nil {
|
||||
member.entry.RecordFailure(err, probeDst)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Total duration = dial time + TTFB
|
||||
duration := time.Since(start)
|
||||
if member.entry != nil {
|
||||
member.entry.RecordSuccessWithLatency(duration)
|
||||
}
|
||||
return duration, nil
|
||||
}
|
||||
}
|
||||
|
||||
// makeReleaseByTagFunc creates a release function that works before member initialization
|
||||
func (p *poolOutbound) makeReleaseByTagFunc(tag string) func() {
|
||||
return func() {
|
||||
releaseSharedMember(tag)
|
||||
}
|
||||
}
|
||||
|
||||
type trackedConn struct {
|
||||
net.Conn
|
||||
once sync.Once
|
||||
release func()
|
||||
onTraffic func(upload, download int64)
|
||||
}
|
||||
|
||||
func (c *trackedConn) Read(b []byte) (int, error) {
|
||||
n, err := c.Conn.Read(b)
|
||||
if n > 0 && c.onTraffic != nil {
|
||||
c.onTraffic(0, int64(n))
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (c *trackedConn) Write(b []byte) (int, error) {
|
||||
n, err := c.Conn.Write(b)
|
||||
if n > 0 && c.onTraffic != nil {
|
||||
c.onTraffic(int64(n), 0)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (c *trackedConn) Close() error {
|
||||
err := c.Conn.Close()
|
||||
c.once.Do(c.release)
|
||||
return err
|
||||
}
|
||||
|
||||
type trackedPacketConn struct {
|
||||
net.PacketConn
|
||||
once sync.Once
|
||||
release func()
|
||||
onTraffic func(upload, download int64)
|
||||
}
|
||||
|
||||
func (c *trackedPacketConn) ReadFrom(b []byte) (int, net.Addr, error) {
|
||||
n, addr, err := c.PacketConn.ReadFrom(b)
|
||||
if n > 0 && c.onTraffic != nil {
|
||||
c.onTraffic(0, int64(n))
|
||||
}
|
||||
return n, addr, err
|
||||
}
|
||||
|
||||
func (c *trackedPacketConn) WriteTo(b []byte, addr net.Addr) (int, error) {
|
||||
n, err := c.PacketConn.WriteTo(b, addr)
|
||||
if n > 0 && c.onTraffic != nil {
|
||||
c.onTraffic(int64(n), 0)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (c *trackedPacketConn) Close() error {
|
||||
err := c.PacketConn.Close()
|
||||
c.once.Do(c.release)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *poolOutbound) incActive(member *memberState) {
|
||||
if member.shared != nil {
|
||||
member.shared.incActive()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *poolOutbound) decActive(member *memberState) {
|
||||
if member.shared != nil {
|
||||
member.shared.decActive()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *poolOutbound) getCandidateBuffer() []*memberState {
|
||||
if buf := p.candidatesPool.Get(); buf != nil {
|
||||
return buf.([]*memberState)
|
||||
}
|
||||
return make([]*memberState, 0, len(p.options.Members))
|
||||
}
|
||||
|
||||
func (p *poolOutbound) putCandidateBuffer(buf []*memberState) {
|
||||
if buf == nil {
|
||||
return
|
||||
}
|
||||
const maxCached = 4096
|
||||
if cap(buf) > maxCached {
|
||||
return
|
||||
}
|
||||
p.candidatesPool.Put(buf[:0])
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package pool
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"easy_proxies/internal/monitor"
|
||||
)
|
||||
|
||||
// sharedMemberState holds failure/blacklist state shared across all pool instances.
|
||||
// This enables hybrid mode where pool and multi-port modes share the same node state.
|
||||
type sharedMemberState struct {
|
||||
mu sync.Mutex
|
||||
failures int
|
||||
blacklisted bool
|
||||
blacklistedUntil time.Time
|
||||
entry atomic.Pointer[monitor.EntryHandle]
|
||||
active atomic.Int32
|
||||
totalUpload atomic.Int64
|
||||
totalDownload atomic.Int64
|
||||
}
|
||||
|
||||
var sharedStateStore sync.Map // map[tag]*sharedMemberState
|
||||
|
||||
// acquireSharedState returns the shared state for a tag, creating if needed.
|
||||
func acquireSharedState(tag string) *sharedMemberState {
|
||||
if v, ok := sharedStateStore.Load(tag); ok {
|
||||
return v.(*sharedMemberState)
|
||||
}
|
||||
state := &sharedMemberState{}
|
||||
actual, _ := sharedStateStore.LoadOrStore(tag, state)
|
||||
return actual.(*sharedMemberState)
|
||||
}
|
||||
|
||||
// lookupSharedState returns the shared state if it exists.
|
||||
func lookupSharedState(tag string) (*sharedMemberState, bool) {
|
||||
v, ok := sharedStateStore.Load(tag)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return v.(*sharedMemberState), true
|
||||
}
|
||||
|
||||
// ResetSharedStateStore clears all shared state (used during config reload).
|
||||
func ResetSharedStateStore() {
|
||||
sharedStateStore.Range(func(key, _ any) bool {
|
||||
sharedStateStore.Delete(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func (s *sharedMemberState) attachEntry(entry *monitor.EntryHandle) {
|
||||
if entry == nil {
|
||||
return
|
||||
}
|
||||
s.entry.Store(entry)
|
||||
}
|
||||
|
||||
func (s *sharedMemberState) entryHandle() *monitor.EntryHandle {
|
||||
return s.entry.Load()
|
||||
}
|
||||
|
||||
// recordFailure increments failure count and triggers blacklist if threshold reached.
|
||||
// Returns: (current failures, blacklisted, blacklist until time)
|
||||
func (s *sharedMemberState) recordFailure(cause error, threshold int, duration time.Duration, destination string) (int, bool, time.Time) {
|
||||
s.mu.Lock()
|
||||
s.failures++
|
||||
count := s.failures
|
||||
triggered := false
|
||||
var until time.Time
|
||||
if s.failures >= threshold {
|
||||
triggered = true
|
||||
until = time.Now().Add(duration)
|
||||
s.failures = 0
|
||||
s.blacklisted = true
|
||||
s.blacklistedUntil = until
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if entry := s.entry.Load(); entry != nil {
|
||||
entry.RecordFailure(cause, destination)
|
||||
if triggered {
|
||||
entry.Blacklist(until)
|
||||
}
|
||||
}
|
||||
return count, triggered, until
|
||||
}
|
||||
|
||||
func (s *sharedMemberState) recordSuccess(destination string) {
|
||||
s.mu.Lock()
|
||||
s.failures = 0
|
||||
s.mu.Unlock()
|
||||
|
||||
if entry := s.entry.Load(); entry != nil {
|
||||
entry.RecordSuccess(destination)
|
||||
}
|
||||
}
|
||||
|
||||
// isBlacklisted checks if the node is currently blacklisted, auto-clearing if expired.
|
||||
func (s *sharedMemberState) isBlacklisted(now time.Time) bool {
|
||||
s.mu.Lock()
|
||||
expired := s.blacklisted && now.After(s.blacklistedUntil)
|
||||
if expired {
|
||||
s.blacklisted = false
|
||||
s.blacklistedUntil = time.Time{}
|
||||
}
|
||||
blacklisted := s.blacklisted
|
||||
s.mu.Unlock()
|
||||
|
||||
if expired {
|
||||
if entry := s.entry.Load(); entry != nil {
|
||||
entry.ClearBlacklist()
|
||||
}
|
||||
}
|
||||
return blacklisted
|
||||
}
|
||||
|
||||
func (s *sharedMemberState) forceRelease() {
|
||||
s.mu.Lock()
|
||||
s.failures = 0
|
||||
s.blacklisted = false
|
||||
s.blacklistedUntil = time.Time{}
|
||||
s.mu.Unlock()
|
||||
|
||||
if entry := s.entry.Load(); entry != nil {
|
||||
entry.ClearBlacklist()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sharedMemberState) incActive() {
|
||||
s.active.Add(1)
|
||||
if entry := s.entry.Load(); entry != nil {
|
||||
entry.IncActive()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sharedMemberState) decActive() {
|
||||
s.active.Add(-1)
|
||||
if entry := s.entry.Load(); entry != nil {
|
||||
entry.DecActive()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sharedMemberState) activeCount() int32 {
|
||||
return s.active.Load()
|
||||
}
|
||||
|
||||
func (s *sharedMemberState) addTraffic(upload, download int64) {
|
||||
if upload > 0 {
|
||||
s.totalUpload.Add(upload)
|
||||
}
|
||||
if download > 0 {
|
||||
s.totalDownload.Add(download)
|
||||
}
|
||||
if entry := s.entry.Load(); entry != nil {
|
||||
entry.AddTraffic(upload, download)
|
||||
}
|
||||
}
|
||||
|
||||
// releaseSharedMember clears blacklist state for a tag (called from release functions).
|
||||
func releaseSharedMember(tag string) {
|
||||
if state, ok := lookupSharedState(tag); ok {
|
||||
state.forceRelease()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LegacyNode represents a node from the old file-based storage.
|
||||
type LegacyNode struct {
|
||||
Name string
|
||||
URI string
|
||||
Source string // inline, nodes_file, subscription, manual
|
||||
Port uint16
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
// MigrateLegacyData imports data from old file-based storage into the Store.
|
||||
// It checks if the database already has nodes; if so, it skips migration.
|
||||
// After successful migration, old files are renamed with .bak suffix.
|
||||
//
|
||||
// Parameters:
|
||||
// - configDir: directory containing config.yaml and node files
|
||||
// - inlineNodes: nodes defined directly in config.yaml
|
||||
// - nodesFilePath: path to nodes.txt (subscription/file nodes)
|
||||
// - subscriptions: list of subscription URLs (to determine source type)
|
||||
func MigrateLegacyData(ctx context.Context, s Store, configDir string, inlineNodes []LegacyNode, nodesFilePath string, subscriptions []string) error {
|
||||
// Check if database already has nodes
|
||||
count, err := s.CountNodes(ctx, NodeFilter{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("check existing nodes: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
log.Printf("[store] database already has %d nodes, skipping legacy migration", count)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("[store] starting legacy data migration...")
|
||||
|
||||
var totalImported int
|
||||
|
||||
// 1. Import inline nodes from config.yaml
|
||||
if len(inlineNodes) > 0 {
|
||||
var nodes []Node
|
||||
for _, ln := range inlineNodes {
|
||||
name := ln.Name
|
||||
if name == "" {
|
||||
name = extractNameFromURI(ln.URI)
|
||||
}
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("node-%d", len(nodes)+1)
|
||||
}
|
||||
nodes = append(nodes, Node{
|
||||
URI: ln.URI,
|
||||
Name: name,
|
||||
Source: NodeSourceInline,
|
||||
Port: ln.Port,
|
||||
Username: ln.Username,
|
||||
Password: ln.Password,
|
||||
Enabled: true,
|
||||
})
|
||||
}
|
||||
if err := s.BulkUpsertNodes(ctx, nodes); err != nil {
|
||||
return fmt.Errorf("import inline nodes: %w", err)
|
||||
}
|
||||
totalImported += len(nodes)
|
||||
log.Printf("[store] imported %d inline nodes from config.yaml", len(nodes))
|
||||
}
|
||||
|
||||
// 2. Import nodes from nodes.txt
|
||||
if nodesFilePath != "" {
|
||||
if _, statErr := os.Stat(nodesFilePath); statErr == nil {
|
||||
fileNodes, err := loadNodesFromLegacyFile(nodesFilePath)
|
||||
if err != nil {
|
||||
log.Printf("[store] ⚠️ failed to parse %s: %v (skipping)", nodesFilePath, err)
|
||||
} else if len(fileNodes) > 0 {
|
||||
// Determine source based on whether subscriptions are configured
|
||||
source := NodeSourceFile
|
||||
if len(subscriptions) > 0 {
|
||||
source = NodeSourceSubscription
|
||||
}
|
||||
|
||||
var nodes []Node
|
||||
for _, uri := range fileNodes {
|
||||
name := extractNameFromURI(uri)
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("node-%d", totalImported+len(nodes)+1)
|
||||
}
|
||||
nodes = append(nodes, Node{
|
||||
URI: uri,
|
||||
Name: name,
|
||||
Source: source,
|
||||
Enabled: true,
|
||||
})
|
||||
}
|
||||
if err := s.BulkUpsertNodes(ctx, nodes); err != nil {
|
||||
return fmt.Errorf("import file nodes: %w", err)
|
||||
}
|
||||
totalImported += len(nodes)
|
||||
log.Printf("[store] imported %d nodes from %s (source: %s)", len(nodes), nodesFilePath, source)
|
||||
|
||||
// Rename old file
|
||||
backupPath := nodesFilePath + ".bak"
|
||||
if err := os.Rename(nodesFilePath, backupPath); err != nil {
|
||||
log.Printf("[store] ⚠️ failed to rename %s to %s: %v", nodesFilePath, backupPath, err)
|
||||
} else {
|
||||
log.Printf("[store] renamed %s -> %s", nodesFilePath, backupPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Import manual nodes from manual_nodes.txt
|
||||
manualPath := filepath.Join(configDir, "manual_nodes.txt")
|
||||
if _, statErr := os.Stat(manualPath); statErr == nil {
|
||||
manualURIs, err := loadNodesFromLegacyFile(manualPath)
|
||||
if err != nil {
|
||||
log.Printf("[store] ⚠️ failed to parse %s: %v (skipping)", manualPath, err)
|
||||
} else if len(manualURIs) > 0 {
|
||||
var nodes []Node
|
||||
for _, uri := range manualURIs {
|
||||
name := extractNameFromURI(uri)
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("manual-%d", len(nodes)+1)
|
||||
}
|
||||
nodes = append(nodes, Node{
|
||||
URI: uri,
|
||||
Name: name,
|
||||
Source: NodeSourceManual,
|
||||
Enabled: true,
|
||||
})
|
||||
}
|
||||
if err := s.BulkUpsertNodes(ctx, nodes); err != nil {
|
||||
return fmt.Errorf("import manual nodes: %w", err)
|
||||
}
|
||||
totalImported += len(nodes)
|
||||
log.Printf("[store] imported %d manual nodes from %s", len(nodes), manualPath)
|
||||
|
||||
// Rename old file
|
||||
backupPath := manualPath + ".bak"
|
||||
if err := os.Rename(manualPath, backupPath); err != nil {
|
||||
log.Printf("[store] ⚠️ failed to rename %s to %s: %v", manualPath, backupPath, err)
|
||||
} else {
|
||||
log.Printf("[store] renamed %s -> %s", manualPath, backupPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if totalImported > 0 {
|
||||
log.Printf("[store] ✅ legacy migration completed: %d total nodes imported", totalImported)
|
||||
} else {
|
||||
log.Printf("[store] no legacy data found to migrate")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadNodesFromLegacyFile reads a file where each line is a proxy URI.
|
||||
// Comments (#) and empty lines are skipped.
|
||||
func loadNodesFromLegacyFile(path string) ([]string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var uris []string
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if isProxyURI(line) {
|
||||
uris = append(uris, line)
|
||||
}
|
||||
}
|
||||
return uris, nil
|
||||
}
|
||||
|
||||
// isProxyURI checks if a string looks like a proxy URI.
|
||||
func isProxyURI(s string) bool {
|
||||
schemes := []string{"vmess://", "vless://", "trojan://", "ss://", "ssr://",
|
||||
"hysteria://", "hysteria2://", "hy2://", "http://", "socks5://"}
|
||||
lower := strings.ToLower(s)
|
||||
for _, scheme := range schemes {
|
||||
if strings.HasPrefix(lower, scheme) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// extractNameFromURI extracts a name from the URI fragment (#name).
|
||||
func extractNameFromURI(uri string) string {
|
||||
parsed, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if parsed.Fragment == "" {
|
||||
return ""
|
||||
}
|
||||
decoded, err := url.QueryUnescape(parsed.Fragment)
|
||||
if err != nil {
|
||||
return parsed.Fragment
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Migration represents a single database schema migration.
|
||||
type Migration struct {
|
||||
Version int
|
||||
Description string
|
||||
Up string
|
||||
}
|
||||
|
||||
// allMigrations returns all migrations in order. New migrations should be
|
||||
// appended to this list with incrementing version numbers.
|
||||
func allMigrations() []Migration {
|
||||
return []Migration{
|
||||
{
|
||||
Version: 1,
|
||||
Description: "initial schema",
|
||||
Up: `
|
||||
-- Nodes table: stores all proxy nodes
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uri TEXT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
port INTEGER NOT NULL DEFAULT 0,
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
password TEXT NOT NULL DEFAULT '',
|
||||
region TEXT NOT NULL DEFAULT '',
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(uri)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_source ON nodes(source);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_region ON nodes(region);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_enabled ON nodes(enabled);
|
||||
|
||||
-- Node runtime statistics
|
||||
CREATE TABLE IF NOT EXISTS node_stats (
|
||||
node_id INTEGER PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
failure_count INTEGER NOT NULL DEFAULT 0,
|
||||
success_count INTEGER NOT NULL DEFAULT 0,
|
||||
blacklisted INTEGER NOT NULL DEFAULT 0,
|
||||
blacklisted_until TEXT NOT NULL DEFAULT '',
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
last_failure_at TEXT NOT NULL DEFAULT '',
|
||||
last_success_at TEXT NOT NULL DEFAULT '',
|
||||
last_latency_ms INTEGER NOT NULL DEFAULT -1,
|
||||
available INTEGER NOT NULL DEFAULT 0,
|
||||
initial_check_done INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Node event timeline for debug tracking
|
||||
CREATE TABLE IF NOT EXISTS node_timeline (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_id INTEGER NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
success INTEGER NOT NULL DEFAULT 0,
|
||||
latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_timeline_node_id ON node_timeline(node_id);
|
||||
|
||||
-- User authentication sessions
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
|
||||
|
||||
-- Subscription refresh status (singleton row)
|
||||
CREATE TABLE IF NOT EXISTS subscription_status (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
last_refresh TEXT NOT NULL DEFAULT '',
|
||||
next_refresh TEXT NOT NULL DEFAULT '',
|
||||
node_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
refresh_count INTEGER NOT NULL DEFAULT 0,
|
||||
is_refreshing INTEGER NOT NULL DEFAULT 0,
|
||||
nodes_hash TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Insert the singleton subscription status row
|
||||
INSERT OR IGNORE INTO subscription_status (id) VALUES (1);
|
||||
`,
|
||||
},
|
||||
{
|
||||
Version: 2,
|
||||
Description: "add traffic columns to node_stats",
|
||||
Up: `
|
||||
ALTER TABLE node_stats ADD COLUMN total_upload_bytes INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE node_stats ADD COLUMN total_download_bytes INTEGER NOT NULL DEFAULT 0;
|
||||
`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate applies all pending migrations to the database.
|
||||
// Each migration runs in its own transaction. Already-applied migrations
|
||||
// (tracked in schema_migrations) are skipped.
|
||||
func Migrate(db *sql.DB) error {
|
||||
// Create the migrations tracking table
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
`); err != nil {
|
||||
return fmt.Errorf("create schema_migrations table: %w", err)
|
||||
}
|
||||
|
||||
// Get the current version
|
||||
var currentVersion int
|
||||
row := db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM schema_migrations")
|
||||
if err := row.Scan(¤tVersion); err != nil {
|
||||
return fmt.Errorf("query current version: %w", err)
|
||||
}
|
||||
|
||||
migrations := allMigrations()
|
||||
applied := 0
|
||||
|
||||
for _, m := range migrations {
|
||||
if m.Version <= currentVersion {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("[store] applying migration %d: %s", m.Version, m.Description)
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx for migration %d: %w", m.Version, err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(m.Up); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("execute migration %d (%s): %w", m.Version, m.Description, err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(
|
||||
"INSERT INTO schema_migrations (version, applied_at, description) VALUES (?, ?, ?)",
|
||||
m.Version, time.Now().UTC().Format(time.RFC3339), m.Description,
|
||||
); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("record migration %d: %w", m.Version, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %d: %w", m.Version, err)
|
||||
}
|
||||
|
||||
applied++
|
||||
log.Printf("[store] migration %d applied successfully", m.Version)
|
||||
}
|
||||
|
||||
if applied > 0 {
|
||||
log.Printf("[store] %d migration(s) applied, current version: %d", applied, migrations[len(migrations)-1].Version)
|
||||
} else {
|
||||
log.Printf("[store] database schema is up to date (version %d)", currentVersion)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CurrentVersion returns the current schema version.
|
||||
func CurrentVersion(db *sql.DB) (int, error) {
|
||||
// Check if schema_migrations table exists
|
||||
var count int
|
||||
err := db.QueryRow(`
|
||||
SELECT COUNT(*) FROM sqlite_master
|
||||
WHERE type='table' AND name='schema_migrations'
|
||||
`).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if count == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var version int
|
||||
err = db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM schema_migrations").Scan(&version)
|
||||
return version, err
|
||||
}
|
||||
@@ -0,0 +1,816 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// sqliteStore implements Store using SQLite.
|
||||
type sqliteStore struct {
|
||||
db *sql.DB
|
||||
tx *sql.Tx // non-nil when operating inside WithTx
|
||||
}
|
||||
|
||||
// Open creates a new SQLite-backed Store at the given path.
|
||||
// It applies all pending migrations and sets optimal PRAGMAs.
|
||||
func Open(dbPath string) (Store, error) {
|
||||
dsn := dbPath + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=synchronous(NORMAL)&_pragma=cache_size(-64000)&_pragma=foreign_keys(ON)"
|
||||
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite %q: %w", dbPath, err)
|
||||
}
|
||||
|
||||
// Connection pool settings
|
||||
db.SetMaxOpenConns(1) // SQLite only supports 1 writer
|
||||
db.SetMaxIdleConns(2)
|
||||
db.SetConnMaxLifetime(0) // connections don't expire
|
||||
|
||||
// Verify connection
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("ping sqlite: %w", err)
|
||||
}
|
||||
|
||||
// Run migrations
|
||||
if err := Migrate(db); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[store] SQLite store opened: %s", dbPath)
|
||||
return &sqliteStore{db: db}, nil
|
||||
}
|
||||
|
||||
// conn returns the underlying *sql.Tx or *sql.DB for executing queries.
|
||||
func (s *sqliteStore) conn() querier {
|
||||
if s.tx != nil {
|
||||
return s.tx
|
||||
}
|
||||
return s.db
|
||||
}
|
||||
|
||||
// querier abstracts *sql.DB and *sql.Tx for query execution.
|
||||
type querier interface {
|
||||
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
||||
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
||||
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||||
PrepareContext(ctx context.Context, query string) (*sql.Stmt, error)
|
||||
}
|
||||
|
||||
// ===================== Node operations =====================
|
||||
|
||||
func (s *sqliteStore) ListNodes(ctx context.Context, filter NodeFilter) ([]Node, error) {
|
||||
query := "SELECT id, uri, name, source, port, username, password, region, country, enabled, created_at, updated_at FROM nodes"
|
||||
var conditions []string
|
||||
var args []any
|
||||
|
||||
if filter.Source != "" {
|
||||
conditions = append(conditions, "source = ?")
|
||||
args = append(args, filter.Source)
|
||||
}
|
||||
if filter.Region != "" {
|
||||
conditions = append(conditions, "region = ?")
|
||||
args = append(args, filter.Region)
|
||||
}
|
||||
if filter.Enabled != nil {
|
||||
conditions = append(conditions, "enabled = ?")
|
||||
if *filter.Enabled {
|
||||
args = append(args, 1)
|
||||
} else {
|
||||
args = append(args, 0)
|
||||
}
|
||||
}
|
||||
|
||||
if len(conditions) > 0 {
|
||||
query += " WHERE " + strings.Join(conditions, " AND ")
|
||||
}
|
||||
query += " ORDER BY id ASC"
|
||||
if filter.Limit > 0 {
|
||||
query += fmt.Sprintf(" LIMIT %d", filter.Limit)
|
||||
if filter.Offset > 0 {
|
||||
query += fmt.Sprintf(" OFFSET %d", filter.Offset)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := s.conn().QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list nodes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanNodes(rows)
|
||||
}
|
||||
|
||||
func (s *sqliteStore) GetNode(ctx context.Context, id int64) (*Node, error) {
|
||||
row := s.conn().QueryRowContext(ctx,
|
||||
"SELECT id, uri, name, source, port, username, password, region, country, enabled, created_at, updated_at FROM nodes WHERE id = ?", id)
|
||||
return scanNode(row)
|
||||
}
|
||||
|
||||
func (s *sqliteStore) GetNodeByURI(ctx context.Context, uri string) (*Node, error) {
|
||||
row := s.conn().QueryRowContext(ctx,
|
||||
"SELECT id, uri, name, source, port, username, password, region, country, enabled, created_at, updated_at FROM nodes WHERE uri = ?", uri)
|
||||
return scanNode(row)
|
||||
}
|
||||
|
||||
func (s *sqliteStore) GetNodeByName(ctx context.Context, name string) (*Node, error) {
|
||||
row := s.conn().QueryRowContext(ctx,
|
||||
"SELECT id, uri, name, source, port, username, password, region, country, enabled, created_at, updated_at FROM nodes WHERE name = ?", name)
|
||||
return scanNode(row)
|
||||
}
|
||||
|
||||
func (s *sqliteStore) CreateNode(ctx context.Context, node *Node) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if node.CreatedAt.IsZero() {
|
||||
node.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
if node.UpdatedAt.IsZero() {
|
||||
node.UpdatedAt = time.Now().UTC()
|
||||
}
|
||||
enabled := 0
|
||||
if node.Enabled {
|
||||
enabled = 1
|
||||
}
|
||||
|
||||
result, err := s.conn().ExecContext(ctx,
|
||||
`INSERT INTO nodes (uri, name, source, port, username, password, region, country, enabled, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
node.URI, node.Name, node.Source, node.Port,
|
||||
node.Username, node.Password, node.Region, node.Country,
|
||||
enabled, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create node: %w", err)
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get last insert id: %w", err)
|
||||
}
|
||||
node.ID = id
|
||||
|
||||
// Create initial stats row
|
||||
_, err = s.conn().ExecContext(ctx,
|
||||
"INSERT OR IGNORE INTO node_stats (node_id) VALUES (?)", id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create initial node stats: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) UpdateNode(ctx context.Context, node *Node) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
enabled := 0
|
||||
if node.Enabled {
|
||||
enabled = 1
|
||||
}
|
||||
|
||||
result, err := s.conn().ExecContext(ctx,
|
||||
`UPDATE nodes SET uri=?, name=?, source=?, port=?, username=?, password=?,
|
||||
region=?, country=?, enabled=?, updated_at=?
|
||||
WHERE id=?`,
|
||||
node.URI, node.Name, node.Source, node.Port,
|
||||
node.Username, node.Password, node.Region, node.Country,
|
||||
enabled, now, node.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update node %d: %w", node.ID, err)
|
||||
}
|
||||
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
return fmt.Errorf("node %d not found", node.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) DeleteNode(ctx context.Context, id int64) error {
|
||||
result, err := s.conn().ExecContext(ctx, "DELETE FROM nodes WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete node %d: %w", id, err)
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
return fmt.Errorf("node %d not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) DeleteNodesBySource(ctx context.Context, source string) (int64, error) {
|
||||
result, err := s.conn().ExecContext(ctx, "DELETE FROM nodes WHERE source = ?", source)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete nodes by source %q: %w", source, err)
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *sqliteStore) BulkUpsertNodes(ctx context.Context, nodes []Node) error {
|
||||
if len(nodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
execFn := func(txStore *sqliteStore) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
stmt, err := txStore.conn().PrepareContext(ctx,
|
||||
`INSERT INTO nodes (uri, name, source, port, username, password, region, country, enabled, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(uri) DO UPDATE SET
|
||||
name=excluded.name, source=excluded.source, port=excluded.port,
|
||||
username=excluded.username, password=excluded.password,
|
||||
region=excluded.region, country=excluded.country,
|
||||
updated_at=excluded.updated_at`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare bulk upsert: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for i := range nodes {
|
||||
n := &nodes[i]
|
||||
enabled := 0
|
||||
if n.Enabled {
|
||||
enabled = 1
|
||||
}
|
||||
result, err := stmt.ExecContext(ctx,
|
||||
n.URI, n.Name, n.Source, n.Port,
|
||||
n.Username, n.Password, n.Region, n.Country,
|
||||
enabled, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert node %q: %w", n.URI, err)
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
if id > 0 {
|
||||
n.ID = id
|
||||
}
|
||||
}
|
||||
|
||||
// Create stats rows for new nodes
|
||||
_, err = txStore.conn().ExecContext(ctx,
|
||||
"INSERT OR IGNORE INTO node_stats (node_id) SELECT id FROM nodes")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create stats for new nodes: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// If already in a transaction, execute directly
|
||||
if s.tx != nil {
|
||||
return execFn(s)
|
||||
}
|
||||
|
||||
// Otherwise wrap in a transaction
|
||||
return s.WithTx(ctx, func(tx Store) error {
|
||||
return execFn(tx.(*sqliteStore))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *sqliteStore) CountNodes(ctx context.Context, filter NodeFilter) (int64, error) {
|
||||
query := "SELECT COUNT(*) FROM nodes"
|
||||
var conditions []string
|
||||
var args []any
|
||||
|
||||
if filter.Source != "" {
|
||||
conditions = append(conditions, "source = ?")
|
||||
args = append(args, filter.Source)
|
||||
}
|
||||
if filter.Region != "" {
|
||||
conditions = append(conditions, "region = ?")
|
||||
args = append(args, filter.Region)
|
||||
}
|
||||
if filter.Enabled != nil {
|
||||
conditions = append(conditions, "enabled = ?")
|
||||
if *filter.Enabled {
|
||||
args = append(args, 1)
|
||||
} else {
|
||||
args = append(args, 0)
|
||||
}
|
||||
}
|
||||
|
||||
if len(conditions) > 0 {
|
||||
query += " WHERE " + strings.Join(conditions, " AND ")
|
||||
}
|
||||
|
||||
var count int64
|
||||
err := s.conn().QueryRowContext(ctx, query, args...).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// ===================== Node stats =====================
|
||||
|
||||
func (s *sqliteStore) GetNodeStats(ctx context.Context, nodeID int64) (*NodeStats, error) {
|
||||
row := s.conn().QueryRowContext(ctx,
|
||||
`SELECT node_id, failure_count, success_count, blacklisted, blacklisted_until,
|
||||
last_error, last_failure_at, last_success_at, last_latency_ms,
|
||||
available, initial_check_done, total_upload_bytes, total_download_bytes, updated_at
|
||||
FROM node_stats WHERE node_id = ?`, nodeID)
|
||||
|
||||
stats := &NodeStats{}
|
||||
var blacklistedUntilStr, lastFailureStr, lastSuccessStr, updatedAtStr string
|
||||
var blacklisted, available, initialCheckDone int
|
||||
|
||||
err := row.Scan(
|
||||
&stats.NodeID, &stats.FailureCount, &stats.SuccessCount,
|
||||
&blacklisted, &blacklistedUntilStr,
|
||||
&stats.LastError, &lastFailureStr, &lastSuccessStr,
|
||||
&stats.LastLatencyMs, &available, &initialCheckDone,
|
||||
&stats.TotalUploadBytes, &stats.TotalDownloadBytes, &updatedAtStr,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get node stats %d: %w", nodeID, err)
|
||||
}
|
||||
|
||||
stats.Blacklisted = blacklisted != 0
|
||||
stats.Available = available != 0
|
||||
stats.InitialCheckDone = initialCheckDone != 0
|
||||
stats.BlacklistedUntil = parseTime(blacklistedUntilStr)
|
||||
stats.LastFailureAt = parseTime(lastFailureStr)
|
||||
stats.LastSuccessAt = parseTime(lastSuccessStr)
|
||||
stats.UpdatedAt = parseTime(updatedAtStr)
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) UpsertNodeStats(ctx context.Context, stats *NodeStats) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
blacklisted := 0
|
||||
if stats.Blacklisted {
|
||||
blacklisted = 1
|
||||
}
|
||||
available := 0
|
||||
if stats.Available {
|
||||
available = 1
|
||||
}
|
||||
initialCheckDone := 0
|
||||
if stats.InitialCheckDone {
|
||||
initialCheckDone = 1
|
||||
}
|
||||
|
||||
_, err := s.conn().ExecContext(ctx,
|
||||
`INSERT INTO node_stats (node_id, failure_count, success_count, blacklisted, blacklisted_until,
|
||||
last_error, last_failure_at, last_success_at, last_latency_ms, available, initial_check_done,
|
||||
total_upload_bytes, total_download_bytes, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(node_id) DO UPDATE SET
|
||||
failure_count=excluded.failure_count, success_count=excluded.success_count,
|
||||
blacklisted=excluded.blacklisted, blacklisted_until=excluded.blacklisted_until,
|
||||
last_error=excluded.last_error, last_failure_at=excluded.last_failure_at,
|
||||
last_success_at=excluded.last_success_at, last_latency_ms=excluded.last_latency_ms,
|
||||
available=excluded.available, initial_check_done=excluded.initial_check_done,
|
||||
total_upload_bytes=excluded.total_upload_bytes, total_download_bytes=excluded.total_download_bytes,
|
||||
updated_at=excluded.updated_at`,
|
||||
stats.NodeID, stats.FailureCount, stats.SuccessCount,
|
||||
blacklisted, formatTime(stats.BlacklistedUntil),
|
||||
stats.LastError, formatTime(stats.LastFailureAt), formatTime(stats.LastSuccessAt),
|
||||
stats.LastLatencyMs, available, initialCheckDone,
|
||||
stats.TotalUploadBytes, stats.TotalDownloadBytes, now,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) RecordSuccess(ctx context.Context, nodeID int64, latencyMs int64) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := s.conn().ExecContext(ctx,
|
||||
`UPDATE node_stats SET
|
||||
success_count = success_count + 1,
|
||||
last_success_at = ?,
|
||||
last_latency_ms = ?,
|
||||
available = 1,
|
||||
initial_check_done = 1,
|
||||
updated_at = ?
|
||||
WHERE node_id = ?`,
|
||||
now, latencyMs, now, nodeID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) RecordFailure(ctx context.Context, nodeID int64, errMsg string) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := s.conn().ExecContext(ctx,
|
||||
`UPDATE node_stats SET
|
||||
failure_count = failure_count + 1,
|
||||
last_error = ?,
|
||||
last_failure_at = ?,
|
||||
updated_at = ?
|
||||
WHERE node_id = ?`,
|
||||
errMsg, now, now, nodeID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) SetBlacklist(ctx context.Context, nodeID int64, until time.Time) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := s.conn().ExecContext(ctx,
|
||||
`UPDATE node_stats SET
|
||||
blacklisted = 1,
|
||||
blacklisted_until = ?,
|
||||
failure_count = 0,
|
||||
updated_at = ?
|
||||
WHERE node_id = ?`,
|
||||
formatTime(until), now, nodeID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) ClearBlacklist(ctx context.Context, nodeID int64) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := s.conn().ExecContext(ctx,
|
||||
`UPDATE node_stats SET
|
||||
blacklisted = 0,
|
||||
blacklisted_until = '',
|
||||
updated_at = ?
|
||||
WHERE node_id = ?`,
|
||||
now, nodeID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) ClearAllBlacklists(ctx context.Context) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := s.conn().ExecContext(ctx,
|
||||
`UPDATE node_stats SET blacklisted = 0, blacklisted_until = '', updated_at = ? WHERE blacklisted = 1`,
|
||||
now,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) BatchUpdateStats(ctx context.Context, updates []StatsUpdate) error {
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
execFn := func(txStore *sqliteStore) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
stmt, err := txStore.conn().PrepareContext(ctx,
|
||||
`INSERT INTO node_stats (node_id, failure_count, success_count, blacklisted, blacklisted_until,
|
||||
last_error, last_failure_at, last_success_at, last_latency_ms, available, initial_check_done,
|
||||
total_upload_bytes, total_download_bytes, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(node_id) DO UPDATE SET
|
||||
failure_count=excluded.failure_count, success_count=excluded.success_count,
|
||||
blacklisted=excluded.blacklisted, blacklisted_until=excluded.blacklisted_until,
|
||||
last_error=excluded.last_error, last_failure_at=excluded.last_failure_at,
|
||||
last_success_at=excluded.last_success_at, last_latency_ms=excluded.last_latency_ms,
|
||||
available=excluded.available, initial_check_done=excluded.initial_check_done,
|
||||
total_upload_bytes=excluded.total_upload_bytes, total_download_bytes=excluded.total_download_bytes,
|
||||
updated_at=excluded.updated_at`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare batch stats: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, u := range updates {
|
||||
blacklisted := 0
|
||||
if u.Blacklisted {
|
||||
blacklisted = 1
|
||||
}
|
||||
available := 0
|
||||
if u.Available {
|
||||
available = 1
|
||||
}
|
||||
initialCheckDone := 0
|
||||
if u.InitialCheckDone {
|
||||
initialCheckDone = 1
|
||||
}
|
||||
|
||||
_, err := stmt.ExecContext(ctx,
|
||||
u.NodeID, u.FailureCount, u.SuccessCount,
|
||||
blacklisted, formatTime(u.BlacklistedUntil),
|
||||
u.LastError, formatTime(u.LastFailureAt), formatTime(u.LastSuccessAt),
|
||||
u.LastLatencyMs, available, initialCheckDone,
|
||||
u.TotalUploadBytes, u.TotalDownloadBytes, now,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("batch update stats for node %d: %w", u.NodeID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if s.tx != nil {
|
||||
return execFn(s)
|
||||
}
|
||||
return s.WithTx(ctx, func(tx Store) error {
|
||||
return execFn(tx.(*sqliteStore))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *sqliteStore) GetAllNodeStats(ctx context.Context) (map[int64]*NodeStats, error) {
|
||||
rows, err := s.conn().QueryContext(ctx,
|
||||
`SELECT node_id, failure_count, success_count, blacklisted, blacklisted_until,
|
||||
last_error, last_failure_at, last_success_at, last_latency_ms,
|
||||
available, initial_check_done, total_upload_bytes, total_download_bytes, updated_at
|
||||
FROM node_stats`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get all node stats: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[int64]*NodeStats)
|
||||
for rows.Next() {
|
||||
stats := &NodeStats{}
|
||||
var blacklistedUntilStr, lastFailureStr, lastSuccessStr, updatedAtStr string
|
||||
var blacklisted, available, initialCheckDone int
|
||||
|
||||
err := rows.Scan(
|
||||
&stats.NodeID, &stats.FailureCount, &stats.SuccessCount,
|
||||
&blacklisted, &blacklistedUntilStr,
|
||||
&stats.LastError, &lastFailureStr, &lastSuccessStr,
|
||||
&stats.LastLatencyMs, &available, &initialCheckDone,
|
||||
&stats.TotalUploadBytes, &stats.TotalDownloadBytes, &updatedAtStr,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan node stats: %w", err)
|
||||
}
|
||||
|
||||
stats.Blacklisted = blacklisted != 0
|
||||
stats.Available = available != 0
|
||||
stats.InitialCheckDone = initialCheckDone != 0
|
||||
stats.BlacklistedUntil = parseTime(blacklistedUntilStr)
|
||||
stats.LastFailureAt = parseTime(lastFailureStr)
|
||||
stats.LastSuccessAt = parseTime(lastSuccessStr)
|
||||
stats.UpdatedAt = parseTime(updatedAtStr)
|
||||
|
||||
result[stats.NodeID] = stats
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ===================== Timeline =====================
|
||||
|
||||
func (s *sqliteStore) AppendTimeline(ctx context.Context, nodeID int64, event TimelineEvent) error {
|
||||
_, err := s.conn().ExecContext(ctx,
|
||||
`INSERT INTO node_timeline (node_id, success, latency_ms, error, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
nodeID, boolToInt(event.Success), event.LatencyMs, event.Error,
|
||||
time.Now().UTC().Format(time.RFC3339),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) GetTimeline(ctx context.Context, nodeID int64, limit int) ([]TimelineEvent, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
rows, err := s.conn().QueryContext(ctx,
|
||||
`SELECT id, node_id, success, latency_ms, error, created_at
|
||||
FROM node_timeline WHERE node_id = ?
|
||||
ORDER BY id DESC LIMIT ?`,
|
||||
nodeID, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get timeline for node %d: %w", nodeID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var events []TimelineEvent
|
||||
for rows.Next() {
|
||||
var evt TimelineEvent
|
||||
var success int
|
||||
var createdAtStr string
|
||||
err := rows.Scan(&evt.ID, &evt.NodeID, &success, &evt.LatencyMs, &evt.Error, &createdAtStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan timeline event: %w", err)
|
||||
}
|
||||
evt.Success = success != 0
|
||||
evt.CreatedAt = parseTime(createdAtStr)
|
||||
events = append(events, evt)
|
||||
}
|
||||
|
||||
// Reverse to get chronological order
|
||||
for i, j := 0, len(events)-1; i < j; i, j = i+1, j-1 {
|
||||
events[i], events[j] = events[j], events[i]
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
func (s *sqliteStore) CleanupTimeline(ctx context.Context, keepPerNode int) error {
|
||||
if keepPerNode <= 0 {
|
||||
keepPerNode = 20
|
||||
}
|
||||
|
||||
_, err := s.conn().ExecContext(ctx,
|
||||
`DELETE FROM node_timeline WHERE id NOT IN (
|
||||
SELECT id FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (PARTITION BY node_id ORDER BY id DESC) as rn
|
||||
FROM node_timeline
|
||||
) WHERE rn <= ?
|
||||
)`, keepPerNode,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ===================== Sessions =====================
|
||||
|
||||
func (s *sqliteStore) CreateSession(ctx context.Context, session *Session) error {
|
||||
_, err := s.conn().ExecContext(ctx,
|
||||
`INSERT INTO sessions (token, created_at, expires_at) VALUES (?, ?, ?)`,
|
||||
session.Token,
|
||||
session.CreatedAt.UTC().Format(time.RFC3339),
|
||||
session.ExpiresAt.UTC().Format(time.RFC3339),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) GetSession(ctx context.Context, token string) (*Session, error) {
|
||||
row := s.conn().QueryRowContext(ctx,
|
||||
"SELECT token, created_at, expires_at FROM sessions WHERE token = ?", token)
|
||||
|
||||
var sess Session
|
||||
var createdAtStr, expiresAtStr string
|
||||
err := row.Scan(&sess.Token, &createdAtStr, &expiresAtStr)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get session: %w", err)
|
||||
}
|
||||
|
||||
sess.CreatedAt = parseTime(createdAtStr)
|
||||
sess.ExpiresAt = parseTime(expiresAtStr)
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) DeleteSession(ctx context.Context, token string) error {
|
||||
_, err := s.conn().ExecContext(ctx, "DELETE FROM sessions WHERE token = ?", token)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) CleanupExpiredSessions(ctx context.Context) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := s.conn().ExecContext(ctx, "DELETE FROM sessions WHERE expires_at < ?", now)
|
||||
return err
|
||||
}
|
||||
|
||||
// ===================== Subscription status =====================
|
||||
|
||||
func (s *sqliteStore) GetSubscriptionStatus(ctx context.Context) (*SubscriptionStatus, error) {
|
||||
row := s.conn().QueryRowContext(ctx,
|
||||
`SELECT last_refresh, next_refresh, node_count, last_error,
|
||||
refresh_count, is_refreshing, nodes_hash, updated_at
|
||||
FROM subscription_status WHERE id = 1`)
|
||||
|
||||
var status SubscriptionStatus
|
||||
var lastRefreshStr, nextRefreshStr, updatedAtStr string
|
||||
var isRefreshing int
|
||||
|
||||
err := row.Scan(
|
||||
&lastRefreshStr, &nextRefreshStr, &status.NodeCount,
|
||||
&status.LastError, &status.RefreshCount, &isRefreshing,
|
||||
&status.NodesHash, &updatedAtStr,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return &SubscriptionStatus{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get subscription status: %w", err)
|
||||
}
|
||||
|
||||
status.IsRefreshing = isRefreshing != 0
|
||||
status.LastRefresh = parseTime(lastRefreshStr)
|
||||
status.NextRefresh = parseTime(nextRefreshStr)
|
||||
status.UpdatedAt = parseTime(updatedAtStr)
|
||||
|
||||
return &status, nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) UpdateSubscriptionStatus(ctx context.Context, status *SubscriptionStatus) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
isRefreshing := 0
|
||||
if status.IsRefreshing {
|
||||
isRefreshing = 1
|
||||
}
|
||||
|
||||
_, err := s.conn().ExecContext(ctx,
|
||||
`INSERT INTO subscription_status (id, last_refresh, next_refresh, node_count, last_error,
|
||||
refresh_count, is_refreshing, nodes_hash, updated_at)
|
||||
VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
last_refresh=excluded.last_refresh, next_refresh=excluded.next_refresh,
|
||||
node_count=excluded.node_count, last_error=excluded.last_error,
|
||||
refresh_count=excluded.refresh_count, is_refreshing=excluded.is_refreshing,
|
||||
nodes_hash=excluded.nodes_hash, updated_at=excluded.updated_at`,
|
||||
formatTime(status.LastRefresh), formatTime(status.NextRefresh),
|
||||
status.NodeCount, status.LastError, status.RefreshCount,
|
||||
isRefreshing, status.NodesHash, now,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ===================== Lifecycle =====================
|
||||
|
||||
func (s *sqliteStore) Close() error {
|
||||
if s.db != nil {
|
||||
return s.db.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) WithTx(ctx context.Context, fn func(tx Store) error) error {
|
||||
if s.tx != nil {
|
||||
// Already in a transaction, just execute
|
||||
return fn(s)
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
|
||||
txStore := &sqliteStore{db: s.db, tx: tx}
|
||||
if err := fn(txStore); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ===================== Helpers =====================
|
||||
|
||||
func scanNode(row *sql.Row) (*Node, error) {
|
||||
var n Node
|
||||
var enabled int
|
||||
var createdAtStr, updatedAtStr string
|
||||
|
||||
err := row.Scan(
|
||||
&n.ID, &n.URI, &n.Name, &n.Source, &n.Port,
|
||||
&n.Username, &n.Password, &n.Region, &n.Country,
|
||||
&enabled, &createdAtStr, &updatedAtStr,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
n.Enabled = enabled != 0
|
||||
n.CreatedAt = parseTime(createdAtStr)
|
||||
n.UpdatedAt = parseTime(updatedAtStr)
|
||||
return &n, nil
|
||||
}
|
||||
|
||||
func scanNodes(rows *sql.Rows) ([]Node, error) {
|
||||
var nodes []Node
|
||||
for rows.Next() {
|
||||
var n Node
|
||||
var enabled int
|
||||
var createdAtStr, updatedAtStr string
|
||||
|
||||
err := rows.Scan(
|
||||
&n.ID, &n.URI, &n.Name, &n.Source, &n.Port,
|
||||
&n.Username, &n.Password, &n.Region, &n.Country,
|
||||
&enabled, &createdAtStr, &updatedAtStr,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
n.Enabled = enabled != 0
|
||||
n.CreatedAt = parseTime(createdAtStr)
|
||||
n.UpdatedAt = parseTime(updatedAtStr)
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
return nodes, rows.Err()
|
||||
}
|
||||
|
||||
func parseTime(s string) time.Time {
|
||||
if s == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
// Try other formats
|
||||
t, err = time.Parse("2006-01-02 15:04:05", s)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func formatTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// Package store defines the data persistence layer for easy_proxies.
|
||||
// It abstracts all storage operations behind the Store interface,
|
||||
// allowing different backend implementations (e.g., SQLite).
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Store defines all data storage operations.
|
||||
type Store interface {
|
||||
// --- Node operations ---
|
||||
|
||||
// ListNodes returns nodes matching the given filter.
|
||||
ListNodes(ctx context.Context, filter NodeFilter) ([]Node, error)
|
||||
|
||||
// GetNode returns a node by its ID.
|
||||
GetNode(ctx context.Context, id int64) (*Node, error)
|
||||
|
||||
// GetNodeByURI returns a node by its URI.
|
||||
GetNodeByURI(ctx context.Context, uri string) (*Node, error)
|
||||
|
||||
// GetNodeByName returns a node by its name.
|
||||
GetNodeByName(ctx context.Context, name string) (*Node, error)
|
||||
|
||||
// CreateNode inserts a new node and returns its assigned ID.
|
||||
CreateNode(ctx context.Context, node *Node) error
|
||||
|
||||
// UpdateNode updates an existing node by ID.
|
||||
UpdateNode(ctx context.Context, node *Node) error
|
||||
|
||||
// DeleteNode removes a node by ID (cascading to stats/timeline).
|
||||
DeleteNode(ctx context.Context, id int64) error
|
||||
|
||||
// DeleteNodesBySource removes all nodes with the given source.
|
||||
// Returns the number of deleted rows.
|
||||
DeleteNodesBySource(ctx context.Context, source string) (int64, error)
|
||||
|
||||
// BulkUpsertNodes inserts or updates nodes in a single transaction.
|
||||
// Nodes are matched by URI for upsert logic.
|
||||
BulkUpsertNodes(ctx context.Context, nodes []Node) error
|
||||
|
||||
// CountNodes returns the total number of nodes matching the filter.
|
||||
CountNodes(ctx context.Context, filter NodeFilter) (int64, error)
|
||||
|
||||
// --- Node stats ---
|
||||
|
||||
// GetNodeStats returns runtime statistics for a node.
|
||||
GetNodeStats(ctx context.Context, nodeID int64) (*NodeStats, error)
|
||||
|
||||
// UpsertNodeStats creates or updates node statistics.
|
||||
UpsertNodeStats(ctx context.Context, stats *NodeStats) error
|
||||
|
||||
// RecordSuccess increments success count and updates latency.
|
||||
RecordSuccess(ctx context.Context, nodeID int64, latencyMs int64) error
|
||||
|
||||
// RecordFailure increments failure count and records the error.
|
||||
RecordFailure(ctx context.Context, nodeID int64, errMsg string) error
|
||||
|
||||
// SetBlacklist marks a node as blacklisted until the given time.
|
||||
SetBlacklist(ctx context.Context, nodeID int64, until time.Time) error
|
||||
|
||||
// ClearBlacklist removes the blacklist flag for a node.
|
||||
ClearBlacklist(ctx context.Context, nodeID int64) error
|
||||
|
||||
// ClearAllBlacklists removes all blacklist flags.
|
||||
ClearAllBlacklists(ctx context.Context) error
|
||||
|
||||
// BatchUpdateStats applies multiple stat updates in a single transaction.
|
||||
BatchUpdateStats(ctx context.Context, updates []StatsUpdate) error
|
||||
|
||||
// GetAllNodeStats returns stats for all nodes (used for bulk restore).
|
||||
GetAllNodeStats(ctx context.Context) (map[int64]*NodeStats, error)
|
||||
|
||||
// --- Timeline ---
|
||||
|
||||
// AppendTimeline adds an event to a node's timeline.
|
||||
AppendTimeline(ctx context.Context, nodeID int64, event TimelineEvent) error
|
||||
|
||||
// GetTimeline returns the most recent events for a node.
|
||||
GetTimeline(ctx context.Context, nodeID int64, limit int) ([]TimelineEvent, error)
|
||||
|
||||
// CleanupTimeline removes old timeline events, keeping only the most recent per node.
|
||||
CleanupTimeline(ctx context.Context, keepPerNode int) error
|
||||
|
||||
// --- Sessions ---
|
||||
|
||||
// CreateSession stores a new session.
|
||||
CreateSession(ctx context.Context, session *Session) error
|
||||
|
||||
// GetSession retrieves a session by token.
|
||||
GetSession(ctx context.Context, token string) (*Session, error)
|
||||
|
||||
// DeleteSession removes a session by token.
|
||||
DeleteSession(ctx context.Context, token string) error
|
||||
|
||||
// CleanupExpiredSessions removes all expired sessions.
|
||||
CleanupExpiredSessions(ctx context.Context) error
|
||||
|
||||
// --- Subscription status ---
|
||||
|
||||
// GetSubscriptionStatus returns the current subscription refresh status.
|
||||
GetSubscriptionStatus(ctx context.Context) (*SubscriptionStatus, error)
|
||||
|
||||
// UpdateSubscriptionStatus creates or updates the subscription status.
|
||||
UpdateSubscriptionStatus(ctx context.Context, status *SubscriptionStatus) error
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
// Close releases all resources held by the store.
|
||||
Close() error
|
||||
|
||||
// WithTx executes fn within a transaction. If fn returns an error,
|
||||
// the transaction is rolled back; otherwise it is committed.
|
||||
WithTx(ctx context.Context, fn func(tx Store) error) error
|
||||
}
|
||||
|
||||
// --- Data models ---
|
||||
|
||||
// Node represents a proxy node stored in the database.
|
||||
type Node struct {
|
||||
ID int64 `json:"id"`
|
||||
URI string `json:"uri"`
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"` // inline, nodes_file, subscription, manual
|
||||
Port uint16 `json:"port"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// NodeFilter specifies criteria for listing nodes.
|
||||
type NodeFilter struct {
|
||||
Source string // Filter by source (empty = all)
|
||||
Region string // Filter by region (empty = all)
|
||||
Enabled *bool // Filter by enabled status (nil = all)
|
||||
Limit int // Max results (0 = no limit)
|
||||
Offset int // Pagination offset
|
||||
}
|
||||
|
||||
// NodeStats holds runtime statistics for a node.
|
||||
type NodeStats struct {
|
||||
NodeID int64 `json:"node_id"`
|
||||
FailureCount int `json:"failure_count"`
|
||||
SuccessCount int64 `json:"success_count"`
|
||||
Blacklisted bool `json:"blacklisted"`
|
||||
BlacklistedUntil time.Time `json:"blacklisted_until"`
|
||||
LastError string `json:"last_error"`
|
||||
LastFailureAt time.Time `json:"last_failure_at"`
|
||||
LastSuccessAt time.Time `json:"last_success_at"`
|
||||
LastLatencyMs int64 `json:"last_latency_ms"` // -1 = untested
|
||||
Available bool `json:"available"`
|
||||
InitialCheckDone bool `json:"initial_check_done"`
|
||||
TotalUploadBytes int64 `json:"total_upload_bytes"`
|
||||
TotalDownloadBytes int64 `json:"total_download_bytes"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// StatsUpdate represents a batch update for node statistics.
|
||||
type StatsUpdate struct {
|
||||
NodeID int64
|
||||
FailureCount int
|
||||
SuccessCount int64
|
||||
Blacklisted bool
|
||||
BlacklistedUntil time.Time
|
||||
LastError string
|
||||
LastFailureAt time.Time
|
||||
LastSuccessAt time.Time
|
||||
LastLatencyMs int64
|
||||
Available bool
|
||||
InitialCheckDone bool
|
||||
TotalUploadBytes int64
|
||||
TotalDownloadBytes int64
|
||||
}
|
||||
|
||||
// TimelineEvent represents a single usage event for debug tracking.
|
||||
type TimelineEvent struct {
|
||||
ID int64 `json:"id"`
|
||||
NodeID int64 `json:"node_id"`
|
||||
Success bool `json:"success"`
|
||||
LatencyMs int64 `json:"latency_ms"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Session represents a user authentication session.
|
||||
type Session struct {
|
||||
Token string `json:"token"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// SubscriptionStatus represents subscription refresh status.
|
||||
type SubscriptionStatus struct {
|
||||
LastRefresh time.Time `json:"last_refresh"`
|
||||
NextRefresh time.Time `json:"next_refresh"`
|
||||
NodeCount int `json:"node_count"`
|
||||
LastError string `json:"last_error"`
|
||||
RefreshCount int `json:"refresh_count"`
|
||||
IsRefreshing bool `json:"is_refreshing"`
|
||||
NodesHash string `json:"nodes_hash"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Node source constants (matching config.NodeSource values).
|
||||
const (
|
||||
NodeSourceInline = "inline"
|
||||
NodeSourceFile = "nodes_file"
|
||||
NodeSourceSubscription = "subscription"
|
||||
NodeSourceManual = "manual"
|
||||
)
|
||||
@@ -0,0 +1,684 @@
|
||||
package subscription
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"easy_proxies/internal/boxmgr"
|
||||
"easy_proxies/internal/config"
|
||||
"easy_proxies/internal/monitor"
|
||||
"easy_proxies/internal/store"
|
||||
)
|
||||
|
||||
// Logger defines logging interface.
|
||||
type Logger interface {
|
||||
Infof(format string, args ...any)
|
||||
Warnf(format string, args ...any)
|
||||
Errorf(format string, args ...any)
|
||||
}
|
||||
|
||||
// Option configures the Manager.
|
||||
type Option func(*Manager)
|
||||
|
||||
// WithLogger sets a custom logger.
|
||||
func WithLogger(l Logger) Option {
|
||||
return func(m *Manager) { m.logger = l }
|
||||
}
|
||||
|
||||
// WithStore sets the data store.
|
||||
func WithStore(s store.Store) Option {
|
||||
return func(m *Manager) { m.store = s }
|
||||
}
|
||||
|
||||
// Ensure Manager implements boxmgr.ConfigUpdateListener.
|
||||
var _ boxmgr.ConfigUpdateListener = (*Manager)(nil)
|
||||
|
||||
// Manager handles periodic subscription refresh.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
baseCfg *config.Config
|
||||
boxMgr *boxmgr.Manager
|
||||
logger Logger
|
||||
httpClient *http.Client // Custom HTTP client with connection pooling
|
||||
store store.Store // Data store for persisting nodes
|
||||
|
||||
status monitor.SubscriptionStatus
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
refreshMu sync.Mutex // prevents concurrent refreshes
|
||||
manualRefresh chan struct{}
|
||||
configChanged chan struct{} // signals config updates to the refresh loop
|
||||
}
|
||||
|
||||
// New creates a SubscriptionManager.
|
||||
func New(cfg *config.Config, boxMgr *boxmgr.Manager, opts ...Option) *Manager {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Create optimized HTTP client with connection pooling
|
||||
transport := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
ResponseHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
httpClient := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: 60 * time.Second, // Overall timeout
|
||||
}
|
||||
|
||||
m := &Manager{
|
||||
baseCfg: cfg,
|
||||
boxMgr: boxMgr,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
manualRefresh: make(chan struct{}, 1),
|
||||
configChanged: make(chan struct{}, 1),
|
||||
httpClient: httpClient,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(m)
|
||||
}
|
||||
if m.logger == nil {
|
||||
m.logger = defaultLogger{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Start begins the background goroutine that manages periodic subscription refresh.
|
||||
// The goroutine dynamically checks config to decide whether to actually perform refreshes,
|
||||
// so it's safe to call Start() even when subscription refresh is initially disabled.
|
||||
func (m *Manager) Start() {
|
||||
if m.isEnabled() {
|
||||
m.logger.Infof("starting subscription refresh, interval: %s", m.currentInterval())
|
||||
} else {
|
||||
m.logger.Infof("subscription manager started (auto-refresh currently disabled, will activate on config change)")
|
||||
}
|
||||
|
||||
go m.refreshLoop()
|
||||
}
|
||||
|
||||
// Stop stops the periodic refresh.
|
||||
func (m *Manager) Stop() {
|
||||
if m.cancel != nil {
|
||||
m.cancel()
|
||||
}
|
||||
|
||||
// Close idle connections
|
||||
if m.httpClient != nil {
|
||||
m.httpClient.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshNow triggers an immediate refresh, regardless of whether auto-refresh is enabled.
|
||||
// It only requires that subscription URLs are configured.
|
||||
func (m *Manager) RefreshNow() error {
|
||||
m.mu.RLock()
|
||||
hasSubscriptions := len(m.baseCfg.Subscriptions) > 0
|
||||
timeout := m.baseCfg.SubscriptionRefresh.Timeout
|
||||
healthCheckTimeout := m.baseCfg.SubscriptionRefresh.HealthCheckTimeout
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !hasSubscriptions {
|
||||
return fmt.Errorf("没有配置订阅链接")
|
||||
}
|
||||
|
||||
select {
|
||||
case m.manualRefresh <- struct{}{}:
|
||||
default:
|
||||
// Already a refresh pending
|
||||
}
|
||||
|
||||
// Wait for refresh to complete or timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(m.ctx, timeout+healthCheckTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Poll status until refresh completes
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
startCount := m.Status().RefreshCount
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("refresh timeout")
|
||||
case <-ticker.C:
|
||||
status := m.Status()
|
||||
if status.RefreshCount > startCount {
|
||||
if status.LastError != "" {
|
||||
return fmt.Errorf("refresh failed: %s", status.LastError)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Status returns the current refresh status, including dynamic config state.
|
||||
func (m *Manager) Status() monitor.SubscriptionStatus {
|
||||
m.mu.RLock()
|
||||
status := m.status
|
||||
status.Enabled = m.baseCfg.SubscriptionRefresh.Enabled
|
||||
status.HasSubscriptions = len(m.baseCfg.Subscriptions) > 0
|
||||
m.mu.RUnlock()
|
||||
|
||||
// Check if nodes have been modified since last refresh
|
||||
status.NodesModified = m.CheckNodesModified()
|
||||
return status
|
||||
}
|
||||
|
||||
// refreshLoop runs the background loop that manages periodic and manual refreshes.
|
||||
// It dynamically reads config to decide whether to auto-refresh and at what interval.
|
||||
func (m *Manager) refreshLoop() {
|
||||
interval := m.currentInterval()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Set initial next refresh time
|
||||
m.mu.Lock()
|
||||
if m.isEnabledLocked() {
|
||||
m.status.NextRefresh = time.Now().Add(interval)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-m.ctx.Done():
|
||||
return
|
||||
|
||||
case <-ticker.C:
|
||||
// Dynamically adjust interval if config changed
|
||||
newInterval := m.currentInterval()
|
||||
if newInterval != interval {
|
||||
m.logger.Infof("subscription refresh interval changed: %s → %s", interval, newInterval)
|
||||
interval = newInterval
|
||||
ticker.Reset(interval)
|
||||
}
|
||||
|
||||
// Only auto-refresh when enabled and subscriptions exist
|
||||
if m.isEnabled() {
|
||||
m.doRefresh()
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
if m.isEnabledLocked() {
|
||||
m.status.NextRefresh = time.Now().Add(interval)
|
||||
} else {
|
||||
m.status.NextRefresh = time.Time{}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
case <-m.manualRefresh:
|
||||
// Manual refresh always executes (caller already verified subscriptions exist)
|
||||
m.doRefresh()
|
||||
// Reset ticker and recalculate interval after manual refresh
|
||||
newInterval := m.currentInterval()
|
||||
if newInterval != interval {
|
||||
interval = newInterval
|
||||
}
|
||||
ticker.Reset(interval)
|
||||
m.mu.Lock()
|
||||
m.status.NextRefresh = time.Now().Add(interval)
|
||||
m.mu.Unlock()
|
||||
|
||||
case <-m.configChanged:
|
||||
// Config was updated (e.g., after reload), recalculate interval
|
||||
newInterval := m.currentInterval()
|
||||
if newInterval != interval {
|
||||
m.logger.Infof("subscription refresh interval changed: %s → %s", interval, newInterval)
|
||||
interval = newInterval
|
||||
ticker.Reset(interval)
|
||||
}
|
||||
m.mu.Lock()
|
||||
if m.isEnabledLocked() {
|
||||
m.status.NextRefresh = time.Now().Add(interval)
|
||||
} else {
|
||||
m.status.NextRefresh = time.Time{}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isEnabled checks if auto-refresh should run (acquires read lock).
|
||||
func (m *Manager) isEnabled() bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.isEnabledLocked()
|
||||
}
|
||||
|
||||
// isEnabledLocked checks if auto-refresh should run (caller must hold mu).
|
||||
func (m *Manager) isEnabledLocked() bool {
|
||||
return m.baseCfg.SubscriptionRefresh.Enabled && len(m.baseCfg.Subscriptions) > 0
|
||||
}
|
||||
|
||||
// currentInterval returns the configured refresh interval (acquires read lock).
|
||||
func (m *Manager) currentInterval() time.Duration {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.currentIntervalLocked()
|
||||
}
|
||||
|
||||
// currentIntervalLocked returns the configured refresh interval (caller must hold mu).
|
||||
func (m *Manager) currentIntervalLocked() time.Duration {
|
||||
interval := m.baseCfg.SubscriptionRefresh.Interval
|
||||
if interval <= 0 {
|
||||
interval = 1 * time.Hour
|
||||
}
|
||||
return interval
|
||||
}
|
||||
|
||||
// doRefresh performs a single refresh operation.
|
||||
// It fetches subscription nodes and persists them to the SQLite Store,
|
||||
// then reloads the config. Manual nodes are preserved in the Store.
|
||||
func (m *Manager) doRefresh() {
|
||||
// Prevent concurrent refreshes
|
||||
if !m.refreshMu.TryLock() {
|
||||
m.logger.Warnf("refresh already in progress, skipping")
|
||||
return
|
||||
}
|
||||
defer m.refreshMu.Unlock()
|
||||
|
||||
m.mu.Lock()
|
||||
m.status.IsRefreshing = true
|
||||
m.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
m.mu.Lock()
|
||||
m.status.IsRefreshing = false
|
||||
m.status.RefreshCount++
|
||||
m.mu.Unlock()
|
||||
}()
|
||||
|
||||
m.logger.Infof("starting subscription refresh")
|
||||
|
||||
// Fetch nodes from all subscriptions
|
||||
nodes, err := m.fetchAllSubscriptions()
|
||||
if err != nil {
|
||||
m.logger.Errorf("fetch subscriptions failed: %v", err)
|
||||
m.mu.Lock()
|
||||
m.status.LastError = err.Error()
|
||||
m.status.LastRefresh = time.Now()
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if len(nodes) == 0 {
|
||||
m.logger.Warnf("no nodes fetched from subscriptions")
|
||||
m.mu.Lock()
|
||||
m.status.LastError = "no nodes fetched"
|
||||
m.status.LastRefresh = time.Now()
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
m.logger.Infof("fetched %d nodes from subscriptions", len(nodes))
|
||||
|
||||
// Persist subscription nodes to Store (replace old subscription nodes)
|
||||
if m.store != nil {
|
||||
// Delete old subscription nodes
|
||||
deleted, err := m.store.DeleteNodesBySource(m.ctx, store.NodeSourceSubscription)
|
||||
if err != nil {
|
||||
m.logger.Warnf("failed to delete old subscription nodes from store: %v", err)
|
||||
} else if deleted > 0 {
|
||||
m.logger.Infof("deleted %d old subscription nodes from store", deleted)
|
||||
}
|
||||
|
||||
// Insert new subscription nodes
|
||||
var storeNodes []store.Node
|
||||
for _, n := range nodes {
|
||||
name := strings.TrimSpace(n.Name)
|
||||
uri := strings.TrimSpace(n.URI)
|
||||
if name == "" {
|
||||
if parsed, parseErr := url.Parse(uri); parseErr == nil && parsed.Fragment != "" {
|
||||
if decoded, decErr := url.QueryUnescape(parsed.Fragment); decErr == nil {
|
||||
name = decoded
|
||||
} else {
|
||||
name = parsed.Fragment
|
||||
}
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("sub-node-%d", len(storeNodes)+1)
|
||||
}
|
||||
storeNodes = append(storeNodes, store.Node{
|
||||
URI: uri,
|
||||
Name: name,
|
||||
Source: store.NodeSourceSubscription,
|
||||
Enabled: true,
|
||||
})
|
||||
}
|
||||
if err := m.store.BulkUpsertNodes(m.ctx, storeNodes); err != nil {
|
||||
m.logger.Errorf("failed to save subscription nodes to store: %v", err)
|
||||
m.mu.Lock()
|
||||
m.status.LastError = fmt.Sprintf("save to store: %v", err)
|
||||
m.status.LastRefresh = time.Now()
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
m.logger.Infof("saved %d subscription nodes to store", len(storeNodes))
|
||||
} else {
|
||||
m.logger.Errorf("store is not available, cannot persist subscription nodes")
|
||||
m.mu.Lock()
|
||||
m.status.LastError = "store not available"
|
||||
m.status.LastRefresh = time.Now()
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Get current port mapping to preserve existing node ports
|
||||
portMap := m.boxMgr.CurrentPortMap()
|
||||
|
||||
// Create new config with updated subscription nodes + preserved manual nodes
|
||||
newCfg := m.createNewConfig(nodes)
|
||||
|
||||
// Trigger BoxManager reload with port preservation
|
||||
if err := m.boxMgr.ReloadWithPortMap(newCfg, portMap); err != nil {
|
||||
m.logger.Errorf("reload failed: %v", err)
|
||||
m.mu.Lock()
|
||||
m.status.LastError = err.Error()
|
||||
m.status.LastRefresh = time.Now()
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Count total nodes including manual
|
||||
totalNodes := len(newCfg.Nodes)
|
||||
m.mu.Lock()
|
||||
m.status.LastRefresh = time.Now()
|
||||
m.status.NodeCount = totalNodes
|
||||
m.status.LastError = ""
|
||||
m.mu.Unlock()
|
||||
|
||||
m.logger.Infof("subscription refresh completed, %d total nodes active (%d from subscription)", totalNodes, len(nodes))
|
||||
}
|
||||
|
||||
// OnConfigUpdate is called by boxmgr after a successful reload.
|
||||
// It updates the subscription manager's reference to the latest config
|
||||
// so that subsequent refreshes use updated subscription URLs and settings.
|
||||
func (m *Manager) OnConfigUpdate(cfg *config.Config) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.baseCfg = cfg
|
||||
m.mu.Unlock()
|
||||
m.logger.Infof("subscription manager config updated after reload")
|
||||
|
||||
// Notify the refresh loop about config changes so it can
|
||||
// recalculate interval and enable/disable auto-refresh dynamically.
|
||||
select {
|
||||
case m.configChanged <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// CheckNodesModified always returns false — with SQLite Store,
|
||||
// node modifications are tracked in the database, not via file hashes.
|
||||
func (m *Manager) CheckNodesModified() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// MarkNodesModified updates the modification status.
|
||||
func (m *Manager) MarkNodesModified() {
|
||||
m.mu.Lock()
|
||||
m.status.NodesModified = true
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// fetchAllSubscriptions fetches nodes from all configured subscription URLs.
|
||||
func (m *Manager) fetchAllSubscriptions() ([]config.NodeConfig, error) {
|
||||
var allNodes []config.NodeConfig
|
||||
var lastErr error
|
||||
|
||||
timeout := m.baseCfg.SubscriptionRefresh.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
|
||||
for _, subURL := range m.baseCfg.Subscriptions {
|
||||
nodes, err := m.fetchSubscription(subURL, timeout)
|
||||
if err != nil {
|
||||
m.logger.Warnf("failed to fetch %s: %v", subURL, err)
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
m.logger.Infof("fetched %d nodes from subscription", len(nodes))
|
||||
allNodes = append(allNodes, nodes...)
|
||||
}
|
||||
|
||||
if len(allNodes) == 0 && lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
return allNodes, nil
|
||||
}
|
||||
|
||||
// fetchSubscription fetches and parses a single subscription URL.
|
||||
func (m *Manager) fetchSubscription(subURL string, timeout time.Duration) ([]config.NodeConfig, error) {
|
||||
ctx, cancel := context.WithTimeout(m.ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", subURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
req.Header.Set("Accept", "*/*")
|
||||
|
||||
// Use custom HTTP client with connection pooling
|
||||
resp, err := m.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Limit read size to prevent memory exhaustion
|
||||
const maxBodySize = 10 * 1024 * 1024 // 10MB
|
||||
limitedReader := io.LimitReader(resp.Body, maxBodySize)
|
||||
|
||||
body, err := io.ReadAll(limitedReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
return parseSubscriptionContent(string(body))
|
||||
}
|
||||
|
||||
// createNewConfig creates a new config with updated subscription nodes while
|
||||
// preserving inline nodes (from config.yaml) and manual nodes (from Store).
|
||||
func (m *Manager) createNewConfig(subNodes []config.NodeConfig) *config.Config {
|
||||
// Deep copy base config (uses Clone to avoid copying the mutex)
|
||||
m.mu.RLock()
|
||||
baseCfg := m.baseCfg
|
||||
m.mu.RUnlock()
|
||||
|
||||
newCfg := baseCfg.Clone()
|
||||
|
||||
// Start with inline nodes from config.yaml
|
||||
var allNodes []config.NodeConfig
|
||||
for _, node := range baseCfg.Nodes {
|
||||
if node.Source == config.NodeSourceInline {
|
||||
allNodes = append(allNodes, node)
|
||||
}
|
||||
}
|
||||
|
||||
// Process subscription node names and mark source
|
||||
for i := range subNodes {
|
||||
subNodes[i].Name = strings.TrimSpace(subNodes[i].Name)
|
||||
subNodes[i].URI = strings.TrimSpace(subNodes[i].URI)
|
||||
subNodes[i].Source = config.NodeSourceSubscription
|
||||
|
||||
// Extract name from URI fragment if not provided
|
||||
if subNodes[i].Name == "" {
|
||||
if parsed, err := url.Parse(subNodes[i].URI); err == nil && parsed.Fragment != "" {
|
||||
if decoded, err := url.QueryUnescape(parsed.Fragment); err == nil {
|
||||
subNodes[i].Name = decoded
|
||||
} else {
|
||||
subNodes[i].Name = parsed.Fragment
|
||||
}
|
||||
}
|
||||
}
|
||||
if subNodes[i].Name == "" {
|
||||
subNodes[i].Name = fmt.Sprintf("node-%d", i)
|
||||
}
|
||||
}
|
||||
allNodes = append(allNodes, subNodes...)
|
||||
|
||||
// Load manual nodes from Store
|
||||
if m.store != nil {
|
||||
storeManualNodes, err := m.store.ListNodes(m.ctx, store.NodeFilter{Source: store.NodeSourceManual})
|
||||
if err != nil {
|
||||
m.logger.Warnf("failed to load manual nodes from store: %v", err)
|
||||
} else if len(storeManualNodes) > 0 {
|
||||
for _, sn := range storeManualNodes {
|
||||
name := strings.TrimSpace(sn.Name)
|
||||
uri := strings.TrimSpace(sn.URI)
|
||||
if name == "" {
|
||||
if parsed, err := url.Parse(uri); err == nil && parsed.Fragment != "" {
|
||||
if decoded, err := url.QueryUnescape(parsed.Fragment); err == nil {
|
||||
name = decoded
|
||||
} else {
|
||||
name = parsed.Fragment
|
||||
}
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("manual-%d", sn.ID)
|
||||
}
|
||||
allNodes = append(allNodes, config.NodeConfig{
|
||||
Name: name,
|
||||
URI: uri,
|
||||
Port: sn.Port,
|
||||
Username: sn.Username,
|
||||
Password: sn.Password,
|
||||
Source: config.NodeSourceManual,
|
||||
})
|
||||
}
|
||||
m.logger.Infof("preserved %d manual nodes from store during subscription refresh", len(storeManualNodes))
|
||||
}
|
||||
}
|
||||
|
||||
// Assign port numbers to all nodes in multi-port mode
|
||||
if newCfg.Mode == "multi-port" {
|
||||
portCursor := newCfg.MultiPort.BasePort
|
||||
for i := range allNodes {
|
||||
allNodes[i].Port = portCursor
|
||||
portCursor++
|
||||
// Apply default credentials
|
||||
if allNodes[i].Username == "" {
|
||||
allNodes[i].Username = newCfg.MultiPort.Username
|
||||
allNodes[i].Password = newCfg.MultiPort.Password
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newCfg.Nodes = allNodes
|
||||
return newCfg
|
||||
}
|
||||
|
||||
// parseSubscriptionContent parses subscription content in various formats.
|
||||
// This is a simplified version - the full implementation is in config package.
|
||||
func parseSubscriptionContent(content string) ([]config.NodeConfig, error) {
|
||||
content = strings.TrimSpace(content)
|
||||
|
||||
// Check if it's base64 encoded
|
||||
if isBase64(content) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(content)
|
||||
if err != nil {
|
||||
decoded, err = base64.RawStdEncoding.DecodeString(content)
|
||||
if err != nil {
|
||||
return parseNodesFromContent(content)
|
||||
}
|
||||
}
|
||||
content = string(decoded)
|
||||
}
|
||||
|
||||
// Parse as plain text (one URI per line)
|
||||
return parseNodesFromContent(content)
|
||||
}
|
||||
|
||||
func isBase64(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) == 0 {
|
||||
return false
|
||||
}
|
||||
s = strings.ReplaceAll(s, "\n", "")
|
||||
s = strings.ReplaceAll(s, "\r", "")
|
||||
if strings.Contains(s, "://") {
|
||||
return false
|
||||
}
|
||||
_, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
_, err = base64.RawStdEncoding.DecodeString(s)
|
||||
}
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func parseNodesFromContent(content string) ([]config.NodeConfig, error) {
|
||||
var nodes []config.NodeConfig
|
||||
lines := strings.Split(content, "\n")
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if isProxyURI(line) {
|
||||
nodes = append(nodes, config.NodeConfig{URI: line})
|
||||
}
|
||||
}
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
func isProxyURI(s string) bool {
|
||||
schemes := []string{"vmess://", "vless://", "trojan://", "ss://", "ssr://", "hysteria://", "hysteria2://", "hy2://"}
|
||||
lower := strings.ToLower(s)
|
||||
for _, scheme := range schemes {
|
||||
if strings.HasPrefix(lower, scheme) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type defaultLogger struct{}
|
||||
|
||||
func (defaultLogger) Infof(format string, args ...any) {
|
||||
log.Printf("[subscription] "+format, args...)
|
||||
}
|
||||
|
||||
func (defaultLogger) Warnf(format string, args ...any) {
|
||||
log.Printf("[subscription] WARN: "+format, args...)
|
||||
}
|
||||
|
||||
func (defaultLogger) Errorf(format string, args ...any) {
|
||||
log.Printf("[subscription] ERROR: "+format, args...)
|
||||
}
|
||||
Reference in New Issue
Block a user