chore: sub-store Go 重写项目初始化
This commit is contained in:
@@ -0,0 +1,651 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/peterqiu0516/sub-store/internal/model"
|
||||
)
|
||||
|
||||
var (
|
||||
// qxKindRegex matches QX-style proxy lines: "shadowsocks = ...", "vmess = ...", etc.
|
||||
qxKindRegex = regexp.MustCompile(`(?i)^\s*(shadowsocks|vmess|vless|trojan|http|socks5|anytls)\s*=`)
|
||||
// namedKindRegex matches named Surge/Loon-style lines: "name = kind, ..."
|
||||
namedKindRegex = regexp.MustCompile(`^\s*[^=\n]{1,120}\s*=`)
|
||||
)
|
||||
|
||||
// ParseClientProxyLine dispatches a client config line to QX or named parser.
|
||||
// Returns nil if the line doesn't match either format.
|
||||
func ParseClientProxyLine(line string, index int) model.ProxyNode {
|
||||
defer func() { _ = recover() }()
|
||||
if qxKindRegex.MatchString(line) {
|
||||
return ParseQxProxyLine(line, index)
|
||||
}
|
||||
if namedKindRegex.MatchString(line) {
|
||||
return ParseNamedClientProxyLine(line, index)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseQxProxyLine parses a Quantumult X style proxy config line.
|
||||
// Format: kind = server:port, tag=name, ...
|
||||
func ParseQxProxyLine(line string, index int) model.ProxyNode {
|
||||
equalIndex := strings.Index(line, "=")
|
||||
if equalIndex <= 0 {
|
||||
return nil
|
||||
}
|
||||
kind := strings.ToLower(strings.TrimSpace(line[:equalIndex]))
|
||||
parts := SplitClientCsv(line[equalIndex+1:])
|
||||
if len(parts) == 0 {
|
||||
return nil
|
||||
}
|
||||
server, rawPort := SplitHostPort(parts[0])
|
||||
options := ParseClientOptions(parts[1:])
|
||||
name := orDefault(ClientOption(options, "tag"), fmt.Sprintf("%s-%d", kind, index+1))
|
||||
|
||||
defaultPort := 443
|
||||
if kind == "http" || kind == "socks5" {
|
||||
defaultPort = 80
|
||||
}
|
||||
port := toFloat(rawPort)
|
||||
if port == 0 {
|
||||
if p := toFloat(ClientOption(options, "port")); p != 0 {
|
||||
port = p
|
||||
} else {
|
||||
port = float64(defaultPort)
|
||||
}
|
||||
}
|
||||
|
||||
tls := qxTlsEnabled(options)
|
||||
common := ClientCommonOptions(options)
|
||||
|
||||
switch kind {
|
||||
case "shadowsocks":
|
||||
return StripUndefined(map[string]any{
|
||||
"name": name,
|
||||
"type": "ss",
|
||||
"server": server,
|
||||
"port": port,
|
||||
"cipher": ClientOption(options, "method"),
|
||||
"password": ClientOption(options, "password"),
|
||||
"plugin": qxPlugin(options),
|
||||
"plugin-opts": qxPluginOptions(options),
|
||||
"udp": OptionBoolean(ClientOption(options, "udp-relay")),
|
||||
"tfo": OptionBoolean(ClientOption(options, "fast-open")),
|
||||
skipCertVerify: common[skipCertVerify],
|
||||
clientFingerprint: common[clientFingerprint],
|
||||
})
|
||||
case "vmess", "vless":
|
||||
node := map[string]any{
|
||||
"name": name,
|
||||
"type": kind,
|
||||
"server": server,
|
||||
"port": port,
|
||||
"uuid": firstNonEmpty(ClientOption(options, "password"), ClientOption(options, "uuid"), ClientOption(options, "username")),
|
||||
"network": qxNetwork(options),
|
||||
"tls": tls,
|
||||
"servername": firstNonEmpty(ClientOption(options, "tls-host"), ClientOption(options, "obfs-host")),
|
||||
"ws-opts": qxWsOptions(options),
|
||||
"reality-opts": parseRealityOptions(options),
|
||||
"flow": ClientOption(options, "flow"),
|
||||
"udp": OptionBoolean(ClientOption(options, "udp-relay")),
|
||||
"tfo": OptionBoolean(ClientOption(options, "fast-open")),
|
||||
skipCertVerify: common[skipCertVerify],
|
||||
clientFingerprint: common[clientFingerprint],
|
||||
}
|
||||
if kind == "vmess" {
|
||||
node["cipher"] = orDefault(ClientOption(options, "method"), "auto")
|
||||
node["alterId"] = NumberOrUndefined(firstNonEmpty(ClientOption(options, "alterId"), ClientOption(options, "alterid")))
|
||||
} else {
|
||||
node["encryption"] = orDefault(ClientOption(options, "encryption"), "none")
|
||||
}
|
||||
return StripUndefined(node)
|
||||
case "trojan", "anytls":
|
||||
return StripUndefined(map[string]any{
|
||||
"name": name,
|
||||
"type": kind,
|
||||
"server": server,
|
||||
"port": port,
|
||||
"password": ClientOption(options, "password"),
|
||||
"sni": firstNonEmpty(ClientOption(options, "tls-host"), ClientOption(options, "sni"), ClientOption(options, "obfs-host")),
|
||||
"reality-opts": parseRealityOptions(options),
|
||||
"udp": OptionBoolean(ClientOption(options, "udp-relay")),
|
||||
"tfo": OptionBoolean(ClientOption(options, "fast-open")),
|
||||
skipCertVerify: common[skipCertVerify],
|
||||
clientFingerprint: common[clientFingerprint],
|
||||
})
|
||||
case "http", "socks5":
|
||||
return StripUndefined(map[string]any{
|
||||
"name": name,
|
||||
"type": kind,
|
||||
"server": server,
|
||||
"port": port,
|
||||
"username": ClientOption(options, "username"),
|
||||
"password": ClientOption(options, "password"),
|
||||
"tls": tls,
|
||||
"udp": OptionBoolean(ClientOption(options, "udp-relay")),
|
||||
"tfo": OptionBoolean(ClientOption(options, "fast-open")),
|
||||
skipCertVerify: common[skipCertVerify],
|
||||
clientFingerprint: common[clientFingerprint],
|
||||
})
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ParseNamedClientProxyLine parses a Surge/Loon style named proxy config line.
|
||||
// Format: name = kind, server, port, [positional...], [key=value...]
|
||||
func ParseNamedClientProxyLine(line string, index int) model.ProxyNode {
|
||||
equalIndex := strings.Index(line, "=")
|
||||
if equalIndex < 0 {
|
||||
return nil
|
||||
}
|
||||
name := strings.TrimSpace(line[:equalIndex])
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("proxy-%d", index+1)
|
||||
}
|
||||
parts := SplitClientCsv(line[equalIndex+1:])
|
||||
if len(parts) < 3 {
|
||||
return nil
|
||||
}
|
||||
rawKind := strings.ToLower(strings.TrimSpace(parts[0]))
|
||||
kind := NormalizeClientProxyKind(parts[0])
|
||||
server := parts[1]
|
||||
port := toFloat(parts[2])
|
||||
positional := parts[3:]
|
||||
var positionalValues []string
|
||||
for _, p := range positional {
|
||||
if !strings.Contains(p, "=") {
|
||||
positionalValues = append(positionalValues, p)
|
||||
}
|
||||
}
|
||||
options := ParseClientOptions(positional)
|
||||
common := ClientCommonOptions(options)
|
||||
|
||||
if kind == "" || server == "" || port == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch kind {
|
||||
case "ss":
|
||||
var pluginOpts any
|
||||
if obfs := firstNonEmpty(ClientOption(options, "obfs"), ClientOption(options, "obfs-name")); obfs != "" {
|
||||
pluginOpts = StripUndefined(map[string]any{
|
||||
"mode": obfs,
|
||||
"host": ClientOption(options, "obfs-host"),
|
||||
"path": ClientOption(options, "obfs-uri"),
|
||||
})
|
||||
}
|
||||
return StripUndefined(map[string]any{
|
||||
"name": name,
|
||||
"type": "ss",
|
||||
"server": server,
|
||||
"port": port,
|
||||
"cipher": firstNonEmpty(ClientOption(options, "encrypt-method"), ClientOption(options, "method"), getAt(positionalValues, 0)),
|
||||
"password": firstNonEmpty(ClientOption(options, "password"), getAt(positionalValues, 1)),
|
||||
"plugin": ternary(firstNonEmpty(ClientOption(options, "obfs"), ClientOption(options, "obfs-name")) != "", "obfs", ""),
|
||||
"plugin-opts": pluginOpts,
|
||||
"udp": OptionBoolean(firstNonEmpty(ClientOption(options, "udp"), ClientOption(options, "udp-relay"))),
|
||||
"tfo": OptionBoolean(ClientOption(options, "fast-open")),
|
||||
skipCertVerify: common[skipCertVerify],
|
||||
clientFingerprint: common[clientFingerprint],
|
||||
})
|
||||
case "ssr":
|
||||
return StripUndefined(map[string]any{
|
||||
"name": name,
|
||||
"type": "ssr",
|
||||
"server": server,
|
||||
"port": port,
|
||||
"cipher": firstNonEmpty(getAt(positionalValues, 0), ClientOption(options, "encrypt-method"), ClientOption(options, "method")),
|
||||
"password": firstNonEmpty(getAt(positionalValues, 1), ClientOption(options, "password")),
|
||||
"protocol": orDefault(ClientOption(options, "protocol"), "origin"),
|
||||
"obfs": orDefault(ClientOption(options, "obfs"), "plain"),
|
||||
"protocol-param": firstNonEmpty(ClientOption(options, "protocol-param"), ClientOption(options, "protoparam")),
|
||||
"obfs-param": firstNonEmpty(ClientOption(options, "obfs-param"), ClientOption(options, "obfsparam")),
|
||||
"udp": OptionBoolean(firstNonEmpty(ClientOption(options, "udp"), ClientOption(options, "udp-relay"))),
|
||||
skipCertVerify: common[skipCertVerify],
|
||||
clientFingerprint: common[clientFingerprint],
|
||||
})
|
||||
case "vmess", "vless":
|
||||
tls := false
|
||||
if b := OptionBoolean(firstNonEmpty(ClientOption(options, "tls"), ClientOption(options, "over-tls"))); b != nil {
|
||||
tls = *b
|
||||
}
|
||||
node := map[string]any{
|
||||
"name": name,
|
||||
"type": kind,
|
||||
"server": server,
|
||||
"port": port,
|
||||
"uuid": firstNonEmpty(ClientOption(options, "username"), ClientOption(options, "password"), getAt(positionalValues, 1), getAt(positionalValues, 0)),
|
||||
"network": namedClientNetwork(options),
|
||||
"tls": tls,
|
||||
"servername": firstNonEmpty(ClientOption(options, "sni"), ClientOption(options, "tls-name"), ClientOption(options, "tls-host")),
|
||||
"ws-opts": namedClientWsOptions(options),
|
||||
"reality-opts": parseRealityOptions(options),
|
||||
"flow": ClientOption(options, "flow"),
|
||||
"udp": OptionBoolean(firstNonEmpty(ClientOption(options, "udp"), ClientOption(options, "udp-relay"))),
|
||||
"tfo": OptionBoolean(ClientOption(options, "fast-open")),
|
||||
skipCertVerify: common[skipCertVerify],
|
||||
clientFingerprint: common[clientFingerprint],
|
||||
}
|
||||
if kind == "vmess" {
|
||||
node["cipher"] = firstNonEmpty(getAt(positionalValues, 0), ClientOption(options, "encrypt-method"), ClientOption(options, "method"), "auto")
|
||||
node["alterId"] = NumberOrUndefined(firstNonEmpty(ClientOption(options, "alterId"), ClientOption(options, "alterid")))
|
||||
} else {
|
||||
node["encryption"] = orDefault(ClientOption(options, "encryption"), "none")
|
||||
}
|
||||
return StripUndefined(node)
|
||||
case "trojan", "anytls":
|
||||
return StripUndefined(map[string]any{
|
||||
"name": name,
|
||||
"type": kind,
|
||||
"server": server,
|
||||
"port": port,
|
||||
"password": firstNonEmpty(ClientOption(options, "password"), getAt(positionalValues, 0)),
|
||||
"sni": firstNonEmpty(ClientOption(options, "sni"), ClientOption(options, "tls-name"), ClientOption(options, "tls-host")),
|
||||
"reality-opts": parseRealityOptions(options),
|
||||
"udp": OptionBoolean(firstNonEmpty(ClientOption(options, "udp"), ClientOption(options, "udp-relay"))),
|
||||
"tfo": OptionBoolean(ClientOption(options, "fast-open")),
|
||||
skipCertVerify: common[skipCertVerify],
|
||||
clientFingerprint: common[clientFingerprint],
|
||||
})
|
||||
case "http", "socks5":
|
||||
tls := rawKind == "https" || rawKind == "socks5-tls"
|
||||
if b := OptionBoolean(firstNonEmpty(ClientOption(options, "tls"), ClientOption(options, "over-tls"))); b != nil {
|
||||
tls = *b
|
||||
}
|
||||
proxyType := kind
|
||||
if kind == "socks5" {
|
||||
proxyType = "socks5"
|
||||
} else {
|
||||
proxyType = "http"
|
||||
}
|
||||
return StripUndefined(map[string]any{
|
||||
"name": name,
|
||||
"type": proxyType,
|
||||
"server": server,
|
||||
"port": port,
|
||||
"username": ClientOption(options, "username"),
|
||||
"password": ClientOption(options, "password"),
|
||||
"tls": tls,
|
||||
"udp": OptionBoolean(firstNonEmpty(ClientOption(options, "udp"), ClientOption(options, "udp-relay"))),
|
||||
"tfo": OptionBoolean(ClientOption(options, "fast-open")),
|
||||
skipCertVerify: common[skipCertVerify],
|
||||
clientFingerprint: common[clientFingerprint],
|
||||
})
|
||||
case "hysteria2":
|
||||
scv := common[skipCertVerify]
|
||||
if b := OptionBoolean(ClientOption(options, "skip-cert-verify")); b != nil {
|
||||
scv = *b
|
||||
}
|
||||
return StripUndefined(map[string]any{
|
||||
"name": name,
|
||||
"type": "hysteria2",
|
||||
"server": server,
|
||||
"port": port,
|
||||
"password": firstNonEmpty(ClientOption(options, "password"), getAt(positionalValues, 0)),
|
||||
"sni": firstNonEmpty(ClientOption(options, "sni"), ClientOption(options, "tls-name")),
|
||||
"obfs": ClientOption(options, "obfs"),
|
||||
"obfs-password": firstNonEmpty(ClientOption(options, "obfs-password"), ClientOption(options, "gecko-password")),
|
||||
skipCertVerify: scv,
|
||||
clientFingerprint: common[clientFingerprint],
|
||||
})
|
||||
case "tuic":
|
||||
scv := common[skipCertVerify]
|
||||
if b := OptionBoolean(ClientOption(options, "skip-cert-verify")); b != nil {
|
||||
scv = *b
|
||||
}
|
||||
return StripUndefined(map[string]any{
|
||||
"name": name,
|
||||
"type": "tuic",
|
||||
"server": server,
|
||||
"port": port,
|
||||
"uuid": firstNonEmpty(ClientOption(options, "uuid"), getAt(positionalValues, 0)),
|
||||
"password": firstNonEmpty(ClientOption(options, "password"), getAt(positionalValues, 1)),
|
||||
"sni": ClientOption(options, "sni"),
|
||||
"alpn": CommaList(ClientOption(options, "alpn")),
|
||||
skipCertVerify: scv,
|
||||
clientFingerprint: common[clientFingerprint],
|
||||
})
|
||||
case "snell":
|
||||
version := NumberOrUndefined(ClientOption(options, "version"))
|
||||
if version == nil {
|
||||
version = float64(3)
|
||||
}
|
||||
return StripUndefined(map[string]any{
|
||||
"name": name,
|
||||
"type": "snell",
|
||||
"server": server,
|
||||
"port": port,
|
||||
"psk": firstNonEmpty(ClientOption(options, "psk"), ClientOption(options, "password"), getAt(positionalValues, 0)),
|
||||
"version": version,
|
||||
"obfs": ClientOption(options, "obfs"),
|
||||
"obfs-host": ClientOption(options, "obfs-host"),
|
||||
skipCertVerify: common[skipCertVerify],
|
||||
clientFingerprint: common[clientFingerprint],
|
||||
})
|
||||
case "ssh":
|
||||
return StripUndefined(map[string]any{
|
||||
"name": name,
|
||||
"type": "ssh",
|
||||
"server": server,
|
||||
"port": port,
|
||||
"username": firstNonEmpty(ClientOption(options, "username"), getAt(positionalValues, 0)),
|
||||
"password": firstNonEmpty(ClientOption(options, "password"), getAt(positionalValues, 1)),
|
||||
"private-key": ClientOption(options, "private-key"),
|
||||
"host-key": ClientOption(options, "host-key"),
|
||||
skipCertVerify: common[skipCertVerify],
|
||||
clientFingerprint: common[clientFingerprint],
|
||||
})
|
||||
case "h2-connect":
|
||||
tls := true
|
||||
if b := OptionBoolean(ClientOption(options, "tls")); b != nil {
|
||||
tls = *b
|
||||
}
|
||||
return StripUndefined(map[string]any{
|
||||
"name": name,
|
||||
"type": "h2-connect",
|
||||
"server": server,
|
||||
"port": port,
|
||||
"username": ClientOption(options, "username"),
|
||||
"password": ClientOption(options, "password"),
|
||||
"tls": tls,
|
||||
"sni": firstNonEmpty(ClientOption(options, "sni"), ClientOption(options, "tls-name")),
|
||||
skipCertVerify: common[skipCertVerify],
|
||||
clientFingerprint: common[clientFingerprint],
|
||||
})
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeClientProxyKind maps client proxy kind aliases to canonical names.
|
||||
// Per review-resolution #23.
|
||||
func NormalizeClientProxyKind(input string) string {
|
||||
value := strings.ToLower(strings.TrimSpace(input))
|
||||
switch value {
|
||||
case "shadowsocks":
|
||||
return "ss"
|
||||
case "socks5-tls":
|
||||
return "socks5"
|
||||
case "https":
|
||||
return "http"
|
||||
case "hysteria2", "hysteria 2":
|
||||
return "hysteria2"
|
||||
case "tuic-v5":
|
||||
return "tuic"
|
||||
case "ss", "ssr", "vmess", "vless", "trojan", "http", "socks5", "tuic", "anytls", "snell", "ssh", "h2-connect":
|
||||
return value
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// SplitClientCsv splits a CSV string with quote handling.
|
||||
// Empty parts are filtered out (matching TS implementation).
|
||||
func SplitClientCsv(input string) []string {
|
||||
var parts []string
|
||||
var current strings.Builder
|
||||
quote := ""
|
||||
for _, char := range input {
|
||||
if quote != "" {
|
||||
if string(char) == quote {
|
||||
quote = ""
|
||||
} else {
|
||||
current.WriteRune(char)
|
||||
}
|
||||
} else if char == '"' || char == '\'' {
|
||||
quote = string(char)
|
||||
} else if char == ',' {
|
||||
parts = append(parts, strings.TrimSpace(current.String()))
|
||||
current.Reset()
|
||||
} else {
|
||||
current.WriteRune(char)
|
||||
}
|
||||
}
|
||||
parts = append(parts, strings.TrimSpace(current.String()))
|
||||
// Filter empty parts
|
||||
var result []string
|
||||
for _, p := range parts {
|
||||
if p != "" {
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ParseClientOptions parses key=value parts into a map.
|
||||
// Keys are lowercased. Values are unquoted.
|
||||
func ParseClientOptions(parts []string) map[string]string {
|
||||
options := make(map[string]string)
|
||||
for _, part := range parts {
|
||||
equalIndex := strings.Index(part, "=")
|
||||
if equalIndex <= 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(part[:equalIndex]))
|
||||
value := unquoteClientValue(strings.TrimSpace(part[equalIndex+1:]))
|
||||
options[key] = value
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
// ClientOption retrieves a value from the options map (case-insensitive key already lowered).
|
||||
func ClientOption(options map[string]string, key string) string {
|
||||
return options[strings.ToLower(key)]
|
||||
}
|
||||
|
||||
// OptionBoolean parses a string value into a *bool.
|
||||
// Returns nil for empty/unrecognized values.
|
||||
// Per review-resolution: "1","true","yes","on","enabled" → true;
|
||||
// "0","false","no","off","disabled" → false; otherwise nil.
|
||||
func OptionBoolean(value string) *bool {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
switch normalized {
|
||||
case "1", "true", "yes", "on", "enabled":
|
||||
b := true
|
||||
return &b
|
||||
case "0", "false", "no", "off", "disabled":
|
||||
b := false
|
||||
return &b
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ClientCommonOptions extracts common options (skip-cert-verify, client-fingerprint).
|
||||
func ClientCommonOptions(options map[string]string) map[string]any {
|
||||
result := map[string]any{}
|
||||
scv := OptionBoolean(ClientOption(options, "skip-cert-verify"))
|
||||
if scv == nil {
|
||||
scv = OptionBooleanInverted(ClientOption(options, "tls-verification"))
|
||||
}
|
||||
if scv != nil {
|
||||
result[skipCertVerify] = *scv
|
||||
}
|
||||
if fp := firstNonEmpty(ClientOption(options, "client-fingerprint"), ClientOption(options, "fingerprint")); fp != "" {
|
||||
result[clientFingerprint] = fp
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Constants for common option keys to avoid typos.
|
||||
const (
|
||||
skipCertVerify = "skip-cert-verify"
|
||||
clientFingerprint = "client-fingerprint"
|
||||
)
|
||||
|
||||
// OptionBooleanInverted parses a value where the semantics are inverted
|
||||
// (e.g. tls-verification where true means don't skip).
|
||||
func OptionBooleanInverted(value string) *bool {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
switch normalized {
|
||||
case "1", "true", "yes", "on", "enabled":
|
||||
b := false
|
||||
return &b
|
||||
case "0", "false", "no", "off", "disabled":
|
||||
b := true
|
||||
return &b
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// unquoteClientValue removes surrounding quotes from a value.
|
||||
func unquoteClientValue(input string) string {
|
||||
text := strings.TrimSpace(input)
|
||||
if len(text) < 2 {
|
||||
return text
|
||||
}
|
||||
first := text[0]
|
||||
last := text[len(text)-1]
|
||||
if (first == '"' || first == '\'') && last == first {
|
||||
return text[1 : len(text)-1]
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// qxTlsEnabled determines if TLS is enabled for a QX proxy.
|
||||
func qxTlsEnabled(options map[string]string) bool {
|
||||
obfs := strings.ToLower(ClientOption(options, "obfs"))
|
||||
if obfs == "tls" || obfs == "wss" || obfs == "over-tls" {
|
||||
return true
|
||||
}
|
||||
if b := OptionBoolean(ClientOption(options, "over-tls")); b != nil && *b {
|
||||
return true
|
||||
}
|
||||
if b := OptionBoolean(ClientOption(options, "tls")); b != nil && *b {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// qxNetwork determines the network type for a QX proxy.
|
||||
func qxNetwork(options map[string]string) string {
|
||||
obfs := strings.ToLower(ClientOption(options, "obfs"))
|
||||
if obfs == "ws" || obfs == "wss" {
|
||||
return "ws"
|
||||
}
|
||||
return "tcp"
|
||||
}
|
||||
|
||||
// qxWsOptions builds ws-opts for a QX proxy.
|
||||
func qxWsOptions(options map[string]string) any {
|
||||
if qxNetwork(options) != "ws" {
|
||||
return nil
|
||||
}
|
||||
wsOpts := map[string]any{
|
||||
"path": orDefault(ClientOption(options, "obfs-uri"), "/"),
|
||||
}
|
||||
host := ClientOption(options, "obfs-host")
|
||||
if host != "" {
|
||||
wsOpts["headers"] = map[string]any{"Host": host}
|
||||
}
|
||||
return StripUndefined(wsOpts)
|
||||
}
|
||||
|
||||
// qxPlugin determines the plugin name for a QX proxy.
|
||||
func qxPlugin(options map[string]string) any {
|
||||
obfs := strings.ToLower(ClientOption(options, "obfs"))
|
||||
if obfs == "http" || obfs == "shadowsocks-http" {
|
||||
return "obfs"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// qxPluginOptions builds plugin-opts for a QX proxy.
|
||||
func qxPluginOptions(options map[string]string) any {
|
||||
if qxPlugin(options) == nil {
|
||||
return nil
|
||||
}
|
||||
return StripUndefined(map[string]any{
|
||||
"mode": "http",
|
||||
"host": ClientOption(options, "obfs-host"),
|
||||
"path": ClientOption(options, "obfs-uri"),
|
||||
})
|
||||
}
|
||||
|
||||
// namedClientNetwork determines the network type for a named client proxy.
|
||||
func namedClientNetwork(options map[string]string) string {
|
||||
if b := OptionBoolean(ClientOption(options, "ws")); b != nil && *b {
|
||||
return "ws"
|
||||
}
|
||||
transport := firstNonEmpty(ClientOption(options, "transport"), ClientOption(options, "network"))
|
||||
if transport != "" {
|
||||
return transport
|
||||
}
|
||||
return "tcp"
|
||||
}
|
||||
|
||||
// namedClientWsOptions builds ws-opts for a named client proxy.
|
||||
func namedClientWsOptions(options map[string]string) any {
|
||||
if namedClientNetwork(options) != "ws" {
|
||||
return nil
|
||||
}
|
||||
wsOpts := map[string]any{
|
||||
"path": firstNonEmpty(ClientOption(options, "ws-path"), ClientOption(options, "path"), "/"),
|
||||
}
|
||||
host := ClientOption(options, "ws-headers")
|
||||
if host != "" {
|
||||
// Remove leading "Host:" prefix (case-insensitive)
|
||||
host = regexp.MustCompile(`(?i)^Host:`).ReplaceAllString(host, "")
|
||||
host = strings.TrimSpace(host)
|
||||
}
|
||||
if host == "" {
|
||||
host = firstNonEmpty(ClientOption(options, "ws-host"), ClientOption(options, "host"))
|
||||
}
|
||||
if host != "" {
|
||||
wsOpts["headers"] = map[string]any{"Host": host}
|
||||
}
|
||||
return StripUndefined(wsOpts)
|
||||
}
|
||||
|
||||
// parseRealityOptions extracts reality-opts from client options.
|
||||
func parseRealityOptions(options map[string]string) any {
|
||||
publicKey := firstNonEmpty(ClientOption(options, "reality-base64-pubkey"), ClientOption(options, "public-key"))
|
||||
shortId := firstNonEmpty(ClientOption(options, "reality-hex-shortid"), ClientOption(options, "short-id"))
|
||||
if publicKey == "" {
|
||||
return nil
|
||||
}
|
||||
return StripUndefined(map[string]any{
|
||||
"public-key": publicKey,
|
||||
"short-id": shortId,
|
||||
})
|
||||
}
|
||||
|
||||
// toFloat parses a string to float64, returning 0 on failure.
|
||||
func toFloat(s string) float64 {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// getAt returns the element at index i, or "" if out of bounds.
|
||||
func getAt(slice []string, i int) string {
|
||||
if i < 0 || i >= len(slice) {
|
||||
return ""
|
||||
}
|
||||
return slice[i]
|
||||
}
|
||||
|
||||
// ternary returns a if cond is true, else b.
|
||||
func ternary(cond bool, a, b string) string {
|
||||
if cond {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/peterqiu0516/sub-store/internal/model"
|
||||
"github.com/peterqiu0516/sub-store/internal/util"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
// reURIScheme matches a URI scheme like "vless://", "ss://", etc.
|
||||
reURIScheme = regexp.MustCompile(`(?im)^[a-z][a-z0-9+.-]*://`)
|
||||
// reYamlKeys matches YAML top-level keys: proxies, proxy-groups, rules
|
||||
reYamlKeys = regexp.MustCompile(`(?m)^\s*(proxies|proxy-groups|rules)\s*:`)
|
||||
// reJSONStart matches the start of a JSON array or object
|
||||
reJSONStart = regexp.MustCompile(`^\s*[\[{]`)
|
||||
// reQxConfig matches QX-style config lines: "shadowsocks = ...", "vmess = ...", etc.
|
||||
reQxConfig = regexp.MustCompile(`(?im)^\s*(shadowsocks|vmess|vless|trojan|http|socks5|anytls)\s*=`)
|
||||
// reNamedConfig matches Surge/Loon named config lines
|
||||
reNamedConfig = regexp.MustCompile(`(?im)^\s*[^=\n]{1,80}\s*=\s*(ss|shadowsocks|ssr|vmess|vless|trojan|http|https|socks5|socks5-tls|hysteria2|hysteria|anytls|tuic|tuic-v5)\s*,`)
|
||||
// reCommentOrSection matches lines to skip: comments (#, ;) and section headers ([...])
|
||||
reCommentOrSection = regexp.MustCompile(`^\s*(#|;|\[[^\]]+\])`)
|
||||
)
|
||||
|
||||
// ParseProxies is the main entry point for parsing subscription content.
|
||||
// It trims the input, detects JSON/YAML/URI-line format, and dispatches accordingly.
|
||||
func ParseProxies(raw string) []model.ProxyNode {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
if reJSONStart.MatchString(text) {
|
||||
return ParseJsonProxies(text)
|
||||
}
|
||||
if reYamlKeys.MatchString(text) {
|
||||
return ParseYamlProxies(text)
|
||||
}
|
||||
return ParseProxyLines(text)
|
||||
}
|
||||
|
||||
// DecodeMaybeBase64 tries structured detection first; if the input doesn't
|
||||
// look like a structured subscription, attempts base64 decode.
|
||||
func DecodeMaybeBase64(raw string) string {
|
||||
text := strings.TrimSpace(raw)
|
||||
if LooksLikeStructuredSubscription(text) {
|
||||
return raw
|
||||
}
|
||||
// Try base64 decode (strip all whitespace first)
|
||||
cleaned := regexp.MustCompile(`\s+`).ReplaceAllString(text, "")
|
||||
decoded, err := util.DecodeBase64Auto(cleaned)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
if LooksLikeStructuredSubscription(strings.TrimSpace(decoded)) {
|
||||
return decoded
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// LooksLikeStructuredSubscription checks if the text looks like structured
|
||||
// subscription content (URI scheme, YAML keys, JSON, or client config lines).
|
||||
func LooksLikeStructuredSubscription(text string) bool {
|
||||
return reURIScheme.MatchString(text) ||
|
||||
reYamlKeys.MatchString(text) ||
|
||||
reJSONStart.MatchString(text) ||
|
||||
reQxConfig.MatchString(text) ||
|
||||
reNamedConfig.MatchString(text)
|
||||
}
|
||||
|
||||
// ParseJsonProxies parses a JSON array of proxies or {"proxies": [...]}.
|
||||
func ParseJsonProxies(raw string) []model.ProxyNode {
|
||||
var payload any
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var rawList []any
|
||||
switch v := payload.(type) {
|
||||
case []any:
|
||||
rawList = v
|
||||
case map[string]any:
|
||||
if arr, ok := v["proxies"].([]any); ok {
|
||||
rawList = arr
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
var result []model.ProxyNode
|
||||
for _, item := range rawList {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
normalized := NormalizeProxy(m)
|
||||
if IsProxyNode(normalized) {
|
||||
result = append(result, normalized)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ParseYamlProxies parses a YAML document with a "proxies:" key.
|
||||
func ParseYamlProxies(raw string) []model.ProxyNode {
|
||||
var payload map[string]any
|
||||
if err := yaml.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
rawList, ok := payload["proxies"].([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
var result []model.ProxyNode
|
||||
for _, item := range rawList {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
normalized := NormalizeProxy(m)
|
||||
if IsProxyNode(normalized) {
|
||||
result = append(result, normalized)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ParseProxyLines splits raw text into lines, skips comments/blanks/section headers,
|
||||
// and parses each line as a URI or client config line.
|
||||
func ParseProxyLines(raw string) []model.ProxyNode {
|
||||
lines := strings.Split(raw, "\n")
|
||||
var result []model.ProxyNode
|
||||
index := 0
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
||||
continue
|
||||
}
|
||||
if reCommentOrSection.MatchString(line) {
|
||||
continue
|
||||
}
|
||||
|
||||
var node model.ProxyNode
|
||||
if node = ParseProxyUri(line, index); node == nil {
|
||||
node = ParseClientProxyLine(line, index)
|
||||
}
|
||||
if node != nil {
|
||||
result = append(result, node)
|
||||
index++
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/peterqiu0516/sub-store/internal/model"
|
||||
)
|
||||
|
||||
// NormalizeProxy ensures name/type are strings and port is a number,
|
||||
// then strips undefined (nil and "") values.
|
||||
func NormalizeProxy(input map[string]any) model.ProxyNode {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]any, len(input)+3)
|
||||
for k, v := range input {
|
||||
result[k] = v
|
||||
}
|
||||
if name, ok := result["name"]; ok {
|
||||
result["name"] = toString(name)
|
||||
} else {
|
||||
result["name"] = ""
|
||||
}
|
||||
if t, ok := result["type"]; ok {
|
||||
result["type"] = toString(t)
|
||||
} else {
|
||||
result["type"] = ""
|
||||
}
|
||||
if port, exists := result["port"]; exists {
|
||||
result["port"] = toNumberOrUndefined(port)
|
||||
}
|
||||
return StripUndefined(result)
|
||||
}
|
||||
|
||||
// IsProxyNode returns true if the node has both a name and a type.
|
||||
func IsProxyNode(node model.ProxyNode) bool {
|
||||
if node == nil {
|
||||
return false
|
||||
}
|
||||
name, ok := node["name"].(string)
|
||||
if !ok || name == "" {
|
||||
return false
|
||||
}
|
||||
t, ok := node["type"].(string)
|
||||
if !ok || t == "" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// StripUndefined removes nil and "" values from a map.
|
||||
// This mirrors the TS stripUndefined which filters out undefined and "".
|
||||
func StripUndefined(input map[string]any) map[string]any {
|
||||
if input == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
result := make(map[string]any, len(input))
|
||||
for k, v := range input {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
if s, ok := v.(string); ok && s == "" {
|
||||
continue
|
||||
}
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// AddPreviewIds adds an "id" field to each proxy computed by StableProxyId.
|
||||
func AddPreviewIds(proxies []model.ProxyNode) []model.ProxyNode {
|
||||
result := make([]model.ProxyNode, len(proxies))
|
||||
for i, proxy := range proxies {
|
||||
id := StableProxyId(proxy, i)
|
||||
merged := make(map[string]any, len(proxy)+1)
|
||||
merged["id"] = id
|
||||
for k, v := range proxy {
|
||||
merged[k] = v
|
||||
}
|
||||
result[i] = merged
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// StableProxyId builds a deterministic identifier from name|type|server|port|index.
|
||||
func StableProxyId(proxy model.ProxyNode, index int) string {
|
||||
return fmt.Sprintf("%s|%s|%s|%s|%d",
|
||||
toString(proxy["name"]),
|
||||
toString(proxy["type"]),
|
||||
toString(proxy["server"]),
|
||||
portString(proxy["port"]),
|
||||
index,
|
||||
)
|
||||
}
|
||||
|
||||
// EnsureUniqueProxyNames appends -2, -3, etc. to duplicate proxy names.
|
||||
func EnsureUniqueProxyNames(proxies []model.ProxyNode) []model.ProxyNode {
|
||||
seen := make(map[string]int, len(proxies))
|
||||
result := make([]model.ProxyNode, len(proxies))
|
||||
for i, proxy := range proxies {
|
||||
if proxy == nil {
|
||||
result[i] = proxy
|
||||
continue
|
||||
}
|
||||
name := toString(proxy["name"])
|
||||
count := seen[name]
|
||||
seen[name] = count + 1
|
||||
if count == 0 {
|
||||
result[i] = proxy
|
||||
continue
|
||||
}
|
||||
merged := make(map[string]any, len(proxy))
|
||||
for k, v := range proxy {
|
||||
merged[k] = v
|
||||
}
|
||||
merged["name"] = fmt.Sprintf("%s-%d", name, count+1)
|
||||
result[i] = merged
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// toString converts an any value to its string representation.
|
||||
func toString(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return val
|
||||
case bool:
|
||||
return strconv.FormatBool(val)
|
||||
case float64:
|
||||
return strconv.FormatFloat(val, 'f', -1, 64)
|
||||
case float32:
|
||||
return strconv.FormatFloat(float64(val), 'f', -1, 32)
|
||||
case int:
|
||||
return strconv.Itoa(val)
|
||||
case int64:
|
||||
return strconv.FormatInt(val, 10)
|
||||
case json.Number:
|
||||
return val.String()
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// portString returns the string representation of a port value.
|
||||
func portString(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return toString(v)
|
||||
}
|
||||
|
||||
// toNumberOrUndefined converts a value to a float64 (Go's JSON number),
|
||||
// returning nil if the value is nil or cannot be parsed.
|
||||
func toNumberOrUndefined(v any) any {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
return val
|
||||
case float32:
|
||||
return float64(val)
|
||||
case int:
|
||||
return float64(val)
|
||||
case int64:
|
||||
return float64(val)
|
||||
case string:
|
||||
if val == "" {
|
||||
return nil
|
||||
}
|
||||
n, err := strconv.ParseFloat(val, 64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return n
|
||||
case bool:
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/peterqiu0516/sub-store/internal/model"
|
||||
)
|
||||
|
||||
func TestParseSS(t *testing.T) {
|
||||
plain := "aes-256-gcm:password123@1.2.3.4:8388"
|
||||
encoded := base64.RawURLEncoding.EncodeToString([]byte(plain))
|
||||
uri := "ss://" + encoded + "#TestSS"
|
||||
nodes := ParseProxies(uri)
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("expected 1 node, got %d", len(nodes))
|
||||
}
|
||||
n := nodes[0]
|
||||
if n["type"] != "ss" {
|
||||
t.Errorf("expected type ss, got %v", n["type"])
|
||||
}
|
||||
if n["server"] != "1.2.3.4" {
|
||||
t.Errorf("expected server 1.2.3.4, got %v", n["server"])
|
||||
}
|
||||
if n["name"] != "TestSS" {
|
||||
t.Errorf("expected name TestSS, got %v", n["name"])
|
||||
}
|
||||
if n["cipher"] != "aes-256-gcm" {
|
||||
t.Errorf("expected cipher aes-256-gcm, got %v", n["cipher"])
|
||||
}
|
||||
if n["password"] != "password123" {
|
||||
t.Errorf("expected password password123, got %v", n["password"])
|
||||
}
|
||||
port, _ := n["port"].(float64)
|
||||
if port != 8388 {
|
||||
t.Errorf("expected port 8388, got %v", n["port"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSURLForm(t *testing.T) {
|
||||
uri := "ss://aes-256-gcm:password123@1.2.3.4:8388#TestSS"
|
||||
nodes := ParseProxies(uri)
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("expected 1 node, got %d", len(nodes))
|
||||
}
|
||||
n := nodes[0]
|
||||
if n["type"] != "ss" {
|
||||
t.Errorf("expected type ss, got %v", n["type"])
|
||||
}
|
||||
if n["cipher"] != "aes-256-gcm" {
|
||||
t.Errorf("expected cipher aes-256-gcm, got %v", n["cipher"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSR(t *testing.T) {
|
||||
// SSR format: base64(server:port:protocol:method:obfs:base64(password)/?remarks=base64(name))
|
||||
ssrMain := "1.2.3.4:8388:auth_aes128_sha1:aes-256-cfb:http_simple:" + base64.RawURLEncoding.EncodeToString([]byte("pass"))
|
||||
encoded := base64.RawURLEncoding.EncodeToString([]byte(ssrMain + "/?remarks=" + base64.RawURLEncoding.EncodeToString([]byte("TestSSR"))))
|
||||
uri := "ssr://" + encoded
|
||||
nodes := ParseProxies(uri)
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("expected 1 node, got %d", len(nodes))
|
||||
}
|
||||
n := nodes[0]
|
||||
if n["type"] != "ssr" {
|
||||
t.Errorf("expected type ssr, got %v", n["type"])
|
||||
}
|
||||
if n["server"] != "1.2.3.4" {
|
||||
t.Errorf("expected server 1.2.3.4, got %v", n["server"])
|
||||
}
|
||||
if n["obfs"] != "http_simple" {
|
||||
t.Errorf("expected obfs http_simple, got %v", n["obfs"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVMess(t *testing.T) {
|
||||
vmJSON := `{"v":"2","ps":"TestVM","add":"1.2.3.4","port":"443","id":"uuid-1234","aid":"0","scy":"auto","net":"ws","type":"none","host":"example.com","path":"/path","tls":"tls","sni":"example.com"}`
|
||||
encoded := base64.RawURLEncoding.EncodeToString([]byte(vmJSON))
|
||||
uri := "vmess://" + encoded
|
||||
nodes := ParseProxies(uri)
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("expected 1 node, got %d", len(nodes))
|
||||
}
|
||||
n := nodes[0]
|
||||
if n["type"] != "vmess" {
|
||||
t.Errorf("expected type vmess, got %v", n["type"])
|
||||
}
|
||||
if n["server"] != "1.2.3.4" {
|
||||
t.Errorf("expected server 1.2.3.4, got %v", n["server"])
|
||||
}
|
||||
if n["uuid"] != "uuid-1234" {
|
||||
t.Errorf("expected uuid uuid-1234, got %v", n["uuid"])
|
||||
}
|
||||
if n["network"] != "ws" {
|
||||
t.Errorf("expected network ws, got %v", n["network"])
|
||||
}
|
||||
if n["tls"] != true {
|
||||
t.Errorf("expected tls true, got %v", n["tls"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVLESS(t *testing.T) {
|
||||
uri := "vless://uuid-1234@1.2.3.4:443?encryption=none&security=tls&type=ws&host=example.com&path=%2Fpath&sni=example.com#TestVLESS"
|
||||
nodes := ParseProxies(uri)
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("expected 1 node, got %d", len(nodes))
|
||||
}
|
||||
n := nodes[0]
|
||||
if n["type"] != "vless" {
|
||||
t.Errorf("expected type vless, got %v", n["type"])
|
||||
}
|
||||
if n["uuid"] != "uuid-1234" {
|
||||
t.Errorf("expected uuid uuid-1234, got %v", n["uuid"])
|
||||
}
|
||||
if n["network"] != "ws" {
|
||||
t.Errorf("expected network ws, got %v", n["network"])
|
||||
}
|
||||
if n["tls"] != true {
|
||||
t.Errorf("expected tls true, got %v", n["tls"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrojan(t *testing.T) {
|
||||
uri := "trojan://password123@1.2.3.4:443?sni=example.com&type=ws&host=example.com&path=%2Fpath#TestTrojan"
|
||||
nodes := ParseProxies(uri)
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("expected 1 node, got %d", len(nodes))
|
||||
}
|
||||
n := nodes[0]
|
||||
if n["type"] != "trojan" {
|
||||
t.Errorf("expected type trojan, got %v", n["type"])
|
||||
}
|
||||
if n["password"] != "password123" {
|
||||
t.Errorf("expected password password123, got %v", n["password"])
|
||||
}
|
||||
if n["sni"] != "example.com" {
|
||||
t.Errorf("expected sni example.com, got %v", n["sni"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHysteria2(t *testing.T) {
|
||||
uri := "hysteria2://password123@1.2.3.4:443?sni=example.com&insecure=1#TestHys2"
|
||||
nodes := ParseProxies(uri)
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("expected 1 node, got %d", len(nodes))
|
||||
}
|
||||
n := nodes[0]
|
||||
if n["type"] != "hysteria2" {
|
||||
t.Errorf("expected type hysteria2, got %v", n["type"])
|
||||
}
|
||||
if n["password"] != "password123" {
|
||||
t.Errorf("expected password password123, got %v", n["password"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMultipleLines(t *testing.T) {
|
||||
lines := []string{
|
||||
"ss://aes-256-gcm:pass@1.2.3.4:8388#Node1",
|
||||
"ss://aes-256-gcm:pass@5.6.7.8:8388#Node2",
|
||||
"ss://aes-256-gcm:pass@9.10.11.12:8388#Node3",
|
||||
}
|
||||
nodes := ParseProxies(strings.Join(lines, "\n"))
|
||||
if len(nodes) != 3 {
|
||||
t.Fatalf("expected 3 nodes, got %d", len(nodes))
|
||||
}
|
||||
if nodes[0]["name"] != "Node1" {
|
||||
t.Errorf("expected name Node1, got %v", nodes[0]["name"])
|
||||
}
|
||||
if nodes[2]["name"] != "Node3" {
|
||||
t.Errorf("expected name Node3, got %v", nodes[2]["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBase64Content(t *testing.T) {
|
||||
lines := []string{
|
||||
"ss://aes-256-gcm:pass@1.2.3.4:8388#Node1",
|
||||
"ss://aes-256-gcm:pass@5.6.7.8:8388#Node2",
|
||||
}
|
||||
plain := strings.Join(lines, "\n")
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(plain))
|
||||
// DecodeMaybeBase64 should detect and decode base64 content
|
||||
decoded := DecodeMaybeBase64(encoded)
|
||||
nodes := ParseProxies(decoded)
|
||||
if len(nodes) != 2 {
|
||||
t.Fatalf("expected 2 nodes from base64 content, got %d", len(nodes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEmptyContent(t *testing.T) {
|
||||
nodes := ParseProxies("")
|
||||
if len(nodes) != 0 {
|
||||
t.Errorf("expected 0 nodes for empty content, got %d", len(nodes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInvalidContent(t *testing.T) {
|
||||
nodes := ParseProxies("this is not a valid proxy URI")
|
||||
if len(nodes) != 0 {
|
||||
t.Errorf("expected 0 nodes for invalid content, got %d", len(nodes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONContent(t *testing.T) {
|
||||
jsonContent := `[{"type":"ss","server":"1.2.3.4","port":8388,"cipher":"aes-256-gcm","password":"pass","name":"TestJSON"}]`
|
||||
nodes := ParseProxies(jsonContent)
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("expected 1 node from JSON, got %d", len(nodes))
|
||||
}
|
||||
if nodes[0]["type"] != "ss" {
|
||||
t.Errorf("expected type ss, got %v", nodes[0]["type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureUniqueProxyNames(t *testing.T) {
|
||||
nodes := []model.ProxyNode{
|
||||
{"name": "Same", "server": "1.1.1.1"},
|
||||
{"name": "Same", "server": "2.2.2.2"},
|
||||
{"name": "Same", "server": "3.3.3.3"},
|
||||
{"name": "Unique", "server": "4.4.4.4"},
|
||||
}
|
||||
result := EnsureUniqueProxyNames(nodes)
|
||||
if result[0]["name"] != "Same" {
|
||||
t.Errorf("first name should be unchanged, got %v", result[0]["name"])
|
||||
}
|
||||
if result[1]["name"] == "Same" {
|
||||
t.Error("second duplicate should be renamed")
|
||||
}
|
||||
if result[3]["name"] != "Unique" {
|
||||
t.Errorf("unique name should be unchanged, got %v", result[3]["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddPreviewIds(t *testing.T) {
|
||||
nodes := []model.ProxyNode{
|
||||
{"name": "A", "server": "1.1.1.1"},
|
||||
{"name": "B", "server": "2.2.2.2"},
|
||||
}
|
||||
result := AddPreviewIds(nodes)
|
||||
if _, ok := result[0]["id"]; !ok {
|
||||
t.Error("expected id field on first node")
|
||||
}
|
||||
if _, ok := result[1]["id"]; !ok {
|
||||
t.Error("expected id field on second node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeMaybeBase64(t *testing.T) {
|
||||
plain := "ss://pass@host:port#name"
|
||||
if DecodeMaybeBase64(plain) != plain {
|
||||
t.Error("plain text should pass through")
|
||||
}
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(plain))
|
||||
decoded := DecodeMaybeBase64(encoded)
|
||||
if !strings.Contains(decoded, "ss://") {
|
||||
t.Errorf("base64 should be decoded, got: %s", decoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeJSONSerialization(t *testing.T) {
|
||||
uri := "ss://aes-256-gcm:pass@1.2.3.4:8388#Test"
|
||||
nodes := ParseProxies(uri)
|
||||
if len(nodes) == 0 {
|
||||
t.Fatal("no nodes parsed")
|
||||
}
|
||||
data, err := json.Marshal(nodes[0])
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal node: %v", err)
|
||||
}
|
||||
var back map[string]any
|
||||
if err := json.Unmarshal(data, &back); err != nil {
|
||||
t.Fatalf("failed to unmarshal node: %v", err)
|
||||
}
|
||||
if back["type"] != "ss" {
|
||||
t.Errorf("expected type ss after round-trip, got %v", back["type"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/peterqiu0516/sub-store/internal/model"
|
||||
"github.com/peterqiu0516/sub-store/internal/util"
|
||||
)
|
||||
|
||||
// SplitHostPort splits a host:port string using strings.LastIndex(":").
|
||||
// Per review-resolution #13: not net.SplitHostPort, mirrors TS lastIndexOf(":").
|
||||
func SplitHostPort(s string) (host, port string) {
|
||||
value := strings.TrimSpace(s)
|
||||
lastColon := strings.LastIndex(value, ":")
|
||||
if lastColon <= 0 {
|
||||
return value, ""
|
||||
}
|
||||
return value[:lastColon], value[lastColon+1:]
|
||||
}
|
||||
|
||||
// ParseProxyUri dispatches a single URI line to the appropriate protocol parser.
|
||||
// Returns nil for unrecognized schemes or parse failures.
|
||||
func ParseProxyUri(line string, index int) model.ProxyNode {
|
||||
defer func() { _ = recover() }()
|
||||
switch {
|
||||
case strings.HasPrefix(line, "vless://"):
|
||||
return ParseVless(line, index)
|
||||
case strings.HasPrefix(line, "anytls://"):
|
||||
return ParseAnytls(line, index)
|
||||
case strings.HasPrefix(line, "hysteria://"), strings.HasPrefix(line, "hy://"):
|
||||
return ParseHysteria(line, index)
|
||||
case strings.HasPrefix(line, "hysteria2://"), strings.HasPrefix(line, "hy2://"):
|
||||
return ParseHysteria2(line, index)
|
||||
case strings.HasPrefix(line, "trojan://"):
|
||||
return ParseTrojan(line, index)
|
||||
case strings.HasPrefix(line, "vmess://"):
|
||||
return ParseVmess(line, index)
|
||||
case strings.HasPrefix(line, "ss://"):
|
||||
return ParseShadowsocks(line, index)
|
||||
case strings.HasPrefix(line, "ssr://"):
|
||||
return ParseShadowsocksR(line, index)
|
||||
case strings.HasPrefix(line, "socks://"), strings.HasPrefix(line, "socks5://"), strings.HasPrefix(line, "socks5+tls://"):
|
||||
return ParseSocks(line, index)
|
||||
case strings.HasPrefix(line, "tuic://"):
|
||||
return ParseTuic(line, index)
|
||||
case strings.HasPrefix(line, "wireguard://"), strings.HasPrefix(line, "wg://"):
|
||||
return ParseWireGuard(line, index)
|
||||
case strings.HasPrefix(line, "http://"), strings.HasPrefix(line, "https://"):
|
||||
return ParseHttpProxy(line, index)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// parseURL is a helper that parses a URL and panics on error so the deferred
|
||||
// recover in ParseProxyUri catches it — mirroring the TS `new URL(line)` throw.
|
||||
func parseURL(line string) *url.URL {
|
||||
u, err := url.Parse(line)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// fragmentName extracts and URL-decodes the fragment (node name).
|
||||
// Per review-resolution #14: url.Fragment is raw; use url.QueryUnescape.
|
||||
func fragmentName(u *url.URL, fallback string) string {
|
||||
if u.Fragment == "" {
|
||||
return fallback
|
||||
}
|
||||
decoded, err := url.QueryUnescape(u.Fragment)
|
||||
if err != nil {
|
||||
return u.Fragment
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
// userInfo extracts the URL-decoded username from userinfo.
|
||||
func userInfo(u *url.URL) string {
|
||||
if u.User == nil {
|
||||
return ""
|
||||
}
|
||||
return u.User.Username()
|
||||
}
|
||||
|
||||
// userPassword extracts the URL-decoded password from userinfo.
|
||||
func userPassword(u *url.URL) string {
|
||||
if u.User == nil {
|
||||
return ""
|
||||
}
|
||||
p, _ := u.User.Password()
|
||||
return p
|
||||
}
|
||||
|
||||
// portFromURL returns the port as a number, defaulting to fallback.
|
||||
func portFromURL(u *url.URL, fallback int) float64 {
|
||||
portStr := u.Port()
|
||||
if portStr == "" {
|
||||
return float64(fallback)
|
||||
}
|
||||
n, err := strconv.ParseFloat(portStr, 64)
|
||||
if err != nil {
|
||||
return float64(fallback)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// paramGet returns the first value of a query parameter, "" if absent.
|
||||
// Per review-resolution #14: Go Query().Get returns "" (not nil like JS).
|
||||
func paramGet(u *url.URL, key string) string {
|
||||
return u.Query().Get(key)
|
||||
}
|
||||
|
||||
// paramFirst returns the first non-empty value among the given keys.
|
||||
func paramFirst(u *url.URL, keys ...string) string {
|
||||
q := u.Query()
|
||||
for _, k := range keys {
|
||||
if v := q.Get(k); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ParseVless parses a vless:// URI.
|
||||
func ParseVless(line string, index int) model.ProxyNode {
|
||||
u := parseURL(line)
|
||||
params := u.Query()
|
||||
publicKey := firstNonEmpty(params.Get("pbk"), params.Get("public-key"))
|
||||
shortId := firstNonEmpty(params.Get("sid"), params.Get("short-id"))
|
||||
security := params.Get("security")
|
||||
if security == "" {
|
||||
if publicKey != "" {
|
||||
security = "reality"
|
||||
} else {
|
||||
security = "tls"
|
||||
}
|
||||
}
|
||||
|
||||
node := map[string]any{
|
||||
"name": fragmentName(u, fmt.Sprintf("vless-%d", index+1)),
|
||||
"type": "vless",
|
||||
"server": u.Hostname(),
|
||||
"port": portFromURL(u, 443),
|
||||
"uuid": userInfo(u),
|
||||
"udp": true,
|
||||
"flow": params.Get("flow"),
|
||||
"network": orDefault(params.Get("type"), "tcp"),
|
||||
"tls": security != "none",
|
||||
"servername": params.Get("sni"),
|
||||
"encryption": orDefault(params.Get("encryption"), "none"),
|
||||
"client-fingerprint": orDefault(params.Get("fp"), "chrome"),
|
||||
}
|
||||
if publicKey != "" {
|
||||
node["reality-opts"] = StripUndefined(map[string]any{
|
||||
"public-key": publicKey,
|
||||
"short-id": shortId,
|
||||
"spider-x": orDefault(params.Get("spx"), "/"),
|
||||
})
|
||||
}
|
||||
return StripUndefined(node)
|
||||
}
|
||||
|
||||
// ParseAnytls parses an anytls:// URI.
|
||||
func ParseAnytls(line string, index int) model.ProxyNode {
|
||||
u := parseURL(line)
|
||||
return StripUndefined(map[string]any{
|
||||
"name": fragmentName(u, fmt.Sprintf("anytls-%d", index+1)),
|
||||
"type": "anytls",
|
||||
"server": u.Hostname(),
|
||||
"port": portFromURL(u, 443),
|
||||
"password": userInfo(u),
|
||||
"sni": firstNonEmpty(paramGet(u, "sni"), paramGet(u, "peer")),
|
||||
"skip-cert-verify": BoolParam(firstNonEmpty(paramGet(u, "insecure"), paramGet(u, "allowInsecure"))),
|
||||
"client-fingerprint": orDefault(paramGet(u, "fp"), "chrome"),
|
||||
})
|
||||
}
|
||||
|
||||
// ParseHysteria2 parses a hysteria2:// or hy2:// URI.
|
||||
func ParseHysteria2(line string, index int) model.ProxyNode {
|
||||
normalized := strings.Replace(line, "hy2://", "hysteria2://", 1)
|
||||
u := parseURL(normalized)
|
||||
return StripUndefined(map[string]any{
|
||||
"name": fragmentName(u, fmt.Sprintf("hysteria2-%d", index+1)),
|
||||
"type": "hysteria2",
|
||||
"server": u.Hostname(),
|
||||
"port": portFromURL(u, 443),
|
||||
"password": userInfo(u),
|
||||
"sni": firstNonEmpty(paramGet(u, "sni"), paramGet(u, "peer")),
|
||||
"skip-cert-verify": BoolParam(firstNonEmpty(paramGet(u, "insecure"), paramGet(u, "allowInsecure"))),
|
||||
"obfs": paramGet(u, "obfs"),
|
||||
"obfs-password": firstNonEmpty(paramGet(u, "obfs-password"), paramGet(u, "salamander-password")),
|
||||
})
|
||||
}
|
||||
|
||||
// ParseHysteria parses a hysteria:// or hy:// URI.
|
||||
func ParseHysteria(line string, index int) model.ProxyNode {
|
||||
normalized := strings.Replace(line, "hy://", "hysteria://", 1)
|
||||
u := parseURL(normalized)
|
||||
authStr := userInfo(u)
|
||||
if authStr == "" {
|
||||
authStr = firstNonEmpty(paramGet(u, "auth"), paramGet(u, "auth_str"))
|
||||
}
|
||||
return StripUndefined(map[string]any{
|
||||
"name": fragmentName(u, fmt.Sprintf("hysteria-%d", index+1)),
|
||||
"type": "hysteria",
|
||||
"server": u.Hostname(),
|
||||
"port": portFromURL(u, 443),
|
||||
"auth_str": authStr,
|
||||
"protocol": paramGet(u, "protocol"),
|
||||
"up": firstNonEmpty(paramGet(u, "up"), paramGet(u, "upmbps")),
|
||||
"down": firstNonEmpty(paramGet(u, "down"), paramGet(u, "downmbps")),
|
||||
"sni": firstNonEmpty(paramGet(u, "sni"), paramGet(u, "peer")),
|
||||
"alpn": CommaList(paramGet(u, "alpn")),
|
||||
"obfs": paramGet(u, "obfs"),
|
||||
"obfs-password": paramGet(u, "obfs-password"),
|
||||
"skip-cert-verify": BoolParam(firstNonEmpty(paramGet(u, "insecure"), paramGet(u, "allowInsecure"))),
|
||||
})
|
||||
}
|
||||
|
||||
// ParseTrojan parses a trojan:// URI.
|
||||
func ParseTrojan(line string, index int) model.ProxyNode {
|
||||
u := parseURL(line)
|
||||
return StripUndefined(map[string]any{
|
||||
"name": fragmentName(u, fmt.Sprintf("trojan-%d", index+1)),
|
||||
"type": "trojan",
|
||||
"server": u.Hostname(),
|
||||
"port": portFromURL(u, 443),
|
||||
"password": userInfo(u),
|
||||
"sni": firstNonEmpty(paramGet(u, "sni"), paramGet(u, "peer")),
|
||||
"skip-cert-verify": BoolParam(paramGet(u, "allowInsecure")),
|
||||
"udp": true,
|
||||
})
|
||||
}
|
||||
|
||||
// ParseVmess parses a vmess:// URI with base64-encoded JSON payload.
|
||||
func ParseVmess(line string, index int) model.ProxyNode {
|
||||
encoded := strings.TrimPrefix(line, "vmess://")
|
||||
decoded, err := util.DecodeBase64Auto(strings.TrimSpace(encoded))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(decoded), &payload); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
port := toNumberOrUndefined(payload["port"])
|
||||
node := map[string]any{
|
||||
"name": orDefault(toString(payload["ps"]), fmt.Sprintf("vmess-%d", index+1)),
|
||||
"type": "vmess",
|
||||
"server": toString(payload["add"]),
|
||||
"port": port,
|
||||
"uuid": toString(payload["id"]),
|
||||
"alterId": toNumberOrUndefined(payload["aid"]),
|
||||
"cipher": orDefault(toString(payload["scy"]), "auto"),
|
||||
"tls": toString(payload["tls"]) == "tls",
|
||||
"servername": firstNonEmpty(toString(payload["sni"]), toString(payload["host"])),
|
||||
"network": orDefault(toString(payload["net"]), "tcp"),
|
||||
"udp": true,
|
||||
}
|
||||
if toString(payload["net"]) == "ws" {
|
||||
wsOpts := map[string]any{
|
||||
"path": orDefault(toString(payload["path"]), "/"),
|
||||
}
|
||||
host := toString(payload["host"])
|
||||
if host != "" {
|
||||
wsOpts["headers"] = map[string]any{"Host": host}
|
||||
}
|
||||
node["ws-opts"] = wsOpts
|
||||
}
|
||||
return StripUndefined(node)
|
||||
}
|
||||
|
||||
// ParseShadowsocks parses an ss:// URI.
|
||||
// Handles both ss://base64(cipher:password@host:port) and
|
||||
// ss://base64(cipher:password)@host:port formats.
|
||||
func ParseShadowsocks(line string, index int) model.ProxyNode {
|
||||
defer func() { _ = recover() }()
|
||||
withoutScheme := strings.TrimPrefix(line, "ss://")
|
||||
var main, hash string
|
||||
if idx := strings.Index(withoutScheme, "#"); idx >= 0 {
|
||||
main = withoutScheme[:idx]
|
||||
hash = withoutScheme[idx+1:]
|
||||
} else {
|
||||
main = withoutScheme
|
||||
}
|
||||
|
||||
var decodedMain string
|
||||
if strings.Contains(main, "@") {
|
||||
decodedMain = main
|
||||
} else {
|
||||
// base64-encoded cipher:password@host:port
|
||||
d, err := util.DecodeBase64Auto(main)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
decodedMain = d
|
||||
}
|
||||
|
||||
atIdx := strings.LastIndex(decodedMain, "@")
|
||||
if atIdx < 0 {
|
||||
return nil
|
||||
}
|
||||
userInfoStr := decodedMain[:atIdx]
|
||||
hostInfo := decodedMain[atIdx+1:]
|
||||
|
||||
var decodedUserInfo string
|
||||
if strings.Contains(userInfoStr, ":") {
|
||||
decodedUserInfo = userInfoStr
|
||||
} else {
|
||||
// base64-encoded cipher:password
|
||||
d, err := util.DecodeBase64Auto(userInfoStr)
|
||||
if err != nil {
|
||||
decodedUserInfo = userInfoStr
|
||||
} else {
|
||||
decodedUserInfo = d
|
||||
}
|
||||
}
|
||||
|
||||
colonIdx := strings.Index(decodedUserInfo, ":")
|
||||
var cipher, password string
|
||||
if colonIdx >= 0 {
|
||||
cipher = decodedUserInfo[:colonIdx]
|
||||
password = decodedUserInfo[colonIdx+1:]
|
||||
} else {
|
||||
cipher = decodedUserInfo
|
||||
}
|
||||
|
||||
host, portPart := SplitHostPort(hostInfo)
|
||||
// strip query string from port
|
||||
portStr := portPart
|
||||
if qIdx := strings.Index(portStr, "?"); qIdx >= 0 {
|
||||
portStr = portStr[:qIdx]
|
||||
}
|
||||
port, _ := strconv.ParseFloat(portStr, 64)
|
||||
|
||||
name := fmt.Sprintf("ss-%d", index+1)
|
||||
if hash != "" {
|
||||
decoded, err := url.QueryUnescape(hash)
|
||||
if err != nil {
|
||||
name = hash
|
||||
} else {
|
||||
name = decoded
|
||||
}
|
||||
}
|
||||
|
||||
return StripUndefined(map[string]any{
|
||||
"name": name,
|
||||
"type": "ss",
|
||||
"server": host,
|
||||
"port": port,
|
||||
"cipher": cipher,
|
||||
"password": password,
|
||||
"udp": true,
|
||||
})
|
||||
}
|
||||
|
||||
// ParseShadowsocksR parses an ssr:// URI.
|
||||
// The payload after ssr:// is base64 (RawURL) decoded, then split by ":" and "/?".
|
||||
func ParseShadowsocksR(line string, index int) model.ProxyNode {
|
||||
defer func() { _ = recover() }()
|
||||
encoded := strings.TrimPrefix(line, "ssr://")
|
||||
decoded, err := util.DecodeBase64RawURL(encoded)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var main, rawQuery string
|
||||
if idx := strings.Index(decoded, "/?"); idx >= 0 {
|
||||
main = decoded[:idx]
|
||||
rawQuery = decoded[idx+2:]
|
||||
} else {
|
||||
main = decoded
|
||||
}
|
||||
|
||||
parts := strings.Split(main, ":")
|
||||
if len(parts) < 6 {
|
||||
return nil
|
||||
}
|
||||
server := parts[0]
|
||||
portStr := parts[1]
|
||||
protocol := parts[2]
|
||||
method := parts[3]
|
||||
obfs := parts[4]
|
||||
encodedPassword := parts[5]
|
||||
|
||||
port, _ := strconv.ParseFloat(portStr, 64)
|
||||
|
||||
query, err := url.ParseQuery(rawQuery)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
remarks := query.Get("remarks")
|
||||
var name string
|
||||
if remarks != "" {
|
||||
decodedRemarks, err := util.DecodeBase64RawURL(remarks)
|
||||
if err != nil {
|
||||
name = remarks
|
||||
} else {
|
||||
name = decodedRemarks
|
||||
}
|
||||
} else {
|
||||
name = fmt.Sprintf("ssr-%d", index+1)
|
||||
}
|
||||
|
||||
password, err := util.DecodeBase64RawURL(encodedPassword)
|
||||
if err != nil {
|
||||
password = encodedPassword
|
||||
}
|
||||
|
||||
node := map[string]any{
|
||||
"name": name,
|
||||
"type": "ssr",
|
||||
"server": server,
|
||||
"port": port,
|
||||
"cipher": method,
|
||||
"password": password,
|
||||
"protocol": protocol,
|
||||
"obfs": obfs,
|
||||
"udp": true,
|
||||
}
|
||||
|
||||
if pp := query.Get("protoparam"); pp != "" {
|
||||
decoded, err := util.DecodeBase64RawURL(pp)
|
||||
if err == nil {
|
||||
node["protocol-param"] = decoded
|
||||
}
|
||||
}
|
||||
if op := query.Get("obfsparam"); op != "" {
|
||||
decoded, err := util.DecodeBase64RawURL(op)
|
||||
if err == nil {
|
||||
node["obfs-param"] = decoded
|
||||
}
|
||||
}
|
||||
|
||||
return StripUndefined(node)
|
||||
}
|
||||
|
||||
// ParseSocks parses a socks://, socks5://, or socks5+tls:// URI.
|
||||
func ParseSocks(line string, index int) model.ProxyNode {
|
||||
defer func() { _ = recover() }()
|
||||
normalized := line
|
||||
normalized = strings.Replace(normalized, "socks://", "socks5://", 1)
|
||||
normalized = strings.Replace(normalized, "socks5+tls://", "socks5://", 1)
|
||||
u := parseURL(normalized)
|
||||
if u.Port() == "" {
|
||||
return nil
|
||||
}
|
||||
return StripUndefined(map[string]any{
|
||||
"name": fragmentName(u, fmt.Sprintf("socks5-%d", index+1)),
|
||||
"type": "socks5",
|
||||
"server": u.Hostname(),
|
||||
"port": portFromURL(u, 0),
|
||||
"username": userInfo(u),
|
||||
"password": userPassword(u),
|
||||
"tls": strings.HasPrefix(line, "socks5+tls://") || BoolParam(paramGet(u, "tls")),
|
||||
"udp": true,
|
||||
})
|
||||
}
|
||||
|
||||
// ParseHttpProxy parses an http:// or https:// proxy URI.
|
||||
func ParseHttpProxy(line string, index int) model.ProxyNode {
|
||||
defer func() { _ = recover() }()
|
||||
u := parseURL(line)
|
||||
if u.Port() == "" {
|
||||
return nil
|
||||
}
|
||||
fallback := fmt.Sprintf("http-%d", index+1)
|
||||
if u.Scheme == "https" {
|
||||
fallback = fmt.Sprintf("https-%d", index+1)
|
||||
}
|
||||
return StripUndefined(map[string]any{
|
||||
"name": fragmentName(u, fallback),
|
||||
"type": "http",
|
||||
"server": u.Hostname(),
|
||||
"port": portFromURL(u, 0),
|
||||
"username": userInfo(u),
|
||||
"password": userPassword(u),
|
||||
"tls": u.Scheme == "https",
|
||||
})
|
||||
}
|
||||
|
||||
// ParseTuic parses a tuic:// URI.
|
||||
func ParseTuic(line string, index int) model.ProxyNode {
|
||||
u := parseURL(line)
|
||||
return StripUndefined(map[string]any{
|
||||
"name": fragmentName(u, fmt.Sprintf("tuic-%d", index+1)),
|
||||
"type": "tuic",
|
||||
"server": u.Hostname(),
|
||||
"port": portFromURL(u, 443),
|
||||
"uuid": userInfo(u),
|
||||
"password": userPassword(u),
|
||||
"sni": paramGet(u, "sni"),
|
||||
"alpn": CommaList(paramGet(u, "alpn")),
|
||||
"skip-cert-verify": BoolParam(firstNonEmpty(paramGet(u, "allow_insecure"), paramGet(u, "insecure"))),
|
||||
"disable-sni": BoolParam(firstNonEmpty(paramGet(u, "disable_sni"), paramGet(u, "disable-sni"))),
|
||||
"reduce-rtt": BoolParam(firstNonEmpty(paramGet(u, "reduce_rtt"), paramGet(u, "reduce-rtt"))),
|
||||
"udp-relay-mode": firstNonEmpty(paramGet(u, "udp_relay_mode"), paramGet(u, "udp-relay-mode")),
|
||||
"congestion-controller": firstNonEmpty(paramGet(u, "congestion_control"), paramGet(u, "congestion-controller")),
|
||||
})
|
||||
}
|
||||
|
||||
// ParseWireGuard parses a wireguard:// or wg:// URI.
|
||||
func ParseWireGuard(line string, index int) model.ProxyNode {
|
||||
normalized := strings.Replace(line, "wg://", "wireguard://", 1)
|
||||
u := parseURL(normalized)
|
||||
return StripUndefined(map[string]any{
|
||||
"name": fragmentName(u, fmt.Sprintf("wireguard-%d", index+1)),
|
||||
"type": "wireguard",
|
||||
"server": u.Hostname(),
|
||||
"port": portFromURL(u, 51820),
|
||||
"ip": firstNonEmpty(paramGet(u, "ip"), paramGet(u, "address")),
|
||||
"ipv6": paramGet(u, "ipv6"),
|
||||
"private-key": firstNonEmpty(userInfo(u), paramGet(u, "private-key"), paramGet(u, "privatekey")),
|
||||
"public-key": firstNonEmpty(paramGet(u, "public-key"), paramGet(u, "publickey"), paramGet(u, "peer-public-key")),
|
||||
"pre-shared-key": firstNonEmpty(paramGet(u, "pre-shared-key"), paramGet(u, "presharedkey"), paramGet(u, "psk")),
|
||||
"reserved": paramGet(u, "reserved"),
|
||||
"udp": true,
|
||||
})
|
||||
}
|
||||
|
||||
// BoolParam returns true if value is "1" or "true".
|
||||
func BoolParam(value string) bool {
|
||||
return value == "1" || value == "true"
|
||||
}
|
||||
|
||||
// CommaList splits a comma-separated string into a trimmed []string.
|
||||
// Returns nil if the input is empty or produces no items.
|
||||
func CommaList(value string) []string {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
var list []string
|
||||
for _, item := range strings.Split(value, ",") {
|
||||
trimmed := strings.TrimSpace(item)
|
||||
if trimmed != "" {
|
||||
list = append(list, trimmed)
|
||||
}
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// NumberOrUndefined converts a value to a float64, returning nil if invalid.
|
||||
func NumberOrUndefined(value any) any {
|
||||
return toNumberOrUndefined(value)
|
||||
}
|
||||
|
||||
// firstNonEmpty returns the first non-empty string from the arguments.
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// orDefault returns value if non-empty, otherwise fallback.
|
||||
func orDefault(value, fallback string) string {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
Reference in New Issue
Block a user