fix(sub): isolate Incy subscription routing from Happ headers (#6810)

* sub: isolate Incy subscription routing from Happ headers

The resolved Incy line is written to the body only when IsIncyClient
matches the subscription User-Agent, Incy routing is enabled, and the
resolved text is non-blank. ApplyCommonHeaders computes that same Incy
check once and, for a matching client, skips the Happ routing-header
block, leaving Routing-Enable, the Happ Routing value, and the JSON
routing header unset. ApplyIncyHeaders is called with that incyClient
flag. The new test requests the subscription as INCY/1.0.0/ios and as
Happ/1.2.0 and expects the Incy response to end with the incy:// line
and to have empty Routing headers, and the Happ response to keep the
happ:// Routing header with Routing-Enable true and no incy:// line in
the body.

Fixes #6062

* fix(sub): keep the shared JSON routing profile for INCY clients

The INCY isolation wrapped the whole Routing block, JSON fallback
included, in !incyClient. INCY therefore lost the subJsonRoutingRules
profile that #6402 sends as the Routing header so Happ and INCY fetch
the geo files the baked rules reference, on the raw, JSON and Clash
endpoints alike, while every other client kept it, incy:// deeplinks
included.

INCY accepts a routing link of any scheme in that header
(docs.incy.cc/en/routing), so only the Happ tab is Happ-only: an INCY
request now reads it as blank and falls back to the shared profile,
as the setting text promises. Where the raw body carries INCY's own
profile the header is dropped, because INCY ranks a Routing header
above a base64 body line (docs.incy.cc/en/app-management) and the
shared profile would override it, which is #6062 again.

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
Matt Van Horn
2026-10-13 07:01:32 -07:00
committed by GitHub
parent 74c3cc3882
commit 61885c9126
2 changed files with 67 additions and 2 deletions
+12 -2
View File
@@ -504,11 +504,15 @@ func (a *SUBController) subs(c *gin.Context) {
metadata := a.metadataForSubRequest(func() *SubService { return subReq }, subId, builtinProfileURL(c, scheme, hostWithPort))
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
if a.subIncyEnableRouting && a.subIncyRoutingRules != "" {
// Incy's profile rides the raw body; other clients must not see that line (#6062).
if IsIncyClient(userAgent) && a.subIncyEnableRouting && a.subIncyRoutingRules != "" {
incyRules, _, err := resolveIncyRoutingSource(a.subIncyRoutingRules)
if err == nil && strings.TrimSpace(incyRules) != "" {
result.WriteString(incyRules)
result.WriteString("\n")
// INCY ranks a Routing header above a body line, so the shared
// profile must not override INCY's own one here.
c.Writer.Header().Del("Routing")
}
}
@@ -957,6 +961,12 @@ func (a *SUBController) ApplyCommonHeaders(
c.Writer.Header().Set("Announce", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileAnnounce)))
}
// The Happ tab is Happ-only, so INCY reads it as blank and gets the shared
// JSON profile instead; INCY takes a routing link of any scheme (#6062).
incyClient := c.Request != nil && IsIncyClient(c.GetHeader("User-Agent"))
if incyClient {
profileEnableRouting, profileRoutingRules = false, ""
}
rules, remote, routingErr := resolveRoutingSource(remoteRoutingHapp, profileRoutingRules)
if strings.TrimSpace(profileRoutingRules) == "" {
// Happ/INCY fetch the geo files the baked rules reference through this
@@ -981,5 +991,5 @@ func (a *SUBController) ApplyCommonHeaders(
}
ApplyHappHeaders(c, a.happConfig, happManaged)
ApplyIncyHeaders(c, a.incyConfig, a.incyConfig.AutoDetect && c.Request != nil && IsIncyClient(c.GetHeader("User-Agent")))
ApplyIncyHeaders(c, a.incyConfig, a.incyConfig.AutoDetect && incyClient)
}
+55
View File
@@ -802,3 +802,58 @@ func TestLegacyClashEndpointNormalizesShadowsocksCipher(t *testing.T) {
})
}
}
func TestIncyRoutingIsolatedFromHappHeaders(t *testing.T) {
seedSubDB(t)
seedSubInbound(t, "s1", "vl", 4492, 1, `{"network":"tcp","security":"none"}`)
gin.SetMode(gin.TestMode)
const happRules = "happ://routing/onadd/existing-rules"
const incyRules = "incy://routing/onadd/AbC+/="
sharedRules := "incy://routing/onadd/" + base64.StdEncoding.EncodeToString([]byte(`{"Name":"shared","DirectSites":["geosite:category-ru"]}`))
cases := []struct {
name string
path string
userAgent string
incyRouting bool
wantRouting string
wantRoutingEnable string
wantIncyLine bool
}{
{name: "happ keeps its own tab and never sees the incy line", path: "/sub/s1", userAgent: "Happ/1.2.0", incyRouting: true, wantRouting: happRules, wantRoutingEnable: "true"},
{name: "incy without its own tab gets the shared json profile", path: "/sub/s1", userAgent: "INCY/1.0.0/ios", wantRouting: sharedRules},
{name: "incy body profile is not outranked by a routing header", path: "/sub/s1", userAgent: "INCY/1.0.0/ios", incyRouting: true, wantIncyLine: true},
{name: "incy json documents keep the shared profile header", path: "/json/s1", userAgent: "INCY/1.0.0/ios", incyRouting: true, wantRouting: sharedRules},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
router := gin.New()
NewSUBController(router.Group("/"),
WithSUBEncryption(false),
WithSUBJsonEnabled(true),
WithSUBEnableRouting(true),
WithSUBRoutingRules(happRules),
WithSUBJsonRoutingRules(sharedRules),
WithSUBIncyEnableRouting(tc.incyRouting),
WithSUBIncyRoutingRules(incyRules),
)
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com"+tc.path, nil)
req.Header.Set("User-Agent", tc.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 := resp.Header().Get("Routing"); got != tc.wantRouting {
t.Fatalf("Routing = %q, want %q", got, tc.wantRouting)
}
if got := resp.Header().Get("Routing-Enable"); got != tc.wantRoutingEnable {
t.Fatalf("Routing-Enable = %q, want %q", got, tc.wantRoutingEnable)
}
if got := strings.HasSuffix(body, incyRules+"\n"); got != tc.wantIncyLine || (!got && strings.Contains(body, incyRules)) {
t.Fatalf("incy line at body end = %v, want %v; body=%s", got, tc.wantIncyLine, body)
}
})
}
}