feat: implement custom proxy subscription management and enhance configuration

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

570
custom/manager.go Normal file
View File

@@ -0,0 +1,570 @@
package custom
import (
"crypto/tls"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"strconv"
"sync"
"time"
"golang.org/x/net/proxy"
"goproxy/config"
"goproxy/storage"
"goproxy/validator"
)
// Manager 订阅管理器
type Manager struct {
storage *storage.Storage
validator *validator.Validator
singbox *SingBoxProcess
stopCh chan struct{}
refreshMu sync.Mutex // 防止并发刷新
}
// NewManager 创建订阅管理器
func NewManager(store *storage.Storage, v *validator.Validator, cfg *config.Config) *Manager {
dataDir := ""
if d := os.Getenv("DATA_DIR"); d != "" {
dataDir = d
}
return &Manager{
storage: store,
validator: v,
singbox: NewSingBoxProcess(cfg.SingBoxPath, dataDir, cfg.SingBoxBasePort),
stopCh: make(chan struct{}),
}
}
// Start 启动后台循环
func (m *Manager) Start() {
log.Println("[custom] 订阅管理器启动")
// 启动时立即刷新所有订阅
go m.initialRefresh()
// 订阅刷新循环
go m.refreshLoop()
// 探测唤醒循环
go m.probeLoop()
}
// Stop 停止管理器
func (m *Manager) Stop() {
close(m.stopCh)
m.singbox.Stop()
log.Println("[custom] 订阅管理器已停止")
}
// initialRefresh 启动时刷新所有活跃订阅
func (m *Manager) initialRefresh() {
time.Sleep(3 * time.Second) // 等待其他模块初始化
subs, err := m.storage.GetSubscriptions()
if err != nil || len(subs) == 0 {
return
}
activeSubs := 0
for _, sub := range subs {
if sub.Status == "active" {
activeSubs++
}
}
if activeSubs == 0 {
return
}
log.Printf("[custom] 启动刷新,共 %d 个活跃订阅", activeSubs)
m.RefreshAll()
}
// refreshLoop 订阅刷新循环
func (m *Manager) refreshLoop() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-m.stopCh:
return
case <-ticker.C:
m.checkAndRefresh()
}
}
}
// checkAndRefresh 检查并刷新到期的订阅 + 清理长期无可用节点的订阅
func (m *Manager) checkAndRefresh() {
// 清理连续 7 天无可用节点的订阅
m.cleanupStaleSubscriptions()
subs, err := m.storage.GetSubscriptions()
if err != nil {
log.Printf("[custom] 获取订阅列表失败: %v", err)
return
}
for _, sub := range subs {
if sub.Status != "active" {
continue
}
// 检查是否到刷新时间
if !sub.LastFetch.IsZero() && time.Since(sub.LastFetch) < time.Duration(sub.RefreshMin)*time.Minute {
continue
}
log.Printf("[custom] 🔄 订阅 [%s] 到期,开始刷新", sub.Name)
if err := m.RefreshSubscription(sub.ID); err != nil {
log.Printf("[custom] ❌ 订阅 [%s] 刷新失败: %v", sub.Name, err)
}
}
}
// cleanupStaleSubscriptions 清理连续 7 天无可用节点的订阅
func (m *Manager) cleanupStaleSubscriptions() {
staleSubs, err := m.storage.GetStaleSubscriptions(7)
if err != nil || len(staleSubs) == 0 {
return
}
for _, sub := range staleSubs {
deleted, _ := m.storage.DeleteBySubscriptionID(sub.ID)
m.storage.DeleteSubscription(sub.ID)
log.Printf("[custom] 🗑️ 自动移除订阅 [%s]:连续 7 天无可用节点(清理 %d 个代理)", sub.Name, deleted)
}
// 重建 sing-box 配置
if len(staleSubs) > 0 {
m.RefreshAll()
}
}
// probeLoop 探测唤醒循环
func (m *Manager) probeLoop() {
// 等待初始化完成
time.Sleep(5 * time.Second)
for {
cfg := config.Get()
interval := time.Duration(cfg.CustomProbeInterval) * time.Minute
if interval < time.Minute {
interval = 10 * time.Minute
}
select {
case <-m.stopCh:
return
case <-time.After(interval):
m.probeDisabled()
}
}
}
// probeDisabled 探测被禁用的订阅代理
func (m *Manager) probeDisabled() {
disabled, err := m.storage.GetDisabledCustomProxies()
if err != nil || len(disabled) == 0 {
return
}
log.Printf("[custom] 🔍 探测 %d 个禁用的订阅代理", len(disabled))
cfg := config.Get()
recovered := 0
recoveredSubs := make(map[int64]bool)
for _, proxy := range disabled {
valid, latency, exitIP, exitLocation := m.validator.ValidateOne(proxy)
if valid {
// 检查地理过滤:恢复前确认不在屏蔽列表中
if exitLocation != "" && isGeoBlocked(exitLocation, cfg) {
log.Printf("[custom] 代理 %s 验证通过但被地理过滤 (%s),保持禁用", proxy.Address, exitLocation)
m.storage.UpdateExitInfo(proxy.Address, exitIP, exitLocation, int(latency.Milliseconds()))
continue
}
m.storage.EnableProxy(proxy.Address)
m.storage.UpdateExitInfo(proxy.Address, exitIP, exitLocation, int(latency.Milliseconds()))
recovered++
recoveredSubs[proxy.SubscriptionID] = true
log.Printf("[custom] ✅ 代理 %s 恢复可用 (%dms)", proxy.Address, latency.Milliseconds())
}
}
// 有恢复的代理则更新对应订阅的 last_success
for subID := range recoveredSubs {
if subID > 0 {
m.storage.UpdateSubscriptionSuccess(subID)
}
}
if recovered > 0 {
log.Printf("[custom] 探测完成:%d/%d 恢复可用", recovered, len(disabled))
}
}
// RefreshSubscription 刷新<E588B7><E696B0>个订阅
func (m *Manager) RefreshSubscription(subID int64) error {
m.refreshMu.Lock()
defer m.refreshMu.Unlock()
sub, err := m.storage.GetSubscription(subID)
if err != nil {
return fmt.Errorf("获取订阅失败: %w", err)
}
// 获取订阅内容
data, err := m.fetchSubscriptionData(sub)
if err != nil {
return fmt.Errorf("拉取订阅内容失败: %w", err)
}
// 解析节点
nodes, err := Parse(data, sub.Format)
if err != nil {
return fmt.Errorf("解析订阅内容失败: %w", err)
}
if len(nodes) == 0 {
log.Printf("[custom] ⚠️ 订阅 [%s] 无有效节点", sub.Name)
return nil
}
log.Printf("[custom] 订阅 [%s] 解析到 %d 个节点", sub.Name, len(nodes))
// 先删除该订阅的旧代理
oldDeleted, _ := m.storage.DeleteBySubscriptionID(subID)
if oldDeleted > 0 {
log.Printf("[custom] 🧹 清理订阅 [%s] 旧代理 %d 个", sub.Name, oldDeleted)
}
// 分类节点
var directNodes []ParsedNode
var tunnelNodes []ParsedNode
for _, node := range nodes {
if node.IsDirect() {
directNodes = append(directNodes, node)
} else {
tunnelNodes = append(tunnelNodes, node)
}
}
// 收集所有入池的代理(带正确的协议信息)
var allProxies []storage.Proxy
// 处理可直接使用的 HTTP/SOCKS5 节点
for _, node := range directNodes {
addr := node.DirectAddress()
proto := node.DirectProtocol()
m.storage.AddProxyWithSource(addr, proto, "custom", subID)
allProxies = append(allProxies, storage.Proxy{Address: addr, Protocol: proto, Source: "custom"})
}
if len(directNodes) > 0 {
log.Printf("[custom] 📥 %d 个 HTTP/SOCKS5 节点直接入池", len(directNodes))
}
// 处理需要 sing-box 转换的节点
if len(tunnelNodes) > 0 {
// 收集所有订阅的 tunnel 节点(需合并)
allTunnelNodes, err := m.collectAllTunnelNodes()
if err != nil {
log.Printf("[custom] ⚠️ 收集 tunnel 节点失败: %v", err)
}
// 将当前订阅的 tunnel 节点也加入,去重
nodeMap := make(map[string]ParsedNode)
for _, n := range allTunnelNodes {
nodeMap[n.NodeKey()] = n
}
for _, n := range tunnelNodes {
nodeMap[n.NodeKey()] = n
}
var mergedNodes []ParsedNode
for _, n := range nodeMap {
mergedNodes = append(mergedNodes, n)
}
if err := m.singbox.Reload(mergedNodes); err != nil {
log.Printf("[custom] ❌ sing-box 重载失败: %v", err)
} else {
portMap := m.singbox.GetPortMap()
for _, node := range tunnelNodes {
key := node.NodeKey()
if port, ok := portMap[key]; ok {
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
m.storage.AddProxyWithSource(addr, "socks5", "custom", subID)
allProxies = append(allProxies, storage.Proxy{Address: addr, Protocol: "socks5", Source: "custom"})
}
}
log.Printf("[custom] 📥 %d 个加密节点通过 sing-box 转换入池", len(tunnelNodes))
}
}
// 验证新入池的代理
go m.validateCustomProxies(allProxies, subID)
// 更新订阅信息(记录实际入池的代理数)
m.storage.UpdateSubscriptionFetch(subID, len(allProxies))
log.Printf("[custom] ✅ 订阅 [%s] 刷新完成,解析 %d 节点,入池 %d 个", sub.Name, len(nodes), len(allProxies))
return nil
}
// RefreshAll 刷新所有活跃订阅
func (m *Manager) RefreshAll() {
subs, err := m.storage.GetSubscriptions()
if err != nil {
log.Printf("[custom] 获取订阅列表失败: %v", err)
return
}
for _, sub := range subs {
if sub.Status != "active" {
continue
}
if err := m.RefreshSubscription(sub.ID); err != nil {
log.Printf("[custom] ❌ 订阅 [%s] 刷新失败: %v", sub.Name, err)
}
}
}
// collectAllTunnelNodes 收集所有订阅中需要 tunnel 的节点
func (m *Manager) collectAllTunnelNodes() ([]ParsedNode, error) {
subs, err := m.storage.GetSubscriptions()
if err != nil {
return nil, err
}
var allNodes []ParsedNode
for _, sub := range subs {
if sub.Status != "active" {
continue
}
data, err := m.fetchSubscriptionData(&sub)
if err != nil {
continue
}
nodes, err := Parse(data, sub.Format)
if err != nil {
continue
}
for _, node := range nodes {
if !node.IsDirect() {
allNodes = append(allNodes, node)
}
}
}
return allNodes, nil
}
// fetchSubscriptionData 获取订阅数据
func (m *Manager) fetchSubscriptionData(sub *storage.Subscription) ([]byte, error) {
// 优先使用本地文件
if sub.FilePath != "" {
data, err := os.ReadFile(sub.FilePath)
if err != nil {
return nil, fmt.Errorf("读取文件 %s 失败: %w", sub.FilePath, err)
}
return data, nil
}
// 从 URL 拉取
if sub.URL == "" {
return nil, fmt.Errorf("订阅未配置 URL 或文件路径")
}
// 尝试拉取(直连 → 代理)
data, err := m.fetchWithRetry(sub.URL)
if err != nil {
return nil, err
}
return data, nil
}
// fetchWithRetry 尝试拉取 URL直连 → 代理,多种方式)
func (m *Manager) fetchWithRetry(urlStr string) ([]byte, error) {
// 先尝试直连
data, err := m.fetchURL(urlStr, nil)
if err == nil {
return data, nil
}
log.Printf("[custom] 直连订阅 URL 失败: %v尝试通过代理访问...", err)
// 直连失败,尝试通过池中已有代理访问
for i := 0; i < 3; i++ {
p, pErr := m.storage.GetRandom()
if pErr != nil {
break
}
data, err = m.fetchURL(urlStr, p)
if err == nil {
log.Printf("[custom] ✅ 通过代理 %s 成功访问订阅 URL", p.Address)
return data, nil
}
log.Printf("[custom] 代理 %s 访问订阅 URL 失败: %v", p.Address, err)
}
return nil, fmt.Errorf("直连和代理均无法访问订阅 URL: %w", err)
}
// fetchURL 通过指定代理(或直连)拉取 URL 内容
func (m *Manager) fetchURL(urlStr string, p *storage.Proxy) ([]byte, error) {
transport := &http.Transport{}
if p != nil {
// 通过代理访问时跳过 TLS 验证(免费代理可能 MITM
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
switch p.Protocol {
case "socks5":
dialer, err := proxy.SOCKS5("tcp", p.Address, nil, proxy.Direct)
if err != nil {
return nil, err
}
transport.Dial = dialer.Dial
default: // http
proxyURL, err := url.Parse(fmt.Sprintf("http://%s", p.Address))
if err != nil {
return nil, err
}
transport.Proxy = http.ProxyURL(proxyURL)
}
}
client := &http.Client{Timeout: 30 * time.Second, Transport: transport}
req, err := http.NewRequest("GET", urlStr, nil)
if err != nil {
return nil, err
}
// 用 v2rayN UA大部分机场都会返回完整的节点信息
req.Header.Set("User-Agent", "v2rayN")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
// validateCustomProxies 验证订阅代理,返回可用数
func (m *Manager) validateCustomProxies(proxies []storage.Proxy, subID int64) int {
if len(proxies) == 0 {
return 0
}
log.Printf("[custom] 🔍 开始验证 %d 个订阅代理", len(proxies))
cfg := config.Get()
resultCh := m.validator.ValidateStream(proxies)
valid, invalid := 0, 0
for result := range resultCh {
if result.Valid {
latencyMs := int(result.Latency.Milliseconds())
m.storage.UpdateExitInfo(result.Proxy.Address, result.ExitIP, result.ExitLocation, latencyMs)
// 检查地理过滤
if result.ExitLocation != "" && isGeoBlocked(result.ExitLocation, cfg) {
m.storage.DisableProxy(result.Proxy.Address)
invalid++
} else {
m.storage.EnableProxy(result.Proxy.Address)
valid++
}
} else {
invalid++
m.storage.DisableProxy(result.Proxy.Address)
}
}
// 有可用节点则更新 last_success
if valid > 0 && subID > 0 {
m.storage.UpdateSubscriptionSuccess(subID)
}
log.Printf("[custom] 验证完成:%d 可用,%d 不可用", valid, invalid)
return valid
}
// GetStatus 获取订阅管理器状态
func (m *Manager) GetStatus() map[string]interface{} {
customCount, _ := m.storage.CountBySource("custom")
disabled, _ := m.storage.GetDisabledCustomProxies()
subs, _ := m.storage.GetSubscriptions()
return map[string]interface{}{
"singbox_running": m.singbox.IsRunning(),
"singbox_nodes": m.singbox.GetNodeCount(),
"custom_count": customCount,
"disabled_count": len(disabled),
"subscription_count": len(subs),
}
}
// ValidateSubscription 验证订阅能否解析出节点(不入库,仅检查)
func (m *Manager) ValidateSubscription(url, filePath string) (int, error) {
var data []byte
var err error
if filePath != "" {
data, err = os.ReadFile(filePath)
if err != nil {
return 0, fmt.Errorf("读取文件失败: %w", err)
}
} else if url != "" {
data, err = m.fetchWithRetry(url)
if err != nil {
return 0, err
}
} else {
return 0, fmt.Errorf("未提供 URL 或文件")
}
nodes, err := Parse(data, "auto")
if err != nil {
return 0, err
}
if len(nodes) == 0 {
return 0, fmt.Errorf("解析结果为空,未找到有效代理节点")
}
return len(nodes), nil
}
// isGeoBlocked 检查代理出口位置是否被地理过滤
func isGeoBlocked(exitLocation string, cfg *config.Config) bool {
if exitLocation == "" || len(exitLocation) < 2 {
return false
}
countryCode := exitLocation[:2]
// 白名单模式优先
if len(cfg.AllowedCountries) > 0 {
for _, allowed := range cfg.AllowedCountries {
if countryCode == allowed {
return false
}
}
return true // 不在白名单中
}
// 黑名单模式
for _, blocked := range cfg.BlockedCountries {
if countryCode == blocked {
return true
}
}
return false
}
// GetSingBox 获取 sing-box 进程管理器
func (m *Manager) GetSingBox() *SingBoxProcess {
return m.singbox
}

736
custom/parser.go Normal file
View File

@@ -0,0 +1,736 @@
package custom
import (
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net"
"net/url"
"os"
"strconv"
"strings"
"gopkg.in/yaml.v3"
)
// ParsedNode 解析后的代理节点
type ParsedNode struct {
Name string // 节点名称
Type string // vmess/trojan/ss/vless/hysteria2/http/socks5 等
Server string // 远程服务器地址
Port int // 远程服务器端口
Raw map[string]interface{} // 原始配置字段(用于生成 sing-box 配置)
}
// NodeKey 节点去重 key
func (n *ParsedNode) NodeKey() string {
return fmt.Sprintf("%s:%s:%d", n.Type, n.Server, n.Port)
}
// IsDirect 是否可以直接作为代理使用(不需要 sing-box 转换)
func (n *ParsedNode) IsDirect() bool {
return n.Type == "http" || n.Type == "socks5"
}
// DirectAddress 返回直接代理的地址
func (n *ParsedNode) DirectAddress() string {
return net.JoinHostPort(n.Server, strconv.Itoa(n.Port))
}
// DirectProtocol 返回直接代理的协议名
func (n *ParsedNode) DirectProtocol() string {
if n.Type == "socks5" {
return "socks5"
}
return "http"
}
// Parse 解析订阅内容(全自动检测格式)
func Parse(data []byte, format string) ([]ParsedNode, error) {
// 无论用户选择什么格式,都走自动检测
return parseAutoDetect(data)
}
// parseAutoDetect 自动检测订阅格式并解析
func parseAutoDetect(data []byte) ([]ParsedNode, error) {
content := strings.TrimSpace(string(data))
log.Printf("[custom] 自动检测格式: 内容长度=%d", len(content))
// 1. 尝试 Clash YAML
if looksLikeYAML(content) {
log.Println("[custom] 检测到 Clash YAML 格式")
nodes, err := parseClash(data)
if err == nil && len(nodes) > 0 {
return nodes, nil
}
log.Printf("[custom] YAML 解析无有效节点,继续尝试其他格式...")
}
// 2. 直接包含协议链接vmess://、vless:// 等)
if looksLikeProxyLinks(content) {
log.Println("[custom] 检测到协议链接格式")
return parseProxyLinks(content)
}
// 3. 尝试 Base64 解码
decoded, err := tryBase64Decode(content)
if err != nil {
return nil, fmt.Errorf("无法识别订阅内容格式(非 YAML / 非协议链接 / 非 Base64")
}
decodedStr := strings.TrimSpace(string(decoded))
if decodedStr == "" {
return nil, fmt.Errorf("Base64 解码后内容为空")
}
log.Printf("[custom] Base64 解码成功: %d bytes", len(decoded))
// 解码后是 YAML
if looksLikeYAML(decodedStr) {
log.Println("[custom] Base64 解码后为 Clash YAML 格式")
return parseClash(decoded)
}
// 解码后是协议链接?
if looksLikeProxyLinks(decodedStr) {
log.Println("[custom] Base64 解码后为协议链接格式")
return parseProxyLinks(decodedStr)
}
// 解码后尝试纯文本
nodes, err := parsePlain(decoded)
if err == nil && len(nodes) > 0 {
return nodes, nil
}
return nil, fmt.Errorf("无法识别订阅内容格式")
}
func safePreview(s string, n int) string {
if len(s) > n {
return s[:n] + "..."
}
return s
}
// looksLikeProxyLinks 判断内容是否包含代理协议链接
func looksLikeProxyLinks(s string) bool {
return strings.Contains(s, "vmess://") ||
strings.Contains(s, "vless://") ||
strings.Contains(s, "trojan://") ||
strings.Contains(s, "ss://") ||
strings.Contains(s, "ssr://") ||
strings.Contains(s, "hysteria2://") ||
strings.Contains(s, "hy2://") ||
strings.Contains(s, "tuic://")
}
// clashConfig Clash YAML 配置结构(兼容新旧格式)
type clashConfig struct {
Proxies []map[string]interface{} `yaml:"proxies"`
ProxyOld []map[string]interface{} `yaml:"Proxy"` // 旧版 Clash 格式
}
// getProxies 兼容获取代理列表
func (c *clashConfig) getProxies() []map[string]interface{} {
if len(c.Proxies) > 0 {
return c.Proxies
}
return c.ProxyOld
}
// parseClash 解析 Clash YAML 格式
func parseClash(data []byte) ([]ParsedNode, error) {
content := strings.TrimSpace(string(data))
// 打印前 100 字符帮助调试
preview := content
if len(preview) > 100 {
preview = preview[:100]
}
log.Printf("[custom] 订阅内容预览: %s...", preview)
// 自动检测:如果内容不像 YAML不以常见 YAML 字段开头),尝试 base64 解码
if !looksLikeYAML(content) {
log.Println("[custom] 内容不像 YAML尝试 Base64 解码...")
decoded, err := tryBase64Decode(content)
if err == nil && looksLikeYAML(string(decoded)) {
log.Println("[custom] Base64 解码成功,使用解码后的 YAML")
data = decoded
} else {
log.Println("[custom] Base64 解码后仍不是 YAML按原始内容解析")
}
}
// 使用 yaml.Node 解析,精确提取 proxies 列表
var doc yaml.Node
if err := yaml.Unmarshal(data, &doc); err != nil {
return nil, fmt.Errorf("解析 Clash YAML 失败: %w", err)
}
var proxies []map[string]interface{}
proxies = extractProxiesFromNode(&doc)
if len(proxies) == 0 {
log.Printf("[custom] ⚠️ YAML 中未找到有效的代理节点(内容长度: %d 字节)", len(data))
} else {
log.Printf("[custom] 从 YAML 中提取到 %d 个代理节点", len(proxies))
}
var nodes []ParsedNode
for _, proxy := range proxies {
node, err := parseClashProxy(proxy)
if err != nil {
log.Printf("[custom] 跳过无效节点: %v", err)
continue
}
nodes = append(nodes, *node)
}
log.Printf("[custom] Clash YAML 解析完成,共 %d 个节点", len(nodes))
return nodes, nil
}
// extractProxiesFromNode 从 yaml.Node 树中提取 proxies 列表
func extractProxiesFromNode(doc *yaml.Node) []map[string]interface{} {
if doc == nil {
return nil
}
// doc 是 DocumentNode内容在 Content[0]MappingNode
var root *yaml.Node
if doc.Kind == yaml.DocumentNode && len(doc.Content) > 0 {
root = doc.Content[0]
} else if doc.Kind == yaml.MappingNode {
root = doc
} else {
return nil
}
// 在 MappingNode 中找 proxies 或 Proxy key
for i := 0; i < len(root.Content)-1; i += 2 {
keyNode := root.Content[i]
valNode := root.Content[i+1]
if keyNode.Value == "proxies" || keyNode.Value == "Proxy" {
log.Printf("[custom] 找到 %s 字段: kind=%d tag=%s 子节点数=%d",
keyNode.Value, valNode.Kind, valNode.Tag, len(valNode.Content))
// 把 proxies 段的原始 YAML 写到临时文件方便调试
debugData, _ := yaml.Marshal(valNode)
os.WriteFile("/tmp/goproxy_debug_proxies.yaml", debugData, 0644)
log.Printf("[custom] 调试: proxies 原始数据已写入 /tmp/goproxy_debug_proxies.yaml (%d bytes)", len(debugData))
if valNode.Kind != yaml.SequenceNode {
log.Printf("[custom] proxies 字段不是列表kind=%d tag=%s", valNode.Kind, valNode.Tag)
return nil
}
// 每个 item 是一个 MappingNode解码为 map
var proxies []map[string]interface{}
for idx, itemNode := range valNode.Content {
var m map[string]interface{}
if err := itemNode.Decode(&m); err != nil {
log.Printf("[custom] 解码代理节点 #%d 失败: %v (kind=%d tag=%s)", idx, err, itemNode.Kind, itemNode.Tag)
continue
}
proxies = append(proxies, m)
}
log.Printf("[custom] 成功解码 %d/%d 个代理节点", len(proxies), len(valNode.Content))
return proxies
}
}
log.Printf("[custom] 遍历 %d 个顶级 key 未找到 proxies", len(root.Content)/2)
return nil
}
// looksLikeYAML 判断内容是否看起来像 YAML/Clash 配置
func looksLikeYAML(s string) bool {
s = strings.TrimSpace(s)
// Clash YAML 通常包含 proxies: 或 port: 或以 # 注释开头
return strings.Contains(s, "proxies:") ||
strings.Contains(s, "proxy-groups:") ||
strings.HasPrefix(s, "port:") ||
strings.HasPrefix(s, "mixed-port:") ||
strings.HasPrefix(s, "#") ||
strings.HasPrefix(s, "---")
}
// tryBase64Decode 尝试多种 Base64 变体解码
func tryBase64Decode(s string) ([]byte, error) {
// 去掉所有空白字符(换行、回车、空格)再解码
s = strings.TrimSpace(s)
s = strings.ReplaceAll(s, "\r", "")
s = strings.ReplaceAll(s, "\n", "")
s = strings.ReplaceAll(s, " ", "")
// 标准 Base64
if decoded, err := base64.StdEncoding.DecodeString(s); err == nil {
return decoded, nil
}
// URL-safe Base64
if decoded, err := base64.URLEncoding.DecodeString(s); err == nil {
return decoded, nil
}
// 无填充 Base64
if decoded, err := base64.RawStdEncoding.DecodeString(s); err == nil {
return decoded, nil
}
// 无填充 URL-safe
if decoded, err := base64.RawURLEncoding.DecodeString(s); err == nil {
return decoded, nil
}
return nil, fmt.Errorf("Base64 解码失败")
}
// parseClashProxy 解析单个 Clash 代理节点
func parseClashProxy(proxy map[string]interface{}) (*ParsedNode, error) {
name, _ := proxy["name"].(string)
typ, _ := proxy["type"].(string)
server, _ := proxy["server"].(string)
if typ == "" || server == "" {
return nil, fmt.Errorf("缺少 type 或 server 字段")
}
port := 0
switch v := proxy["port"].(type) {
case int:
port = v
case float64:
port = int(v)
case string:
p, err := strconv.Atoi(v)
if err != nil {
return nil, fmt.Errorf("无效端口: %s", v)
}
port = p
default:
return nil, fmt.Errorf("缺少 port 字段")
}
// 标准化类型名
typ = strings.ToLower(typ)
switch typ {
case "ss":
typ = "shadowsocks"
case "ssr":
typ = "shadowsocksr"
}
// 支持的类型
supported := map[string]bool{
"vmess": true, "vless": true, "trojan": true,
"shadowsocks": true, "shadowsocksr": true,
"hysteria": true, "hysteria2": true, "tuic": true,
"anytls": true,
"http": true, "socks5": true,
}
if !supported[typ] {
return nil, fmt.Errorf("不支持的代理类型: %s", typ)
}
return &ParsedNode{
Name: name,
Type: typ,
Server: server,
Port: port,
Raw: proxy,
}, nil
}
// parseBase64 解析 Base64 编码的纯文本
func parseBase64(data []byte) ([]ParsedNode, error) {
// 尝试标准 Base64 解码
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(data)))
if err != nil {
// 尝试 URL-safe Base64
decoded, err = base64.URLEncoding.DecodeString(strings.TrimSpace(string(data)))
if err != nil {
// 尝试无填充的 Base64
decoded, err = base64.RawStdEncoding.DecodeString(strings.TrimSpace(string(data)))
if err != nil {
return nil, fmt.Errorf("Base64 解码失败: %w", err)
}
}
}
return parsePlain(decoded)
}
// parsePlain 解析纯文本格式(每行一个 IP:PORT
func parsePlain(data []byte) ([]ParsedNode, error) {
lines := strings.Split(string(data), "\n")
var nodes []ParsedNode
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
protocol := "http"
addr := line
// 解析协议前缀
if strings.HasPrefix(line, "socks5://") {
protocol = "socks5"
addr = strings.TrimPrefix(line, "socks5://")
} else if strings.HasPrefix(line, "socks4://") {
protocol = "socks5" // socks4 当 socks5 处理
addr = strings.TrimPrefix(line, "socks4://")
} else if strings.HasPrefix(line, "http://") {
protocol = "http"
addr = strings.TrimPrefix(line, "http://")
} else if strings.HasPrefix(line, "https://") {
protocol = "http"
addr = strings.TrimPrefix(line, "https://")
}
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
continue
}
port, err := strconv.Atoi(portStr)
if err != nil {
continue
}
nodes = append(nodes, ParsedNode{
Name: addr,
Type: protocol,
Server: host,
Port: port,
Raw: map[string]interface{}{"type": protocol, "server": host, "port": port},
})
}
log.Printf("[custom] 纯文本解析完成,共 %d 个节点", len(nodes))
return nodes, nil
}
// parseProxyLinks 解析协议链接格式vmess://, trojan://, ss://, vless:// 等)
func parseProxyLinks(content string) ([]ParsedNode, error) {
lines := strings.Split(content, "\n")
var nodes []ParsedNode
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
node, err := parseProxyLink(line)
if err != nil {
continue
}
nodes = append(nodes, *node)
}
log.Printf("[custom] 协议链接解析完成,共 %d 个节点", len(nodes))
return nodes, nil
}
// parseProxyLink 解析单个协议链接
func parseProxyLink(link string) (*ParsedNode, error) {
link = strings.TrimSpace(link)
switch {
case strings.HasPrefix(link, "vmess://"):
return parseVmessLink(link)
case strings.HasPrefix(link, "vless://"):
return parseStandardLink(link, "vless")
case strings.HasPrefix(link, "trojan://"):
return parseStandardLink(link, "trojan")
case strings.HasPrefix(link, "ss://"):
return parseShadowsocksLink(link)
case strings.HasPrefix(link, "hysteria2://"), strings.HasPrefix(link, "hy2://"):
return parseStandardLink(link, "hysteria2")
case strings.HasPrefix(link, "tuic://"):
return parseStandardLink(link, "tuic")
default:
return nil, fmt.Errorf("不支持的协议链接: %s", link[:min(20, len(link))])
}
}
// parseVmessLink 解析 vmess:// 链接V2rayN JSON base64 格式)
func parseVmessLink(link string) (*ParsedNode, error) {
encoded := strings.TrimPrefix(link, "vmess://")
decoded, err := tryBase64Decode(encoded)
if err != nil {
return nil, fmt.Errorf("vmess base64 解码失败: %w", err)
}
var info map[string]interface{}
if err := json.Unmarshal(decoded, &info); err != nil {
return nil, fmt.Errorf("vmess JSON 解析失败: %w", err)
}
server := fmt.Sprintf("%v", info["add"])
portStr := fmt.Sprintf("%v", info["port"])
port, _ := strconv.Atoi(portStr)
name := fmt.Sprintf("%v", info["ps"])
if name == "" || name == "<nil>" {
name = server
}
// 构建 Clash 兼容的 raw 配置
raw := map[string]interface{}{
"type": "vmess",
"name": name,
"server": server,
"port": port,
"uuid": fmt.Sprintf("%v", info["id"]),
"alterId": getInt(info, "aid"),
"cipher": getStrDefault(info, "scy", "auto"),
}
// TLS
if fmt.Sprintf("%v", info["tls"]) == "tls" {
raw["tls"] = true
if sni, ok := info["sni"]; ok {
raw["sni"] = sni
}
}
// 传输层
net := getStrDefault(info, "net", "tcp")
raw["network"] = net
if net == "ws" {
wsOpts := map[string]interface{}{}
if path := getStr(info, "path"); path != "" {
wsOpts["path"] = path
}
if host := getStr(info, "host"); host != "" {
wsOpts["headers"] = map[string]interface{}{"Host": host}
}
raw["ws-opts"] = wsOpts
} else if net == "grpc" {
grpcOpts := map[string]interface{}{}
if path := getStr(info, "path"); path != "" {
grpcOpts["grpc-service-name"] = path
}
raw["grpc-opts"] = grpcOpts
}
return &ParsedNode{
Name: name,
Type: "vmess",
Server: server,
Port: port,
Raw: raw,
}, nil
}
// parseStandardLink 解析标准 URI 格式链接vless://, trojan://, hysteria2://, tuic://
// 格式: protocol://userinfo@host:port?params#fragment
func parseStandardLink(link string, typ string) (*ParsedNode, error) {
// 去除协议前缀,统一处理
u, err := url.Parse(link)
if err != nil {
return nil, fmt.Errorf("链接解析失败: %w", err)
}
host := u.Hostname()
port, _ := strconv.Atoi(u.Port())
if port == 0 {
port = 443
}
name := u.Fragment
if name == "" {
name = host
}
raw := map[string]interface{}{
"type": typ,
"name": name,
"server": host,
"port": port,
}
// 用户信息password/uuid
if u.User != nil {
password := u.User.Username()
if typ == "trojan" || typ == "hysteria2" {
raw["password"] = password
} else if typ == "vless" || typ == "tuic" {
raw["uuid"] = password
if p, ok := u.User.Password(); ok {
raw["password"] = p // tuic 的 password
}
}
}
// 查询参数
params := u.Query()
// TLS
security := params.Get("security")
if security == "" {
security = params.Get("type") // 有些链接用 type 表示
}
if security != "none" && security != "" || typ == "trojan" || typ == "hysteria2" {
raw["tls"] = true
if sni := params.Get("sni"); sni != "" {
raw["sni"] = sni
}
if fp := params.Get("fp"); fp != "" {
raw["client-fingerprint"] = fp
}
if alpn := params.Get("alpn"); alpn != "" {
raw["alpn"] = strings.Split(alpn, ",")
}
if params.Get("allowInsecure") == "1" || params.Get("insecure") == "1" {
raw["skip-cert-verify"] = true
}
}
// Reality
if security == "reality" {
raw["tls"] = true
realityOpts := map[string]interface{}{}
if pbk := params.Get("pbk"); pbk != "" {
realityOpts["public-key"] = pbk
}
if sid := params.Get("sid"); sid != "" {
realityOpts["short-id"] = sid
}
raw["reality-opts"] = realityOpts
}
// 传输层
netType := params.Get("type")
if netType == "" {
netType = "tcp"
}
raw["network"] = netType
if netType == "ws" {
wsOpts := map[string]interface{}{}
if path := params.Get("path"); path != "" {
wsOpts["path"] = path
}
if host := params.Get("host"); host != "" {
wsOpts["headers"] = map[string]interface{}{"Host": host}
}
raw["ws-opts"] = wsOpts
} else if netType == "grpc" {
grpcOpts := map[string]interface{}{}
if sn := params.Get("serviceName"); sn != "" {
grpcOpts["grpc-service-name"] = sn
}
raw["grpc-opts"] = grpcOpts
}
// Hysteria2 特有
if typ == "hysteria2" {
if obfs := params.Get("obfs"); obfs != "" {
raw["obfs"] = obfs
raw["obfs-password"] = params.Get("obfs-password")
}
}
// VLESS flow
if typ == "vless" {
if flow := params.Get("flow"); flow != "" {
raw["flow"] = flow
}
}
return &ParsedNode{
Name: name,
Type: typ,
Server: host,
Port: port,
Raw: raw,
}, nil
}
// parseShadowsocksLink 解析 ss:// 链接
// 格式1: ss://base64(method:password)@host:port#name
// 格式2: ss://base64(method:password@host:port)#name
func parseShadowsocksLink(link string) (*ParsedNode, error) {
link = strings.TrimPrefix(link, "ss://")
// 分离 fragment (节点名)
name := ""
if idx := strings.Index(link, "#"); idx >= 0 {
name, _ = url.QueryUnescape(link[idx+1:])
link = link[:idx]
}
var server, method, password string
var port int
// 尝试格式1: base64(method:password)@host:port
if idx := strings.LastIndex(link, "@"); idx >= 0 {
userInfo := link[:idx]
hostPort := link[idx+1:]
// 解码 userInfo
decoded, err := tryBase64Decode(userInfo)
if err != nil {
// 可能未编码
decoded = []byte(userInfo)
}
parts := strings.SplitN(string(decoded), ":", 2)
if len(parts) == 2 {
method = parts[0]
password = parts[1]
}
// 分离 host:port去掉查询参数
if qIdx := strings.Index(hostPort, "?"); qIdx >= 0 {
hostPort = hostPort[:qIdx]
}
h, p, err := net.SplitHostPort(hostPort)
if err != nil {
return nil, fmt.Errorf("ss 地址解析失败: %w", err)
}
server = h
port, _ = strconv.Atoi(p)
} else {
// 格式2: 整个 base64 编码
if qIdx := strings.Index(link, "?"); qIdx >= 0 {
link = link[:qIdx]
}
decoded, err := tryBase64Decode(link)
if err != nil {
return nil, fmt.Errorf("ss base64 解码失败: %w", err)
}
// method:password@host:port
s := string(decoded)
atIdx := strings.LastIndex(s, "@")
if atIdx < 0 {
return nil, fmt.Errorf("ss 格式无效")
}
parts := strings.SplitN(s[:atIdx], ":", 2)
if len(parts) == 2 {
method = parts[0]
password = parts[1]
}
h, p, err := net.SplitHostPort(s[atIdx+1:])
if err != nil {
return nil, fmt.Errorf("ss 地址解析失败: %w", err)
}
server = h
port, _ = strconv.Atoi(p)
}
if name == "" {
name = server
}
raw := map[string]interface{}{
"type": "ss",
"name": name,
"server": server,
"port": port,
"cipher": method,
"password": password,
}
return &ParsedNode{
Name: name,
Type: "shadowsocks",
Server: server,
Port: port,
Raw: raw,
}, nil
}

