Files
GoProxy/custom/parser.go
isboyjc f03c3300b4 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.
2026-04-04 22:25:54 +08:00

737 lines
19 KiB
Go
Raw 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 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
}