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 }