Files
sub-store/internal/proxy/client_parser.go
T

652 lines
22 KiB
Go

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
}