From 3c5e9fa7745d08d29c68a4b105aabf7cab1680ca Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Wed, 27 May 2026 10:51:41 +0200 Subject: [PATCH] fix(sub): preserve userinfo encoding in trojan/shadowsocks/hysteria links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The link builders ran the assembled share link through url.Parse + parsedURL.String(), which decodes the userinfo and re-emits it via Go's lenient encoder — sub-delim chars (=, +, ;) are left literal even when the caller had pre-encoded them via encodeUserinfo. Result: copy URL from the panel UI worked (FE never round-trips), but the same inbound in the subscription body became "trojan://abc%2Fdef=ghi+@..." and was rejected by Trojan/Hysteria clients. Replace url.Parse + .String() with a direct string-builder that appends ?query and #fragment without touching the userinfo, and apply it to genHysteriaLink's inline copies too. Also switch the shadowsocks userinfo from base64.StdEncoding (with =/+/ /padding) to base64.RawURLEncoding to match the frontend's Base64.encode(s, true). --- sub/subService.go | 86 +++++++++++++++++++++++++++-------------------- 1 file changed, 49 insertions(+), 37 deletions(-) diff --git a/sub/subService.go b/sub/subService.go index e6697156..f1883fc0 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -496,7 +496,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st proxyParams, security, func(dest string, port int) string { - return fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), dest, port) + return fmt.Sprintf("ss://%s@%s:%d", base64.RawURLEncoding.EncodeToString([]byte(encPart)), dest, port) }, func(ep map[string]any) string { return s.genRemark(inbound, email, ep["remark"].(string)) @@ -504,7 +504,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st ) } - link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), address, inbound.Port) + link := fmt.Sprintf("ss://%s@%s:%d", base64.RawURLEncoding.EncodeToString([]byte(encPart)), address, inbound.Port) return buildLinkWithParams(link, params, s.genRemark(inbound, email, "")) } @@ -601,14 +601,7 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin epRemark, _ := ep["remark"].(string) link := fmt.Sprintf("%s://%s@%s:%d", protocol, auth, dest, int(portF)) - u, _ := url.Parse(link) - q := u.Query() - for k, v := range params { - q.Add(k, v) - } - u.RawQuery = q.Encode() - u.Fragment = s.genRemark(inbound, email, epRemark) - links = append(links, u.String()) + links = append(links, buildLinkWithParams(link, params, s.genRemark(inbound, email, epRemark))) } return strings.Join(links, "\n") } @@ -616,14 +609,7 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin // No external proxy configured — use the inbound's resolved address so // node-managed inbounds get the node's host instead of the central panel's. link := fmt.Sprintf("%s://%s@%s:%d", protocol, auth, s.resolveInboundAddress(inbound), inbound.Port) - url, _ := url.Parse(link) - q := url.Query() - for k, v := range params { - q.Add(k, v) - } - url.RawQuery = q.Encode() - url.Fragment = s.genRemark(inbound, email, "") - return url.String() + return buildLinkWithParams(link, params, s.genRemark(inbound, email, "")) } // loadNodes refreshes nodesByID from the DB. Called once per request so @@ -1045,32 +1031,58 @@ func (s *SubService) buildVmessExternalProxyLinks(externalProxies []any, baseObj return links.String() } +// buildLinkWithParams appends ?query and #fragment to a pre-built +// scheme://userinfo@host:port string without re-parsing it. The caller +// has already escaped userinfo via encodeUserinfo (or chosen a base64 +// alphabet with no reserved chars); a url.Parse + .String() round-trip +// would silently decode that escaping because Go's userinfo emitter +// leaves sub-delims (=, +, ;) literal, which breaks Trojan/Hysteria/SS +// clients that reject those chars in the password. func buildLinkWithParams(link string, params map[string]string, fragment string) string { - parsedURL, _ := url.Parse(link) - q := parsedURL.Query() - for k, v := range params { - q.Add(k, v) - } - parsedURL.RawQuery = q.Encode() - parsedURL.Fragment = fragment - return parsedURL.String() + return appendQueryAndFragment(link, params, fragment, "", false) } +// buildLinkWithParamsAndSecurity is buildLinkWithParams plus an +// external-proxy override: the `security` key in params is replaced with +// the supplied value, and TLS hint fields (alpn/sni/fp) are stripped when +// the override is `none`. func buildLinkWithParamsAndSecurity(link string, params map[string]string, fragment, security string, omitTLSFields bool) string { - parsedURL, _ := url.Parse(link) - q := parsedURL.Query() - for k, v := range params { - if k == "security" { - v = security + return appendQueryAndFragment(link, params, fragment, security, omitTLSFields) +} + +func appendQueryAndFragment(link string, params map[string]string, fragment, securityOverride string, omitTLSFields bool) string { + var sb strings.Builder + sb.WriteString(link) + + if len(params) > 0 { + q := url.Values{} + for k, v := range params { + if securityOverride != "" && k == "security" { + v = securityOverride + } + if omitTLSFields && (k == "alpn" || k == "sni" || k == "fp") { + continue + } + q.Set(k, v) } - if omitTLSFields && (k == "alpn" || k == "sni" || k == "fp") { - continue + encoded := q.Encode() + if encoded != "" { + if strings.Contains(link, "?") { + sb.WriteByte('&') + } else { + sb.WriteByte('?') + } + sb.WriteString(encoded) } - q.Add(k, v) } - parsedURL.RawQuery = q.Encode() - parsedURL.Fragment = fragment - return parsedURL.String() + + if fragment != "" { + sb.WriteByte('#') + // Match the frontend's encodeURIComponent(remark): spaces become + // %20 (not + as in query strings). + sb.WriteString(strings.ReplaceAll(url.QueryEscape(fragment), "+", "%20")) + } + return sb.String() } func (s *SubService) buildExternalProxyURLLinks(