530
custom/singbox.go Normal file
View File

@@ -0,0 +1,530 @@
package custom
import (
"encoding/json"
"fmt"
"log"
"net"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
// SingBoxProcess 管理 sing-box 子进程
type SingBoxProcess struct {
cmd *exec.Cmd
binPath string
configDir string
configFile string
basePort int
portMap map[string]int // nodeKey → 本地端口
nodes []ParsedNode
mu sync.Mutex
running bool
}
// NewSingBoxProcess 创建 sing-box 进程管理器
func NewSingBoxProcess(binPath, dataDir string, basePort int) *SingBoxProcess {
if dataDir == "" {
// 没设置 DATA_DIR 时,使用当前工作目录下的 singbox/
wd, _ := os.Getwd()
dataDir = wd
}
configDir, _ := filepath.Abs(filepath.Join(dataDir, "singbox"))
os.MkdirAll(configDir, 0755)
return &SingBoxProcess{
binPath: binPath,
configDir: configDir,
configFile: filepath.Join(configDir, "config.json"),
basePort: basePort,
portMap: make(map[string]int),
}
}
// Reload 重新加载节点配置并重启 sing-box
func (s *SingBoxProcess) Reload(nodes []ParsedNode) error {
s.mu.Lock()
defer s.mu.Unlock()
// 过滤出需要 sing-box 转换的节点
var tunnelNodes []ParsedNode
for _, n := range nodes {
if !n.IsDirect() {
tunnelNodes = append(tunnelNodes, n)
}
}
if len(tunnelNodes) == 0 {
log.Println("[custom] 无需 sing-box 转换的节点,停止进程")
s.stopLocked()
s.nodes = nil
s.portMap = make(map[string]int)
return nil
}
// 生成配置
if err := s.generateConfig(tunnelNodes); err != nil {
return fmt.Errorf("生成 sing-box 配置失败: %w", err)
}
// 重启进程
s.stopLocked()
if err := s.startLocked(); err != nil {
return fmt.Errorf("启动 sing-box 失败: %w", err)
}
s.nodes = tunnelNodes
return nil
}
// generateConfig 生成 sing-box JSON 配置
func (s *SingBoxProcess) generateConfig(nodes []ParsedNode) error {
s.portMap = make(map[string]int)
port := s.basePort
var inbounds []map[string]interface{}
var outbounds []map[string]interface{}
var rules []map[string]interface{}
for i, node := range nodes {
port++
key := node.NodeKey()
s.portMap[key] = port
tag := fmt.Sprintf("node-%d", i)
// 入站:本地 SOCKS5 监听
inbounds = append(inbounds, map[string]interface{}{
"type": "socks",
"tag": fmt.Sprintf("in-%s", tag),
"listen": "127.0.0.1",
"listen_port": port,
})
// 出站:根据节点类型生成
outbound := buildOutbound(node, tag)
if outbound == nil {
log.Printf("[custom] 跳过不支持的节点类型: %s (%s)", node.Name, node.Type)
delete(s.portMap, key)
continue
}
outbounds = append(outbounds, outbound)
// 路由规则:入站 → 出站
rules = append(rules, map[string]interface{}{
"inbound": []string{fmt.Sprintf("in-%s", tag)},
"outbound": fmt.Sprintf("out-%s", tag),
})
}
// 添加 direct 出站作为默认
outbounds = append(outbounds, map[string]interface{}{
"type": "direct",
"tag": "direct",
})
config := map[string]interface{}{
"log": map[string]interface{}{
"level": "warn",
},
"inbounds": inbounds,
"outbounds": outbounds,
"route": map[string]interface{}{
"rules": rules,
"final": "direct",
},
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.configFile, data, 0644)
}
// buildOutbound 根据节点类型构建 sing-box 出站配置
func buildOutbound(node ParsedNode, tag string) map[string]interface{} {
raw := node.Raw
out := map[string]interface{}{
"tag": fmt.Sprintf("out-%s", tag),
"server": node.Server,
}
// sing-box 使用 server_port 而不是 port
out["server_port"] = node.Port
switch node.Type {
case "vmess":
out["type"] = "vmess"
out["uuid"] = getStr(raw, "uuid")
out["alter_id"] = getInt(raw, "alterId")
out["security"] = getStrDefault(raw, "cipher", "auto")
applyTLS(raw, out)
applyTransport(raw, out)
case "vless":
out["type"] = "vless"
out["uuid"] = getStr(raw, "uuid")
out["flow"] = getStr(raw, "flow")
applyTLS(raw, out)
applyTransport(raw, out)
case "trojan":
out["type"] = "trojan"
out["password"] = getStr(raw, "password")
applyTLS(raw, out)
applyTransport(raw, out)
case "shadowsocks":
out["type"] = "shadowsocks"
out["method"] = getStr(raw, "cipher")
out["password"] = getStr(raw, "password")
if plugin := getStr(raw, "plugin"); plugin != "" {
out["plugin"] = plugin
if pluginOpts, ok := raw["plugin-opts"].(map[string]interface{}); ok {
out["plugin_opts"] = convertPluginOpts(plugin, pluginOpts)
}
}
case "hysteria2":
out["type"] = "hysteria2"
out["password"] = getStr(raw, "password")
applyTLS(raw, out)
case "hysteria":
out["type"] = "hysteria"
out["auth_str"] = getStr(raw, "auth-str")
if up := getStr(raw, "up"); up != "" {
out["up_mbps"] = parseSpeed(up)
}
if down := getStr(raw, "down"); down != "" {
out["down_mbps"] = parseSpeed(down)
}
applyTLS(raw, out)
case "tuic":
out["type"] = "tuic"
out["uuid"] = getStr(raw, "uuid")
out["password"] = getStr(raw, "password")
out["congestion_control"] = getStrDefault(raw, "congestion-controller", "bbr")
applyTLS(raw, out)
case "anytls":
out["type"] = "anytls"
out["password"] = getStr(raw, "password")
// anytls 强制启用 TLS
forceTLS(raw, out)
default:
return nil
}
return out
}
// forceTLS 强制应用 TLS 配置(用于 anytls 等必须 TLS 的协议)
func forceTLS(raw map[string]interface{}, out map[string]interface{}) {
raw["tls"] = true
applyTLS(raw, out)
}
// applyTLS 应用 TLS 配置
func applyTLS(raw map[string]interface{}, out map[string]interface{}) {
tls := getBool(raw, "tls")
// 如果有 sni/alpn/client-fingerprint 也视为需要 TLS
if !tls && getStr(raw, "sni") == "" && getStr(raw, "client-fingerprint") == "" {
return
}
tlsConfig := map[string]interface{}{
"enabled": true,
}
if sni := getStr(raw, "sni"); sni != "" {
tlsConfig["server_name"] = sni
} else if servername := getStr(raw, "servername"); servername != "" {
tlsConfig["server_name"] = servername
}
if getBool(raw, "skip-cert-verify") {
tlsConfig["insecure"] = true
}
if alpn, ok := raw["alpn"].([]interface{}); ok {
var alpnStrs []string
for _, a := range alpn {
if s, ok := a.(string); ok {
alpnStrs = append(alpnStrs, s)
}
}
if len(alpnStrs) > 0 {
tlsConfig["alpn"] = alpnStrs
}
}
if fp := getStr(raw, "client-fingerprint"); fp != "" {
tlsConfig["utls"] = map[string]interface{}{
"enabled": true,
"fingerprint": fp,
}
}
// reality 配置
if realityOpts, ok := raw["reality-opts"].(map[string]interface{}); ok {
tlsConfig["reality"] = map[string]interface{}{
"enabled": true,
"public_key": getStr(realityOpts, "public-key"),
"short_id": getStr(realityOpts, "short-id"),
}
}
out["tls"] = tlsConfig
}
// applyTransport 应用传输层配置
func applyTransport(raw map[string]interface{}, out map[string]interface{}) {
network := getStrDefault(raw, "network", "tcp")
switch network {
case "ws":
transport := map[string]interface{}{
"type": "ws",
}
if wsOpts, ok := raw["ws-opts"].(map[string]interface{}); ok {
if path := getStr(wsOpts, "path"); path != "" {
transport["path"] = path
}
if headers, ok := wsOpts["headers"].(map[string]interface{}); ok {
transport["headers"] = headers
}
}
out["transport"] = transport
case "grpc":
transport := map[string]interface{}{
"type": "grpc",
}
if grpcOpts, ok := raw["grpc-opts"].(map[string]interface{}); ok {
if sn := getStr(grpcOpts, "grpc-service-name"); sn != "" {
transport["service_name"] = sn
}
}
out["transport"] = transport
case "h2":
transport := map[string]interface{}{
"type": "http",
}
if h2Opts, ok := raw["h2-opts"].(map[string]interface{}); ok {
if path := getStr(h2Opts, "path"); path != "" {
transport["path"] = path
}
if host, ok := h2Opts["host"].([]interface{}); ok && len(host) > 0 {
if h, ok := host[0].(string); ok {
transport["host"] = []string{h}
}
}
}
out["transport"] = transport
case "httpupgrade":
transport := map[string]interface{}{
"type": "httpupgrade",
}
if wsOpts, ok := raw["ws-opts"].(map[string]interface{}); ok {
if path := getStr(wsOpts, "path"); path != "" {
transport["path"] = path
}
if headers, ok := wsOpts["headers"].(map[string]interface{}); ok {
if host, ok := headers["Host"].(string); ok {
transport["host"] = host
}
}
}
out["transport"] = transport
}
}
// convertPluginOpts 转换 shadowsocks 插件选项
func convertPluginOpts(plugin string, opts map[string]interface{}) string {
var parts []string
for k, v := range opts {
parts = append(parts, fmt.Sprintf("%s=%v", k, v))
}
return strings.Join(parts, ";")
}
// startLocked 启动 sing-box需持有锁
func (s *SingBoxProcess) startLocked() error {
if _, err := exec.LookPath(s.binPath); err != nil {
return fmt.Errorf("sing-box 未找到: %s请安装 sing-box 或设置 SINGBOX_PATH", s.binPath)
}
s.cmd = exec.Command(s.binPath, "run", "-c", s.configFile, "-D", s.configDir)
s.cmd.Stdout = os.Stdout
s.cmd.Stderr = os.Stderr
if err := s.cmd.Start(); err != nil {
return err
}
s.running = true
// 监控进程退出
go func() {
if s.cmd != nil && s.cmd.Process != nil {
s.cmd.Wait()
s.mu.Lock()
s.running = false
s.mu.Unlock()
}
}()
// 等待端口就绪(最多 10 秒)
log.Printf("[custom] sing-box 启动中,等待端口就绪...")
ready := false
for i := 0; i < 20; i++ {
time.Sleep(500 * time.Millisecond)
// 检查第一个端口是否可连
for _, port := range s.portMap {
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Second)
if err == nil {
conn.Close()
ready = true
break
}
}
if ready {
break
}
}
if !ready {
log.Println("[custom] ⚠️ sing-box 端口未就绪,部分节点可能不可用")
} else {
log.Printf("[custom] ✅ sing-box 启动成功,管理 %d 个节点", len(s.portMap))
}
return nil
}
// stopLocked 停止 sing-box需持有锁
func (s *SingBoxProcess) stopLocked() {
if s.cmd != nil && s.cmd.Process != nil && s.running {
log.Println("[custom] 停止 sing-box 进程...")
s.cmd.Process.Signal(os.Interrupt)
done := make(chan struct{})
go func() {
s.cmd.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
s.cmd.Process.Kill()
}
s.running = false
}
}
// Stop 停止 sing-box
func (s *SingBoxProcess) Stop() {
s.mu.Lock()
defer s.mu.Unlock()
s.stopLocked()
}
// IsRunning 检查进程是否运行中
func (s *SingBoxProcess) IsRunning() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.running
}
// GetLocalAddress 获取节点的本地 SOCKS5 地址
func (s *SingBoxProcess) GetLocalAddress(nodeKey string) string {
s.mu.Lock()
defer s.mu.Unlock()
if port, ok := s.portMap[nodeKey]; ok {
return net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
}
return ""
}
// GetPortMap 获取所有端口映射
func (s *SingBoxProcess) GetPortMap() map[string]int {
s.mu.Lock()
defer s.mu.Unlock()
result := make(map[string]int, len(s.portMap))
for k, v := range s.portMap {
result[k] = v
}
return result
}
// GetNodeCount 获取管理的节点数
func (s *SingBoxProcess) GetNodeCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.portMap)
}
// 辅助函数
func getStr(m map[string]interface{}, key string) string {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
func getStrDefault(m map[string]interface{}, key, def string) string {
if s := getStr(m, key); s != "" {
return s
}
return def
}
func getInt(m map[string]interface{}, key string) int {
if v, ok := m[key]; ok {
switch val := v.(type) {
case int:
return val
case float64:
return int(val)
case string:
n, _ := strconv.Atoi(val)
return n
}
}
return 0
}
func getBool(m map[string]interface{}, key string) bool {
if v, ok := m[key]; ok {
switch val := v.(type) {
case bool:
return val
case string:
return val == "true"
}
}
return false
}
func parseSpeed(s string) int {
s = strings.TrimSpace(s)
s = strings.TrimSuffix(s, " Mbps")
s = strings.TrimSuffix(s, "Mbps")
n, _ := strconv.Atoi(s)
if n == 0 {
n = 100 // 默认 100 Mbps
}
return n
}