Files
ss/internal/subscription/uri_parse.go
2026-07-19 20:01:38 +08:00

276 lines
6.2 KiB
Go

package subscription
import (
"encoding/base64"
"net/url"
"strconv"
"strings"
)
// parseSS parses a ss:// URI into a Clash proxy config map.
// Direct port of SubscriptionManager._parse_ss.
func parseSS(uri string) map[string]interface{} {
if !strings.HasPrefix(uri, "ss://") {
return nil
}
name := "ss"
if idx := strings.Index(uri, "#"); idx != -1 {
fragment := uri[idx+1:]
uri = uri[:idx]
if unescaped, err := url.QueryUnescape(fragment); err == nil {
name = unescaped
} else {
name = fragment
}
}
content := uri[len("ss://"):]
var server, method, password string
var port int
if idx := strings.Index(content, "@"); idx != -1 {
userinfo := content[:idx]
hostport := content[idx+1:]
// Try to decode userinfo first, it might be base64(method:password).
decoded, err := base64URLDecodeWithPadding(userinfo)
if err == nil {
if mIdx := strings.Index(decoded, ":"); mIdx != -1 {
method = decoded[:mIdx]
password = decoded[mIdx+1:]
}
}
if method == "" {
if mIdx := strings.Index(userinfo, ":"); mIdx != -1 {
method = userinfo[:mIdx]
password = userinfo[mIdx+1:]
}
}
if method == "" || password == "" {
return nil
}
hIdx := strings.Index(hostport, ":")
if hIdx == -1 {
return nil
}
server = hostport[:hIdx]
p, err := strconv.Atoi(hostport[hIdx+1:])
if err != nil {
return nil
}
port = p
} else {
// Entire body is likely base64 encoded.
decoded, err := base64URLDecodeWithPadding(content)
if err != nil {
return nil
}
if !strings.Contains(decoded, "@") {
return nil
}
atIdx := strings.Index(decoded, "@")
methodPass := decoded[:atIdx]
hostPort := decoded[atIdx+1:]
mIdx := strings.Index(methodPass, ":")
if mIdx == -1 {
return nil
}
method = methodPass[:mIdx]
password = methodPass[mIdx+1:]
hIdx := strings.Index(hostPort, ":")
if hIdx == -1 {
return nil
}
server = hostPort[:hIdx]
p, err := strconv.Atoi(hostPort[hIdx+1:])
if err != nil {
return nil
}
port = p
}
return map[string]interface{}{
"name": name,
"type": "ss",
"server": server,
"port": port,
"cipher": method,
"password": password,
}
}
// stripBase64Whitespace removes whitespace, matching Python's
// base64.b64decode/urlsafe_b64decode default validate=False leniency, which
// silently ignores embedded newlines/whitespace (common in HTTP response
// bodies) rather than erroring like Go's strict decoder.
func stripBase64Whitespace(s string) string {
return strings.Join(strings.Fields(s), "")
}
// base64URLDecodeWithPadding decodes base64 (URL-safe alphabet), adding
// padding as needed, matching the Python `base64.urlsafe_b64decode` + manual
// padding fixup used throughout subscription_manager.py.
func base64URLDecodeWithPadding(s string) (string, error) {
s = stripBase64Whitespace(s)
if pad := len(s) % 4; pad != 0 {
s += strings.Repeat("=", 4-pad)
}
decoded, err := base64.URLEncoding.DecodeString(s)
if err != nil {
return "", err
}
return string(decoded), nil
}
// base64StdDecodeWithPadding decodes standard-alphabet base64, adding padding
// as needed, matching `base64.b64decode` usage in _convert_content.
func base64StdDecodeWithPadding(s string) (string, error) {
s = stripBase64Whitespace(s)
if pad := len(s) % 4; pad != 0 {
s += strings.Repeat("=", 4-pad)
}
decoded, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return "", err
}
return string(decoded), nil
}
// parseTrojan parses a trojan:// URI into a Clash proxy config map.
// Direct port of SubscriptionManager._parse_trojan.
func parseTrojan(uri string) map[string]interface{} {
if !strings.HasPrefix(uri, "trojan://") {
return nil
}
parsed, err := url.Parse(uri)
if err != nil {
return nil
}
query := parsed.Query()
name := "trojan"
if parsed.Fragment != "" {
if unescaped, err := url.QueryUnescape(parsed.Fragment); err == nil {
name = unescaped
} else {
name = parsed.Fragment
}
}
port, _ := strconv.Atoi(parsed.Port())
config := map[string]interface{}{
"name": name,
"type": "trojan",
"server": parsed.Hostname(),
"port": port,
"password": passwordFromUserinfo(parsed),
"udp": true,
}
if v := query.Get("sni"); v != "" {
config["sni"] = v
} else if v := query.Get("peer"); v != "" {
config["sni"] = v
}
if v := query.Get("allowInsecure"); v != "" {
config["skip-cert-verify"] = v == "1"
}
return config
}
// passwordFromUserinfo returns the username component of a parsed URL, which
// Python's urllib.parse exposes as `.username` (the trojan/vless password or
// UUID is encoded there).
func passwordFromUserinfo(u *url.URL) string {
if u.User == nil {
return ""
}
return u.User.Username()
}
// parseVless parses a vless:// URI into a Clash proxy config map.
// Direct port of SubscriptionManager._parse_vless.
func parseVless(uri string) map[string]interface{} {
if !strings.HasPrefix(uri, "vless://") {
return nil
}
parsed, err := url.Parse(uri)
if err != nil {
return nil
}
query := parsed.Query()
name := "vless"
if parsed.Fragment != "" {
if unescaped, err := url.QueryUnescape(parsed.Fragment); err == nil {
name = unescaped
} else {
name = parsed.Fragment
}
}
port, _ := strconv.Atoi(parsed.Port())
config := map[string]interface{}{
"name": name,
"type": "vless",
"server": parsed.Hostname(),
"port": port,
"uuid": passwordFromUserinfo(parsed),
"udp": true,
"tls": false,
"network": "tcp",
}
if sec := query.Get("security"); sec != "" {
if sec == "tls" {
config["tls"] = true
} else if sec == "reality" {
config["tls"] = true
realityOpts := map[string]interface{}{}
if v := query.Get("pbk"); v != "" {
realityOpts["public-key"] = v
}
if v := query.Get("sid"); v != "" {
realityOpts["short-id"] = v
}
if v := query.Get("sni"); v != "" {
config["servername"] = v
}
config["reality-opts"] = realityOpts
}
}
if v := query.Get("flow"); v != "" {
config["flow"] = v
}
if v := query.Get("type"); v != "" {
config["network"] = v
}
if v := query.Get("sni"); v != "" {
config["servername"] = v
}
if v := query.Get("fp"); v != "" {
config["client-fingerprint"] = v
}
return config
}