Files
Rogee 7fcabe0225
Some checks failed
CI/CD Pipeline / Test (push) Failing after 22m19s
CI/CD Pipeline / Security Scan (push) Failing after 5m57s
CI/CD Pipeline / Build (amd64, darwin) (push) Has been skipped
CI/CD Pipeline / Build (amd64, linux) (push) Has been skipped
CI/CD Pipeline / Build (amd64, windows) (push) Has been skipped
CI/CD Pipeline / Build (arm64, darwin) (push) Has been skipped
CI/CD Pipeline / Build (arm64, linux) (push) Has been skipped
CI/CD Pipeline / Build Docker Image (push) Has been skipped
CI/CD Pipeline / Create Release (push) Has been skipped
first commit
2025-09-28 10:05:07 +08:00

1742 lines
46 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package conversion
import (
"bytes"
"compress/gzip"
"compress/zlib"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
yaml "gopkg.in/yaml.v3"
"github.com/subconverter-go/internal/generator"
"github.com/subconverter-go/internal/logging"
"github.com/subconverter-go/internal/parser"
)
// EngineConversionRequest 引擎内部使用的转换请求
// 基于基础ConversionRequest添加引擎需要的额外字段
type EngineConversionRequest struct {
*ConversionRequest // 嵌入基础请求结构
// 引擎特定字段
InputText string `json:"inputText"` // 输入文本内容
Options *generator.GenerationOptions `json:"options"` // 生成选项
SkipInvalid bool `json:"skipInvalid"` // 是否跳过无效配置
}
// EngineConversionResponse 引擎内部使用的转换响应
// 基于基础ConversionResponse添加引擎需要的额外字段
type EngineConversionResponse struct {
*ConversionResponse // 嵌入基础响应结构
// 引擎特定字段
Output string `json:"output"` // 转换后的配置内容
ProxyCount int `json:"proxyCount"` // 成功转换的代理数量
Errors []string `json:"errors"` // 详细错误列表
DebugInfo *DebugInfo `json:"debugInfo"` // 调试信息
}
// DebugInfo 调试信息结构体
// 包含转换过程的调试数据
type DebugInfo struct {
ParseTime int64 `json:"parseTime"` // 解析耗时(ms)
GenerateTime int64 `json:"generateTime"` // 生成耗时(ms)
TotalTime int64 `json:"totalTime"` // 总耗时(ms)
InputSize int `json:"inputSize"` // 输入大小(bytes)
OutputSize int `json:"outputSize"` // 输出大小(bytes)
// 解析统计
ParseStats *ParseStats `json:"parseStats"` // 解析统计信息
// 生成统计
GenerateStats *GenerateStats `json:"generateStats"` // 生成统计信息
}
// ParseStats 解析统计信息
type ParseStats struct {
TotalInput int `json:"totalInput"` // 输入行数
ValidInput int `json:"validInput"` // 有效输入数
InvalidInput int `json:"invalidInput"` // 无效输入数
Protocols map[string]int `json:"protocols"` // 协议分布
}
// GenerateStats 生成统计信息
type GenerateStats struct {
GeneratedProxies int `json:"generatedProxies"` // 生成的代理数
GeneratedGroups int `json:"generatedGroups"` // 生成的代理组数
GeneratedRules int `json:"generatedRules"` // 生成的规则数
FormatFeatures map[string]bool `json:"formatFeatures"` // 格式特性
}
// ConversionEngine 转换引擎
// 核心转换逻辑的协调器和执行器
type ConversionEngine struct {
logger *logging.Logger
parserMgr *parser.ParserManager
generatorMgr *generator.GeneratorManager
geoResolver GeoIPResolver
geoipCache sync.Map
httpClient *http.Client
fetchCache sync.Map
}
type fetchCacheEntry struct {
data string
expires time.Time
}
const (
defaultFetchTimeout = 15 * time.Second
fetchCacheTTL = 5 * time.Minute
maxFetchBodySize int64 = 5 * 1024 * 1024 // 5MB safeguard
)
// NewConversionEngine 创建新的转换引擎
// 返回初始化好的ConversionEngine实例
func NewConversionEngine(
logger *logging.Logger,
parserMgr *parser.ParserManager,
generatorMgr *generator.GeneratorManager,
) *ConversionEngine {
return &ConversionEngine{
logger: logger,
parserMgr: parserMgr,
generatorMgr: generatorMgr,
geoResolver: newDefaultGeoIPResolver(logger),
httpClient: &http.Client{
Timeout: defaultFetchTimeout,
},
}
}
// SetHTTPClient allows tests or callers to provide a custom HTTP client.
func (ce *ConversionEngine) SetHTTPClient(client *http.Client) {
if client == nil {
ce.httpClient = &http.Client{Timeout: defaultFetchTimeout}
return
}
ce.httpClient = client
}
// Convert 执行转换操作
// 支持从URL或文本内容转换到目标格式
func (ce *ConversionEngine) Convert(request *ConversionRequest) (*ConversionResponse, error) {
ce.logger.WithField("requestId", "engine-"+request.Target).
WithField("targetFormat", request.Target).
WithField("sourceUrl", request.SourceSummary()).
Info("Starting conversion process")
// 验证请求参数
if err := ce.validateRequest(request); err != nil {
return ce.createErrorResponse(request, fmt.Sprintf("Invalid request: %v", err)), nil
}
// 记录开始时间
startTime := time.Now()
// 解析输入配置
configs, parseResult, err := ce.parseInput(request)
if err != nil {
return ce.createErrorResponse(request, fmt.Sprintf("Failed to parse input: %v", err)), nil
}
// 如果没有有效的配置,返回错误
if len(configs) == 0 {
return ce.createErrorResponse(request, "No valid proxy configurations found"), nil
}
filteredConfigs := ce.applyFilters(configs, request)
if len(filteredConfigs) == 0 {
return ce.createErrorResponse(request, "No proxy configurations matched requested filters"), nil
}
transformedConfigs, err := ce.applyTransformations(filteredConfigs, request)
if err != nil {
return ce.createErrorResponse(request, fmt.Sprintf("Invalid request: %v", err)), nil
}
finalConfigs, err := ce.applyFilterScript(transformedConfigs, request)
if err != nil {
return ce.createErrorResponse(request, fmt.Sprintf("Invalid request: %v", err)), nil
}
if len(finalConfigs) == 0 {
return ce.createErrorResponse(request, "No proxy configurations remained after filtering"), nil
}
// 生成目标格式配置
output, generateResult, err := ce.generateOutput(request, finalConfigs)
if err == nil {
if uploadErr := ce.handleUpload(request, output); uploadErr != nil {
return ce.createErrorResponse(request, fmt.Sprintf("Failed to upload content: %v", uploadErr)), nil
}
output = ce.applyManagedConfig(request, output)
}
if err != nil {
return ce.createErrorResponse(request, fmt.Sprintf("Failed to generate output: %v", err)), nil
}
// 记录结束时间
endTime := time.Now()
// 创建成功响应
response := ce.createSuccessResponse(request, output, finalConfigs, parseResult, generateResult)
// 设置调试信息
response.DebugInfo = ce.createDebugInfo(request, startTime, endTime, parseResult, generateResult)
ce.logger.WithField("requestId", "engine-"+request.Target).
WithField("proxyCount", len(configs)).
WithField("duration", endTime.Sub(startTime).Milliseconds()).
Info("Conversion completed successfully")
return response, nil
}
// validateRequest 验证转换请求
func (ce *ConversionEngine) validateRequest(request *ConversionRequest) error {
if request == nil {
return fmt.Errorf("request cannot be nil")
}
// 验证输入源
sources := request.GetSources()
if len(sources) == 0 {
return fmt.Errorf("source URL cannot be empty")
}
// 验证目标格式 - 使用现有的Target字段
if request.Target == "" {
return fmt.Errorf("target format cannot be empty")
}
// 检查目标格式是否支持
supportedFormats := ce.generatorMgr.GetSupportedFormats()
supported := false
for _, format := range supportedFormats {
if format == request.Target {
supported = true
break
}
}
if !supported {
return fmt.Errorf("unsupported target format: %s", request.Target)
}
return nil
}
// parseInput 解析输入配置
func (ce *ConversionEngine) parseInput(request *ConversionRequest) ([]*parser.ProxyConfig, *ParseResult, error) {
var inputText string
// 获取输入文本
sources := request.GetSources()
if len(sources) > 0 {
var builder strings.Builder
for idx, src := range sources {
text, fetchErr := ce.fetchFromURL(src, ce.selectUserAgent(request))
if fetchErr != nil {
return nil, nil, fmt.Errorf("failed to fetch from URL %s: %v", src, fetchErr)
}
normalized := ce.normalizeSubscriptionContent(text)
if normalized == "" {
continue
}
builder.WriteString(normalized)
if !strings.HasSuffix(normalized, "\n") {
builder.WriteByte('\n')
}
if idx < len(sources)-1 {
builder.WriteByte('\n')
}
}
inputText = builder.String()
} else {
// 如果没有URL使用默认的测试配置简化实现
inputText = "ss://aes-256-cfb:password@192.168.1.1:8388"
}
// 解析配置 - 跳过无效配置
return ce.parseConfigurations(inputText, true)
}
// fetchFromURL loads subscription or helper content either from remote HTTP(S)
// endpoints or local files. Results are cached briefly to avoid redundant
// network calls when the same resource is requested multiple times within a
// short window (mirroring the behaviour of the original service).
func (ce *ConversionEngine) fetchFromURL(source string, userAgent string) (string, error) {
if strings.TrimSpace(source) == "" {
return "", fmt.Errorf("empty source URL")
}
cacheKey := ce.buildFetchCacheKey(source, userAgent)
if cached, ok := ce.loadFetchCache(cacheKey); ok {
return cached, nil
}
parsed, err := url.Parse(source)
if err != nil {
return "", fmt.Errorf("invalid URL: %w", err)
}
var (
data []byte
readErr error
)
switch strings.ToLower(parsed.Scheme) {
case "http", "https":
data, readErr = ce.fetchHTTPResource(source, userAgent)
case "file":
resolved := parsed.Path
if resolved == "" {
resolved = parsed.Host
}
data, readErr = os.ReadFile(resolved)
default:
if parsed.Scheme != "" {
readErr = fmt.Errorf("unsupported URL scheme: %s", parsed.Scheme)
} else {
resolved := source
if !filepath.IsAbs(resolved) && parsed.Path != "" {
resolved = parsed.Path
}
data, readErr = os.ReadFile(resolved)
}
}
if readErr != nil {
return "", readErr
}
content := string(data)
ce.storeFetchCache(cacheKey, content)
return content, nil
}
func (ce *ConversionEngine) buildFetchCacheKey(source, userAgent string) string {
key := strings.TrimSpace(source)
ua := strings.TrimSpace(userAgent)
if ua == "" {
return key
}
return key + "::ua=" + ua
}
func (ce *ConversionEngine) selectUserAgent(request *ConversionRequest) string {
if request != nil {
if ua := strings.TrimSpace(request.UserAgent); ua != "" {
return ua
}
switch strings.ToLower(strings.TrimSpace(request.Target)) {
case "clash", "clashr":
return "ClashForWindows/0.20.0"
case "surge":
return "Surge/1109"
case "quanx", "quantumult-x":
return "Quantumult%20X/1.2.0"
case "loon":
return "Loon/2.1.2"
case "surfboard":
return "Surfboard/1.6.0"
case "v2ray":
return "v2rayN/6.30"
}
}
return "subconverter-go/1.0"
}
func (ce *ConversionEngine) normalizeSubscriptionContent(raw string) string {
return ce.normalizeSubscriptionContentWithDepth(raw, 0)
}
func (ce *ConversionEngine) normalizeSubscriptionContentWithDepth(raw string, depth int) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return raw
}
if depth > 4 {
return trimmed
}
if ce.looksLikeClashYAML(trimmed) {
if converted, ok := ce.convertClashYAML(trimmed); ok {
return converted
}
}
if ce.containsProxyIndicators(trimmed) {
return trimmed
}
decoded := ce.decodeBase64Payload(trimmed)
if decoded != "" && decoded != raw {
return ce.normalizeSubscriptionContentWithDepth(decoded, depth+1)
}
return trimmed
}
func (ce *ConversionEngine) containsProxyIndicators(content string) bool {
lower := strings.ToLower(content)
if strings.Contains(lower, "vmess://") || strings.Contains(lower, "ss://") || strings.Contains(lower, "trojan://") || strings.Contains(lower, "vless://") || strings.Contains(lower, "hysteria://") || strings.Contains(lower, "ssr://") {
return true
}
if strings.Contains(lower, "proxies:") && strings.Contains(lower, "proxy-groups:") {
return true
}
return false
}
func (ce *ConversionEngine) decodeBase64Payload(raw string) string {
clean := make([]rune, 0, len(raw))
for _, r := range raw {
if r == '\n' || r == '\r' || r == '\t' || r == ' ' {
continue
}
clean = append(clean, r)
}
if len(clean) == 0 {
return ""
}
data, ok := ce.tryDecodeBase64(string(clean))
if !ok || len(data) == 0 {
return ""
}
plain, err := ce.maybeDecompress(data)
if err == nil {
data = plain
}
if !isLikelyText(data) {
return ""
}
return string(data)
}
func (ce *ConversionEngine) tryDecodeBase64(clean string) ([]byte, bool) {
paddingNeeded := len(clean) % 4
if paddingNeeded != 0 {
clean += strings.Repeat("=", 4-paddingNeeded)
}
for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.URLEncoding} {
decoded, err := enc.DecodeString(clean)
if err == nil && len(decoded) > 0 {
return decoded, true
}
}
return nil, false
}
func (ce *ConversionEngine) maybeDecompress(data []byte) ([]byte, error) {
if len(data) < 2 {
return data, nil
}
if bytes.HasPrefix(data, []byte{0x1f, 0x8b}) {
gr, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return nil, err
}
defer gr.Close()
return io.ReadAll(gr)
}
if data[0] == 0x78 && (data[1] == 0x01 || data[1] == 0x9c || data[1] == 0xda) {
zr, err := zlib.NewReader(bytes.NewReader(data))
if err != nil {
return nil, err
}
defer zr.Close()
return io.ReadAll(zr)
}
return data, nil
}
func isLikelyText(data []byte) bool {
if len(data) == 0 {
return false
}
var printable int
for _, b := range data {
switch {
case b == 0x0a || b == 0x0d || b == 0x09:
printable++
case b >= 0x20 && b <= 0x7e:
printable++
case b >= 0x80:
printable++
default:
return false
}
}
return float64(printable)/float64(len(data)) > 0.7
}
func (ce *ConversionEngine) looksLikeClashYAML(content string) bool {
lower := strings.ToLower(content)
return strings.Contains(lower, "proxies:") && (strings.Contains(lower, "proxy-groups:") || strings.Contains(lower, "proxy-groups:") || strings.Contains(lower, "rules:"))
}
func (ce *ConversionEngine) convertClashYAML(content string) (string, bool) {
var doc struct {
Proxies []map[string]interface{} `yaml:"proxies"`
}
if err := yaml.Unmarshal([]byte(content), &doc); err != nil {
return "", false
}
if len(doc.Proxies) == 0 {
return "", false
}
builder := strings.Builder{}
for _, proxy := range doc.Proxies {
share, ok := ce.shareURIFromClashProxy(proxy)
if !ok || strings.TrimSpace(share) == "" {
continue
}
builder.WriteString(share)
builder.WriteByte('\n')
}
result := strings.TrimSpace(builder.String())
if result == "" {
return "", false
}
return result, true
}
func (ce *ConversionEngine) shareURIFromClashProxy(proxy map[string]interface{}) (string, bool) {
typeVal := strings.ToLower(getString(proxy, "type"))
name := getString(proxy, "name")
if name == "" {
name = getString(proxy, "ps")
}
server := getString(proxy, "server")
port, ok := getInt(proxy, "port")
if !ok {
return "", false
}
switch typeVal {
case "ss", "shadowsocks":
return buildShadowsocksURI(proxy, name, server, port)
case "vmess":
return buildVmessURI(proxy, name, server, port)
case "trojan":
return buildTrojanURI(proxy, name, server, port)
case "socks5", "socks":
return buildSocksURI(proxy, name, server, port)
case "http", "https":
return buildHTTPURI(proxy, name, server, port)
default:
return "", false
}
}
func buildShadowsocksURI(proxy map[string]interface{}, name, server string, port int) (string, bool) {
cipher := getString(proxy, "cipher")
if cipher == "" {
cipher = getString(proxy, "method")
}
password := getString(proxy, "password")
if cipher == "" || password == "" || server == "" {
return "", false
}
userInfo := fmt.Sprintf("%s:%s", cipher, password)
encoded := base64.StdEncoding.EncodeToString([]byte(userInfo))
encoded = strings.TrimRight(encoded, "=")
uri := fmt.Sprintf("ss://%s@%s:%d", encoded, server, port)
plugin := getString(proxy, "plugin")
if plugin != "" {
params := url.Values{}
pluginOpts := fetchPluginOptions(proxy)
params.Set("plugin", pluginOpts)
uri += "?" + params.Encode()
}
if name != "" {
uri += "#" + url.QueryEscape(name)
}
return uri, true
}
func fetchPluginOptions(proxy map[string]interface{}) string {
plugin := getString(proxy, "plugin")
if plugin == "" {
return ""
}
pluginOpts := proxy["plugin-opts"]
switch v := pluginOpts.(type) {
case string:
if strings.TrimSpace(v) == "" {
return plugin
}
return fmt.Sprintf("%s;%s", plugin, v)
case map[string]interface{}:
parts := make([]string, 0, len(v)+1)
parts = append(parts, plugin)
for key, value := range v {
parts = append(parts, fmt.Sprintf("%s=%v", key, value))
}
return strings.Join(parts, ";")
default:
return plugin
}
}
func buildVmessURI(proxy map[string]interface{}, name, server string, port int) (string, bool) {
uuid := getString(proxy, "uuid")
if uuid == "" {
uuid = getString(proxy, "id")
}
if uuid == "" || server == "" {
return "", false
}
alterID, _ := getInt(proxy, "alterId")
if alterID == 0 {
alterID, _ = getInt(proxy, "alterid")
}
scy := getString(proxy, "cipher")
if scy == "" {
scy = getString(proxy, "security")
}
if scy == "" {
scy = "auto"
}
network := strings.ToLower(getString(proxy, "network"))
if network == "" {
network = "tcp"
}
path := getString(proxy, "path")
if path == "" {
if wsOpts, ok := proxy["ws-opts"].(map[string]interface{}); ok {
path = getString(wsOpts, "path")
}
}
host := getString(proxy, "host")
if host == "" {
if headers, ok := proxy["ws-opts"].(map[string]interface{}); ok {
if hMap, ok := headers["headers"].(map[string]interface{}); ok {
host = getString(hMap, "Host")
}
}
}
tlsField := proxy["tls"]
tls := ""
switch v := tlsField.(type) {
case bool:
if v {
tls = "tls"
}
case string:
if strings.EqualFold(v, "tls") || strings.EqualFold(v, "true") {
tls = "tls"
}
}
sni := getString(proxy, "sni")
if sni == "" {
sni = getString(proxy, "servername")
}
var alpn []string
if rawAlpn, ok := proxy["alpn"].([]interface{}); ok {
for _, item := range rawAlpn {
if s, ok := item.(string); ok && s != "" {
alpn = append(alpn, s)
}
}
}
settings := map[string]interface{}{
"v": "2",
"ps": name,
"add": server,
"port": fmt.Sprintf("%d", port),
"id": uuid,
"aid": fmt.Sprintf("%d", alterID),
"scy": scy,
"net": network,
"type": "none",
"host": host,
"path": path,
"tls": tls,
}
if sni != "" {
settings["sni"] = sni
}
if len(alpn) > 0 {
settings["alpn"] = strings.Join(alpn, ",")
}
if fp := getString(proxy, "client-fingerprint"); fp != "" {
settings["fp"] = fp
}
jsonBytes, err := json.Marshal(settings)
if err != nil {
return "", false
}
encoded := base64.StdEncoding.EncodeToString(jsonBytes)
return fmt.Sprintf("vmess://%s", encoded), true
}
func buildTrojanURI(proxy map[string]interface{}, name, server string, port int) (string, bool) {
password := getString(proxy, "password")
if password == "" || server == "" {
return "", false
}
params := url.Values{}
sni := getString(proxy, "sni")
if sni == "" {
sni = getString(proxy, "servername")
}
if sni != "" {
params.Set("sni", sni)
}
alpn := proxy["alpn"]
switch v := alpn.(type) {
case string:
if v != "" {
params.Set("alpn", v)
}
case []interface{}:
slices := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok && s != "" {
slices = append(slices, s)
}
}
if len(slices) > 0 {
params.Set("alpn", strings.Join(slices, ","))
}
}
if network := getString(proxy, "network"); network != "" {
params.Set("type", network)
}
uri := fmt.Sprintf("trojan://%s@%s:%d", password, server, port)
if len(params) > 0 {
uri += "?" + params.Encode()
}
if name != "" {
uri += "#" + url.QueryEscape(name)
}
return uri, true
}
func buildSocksURI(proxy map[string]interface{}, name, server string, port int) (string, bool) {
if server == "" {
return "", false
}
user := getString(proxy, "username")
if user == "" {
user = getString(proxy, "user")
}
pass := getString(proxy, "password")
var auth string
if user != "" || pass != "" {
auth = url.UserPassword(user, pass).String() + "@"
}
uri := fmt.Sprintf("socks5://%s%s:%d", auth, server, port)
if name != "" {
uri += "#" + url.QueryEscape(name)
}
return uri, true
}
func buildHTTPURI(proxy map[string]interface{}, name, server string, port int) (string, bool) {
if server == "" {
return "", false
}
user := getString(proxy, "username")
if user == "" {
user = getString(proxy, "user")
}
pass := getString(proxy, "password")
var auth string
if user != "" || pass != "" {
auth = url.UserPassword(user, pass).String() + "@"
}
scheme := "http"
if tlsEnabled(proxy) {
scheme = "https"
}
uri := fmt.Sprintf("%s://%s%s:%d", scheme, auth, server, port)
if name != "" {
uri += "#" + url.QueryEscape(name)
}
return uri, true
}
func tlsEnabled(proxy map[string]interface{}) bool {
if proxy == nil {
return false
}
if v, ok := proxy["tls"].(bool); ok {
return v
}
if s := strings.ToLower(getString(proxy, "tls")); s == "tls" || s == "true" {
return true
}
return false
}
func getString(m map[string]interface{}, key string) string {
if m == nil {
return ""
}
if val, ok := m[key]; ok {
switch v := val.(type) {
case string:
return v
case fmt.Stringer:
return v.String()
case []byte:
return string(v)
case int, int64, float64:
return fmt.Sprintf("%v", v)
}
}
return ""
}
func getInt(m map[string]interface{}, key string) (int, bool) {
if m == nil {
return 0, false
}
if val, ok := m[key]; ok {
switch v := val.(type) {
case int:
return v, true
case int64:
return int(v), true
case float64:
return int(v), true
case string:
if v == "" {
return 0, false
}
i, err := strconv.Atoi(v)
if err != nil {
return 0, false
}
return i, true
}
}
return 0, false
}
func (ce *ConversionEngine) fetchHTTPResource(source string, userAgent string) ([]byte, error) {
client := ce.httpClient
if client == nil {
client = &http.Client{Timeout: defaultFetchTimeout}
}
req, err := http.NewRequest(http.MethodGet, source, nil)
if err != nil {
return nil, err
}
ua := strings.TrimSpace(userAgent)
if ua == "" {
ua = "subconverter-go/1.0"
}
req.Header.Set("User-Agent", ua)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest {
return nil, fmt.Errorf("fetch failed with status %d", resp.StatusCode)
}
reader := io.LimitReader(resp.Body, maxFetchBodySize+1)
data, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
if int64(len(data)) > maxFetchBodySize {
return nil, errors.New("remote content exceeds allowed size")
}
return data, nil
}
func (ce *ConversionEngine) loadFetchCache(key string) (string, bool) {
if value, ok := ce.fetchCache.Load(key); ok {
if entry, ok := value.(fetchCacheEntry); ok {
if time.Now().Before(entry.expires) {
return entry.data, true
}
ce.fetchCache.Delete(key)
}
}
return "", false
}
func (ce *ConversionEngine) storeFetchCache(key, data string) {
ce.fetchCache.Store(key, fetchCacheEntry{
data: data,
expires: time.Now().Add(fetchCacheTTL),
})
}
// parseConfigurations 解析配置文本
func (ce *ConversionEngine) parseConfigurations(inputText string, skipInvalid bool) ([]*parser.ProxyConfig, *ParseResult, error) {
result := &ParseResult{
ValidConfigs: make([]*parser.ProxyConfig, 0),
InvalidInputs: make([]string, 0),
ErrorMessages: make([]string, 0),
ProtocolStats: make(map[string]int),
}
// 分割输入文本
lines := ce.splitInputLines(inputText)
for i, line := range lines {
line = ce.trimLine(line)
if line == "" {
continue
}
// 尝试解析每一行
config, err := ce.parseSingleLine(line, i+1)
if err != nil {
result.InvalidInputs = append(result.InvalidInputs, line)
result.ErrorMessages = append(result.ErrorMessages, fmt.Sprintf("Line %d: %v", i+1, err))
if !skipInvalid {
return nil, result, fmt.Errorf("failed to parse line %d: %v", i+1, err)
}
continue
}
if config != nil {
result.ValidConfigs = append(result.ValidConfigs, config)
result.ProtocolStats[config.Protocol]++
}
}
ce.logger.WithField("validConfigs", len(result.ValidConfigs)).
WithField("invalidInputs", len(result.InvalidInputs)).
Info("Configuration parsing completed")
return result.ValidConfigs, result, nil
}
// splitInputLines 分割输入文本为行
func (ce *ConversionEngine) splitInputLines(input string) []string {
// 按行分割输入文本
lines := strings.Split(strings.TrimSpace(input), "\n")
// 过滤空行
result := make([]string, 0)
for _, line := range lines {
if trimmed := strings.TrimSpace(line); trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
// trimLine 清理行内容
func (ce *ConversionEngine) trimLine(line string) string {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
return ""
}
// 移除可能存在的 BOM 或零宽度字符
trimmed = strings.TrimLeft(trimmed, "\ufeff\u200b\u200c\u200d\u2060")
trimmed = strings.TrimSpace(trimmed)
if trimmed == "" {
return ""
}
// 跳过整行注释
switch {
case strings.HasPrefix(trimmed, "#"),
strings.HasPrefix(trimmed, ";"),
strings.HasPrefix(trimmed, "//"):
return ""
}
return trimmed
}
// parseSingleLine 解析单行配置
func (ce *ConversionEngine) parseSingleLine(line string, lineNumber int) (*parser.ProxyConfig, error) {
// 尝试自动检测协议并解析
protocol := ce.detectProtocol(line)
if protocol == "" {
return nil, fmt.Errorf("unable to detect protocol for line: %s", line)
}
// 获取对应的解析器
parser, err := ce.parserMgr.GetParser(protocol)
if err != nil {
return nil, fmt.Errorf("no parser available for protocol: %s", protocol)
}
// 解析配置
config, err := parser.Parse(line)
if err != nil {
return nil, fmt.Errorf("failed to parse %s configuration: %v", protocol, err)
}
// 验证配置
if err := parser.Validate(config); err != nil {
return nil, fmt.Errorf("invalid %s configuration: %v", protocol, err)
}
return config, nil
}
// detectProtocol 检测配置协议
func (ce *ConversionEngine) detectProtocol(line string) string {
candidate := strings.TrimSpace(line)
if candidate == "" {
return ""
}
lower := strings.ToLower(candidate)
switch {
case strings.HasPrefix(lower, "ssr://"):
return "ssr"
case strings.HasPrefix(lower, "vmess://"), strings.HasPrefix(lower, "vmess1://"):
return "vmess"
case strings.HasPrefix(lower, "ss://"):
return "ss"
case strings.HasPrefix(lower, "socks5://"), strings.HasPrefix(lower, "socks://"), strings.HasPrefix(lower, "https://t.me/socks"), strings.HasPrefix(lower, "tg://socks"):
return "socks5"
case strings.HasPrefix(lower, "trojan://"):
return "trojan"
case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"), strings.HasPrefix(lower, "tg://http"), strings.HasPrefix(lower, "https://t.me/http"):
return "http"
}
// Fallback: 尝试在字符串中查找协议标识
if strings.Contains(lower, "ssr://") {
return "ssr"
}
if strings.Contains(lower, "vmess://") || strings.Contains(lower, "vmess1://") {
return "vmess"
}
if strings.Contains(lower, "ss://") {
return "ss"
}
if strings.Contains(lower, "trojan://") {
return "trojan"
}
if strings.Contains(lower, "socks5://") || strings.Contains(lower, "socks://") {
return "socks5"
}
if strings.Contains(lower, "http://") || strings.Contains(lower, "https://") {
return "http"
}
return ""
}
// generateOutput 生成输出配置
func (ce *ConversionEngine) generateOutput(request *ConversionRequest, configs []*parser.ProxyConfig) (string, *GenerateResult, error) {
result := &GenerateResult{
GeneratedOutput: "",
FormatFeatures: make(map[string]bool),
}
if request.List {
lines := make([]string, 0, len(configs))
for _, cfg := range configs {
name := strings.TrimSpace(cfg.Name)
if name == "" {
if cfg.Server != "" && cfg.Port != 0 {
name = fmt.Sprintf("%s:%d", cfg.Server, cfg.Port)
} else {
name = cfg.Type
}
}
lines = append(lines, name)
}
output := strings.Join(lines, "\n")
result.GeneratedOutput = output
result.FormatFeatures["list_mode"] = true
return output, result, nil
}
// 获取对应的生成器
gen, err := ce.generatorMgr.GetGenerator(request.Target)
if err != nil {
return "", result, fmt.Errorf("no generator available for format: %s", request.Target)
}
// 创建默认的生成选项
options := &generator.GenerationOptions{
Name: "SubConverter-Go",
Group: request.Group,
IPv6: request.IPv6,
Mode: "rule",
BasePath: request.BasePath,
}
options.AppendType = request.AppendType
options.TFO = request.TFO
options.Script = request.Script
options.SkipCert = request.SkipCert
options.FilterDeprecated = request.FilterDeprecated
options.ExpandRules = request.ExpandRules
options.AppendInfo = request.AppendInfo
options.Prepend = request.Prepend
options.Classic = request.Classic
options.TLS13 = request.TLS13
options.AddEmoji = request.AddEmoji
options.RemoveEmoji = request.RemoveEmoji
options.RenameRules = append([]string{}, request.RenameRules...)
options.CustomGroups = append([]string{}, request.Groups...)
options.CustomRulesets = append([]string{}, request.Rulesets...)
options.CustomProviders = append([]string{}, request.Providers...)
if len(request.Providers) > 0 {
providers := make([]*generator.ProviderDefinition, 0, len(request.Providers))
for _, entry := range request.Providers {
parsed, err := generator.ParseProviderDefinition(entry)
if err != nil {
return "", result, fmt.Errorf("invalid provider definition: %w", err)
}
providers = append(providers, parsed)
}
options.Providers = providers
}
if len(request.Groups) > 0 {
groups := make([]*generator.GroupDefinition, 0, len(request.Groups))
for _, entry := range request.Groups {
parsed, err := generator.ParseGroupDefinition(entry)
if err != nil {
return "", result, fmt.Errorf("invalid group definition: %w", err)
}
groups = append(groups, parsed)
}
options.GroupDefinitions = groups
}
options.FilterScript = request.FilterScript
options.Upload = request.Upload
options.UploadPath = request.UploadPath
options.DeviceID = request.DeviceID
options.Interval = request.Interval
// 验证生成选项
if err := gen.ValidateOptions(options); err != nil {
return "", result, fmt.Errorf("invalid generation options: %v", err)
}
// 生成配置
output, err := gen.Generate(configs, options)
if err != nil {
return "", result, fmt.Errorf("failed to generate %s configuration: %v", request.Target, err)
}
result.GeneratedOutput = output
result.FormatFeatures["success"] = true
result.GenerateStats = &GenerateStats{
GeneratedProxies: len(configs),
GeneratedGroups: len(options.GroupDefinitions),
GeneratedRules: len(options.CustomRulesets),
FormatFeatures: copyFormatFeatures(result.FormatFeatures),
}
return output, result, nil
}
func (ce *ConversionEngine) applyFilters(configs []*parser.ProxyConfig, request *ConversionRequest) []*parser.ProxyConfig {
if len(request.Include) == 0 && len(request.Exclude) == 0 {
return configs
}
includeTokens := make([]string, 0, len(request.Include))
for _, token := range request.Include {
token = strings.ToLower(strings.TrimSpace(token))
if token != "" {
includeTokens = append(includeTokens, token)
}
}
excludeTokens := make([]string, 0, len(request.Exclude))
for _, token := range request.Exclude {
token = strings.ToLower(strings.TrimSpace(token))
if token != "" {
excludeTokens = append(excludeTokens, token)
}
}
base := make([]*parser.ProxyConfig, 0, len(configs))
for _, cfg := range configs {
label := strings.ToLower(strings.TrimSpace(firstNonEmpty(cfg.Remarks, cfg.Name)))
if label == "" {
if cfg.Server != "" && cfg.Port != 0 {
label = fmt.Sprintf("%s:%d", strings.ToLower(cfg.Server), cfg.Port)
} else {
label = strings.ToLower(cfg.Type)
}
}
if matchesAny(label, excludeTokens) {
continue
}
base = append(base, cfg)
}
filtered := base
if len(includeTokens) > 0 {
included := make([]*parser.ProxyConfig, 0, len(base))
for _, cfg := range base {
label := strings.ToLower(strings.TrimSpace(firstNonEmpty(cfg.Remarks, cfg.Name)))
if label == "" {
if cfg.Server != "" && cfg.Port != 0 {
label = fmt.Sprintf("%s:%d", strings.ToLower(cfg.Server), cfg.Port)
} else {
label = strings.ToLower(cfg.Type)
}
}
if matchesAny(label, includeTokens) {
included = append(included, cfg)
}
}
if len(included) > 0 {
filtered = included
}
}
ce.logger.WithField("original", len(configs)).
WithField("after_exclude", len(base)).
WithField("filtered", len(filtered)).
Debug("Applied include/exclude filters to proxy list")
return filtered
}
func (ce *ConversionEngine) applyTransformations(configs []*parser.ProxyConfig, request *ConversionRequest) ([]*parser.ProxyConfig, error) {
result := make([]*parser.ProxyConfig, 0, len(configs))
renameRules, err := ce.compileRenameRules(request)
if err != nil {
return nil, err
}
emojiRules, err := ce.compileEmojiRules(request)
if err != nil {
return nil, err
}
needsRename := len(renameRules) > 0
needsRemove := request.RemoveEmoji
needsEmoji := request.AddEmoji || len(emojiRules) > 0
if !needsRename && !needsRemove && !needsEmoji {
return configs, nil
}
for idx, cfg := range configs {
clone := *cfg
clone.Settings = copySettings(cfg.Settings)
remark := firstNonEmpty(cfg.Remarks, cfg.Name)
if remark == "" {
remark = cfg.Name
}
name := cfg.Name
node := newScriptNode(cfg, remark, request.Group, idx)
if needsRename {
updated, renameErr := applyRenameRules(renameRules, &node, ce.logger)
if renameErr != nil {
return nil, renameErr
}
if trimmed := strings.TrimSpace(updated); trimmed != "" {
remark = trimmed
name = trimmed
node.Remark = trimmed
}
}
if needsRemove {
remark = removeLeadingEmoji(remark)
name = removeLeadingEmoji(name)
node.Remark = remark
}
emoji := ""
if len(emojiRules) > 0 {
e, emojiErr := applyEmojiRules(emojiRules, &node, ce.logger)
if emojiErr != nil {
return nil, emojiErr
}
emoji = strings.TrimSpace(e)
}
if emoji == "" && request.AddEmoji {
emoji = ce.pickEmoji(clone, request)
}
if emoji != "" {
remark = prependEmoji(emoji, remark)
name = prependEmoji(emoji, name)
}
clone.Name = strings.TrimSpace(name)
clone.Remarks = strings.TrimSpace(remark)
result = append(result, &clone)
}
return result, nil
}
func (ce *ConversionEngine) applyFilterScript(configs []*parser.ProxyConfig, request *ConversionRequest) ([]*parser.ProxyConfig, error) {
script, err := ce.prepareFilterScript(request.FilterScript, request.BasePath, request)
if err != nil {
return nil, err
}
if script == "" {
return configs, nil
}
runner, err := ce.newScriptRunner(script, request.BasePath, filterFunctionName)
if err != nil {
return nil, err
}
filtered := make([]*parser.ProxyConfig, 0, len(configs))
for idx, cfg := range configs {
remark := firstNonEmpty(cfg.Remarks, cfg.Name)
if remark == "" {
remark = cfg.Name
}
node := newScriptNode(cfg, remark, request.Group, idx)
shouldRemove, callErr := runner.callBool(node)
if callErr != nil {
ce.logger.WithError(callErr).Warn("filter script execution failed")
return nil, fmt.Errorf("filter script error: %w", callErr)
}
if !shouldRemove {
filtered = append(filtered, cfg)
}
}
return filtered, nil
}
func (ce *ConversionEngine) prepareFilterScript(raw string, basePath string, request *ConversionRequest) (string, error) {
script := strings.TrimSpace(raw)
if script == "" {
return "", nil
}
lower := strings.ToLower(script)
switch {
case strings.HasPrefix(lower, "!!script:"):
return strings.TrimSpace(script[len("!!script:"):]), nil
case strings.HasPrefix(lower, "!!import:"):
content, _, err := ce.loadImportResource(script[len("!!import:"):], basePath, request)
if err != nil {
return "", err
}
return strings.TrimSpace(content), nil
case strings.HasPrefix(lower, "path:"):
return script, nil
case strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://"):
content, err := ce.fetchFromURL(script, ce.selectUserAgent(request))
if err != nil {
return "", err
}
return strings.TrimSpace(content), nil
}
if strings.Contains(script, "function ") {
return script, nil
}
resolved := script
if basePath != "" && !filepath.IsAbs(resolved) {
resolved = filepath.Join(basePath, resolved)
}
if _, err := os.Stat(resolved); err == nil {
return "path:" + script, nil
}
ce.logger.Warnf("Filter script %s not found; skipping", script)
return "", nil
}
func (ce *ConversionEngine) handleUpload(request *ConversionRequest, content string) error {
if !request.Upload {
return nil
}
path := strings.TrimSpace(request.UploadPath)
if path == "" {
ce.logger.Warn("Upload requested without upload_path; skipping")
return nil
}
lower := strings.ToLower(path)
switch {
case strings.HasPrefix(lower, "file://"):
parsed, err := url.Parse(path)
if err != nil {
return err
}
resolved := parsed.Path
if resolved == "" {
resolved = parsed.Host
}
return ce.uploadToFile(resolved, content)
case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"):
return ce.uploadOverHTTP(path, content)
default:
resolved := path
if request.BasePath != "" && !filepath.IsAbs(resolved) {
resolved = filepath.Join(request.BasePath, resolved)
}
return ce.uploadToFile(resolved, content)
}
}
func (ce *ConversionEngine) uploadToFile(destination string, content string) error {
if destination == "" {
return fmt.Errorf("upload destination is empty")
}
dir := filepath.Dir(destination)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
return os.WriteFile(destination, []byte(content), 0o644)
}
func (ce *ConversionEngine) uploadOverHTTP(target string, content string) error {
client := ce.httpClient
if client == nil {
client = &http.Client{Timeout: defaultFetchTimeout}
}
methods := []string{http.MethodPut, http.MethodPost}
for idx, method := range methods {
req, err := http.NewRequest(method, target, strings.NewReader(content))
if err != nil {
return err
}
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
resp, err := client.Do(req)
if err != nil {
if idx == len(methods)-1 {
return err
}
continue
}
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
return nil
}
if idx == len(methods)-1 {
return fmt.Errorf("upload failed with status %d", resp.StatusCode)
}
}
return nil
}
func (ce *ConversionEngine) pickEmoji(cfg parser.ProxyConfig, request *ConversionRequest) string {
switch strings.ToLower(cfg.Protocol) {
case "ss", "ssr":
return "🛰️"
case "vmess":
return "🛸"
case "trojan":
return "🐴"
}
host := strings.ToLower(cfg.Server)
switch {
case strings.Contains(host, "hk"):
return "🇭🇰"
case strings.Contains(host, "jp"):
return "🇯🇵"
case strings.Contains(host, "us") || strings.HasSuffix(host, ".us"):
return "🇺🇸"
case strings.Contains(host, "sg"):
return "🇸🇬"
case strings.Contains(host, "kr"):
return "🇰🇷"
case strings.Contains(host, "tw"):
return "🇹🇼"
}
return ""
}
func removeLeadingEmoji(input string) string {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
return trimmed
}
runes := []rune(trimmed)
if len(runes) == 0 {
return trimmed
}
skip := leadingEmojiLength(runes)
if skip == 0 {
return trimmed
}
return strings.TrimSpace(string(runes[skip:]))
}
func prependEmoji(emoji string, value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return emoji
}
if strings.HasPrefix(trimmed, emoji) {
return trimmed
}
return strings.TrimSpace(emoji + " " + trimmed)
}
func isEmojiRune(r rune) bool {
return r >= 0x1F300 && r <= 0x1FAFF
}
func isRegionalIndicator(r rune) bool {
return r >= 0x1F1E6 && r <= 0x1F1FF
}
func isVariationSelector(r rune) bool {
return r >= 0xFE00 && r <= 0xFE0F
}
func leadingEmojiLength(runes []rune) int {
if len(runes) == 0 {
return 0
}
first := runes[0]
if isEmojiRune(first) {
skip := 1
if len(runes) > skip && isVariationSelector(runes[skip]) {
skip++
}
return skip
}
if isRegionalIndicator(first) {
skip := 1
if len(runes) > skip && isRegionalIndicator(runes[skip]) {
skip++
}
return skip
}
return 0
}
func matchesAny(label string, tokens []string) bool {
for _, token := range tokens {
if token != "" && strings.Contains(label, token) {
return true
}
}
return false
}
func copyFormatFeatures(features map[string]bool) map[string]bool {
if len(features) == 0 {
return map[string]bool{}
}
dup := make(map[string]bool, len(features))
for k, v := range features {
dup[k] = v
}
return dup
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
// createSuccessResponse 创建成功响应
func (ce *ConversionEngine) createSuccessResponse(
request *ConversionRequest,
output string,
configs []*parser.ProxyConfig,
parseResult *ParseResult,
generateResult *GenerateResult,
) *ConversionResponse {
response := NewConversionResponse()
response.Success = true
response.Content = output
response.ContentType = request.GetContentType()
response.TargetFormat = request.Target
response.SourceURL = request.SourceSummary()
response.NodeCount = len(configs)
response.ProcessingTime = 0 // 临时设置为0实际应该在调用处计算
response.Timestamp = time.Now()
if headerVal := ce.profileUpdateHeader(request.Interval); headerVal != "" {
response.AddHeader("profile-update-interval", headerVal)
}
// 添加调试信息
debugInfo := map[string]interface{}{
"parse_time": 0,
"generate_time": 0,
"total_time": 0,
"input_size": 0,
"output_size": len(output),
"proxy_count": len(configs),
"error_count": len(parseResult.ErrorMessages),
"protocols": parseResult.ProtocolStats,
"format_features": generateResult.FormatFeatures,
}
if generateResult.GenerateStats != nil {
debugInfo["generate_stats"] = map[string]interface{}{
"generated_proxies": generateResult.GenerateStats.GeneratedProxies,
"generated_groups": generateResult.GenerateStats.GeneratedGroups,
"generated_rules": generateResult.GenerateStats.GeneratedRules,
"features": generateResult.GenerateStats.FormatFeatures,
}
}
response.DebugInfo = debugInfo
return response
}
// createErrorResponse 创建错误响应
func (ce *ConversionEngine) createErrorResponse(request *ConversionRequest, errorMessage string) *ConversionResponse {
response := NewConversionResponse()
response.Success = false
response.Error = errorMessage
response.ErrorCode = "VALIDATION_ERROR"
response.ErrorDetail = errorMessage
response.TargetFormat = request.Target
response.SourceURL = request.SourceSummary()
response.ProcessingTime = 0
response.Timestamp = time.Now()
return response
}
// createDebugInfo 创建调试信息
func (ce *ConversionEngine) createDebugInfo(
request *ConversionRequest,
startTime, endTime time.Time,
parseResult *ParseResult,
generateResult *GenerateResult,
) map[string]interface{} {
totalTime := endTime.Sub(startTime).Milliseconds()
info := map[string]interface{}{
"parse_time": 0,
"generate_time": 0,
"total_time": totalTime,
"input_size": 0,
"output_size": len(generateResult.GeneratedOutput),
"proxy_count": len(parseResult.ValidConfigs),
"error_count": len(parseResult.ErrorMessages),
"protocols": parseResult.ProtocolStats,
"format_features": generateResult.FormatFeatures,
}
if generateResult.GenerateStats != nil {
info["generate_stats"] = map[string]interface{}{
"generated_proxies": generateResult.GenerateStats.GeneratedProxies,
"generated_groups": generateResult.GenerateStats.GeneratedGroups,
"generated_rules": generateResult.GenerateStats.GeneratedRules,
"features": generateResult.GenerateStats.FormatFeatures,
}
}
return info
}
// ParseResult 解析结果
type ParseResult struct {
ValidConfigs []*parser.ProxyConfig `json:"validConfigs"`
InvalidInputs []string `json:"invalidInputs"`
ErrorMessages []string `json:"errorMessages"`
ProtocolStats map[string]int `json:"protocolStats"`
}
// GenerateResult 生成结果
type GenerateResult struct {
GeneratedOutput string `json:"generatedOutput"`
FormatFeatures map[string]bool `json:"formatFeatures"`
GenerateStats *GenerateStats `json:"generateStats"`
}
func (ce *ConversionEngine) applyManagedConfig(request *ConversionRequest, content string) string {
if strings.ToLower(request.Target) != "surge" {
return content
}
if !request.Upload {
return content
}
managedURL := ce.buildManagedConfigURL(request)
if managedURL == "" {
return content
}
header := fmt.Sprintf("#!MANAGED-CONFIG %s", managedURL)
if interval := ce.normalizeInterval(request.Interval); interval != "" {
header += " interval=" + interval
}
header += fmt.Sprintf(" strict=%t", request.Strict)
return header + "\n\n" + content
}
func (ce *ConversionEngine) buildManagedConfigURL(request *ConversionRequest) string {
params := url.Values{}
params.Set("target", request.Target)
if summary := request.SourceSummary(); summary != "" {
params.Set("url", summary)
}
if request.Strict {
params.Set("strict", "true")
}
if request.Interval != "" {
params.Set("interval", request.Interval)
}
return "/sub?" + params.Encode()
}
func (ce *ConversionEngine) normalizeInterval(raw string) string {
if raw == "" {
return ""
}
value, err := strconv.Atoi(raw)
if err != nil || value <= 0 {
return ""
}
return strconv.Itoa(value)
}
func (ce *ConversionEngine) profileUpdateHeader(raw string) string {
if raw == "" {
return ""
}
value, err := strconv.Atoi(raw)
if err != nil || value <= 0 {
return ""
}
hours := value / 3600
if hours <= 0 {
hours = 1
}
return strconv.Itoa(hours)
}