fix(bridge): normalize legacy vmess links
This commit is contained in:
@@ -6,41 +6,11 @@ package main
|
|||||||
import "C"
|
import "C"
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
"github.com/metacubex/mihomo/common/convert"
|
"github.com/metacubex/mihomo/common/convert"
|
||||||
)
|
)
|
||||||
|
|
||||||
// preprocessSubscription fixes URL encoding issues in subscription links
|
|
||||||
// Decodes the entire URL line to ensure Mihomo parser receives properly unencoded links
|
|
||||||
func preprocessSubscription(subscription string) string {
|
|
||||||
lines := strings.Split(subscription, "\n")
|
|
||||||
var result []string
|
|
||||||
|
|
||||||
for _, line := range lines {
|
|
||||||
line = strings.TrimRight(line, " \r")
|
|
||||||
if line == "" {
|
|
||||||
result = append(result, line)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode the entire URL line
|
|
||||||
// This fixes issues like v2rayN's uuid%3Apassword encoding
|
|
||||||
// Safe for all protocols: url.QueryUnescape only decodes %XX patterns
|
|
||||||
// and leaves structural characters (://, @, ?, #) intact
|
|
||||||
if decoded, err := url.QueryUnescape(line); err == nil {
|
|
||||||
line = decoded
|
|
||||||
}
|
|
||||||
// If decoding fails (malformed %), keep original line
|
|
||||||
|
|
||||||
result = append(result, line)
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(result, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConvertSubscription converts V2Ray subscription links to mihomo proxy configs
|
// ConvertSubscription converts V2Ray subscription links to mihomo proxy configs
|
||||||
//
|
//
|
||||||
//export ConvertSubscription
|
//export ConvertSubscription
|
||||||
|
|||||||
181
bridge/preprocess.go
Normal file
181
bridge/preprocess.go
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// preprocessSubscription fixes URL encoding issues and legacy share links before
|
||||||
|
// the subscription is handed to mihomo's parser.
|
||||||
|
func preprocessSubscription(subscription string) string {
|
||||||
|
lines := strings.Split(subscription, "\n")
|
||||||
|
result := make([]string, 0, len(lines))
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimRight(line, " \r")
|
||||||
|
if line == "" {
|
||||||
|
result = append(result, line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode the entire URL line. This fixes inputs such as v2rayN's
|
||||||
|
// uuid%3Apassword encoding and keeps malformed percent escapes unchanged.
|
||||||
|
if decoded, err := url.QueryUnescape(line); err == nil {
|
||||||
|
line = decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
line = normalizeLegacyShadowrocketVMess(line)
|
||||||
|
result = append(result, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(result, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLegacyShadowrocketVMess(line string) string {
|
||||||
|
const prefix = "vmess://"
|
||||||
|
if !strings.HasPrefix(line, prefix) {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
body := strings.TrimPrefix(line, prefix)
|
||||||
|
queryStart := strings.IndexByte(body, '?')
|
||||||
|
if queryStart < 0 {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded := body[:queryStart]
|
||||||
|
rawQuery := body[queryStart+1:]
|
||||||
|
if encoded == "" || rawQuery == "" {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, ok := decodeLooseBase64(encoded)
|
||||||
|
if !ok {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
cipher, remainder, ok := strings.Cut(decoded, ":")
|
||||||
|
if !ok {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
uuid, serverPort, ok := strings.Cut(remainder, "@")
|
||||||
|
if !ok {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
server, port, ok := splitHostPortLoose(serverPort)
|
||||||
|
if !ok || uuid == "" || server == "" || port == "" {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
if _, err := strconv.Atoi(port); err != nil {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
query, err := url.ParseQuery(rawQuery)
|
||||||
|
if err != nil {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
aid := firstQueryValue(query, "aid", "alterId")
|
||||||
|
if aid == "" {
|
||||||
|
aid = "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
network := "tcp"
|
||||||
|
headerType := "none"
|
||||||
|
host := ""
|
||||||
|
path := ""
|
||||||
|
obfs := strings.ToLower(firstQueryValue(query, "obfs"))
|
||||||
|
switch obfs {
|
||||||
|
case "websocket", "ws":
|
||||||
|
network = "ws"
|
||||||
|
host = firstQueryValue(query, "obfsParam", "host", "wsHost")
|
||||||
|
path = firstQueryValue(query, "path", "wspath")
|
||||||
|
case "":
|
||||||
|
if value := firstQueryValue(query, "network", "net", "type"); value != "" {
|
||||||
|
network = strings.ToLower(value)
|
||||||
|
}
|
||||||
|
host = firstQueryValue(query, "wsHost", "host")
|
||||||
|
path = firstQueryValue(query, "wspath", "path")
|
||||||
|
if value := firstQueryValue(query, "headerType"); value != "" {
|
||||||
|
headerType = value
|
||||||
|
}
|
||||||
|
case "none":
|
||||||
|
headerType = "none"
|
||||||
|
default:
|
||||||
|
headerType = obfs
|
||||||
|
}
|
||||||
|
|
||||||
|
tls := ""
|
||||||
|
switch strings.ToLower(firstQueryValue(query, "tls", "security")) {
|
||||||
|
case "1", "true", "tls":
|
||||||
|
tls = "tls"
|
||||||
|
}
|
||||||
|
|
||||||
|
remarks := firstQueryValue(query, "remarks", "remark", "name")
|
||||||
|
if remarks == "" {
|
||||||
|
remarks = server + ":" + port
|
||||||
|
}
|
||||||
|
|
||||||
|
values := map[string]string{
|
||||||
|
"v": "2",
|
||||||
|
"ps": remarks,
|
||||||
|
"add": server,
|
||||||
|
"port": port,
|
||||||
|
"id": uuid,
|
||||||
|
"aid": aid,
|
||||||
|
"scy": cipher,
|
||||||
|
"net": network,
|
||||||
|
"type": headerType,
|
||||||
|
"host": host,
|
||||||
|
"path": path,
|
||||||
|
"tls": tls,
|
||||||
|
}
|
||||||
|
if sni := firstQueryValue(query, "sni", "peer"); sni != "" {
|
||||||
|
values["sni"] = sni
|
||||||
|
}
|
||||||
|
if alpn := firstQueryValue(query, "alpn"); alpn != "" {
|
||||||
|
values["alpn"] = alpn
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(values)
|
||||||
|
if err != nil {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
return prefix + base64.StdEncoding.EncodeToString(jsonBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeLooseBase64(value string) (string, bool) {
|
||||||
|
encodings := []*base64.Encoding{
|
||||||
|
base64.RawURLEncoding,
|
||||||
|
base64.URLEncoding,
|
||||||
|
base64.RawStdEncoding,
|
||||||
|
base64.StdEncoding,
|
||||||
|
}
|
||||||
|
for _, encoding := range encodings {
|
||||||
|
decoded, err := encoding.DecodeString(value)
|
||||||
|
if err == nil {
|
||||||
|
return string(decoded), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitHostPortLoose(value string) (string, string, bool) {
|
||||||
|
colon := strings.LastIndexByte(value, ':')
|
||||||
|
if colon <= 0 || colon == len(value)-1 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return strings.Trim(value[:colon], "[]"), value[colon+1:], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstQueryValue(values url.Values, keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
if value := values.Get(key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
55
bridge/preprocess_test.go
Normal file
55
bridge/preprocess_test.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/metacubex/mihomo/common/convert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPreprocessLegacyShadowrocketVmess(t *testing.T) {
|
||||||
|
input := strings.Join([]string{
|
||||||
|
"vmess://YXV0bzowNmNlNzU4Yy1iNTNkLTQ2NzQtOTdhNy01M2U4YmFhOGQwMjlAMjE2LjE0NC4yMjQuNjk6MzMwNg?remarks=%E7%BE%8E%E5%9B%BD%E8%87%AA%E5%BB%BA&path=/&obfs=none&alterId=0",
|
||||||
|
"vmess://YXV0bzpmMmJiMmE4ZC02YWM0LTQ3NGYtYjJlYS1lMjJjNzhlYjkwMGZAMTI5LjE1MS4yNS4xOjE4MjU?remarks=US-O&udp=1&alterId=0",
|
||||||
|
}, "\n")
|
||||||
|
|
||||||
|
proxies, err := convert.ConvertsV2Ray([]byte(preprocessSubscription(input)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("convert error: %v", err)
|
||||||
|
}
|
||||||
|
if len(proxies) != 2 {
|
||||||
|
t.Fatalf("got %d proxies: %#v", len(proxies), proxies)
|
||||||
|
}
|
||||||
|
|
||||||
|
first := proxies[0]
|
||||||
|
if first["type"] != "vmess" {
|
||||||
|
t.Fatalf("unexpected first type: %#v", first["type"])
|
||||||
|
}
|
||||||
|
if first["name"] != "美国自建" {
|
||||||
|
t.Fatalf("unexpected first name: %#v", first["name"])
|
||||||
|
}
|
||||||
|
if first["server"] != "216.144.224.69" {
|
||||||
|
t.Fatalf("unexpected first server: %#v", first["server"])
|
||||||
|
}
|
||||||
|
if first["port"] != "3306" {
|
||||||
|
t.Fatalf("unexpected first port: %#v", first["port"])
|
||||||
|
}
|
||||||
|
if first["uuid"] != "06ce758c-b53d-4674-97a7-53e8baa8d029" {
|
||||||
|
t.Fatalf("unexpected first uuid: %#v", first["uuid"])
|
||||||
|
}
|
||||||
|
|
||||||
|
second := proxies[1]
|
||||||
|
if second["server"] != "129.151.25.1" {
|
||||||
|
t.Fatalf("unexpected second server: %#v", second["server"])
|
||||||
|
}
|
||||||
|
if second["port"] != "1825" {
|
||||||
|
t.Fatalf("unexpected second port: %#v", second["port"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreprocessKeepsStandardVmess(t *testing.T) {
|
||||||
|
input := "vmess://uuid@example.com:443?encryption=auto#name"
|
||||||
|
if got := preprocessSubscription(input); got != input {
|
||||||
|
t.Fatalf("standard vmess changed:\nwant %q\n got %q", input, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user