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 node["network"] == "ws" { wsOpts := map[string]any{ "path": orDefault(params.Get("path"), "/"), } host := params.Get("host") if host != "" { wsOpts["headers"] = map[string]any{"Host": host} } node["ws-opts"] = wsOpts } 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) // WireGuard keys are base64-encoded and may contain '+' characters. // Go's url.Query() treats '+' as space (form-encoding behavior), which corrupts base64 keys. // Use rawQueryUnescapeForKeys to preserve '+' in key values. 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(rawParamGet(u, "ip"), rawParamGet(u, "address")), "ipv6": rawParamGet(u, "ipv6"), "private-key": firstNonEmpty(userInfo(u), rawParamGet(u, "private-key"), rawParamGet(u, "privatekey")), "public-key": firstNonEmpty(rawParamGet(u, "public-key"), rawParamGet(u, "publickey"), rawParamGet(u, "peer-public-key")), "pre-shared-key": firstNonEmpty(rawParamGet(u, "pre-shared-key"), rawParamGet(u, "presharedkey"), rawParamGet(u, "psk")), "reserved": rawParamGet(u, "reserved"), "udp": true, }) } // rawParamGet retrieves a query parameter value while preserving '+' characters. // This is necessary for base64-encoded values (e.g., WireGuard keys) where // Go's standard url.Query() would incorrectly convert '+' to spaces. func rawParamGet(u *url.URL, key string) string { raw := u.RawQuery if raw == "" { return "" } for _, pair := range strings.Split(raw, "&") { kv := strings.SplitN(pair, "=", 2) if len(kv) != 2 { continue } k, _ := url.QueryUnescape(kv[0]) if k != key { continue } // Unescape manually, but preserve '+' as literal '+' val := strings.ReplaceAll(kv[1], "+", "%2B") v, err := url.QueryUnescape(val) if err != nil { return kv[1] } return v } return "" } // 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 }