chore: sub-store Go 重写项目初始化

This commit is contained in:
2026-07-27 14:38:13 +08:00
commit 90e53aa754
85 changed files with 15271 additions and 0 deletions
+651
View File
@@ -0,0 +1,651 @@
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
}
+156
View File
@@ -0,0 +1,156 @@
package proxy
import (
"encoding/json"
"regexp"
"strings"
"github.com/peterqiu0516/sub-store/internal/model"
"github.com/peterqiu0516/sub-store/internal/util"
"gopkg.in/yaml.v3"
)
var (
// reURIScheme matches a URI scheme like "vless://", "ss://", etc.
reURIScheme = regexp.MustCompile(`(?im)^[a-z][a-z0-9+.-]*://`)
// reYamlKeys matches YAML top-level keys: proxies, proxy-groups, rules
reYamlKeys = regexp.MustCompile(`(?m)^\s*(proxies|proxy-groups|rules)\s*:`)
// reJSONStart matches the start of a JSON array or object
reJSONStart = regexp.MustCompile(`^\s*[\[{]`)
// reQxConfig matches QX-style config lines: "shadowsocks = ...", "vmess = ...", etc.
reQxConfig = regexp.MustCompile(`(?im)^\s*(shadowsocks|vmess|vless|trojan|http|socks5|anytls)\s*=`)
// reNamedConfig matches Surge/Loon named config lines
reNamedConfig = regexp.MustCompile(`(?im)^\s*[^=\n]{1,80}\s*=\s*(ss|shadowsocks|ssr|vmess|vless|trojan|http|https|socks5|socks5-tls|hysteria2|hysteria|anytls|tuic|tuic-v5)\s*,`)
// reCommentOrSection matches lines to skip: comments (#, ;) and section headers ([...])
reCommentOrSection = regexp.MustCompile(`^\s*(#|;|\[[^\]]+\])`)
)
// ParseProxies is the main entry point for parsing subscription content.
// It trims the input, detects JSON/YAML/URI-line format, and dispatches accordingly.
func ParseProxies(raw string) []model.ProxyNode {
text := strings.TrimSpace(raw)
if text == "" {
return nil
}
if reJSONStart.MatchString(text) {
return ParseJsonProxies(text)
}
if reYamlKeys.MatchString(text) {
return ParseYamlProxies(text)
}
return ParseProxyLines(text)
}
// DecodeMaybeBase64 tries structured detection first; if the input doesn't
// look like a structured subscription, attempts base64 decode.
func DecodeMaybeBase64(raw string) string {
text := strings.TrimSpace(raw)
if LooksLikeStructuredSubscription(text) {
return raw
}
// Try base64 decode (strip all whitespace first)
cleaned := regexp.MustCompile(`\s+`).ReplaceAllString(text, "")
decoded, err := util.DecodeBase64Auto(cleaned)
if err != nil {
return raw
}
if LooksLikeStructuredSubscription(strings.TrimSpace(decoded)) {
return decoded
}
return raw
}
// LooksLikeStructuredSubscription checks if the text looks like structured
// subscription content (URI scheme, YAML keys, JSON, or client config lines).
func LooksLikeStructuredSubscription(text string) bool {
return reURIScheme.MatchString(text) ||
reYamlKeys.MatchString(text) ||
reJSONStart.MatchString(text) ||
reQxConfig.MatchString(text) ||
reNamedConfig.MatchString(text)
}
// ParseJsonProxies parses a JSON array of proxies or {"proxies": [...]}.
func ParseJsonProxies(raw string) []model.ProxyNode {
var payload any
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return nil
}
var rawList []any
switch v := payload.(type) {
case []any:
rawList = v
case map[string]any:
if arr, ok := v["proxies"].([]any); ok {
rawList = arr
}
default:
return nil
}
var result []model.ProxyNode
for _, item := range rawList {
if m, ok := item.(map[string]any); ok {
normalized := NormalizeProxy(m)
if IsProxyNode(normalized) {
result = append(result, normalized)
}
}
}
return result
}
// ParseYamlProxies parses a YAML document with a "proxies:" key.
func ParseYamlProxies(raw string) []model.ProxyNode {
var payload map[string]any
if err := yaml.Unmarshal([]byte(raw), &payload); err != nil {
return nil
}
rawList, ok := payload["proxies"].([]any)
if !ok {
return nil
}
var result []model.ProxyNode
for _, item := range rawList {
if m, ok := item.(map[string]any); ok {
normalized := NormalizeProxy(m)
if IsProxyNode(normalized) {
result = append(result, normalized)
}
}
}
return result
}
// ParseProxyLines splits raw text into lines, skips comments/blanks/section headers,
// and parses each line as a URI or client config line.
func ParseProxyLines(raw string) []model.ProxyNode {
lines := strings.Split(raw, "\n")
var result []model.ProxyNode
index := 0
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
if reCommentOrSection.MatchString(line) {
continue
}
var node model.ProxyNode
if node = ParseProxyUri(line, index); node == nil {
node = ParseClientProxyLine(line, index)
}
if node != nil {
result = append(result, node)
index++
}
}
return result
}
+186
View File
@@ -0,0 +1,186 @@
package proxy
import (
"encoding/json"
"fmt"
"strconv"
"github.com/peterqiu0516/sub-store/internal/model"
)
// NormalizeProxy ensures name/type are strings and port is a number,
// then strips undefined (nil and "") values.
func NormalizeProxy(input map[string]any) model.ProxyNode {
if input == nil {
return nil
}
result := make(map[string]any, len(input)+3)
for k, v := range input {
result[k] = v
}
if name, ok := result["name"]; ok {
result["name"] = toString(name)
} else {
result["name"] = ""
}
if t, ok := result["type"]; ok {
result["type"] = toString(t)
} else {
result["type"] = ""
}
if port, exists := result["port"]; exists {
result["port"] = toNumberOrUndefined(port)
}
return StripUndefined(result)
}
// IsProxyNode returns true if the node has both a name and a type.
func IsProxyNode(node model.ProxyNode) bool {
if node == nil {
return false
}
name, ok := node["name"].(string)
if !ok || name == "" {
return false
}
t, ok := node["type"].(string)
if !ok || t == "" {
return false
}
return true
}
// StripUndefined removes nil and "" values from a map.
// This mirrors the TS stripUndefined which filters out undefined and "".
func StripUndefined(input map[string]any) map[string]any {
if input == nil {
return map[string]any{}
}
result := make(map[string]any, len(input))
for k, v := range input {
if v == nil {
continue
}
if s, ok := v.(string); ok && s == "" {
continue
}
result[k] = v
}
return result
}
// AddPreviewIds adds an "id" field to each proxy computed by StableProxyId.
func AddPreviewIds(proxies []model.ProxyNode) []model.ProxyNode {
result := make([]model.ProxyNode, len(proxies))
for i, proxy := range proxies {
id := StableProxyId(proxy, i)
merged := make(map[string]any, len(proxy)+1)
merged["id"] = id
for k, v := range proxy {
merged[k] = v
}
result[i] = merged
}
return result
}
// StableProxyId builds a deterministic identifier from name|type|server|port|index.
func StableProxyId(proxy model.ProxyNode, index int) string {
return fmt.Sprintf("%s|%s|%s|%s|%d",
toString(proxy["name"]),
toString(proxy["type"]),
toString(proxy["server"]),
portString(proxy["port"]),
index,
)
}
// EnsureUniqueProxyNames appends -2, -3, etc. to duplicate proxy names.
func EnsureUniqueProxyNames(proxies []model.ProxyNode) []model.ProxyNode {
seen := make(map[string]int, len(proxies))
result := make([]model.ProxyNode, len(proxies))
for i, proxy := range proxies {
if proxy == nil {
result[i] = proxy
continue
}
name := toString(proxy["name"])
count := seen[name]
seen[name] = count + 1
if count == 0 {
result[i] = proxy
continue
}
merged := make(map[string]any, len(proxy))
for k, v := range proxy {
merged[k] = v
}
merged["name"] = fmt.Sprintf("%s-%d", name, count+1)
result[i] = merged
}
return result
}
// toString converts an any value to its string representation.
func toString(v any) string {
if v == nil {
return ""
}
switch val := v.(type) {
case string:
return val
case bool:
return strconv.FormatBool(val)
case float64:
return strconv.FormatFloat(val, 'f', -1, 64)
case float32:
return strconv.FormatFloat(float64(val), 'f', -1, 32)
case int:
return strconv.Itoa(val)
case int64:
return strconv.FormatInt(val, 10)
case json.Number:
return val.String()
default:
return fmt.Sprintf("%v", v)
}
}
// portString returns the string representation of a port value.
func portString(v any) string {
if v == nil {
return ""
}
return toString(v)
}
// toNumberOrUndefined converts a value to a float64 (Go's JSON number),
// returning nil if the value is nil or cannot be parsed.
func toNumberOrUndefined(v any) any {
if v == nil {
return nil
}
switch val := v.(type) {
case float64:
return val
case float32:
return float64(val)
case int:
return float64(val)
case int64:
return float64(val)
case string:
if val == "" {
return nil
}
n, err := strconv.ParseFloat(val, 64)
if err != nil {
return nil
}
return n
case bool:
return nil
default:
return nil
}
}
+278
View File
@@ -0,0 +1,278 @@
package proxy
import (
"encoding/base64"
"encoding/json"
"strings"
"testing"
"github.com/peterqiu0516/sub-store/internal/model"
)
func TestParseSS(t *testing.T) {
plain := "aes-256-gcm:password123@1.2.3.4:8388"
encoded := base64.RawURLEncoding.EncodeToString([]byte(plain))
uri := "ss://" + encoded + "#TestSS"
nodes := ParseProxies(uri)
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
n := nodes[0]
if n["type"] != "ss" {
t.Errorf("expected type ss, got %v", n["type"])
}
if n["server"] != "1.2.3.4" {
t.Errorf("expected server 1.2.3.4, got %v", n["server"])
}
if n["name"] != "TestSS" {
t.Errorf("expected name TestSS, got %v", n["name"])
}
if n["cipher"] != "aes-256-gcm" {
t.Errorf("expected cipher aes-256-gcm, got %v", n["cipher"])
}
if n["password"] != "password123" {
t.Errorf("expected password password123, got %v", n["password"])
}
port, _ := n["port"].(float64)
if port != 8388 {
t.Errorf("expected port 8388, got %v", n["port"])
}
}
func TestParseSSURLForm(t *testing.T) {
uri := "ss://aes-256-gcm:password123@1.2.3.4:8388#TestSS"
nodes := ParseProxies(uri)
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
n := nodes[0]
if n["type"] != "ss" {
t.Errorf("expected type ss, got %v", n["type"])
}
if n["cipher"] != "aes-256-gcm" {
t.Errorf("expected cipher aes-256-gcm, got %v", n["cipher"])
}
}
func TestParseSSR(t *testing.T) {
// SSR format: base64(server:port:protocol:method:obfs:base64(password)/?remarks=base64(name))
ssrMain := "1.2.3.4:8388:auth_aes128_sha1:aes-256-cfb:http_simple:" + base64.RawURLEncoding.EncodeToString([]byte("pass"))
encoded := base64.RawURLEncoding.EncodeToString([]byte(ssrMain + "/?remarks=" + base64.RawURLEncoding.EncodeToString([]byte("TestSSR"))))
uri := "ssr://" + encoded
nodes := ParseProxies(uri)
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
n := nodes[0]
if n["type"] != "ssr" {
t.Errorf("expected type ssr, got %v", n["type"])
}
if n["server"] != "1.2.3.4" {
t.Errorf("expected server 1.2.3.4, got %v", n["server"])
}
if n["obfs"] != "http_simple" {
t.Errorf("expected obfs http_simple, got %v", n["obfs"])
}
}
func TestParseVMess(t *testing.T) {
vmJSON := `{"v":"2","ps":"TestVM","add":"1.2.3.4","port":"443","id":"uuid-1234","aid":"0","scy":"auto","net":"ws","type":"none","host":"example.com","path":"/path","tls":"tls","sni":"example.com"}`
encoded := base64.RawURLEncoding.EncodeToString([]byte(vmJSON))
uri := "vmess://" + encoded
nodes := ParseProxies(uri)
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
n := nodes[0]
if n["type"] != "vmess" {
t.Errorf("expected type vmess, got %v", n["type"])
}
if n["server"] != "1.2.3.4" {
t.Errorf("expected server 1.2.3.4, got %v", n["server"])
}
if n["uuid"] != "uuid-1234" {
t.Errorf("expected uuid uuid-1234, got %v", n["uuid"])
}
if n["network"] != "ws" {
t.Errorf("expected network ws, got %v", n["network"])
}
if n["tls"] != true {
t.Errorf("expected tls true, got %v", n["tls"])
}
}
func TestParseVLESS(t *testing.T) {
uri := "vless://uuid-1234@1.2.3.4:443?encryption=none&security=tls&type=ws&host=example.com&path=%2Fpath&sni=example.com#TestVLESS"
nodes := ParseProxies(uri)
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
n := nodes[0]
if n["type"] != "vless" {
t.Errorf("expected type vless, got %v", n["type"])
}
if n["uuid"] != "uuid-1234" {
t.Errorf("expected uuid uuid-1234, got %v", n["uuid"])
}
if n["network"] != "ws" {
t.Errorf("expected network ws, got %v", n["network"])
}
if n["tls"] != true {
t.Errorf("expected tls true, got %v", n["tls"])
}
}
func TestParseTrojan(t *testing.T) {
uri := "trojan://password123@1.2.3.4:443?sni=example.com&type=ws&host=example.com&path=%2Fpath#TestTrojan"
nodes := ParseProxies(uri)
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
n := nodes[0]
if n["type"] != "trojan" {
t.Errorf("expected type trojan, got %v", n["type"])
}
if n["password"] != "password123" {
t.Errorf("expected password password123, got %v", n["password"])
}
if n["sni"] != "example.com" {
t.Errorf("expected sni example.com, got %v", n["sni"])
}
}
func TestParseHysteria2(t *testing.T) {
uri := "hysteria2://password123@1.2.3.4:443?sni=example.com&insecure=1#TestHys2"
nodes := ParseProxies(uri)
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
n := nodes[0]
if n["type"] != "hysteria2" {
t.Errorf("expected type hysteria2, got %v", n["type"])
}
if n["password"] != "password123" {
t.Errorf("expected password password123, got %v", n["password"])
}
}
func TestParseMultipleLines(t *testing.T) {
lines := []string{
"ss://aes-256-gcm:pass@1.2.3.4:8388#Node1",
"ss://aes-256-gcm:pass@5.6.7.8:8388#Node2",
"ss://aes-256-gcm:pass@9.10.11.12:8388#Node3",
}
nodes := ParseProxies(strings.Join(lines, "\n"))
if len(nodes) != 3 {
t.Fatalf("expected 3 nodes, got %d", len(nodes))
}
if nodes[0]["name"] != "Node1" {
t.Errorf("expected name Node1, got %v", nodes[0]["name"])
}
if nodes[2]["name"] != "Node3" {
t.Errorf("expected name Node3, got %v", nodes[2]["name"])
}
}
func TestParseBase64Content(t *testing.T) {
lines := []string{
"ss://aes-256-gcm:pass@1.2.3.4:8388#Node1",
"ss://aes-256-gcm:pass@5.6.7.8:8388#Node2",
}
plain := strings.Join(lines, "\n")
encoded := base64.StdEncoding.EncodeToString([]byte(plain))
// DecodeMaybeBase64 should detect and decode base64 content
decoded := DecodeMaybeBase64(encoded)
nodes := ParseProxies(decoded)
if len(nodes) != 2 {
t.Fatalf("expected 2 nodes from base64 content, got %d", len(nodes))
}
}
func TestParseEmptyContent(t *testing.T) {
nodes := ParseProxies("")
if len(nodes) != 0 {
t.Errorf("expected 0 nodes for empty content, got %d", len(nodes))
}
}
func TestParseInvalidContent(t *testing.T) {
nodes := ParseProxies("this is not a valid proxy URI")
if len(nodes) != 0 {
t.Errorf("expected 0 nodes for invalid content, got %d", len(nodes))
}
}
func TestParseJSONContent(t *testing.T) {
jsonContent := `[{"type":"ss","server":"1.2.3.4","port":8388,"cipher":"aes-256-gcm","password":"pass","name":"TestJSON"}]`
nodes := ParseProxies(jsonContent)
if len(nodes) != 1 {
t.Fatalf("expected 1 node from JSON, got %d", len(nodes))
}
if nodes[0]["type"] != "ss" {
t.Errorf("expected type ss, got %v", nodes[0]["type"])
}
}
func TestEnsureUniqueProxyNames(t *testing.T) {
nodes := []model.ProxyNode{
{"name": "Same", "server": "1.1.1.1"},
{"name": "Same", "server": "2.2.2.2"},
{"name": "Same", "server": "3.3.3.3"},
{"name": "Unique", "server": "4.4.4.4"},
}
result := EnsureUniqueProxyNames(nodes)
if result[0]["name"] != "Same" {
t.Errorf("first name should be unchanged, got %v", result[0]["name"])
}
if result[1]["name"] == "Same" {
t.Error("second duplicate should be renamed")
}
if result[3]["name"] != "Unique" {
t.Errorf("unique name should be unchanged, got %v", result[3]["name"])
}
}
func TestAddPreviewIds(t *testing.T) {
nodes := []model.ProxyNode{
{"name": "A", "server": "1.1.1.1"},
{"name": "B", "server": "2.2.2.2"},
}
result := AddPreviewIds(nodes)
if _, ok := result[0]["id"]; !ok {
t.Error("expected id field on first node")
}
if _, ok := result[1]["id"]; !ok {
t.Error("expected id field on second node")
}
}
func TestDecodeMaybeBase64(t *testing.T) {
plain := "ss://pass@host:port#name"
if DecodeMaybeBase64(plain) != plain {
t.Error("plain text should pass through")
}
encoded := base64.StdEncoding.EncodeToString([]byte(plain))
decoded := DecodeMaybeBase64(encoded)
if !strings.Contains(decoded, "ss://") {
t.Errorf("base64 should be decoded, got: %s", decoded)
}
}
func TestNodeJSONSerialization(t *testing.T) {
uri := "ss://aes-256-gcm:pass@1.2.3.4:8388#Test"
nodes := ParseProxies(uri)
if len(nodes) == 0 {
t.Fatal("no nodes parsed")
}
data, err := json.Marshal(nodes[0])
if err != nil {
t.Fatalf("failed to marshal node: %v", err)
}
var back map[string]any
if err := json.Unmarshal(data, &back); err != nil {
t.Fatalf("failed to unmarshal node: %v", err)
}
if back["type"] != "ss" {
t.Errorf("expected type ss after round-trip, got %v", back["type"])
}
}
+573
View File
@@ -0,0 +1,573 @@
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
}