Files
2026-07-28 10:32:21 +08:00

615 lines
18 KiB
Go

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
}