diff --git a/internal/sub/controller.go b/internal/sub/controller.go index 3bc5dff5d..b25725fa4 100644 --- a/internal/sub/controller.go +++ b/internal/sub/controller.go @@ -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) } diff --git a/internal/sub/controller_test.go b/internal/sub/controller_test.go index 7af2aa6ac..8d5735187 100644 --- a/internal/sub/controller_test.go +++ b/internal/sub/controller_test.go @@ -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) + } + }) + } +}