fix(bridge): normalize legacy vmess links

This commit is contained in:
Aethersailor
2026-05-30 23:00:47 +08:00
parent 6554bf6288
commit 2e32922701
3 changed files with 236 additions and 30 deletions

View File

@@ -6,41 +6,11 @@ package main
import "C"
import (
"encoding/json"
"net/url"
"strings"
"unsafe"
"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
//
//export ConvertSubscription

181
bridge/preprocess.go Normal file
View 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
View 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)
}
}