574 lines
17 KiB
Go
574 lines
17 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 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
|
|
}
|