chore: 准备发布 v1.0.0 版本

This commit is contained in:
xiamuceer-j
2026-03-02 13:13:33 +08:00
parent f57ae3c712
commit 8163e4c193
58 changed files with 19545 additions and 1 deletions
+284
View File
@@ -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
+934
View File
@@ -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: &regionPoolOptions,
})
}
// 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
+615
View File
@@ -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 "❓"
}
}
+271
View File
@@ -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
+14
View File
@@ -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>
+1
View File
@@ -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

+21
View File
@@ -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
}
+868
View File
@@ -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
+749
View File
@@ -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])
}
+166
View File
@@ -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()
}
}
+212
View File
@@ -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
}
+196
View File
@@ -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(&currentVersion); 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
}
+816
View File
@@ -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
}
+216
View File
@@ -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"
)
+684
View File
@@ -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...)
}