diff --git a/internal/sub/endpoint_test.go b/internal/sub/endpoint_test.go index ebfa71da7..7d5772fa0 100644 --- a/internal/sub/endpoint_test.go +++ b/internal/sub/endpoint_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/url" + "strconv" "strings" "testing" @@ -231,3 +232,25 @@ func TestGenVlessLinkEscapesID(t *testing.T) { t.Fatalf("vless id not escaped: %q", got) } } + +// The panel builds its own link and QR through the browser's URL parser, which escapes +// userinfo with the WHATWG userinfo set; a subscription escaping more serves another id. +func TestGenVlessLinkEscapesIDLikeThePanel(t *testing.T) { + for id, want := range map[string]string{ + "team+1": "team+1", + "a=b": "a%3Db", + "x:y;z": "x%3Ay%3Bz", + "ok!$,'()*~": "ok!$,'()*~", + "é": "%C3%A9", + } { + in := &model.Inbound{ + Listen: "203.0.113.5", Port: 443, Protocol: model.VLESS, Remark: "n", + Settings: `{"clients":[{"id":` + strconv.Quote(id) + `,"email":"user"}],"decryption":"none"}`, + StreamSettings: `{"network":"tcp","security":"none"}`, + } + got := NewSubService("").genVlessLink(in, "user") + if !strings.HasPrefix(got, "vless://"+want+"@203.0.113.5:443?") { + t.Errorf("id %q: link %q, want userinfo %q", id, got, want) + } + } +} diff --git a/internal/sub/service.go b/internal/sub/service.go index 23523b926..2c4c005f7 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -1432,7 +1432,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { params, security, func(ep map[string]any, dest string, port int) string { - return fmt.Sprintf("vless://%s@%s", encodeUserinfo(applyVlessRoute(uuid, hostVlessRoute(ep))), joinHostPort(dest, port)) + return fmt.Sprintf("vless://%s@%s", encodeLinkUserinfo(applyVlessRoute(uuid, hostVlessRoute(ep))), joinHostPort(dest, port)) }, func(ep map[string]any) string { return s.endpointRemark(inbound, email, ep, streamNetwork) @@ -1440,7 +1440,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { ) } - link := fmt.Sprintf("vless://%s@%s", encodeUserinfo(uuid), joinHostPort(address, port)) + link := fmt.Sprintf("vless://%s@%s", encodeLinkUserinfo(uuid), joinHostPort(address, port)) return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", streamNetwork)) } @@ -1508,6 +1508,21 @@ func encodeUserinfo(s string) string { return strings.ReplaceAll(url.QueryEscape(s), "+", "%20") } +// encodeLinkUserinfo escapes with the WHATWG userinfo set, as the panel's own links do through +// the browser's URL parser, so a non-UUID id reads the same in a subscription and a QR. +func encodeLinkUserinfo(s string) string { + const escaped = " \"#<>?`{}/:;=@[\\]^|" + var sb strings.Builder + for _, b := range []byte(s) { + if b < 0x20 || b > 0x7e || strings.IndexByte(escaped, b) >= 0 { + fmt.Fprintf(&sb, "%%%02X", b) + } else { + sb.WriteByte(b) + } + } + return sb.String() +} + // joinHostPort wraps an IPv6 host in square brackets the way RFC 3986 // requires for URI authorities, while leaving IPv4 addresses and hostnames // untouched. It also strips any brackets already present on the input so