mirror of
https://github.com/isboyjc/GoProxy.git
synced 2026-05-12 02:19:44 +08:00
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:
570
custom/manager.go
Normal file
570
custom/manager.go
Normal 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
736
custom/parser.go
Normal 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
530
custom/singbox.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user