feat: migrate to golang
This commit is contained in:
275
internal/subscription/uri_parse.go
Normal file
275
internal/subscription/uri_parse.go
Normal file
@ -0,0 +1,275 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user