fix(sub): send the serverDescription tail only to clients that read it (#7108)

A Host Description is appended to every raw-subscription node name as a base64
`?serverDescription=` tail. Only Happ and INCY read it; v2rayN, v2rayNG,
OpenClash and the rest show it as part of the node name, and the only way out
was clearing the Description (#7105).

The raw subscription body now carries the tail only when the requesting
User-Agent is Happ or INCY, for the endpoint links and the Hysteria2 loop
alike. Copied links, QR codes and the panel's link export keep it, since the
server cannot tell which client they end up in.

Closes #7105
This commit is contained in:
Jay Hemnani
2026-11-13 04:54:13 +05:30
committed by GitHub
parent 7367ff75cd
commit 243b5c9da7
5 changed files with 37 additions and 2 deletions
+1
View File
@@ -502,6 +502,7 @@ func (a *SUBController) subs(c *gin.Context) {
scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c) scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
subReq := a.subService.ForRequest(host) subReq := a.subService.ForRequest(host)
subReq.subscriptionBody = true subReq.subscriptionBody = true
subReq.omitServerDescription = !IsHappClient(userAgent) && !IsIncyClient(userAgent)
subs, _, _, traffic, err := subReq.getSubs(subId) subs, _, _, traffic, err := subReq.getSubs(subId)
if err != nil || subs == nil { if err != nil || subs == nil {
writeSubError(c, err) writeSubError(c, err)
+26
View File
@@ -803,6 +803,32 @@ func TestLegacyClashEndpointNormalizesShadowsocksCipher(t *testing.T) {
} }
} }
// #7105: only Happ and INCY read the tail; other clients show it in the node name.
func TestRawSubServerDescriptionByClient(t *testing.T) {
seedSubDB(t)
seedSubInbound(t, "s1", "vl", 4492, 1, `{"network":"tcp","security":"none","externalProxy":[{"forceTls":"same","dest":"a.example.com","port":443,"remark":"A","serverDescription":"Berlin"}]}`)
gin.SetMode(gin.TestMode)
cases := map[string]bool{"Happ/3.1": true, "INCY/1.0.0/ios": true, "v2rayN/7.10": false}
for userAgent, wantTail := range cases {
t.Run(userAgent, func(t *testing.T) {
router := gin.New()
NewSUBController(router.Group("/"), WithSUBEncryption(false))
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
req.Header.Set("User-Agent", userAgent)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
body := resp.Body.String()
if resp.Code != http.StatusOK || !strings.Contains(body, "vless") {
t.Fatalf("status=%d, body=%s", resp.Code, body)
}
if got := strings.Contains(body, "?serverDescription=QmVybGlu"); got != wantTail {
t.Fatalf("tail present = %v, want %v; body=%s", got, wantTail, body)
}
})
}
}
func TestIncyRoutingIsolatedFromHappHeaders(t *testing.T) { func TestIncyRoutingIsolatedFromHappHeaders(t *testing.T) {
seedSubDB(t) seedSubDB(t)
seedSubInbound(t, "s1", "vl", 4492, 1, `{"network":"tcp","security":"none"}`) seedSubInbound(t, "s1", "vl", 4492, 1, `{"network":"tcp","security":"none"}`)
+1 -1
View File
@@ -136,7 +136,7 @@ func (s *SubService) buildEndpointLinks(
applyEndpointFinalMask(e, nextParams) applyEndpointFinalMask(e, nextParams)
applyEndpointAllowInsecure(e, nextParams, securityToApply) applyEndpointAllowInsecure(e, nextParams, securityToApply)
remark := makeRemark(e) remark := makeRemark(e)
if e.ServerDescription != "" { if e.ServerDescription != "" && !s.omitServerDescription {
remark = appendHappServerDescription(remark, e.ServerDescription) remark = appendHappServerDescription(remark, e.ServerDescription)
} }
links = append(links, buildLinkWithParamsAndSecurity( links = append(links, buildLinkWithParamsAndSecurity(
@@ -52,6 +52,9 @@ func TestGenHysteriaLinkAppendsHostServerDescription(t *testing.T) {
if !strings.HasSuffix(got, "?serverDescription=V2ktRmk=") { if !strings.HasSuffix(got, "?serverDescription=V2ktRmk=") {
t.Fatalf("host serverDescription missing from fragment.\n got: %s\nwant suffix: ?serverDescription=V2ktRmk=", got) t.Fatalf("host serverDescription missing from fragment.\n got: %s\nwant suffix: ?serverDescription=V2ktRmk=", got)
} }
if got := (&SubService{omitServerDescription: true}).genHysteriaLink(in, "user"); strings.Contains(got, "serverDescription") {
t.Fatalf("tail not omitted: %s", got)
}
}) })
} }
} }
+5
View File
@@ -46,6 +46,9 @@ type SubService struct {
// other context — the sub info page, the panel's link/QR displays — renders // other context — the sub info page, the panel's link/QR displays — renders
// the name-only template, like Remnawave. // the name-only template, like Remnawave.
subscriptionBody bool subscriptionBody bool
// omitServerDescription drops the ?serverDescription= tail, which only Happ
// and INCY read; other clients show it in the node name (#7105).
omitServerDescription bool
// usageShown emits info once per subscription identity, including twins. // usageShown emits info once per subscription identity, including twins.
// PrepareForRequest resets this per-request state. // PrepareForRequest resets this per-request state.
usageShown map[string]bool usageShown map[string]bool
@@ -1744,7 +1747,9 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
// VLESS/Trojan/SS get the host's description through buildEndpointLinks; // VLESS/Trojan/SS get the host's description through buildEndpointLinks;
// this loop renders the fragment itself, so add it here too (#6738). // this loop renders the fragment itself, so add it here too (#6738).
remark := s.endpointRemark(inbound, email, ep, "quic") remark := s.endpointRemark(inbound, email, ep, "quic")
if !s.omitServerDescription {
remark = appendHappServerDescription(remark, externalProxyToEndpoint(ep).ServerDescription) remark = appendHappServerDescription(remark, externalProxyToEndpoint(ep).ServerDescription)
}
links = append(links, buildLinkWithParams(link, epParams, remark)) links = append(links, buildLinkWithParams(link, epParams, remark))
} }
return strings.Join(links, "\n") return strings.Join(links, "\n")