276 lines
6.2 KiB
Go
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
|
|
}
|