diff --git a/internal/sub/controller.go b/internal/sub/controller.go index 79b3a5b11..e495bcb19 100644 --- a/internal/sub/controller.go +++ b/internal/sub/controller.go @@ -335,18 +335,18 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo // initRouter registers HTTP routes for subscription links and JSON endpoints // on the provided router group. func (a *SUBController) initRouter(g *gin.RouterGroup) { - gLink := g.Group(a.subPath) + gLink := g.Group(a.subPath, setNoCacheHeaders) gLink.GET(":subid", a.subs) gLink.HEAD(":subid", a.subs) gLink.GET(":subid/hwid-status", a.hwidStatus) gLink.HEAD(":subid/hwid-status", a.hwidStatus) if a.jsonEnabled { - gJson := g.Group(a.subJsonPath) + gJson := g.Group(a.subJsonPath, setNoCacheHeaders) gJson.GET(":subid", a.subJsons) gJson.HEAD(":subid", a.subJsons) } if a.clashEnabled { - gClash := g.Group(a.subClashPath) + gClash := g.Group(a.subClashPath, setNoCacheHeaders) gClash.GET(":subid", a.subClashs) gClash.HEAD(":subid", a.subClashs) if sameSubscriptionPath(a.subClashPath, subMihomoPath) { @@ -354,14 +354,14 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) { } else if owner := a.configuredSubscriptionPathOwner(subMihomoPath); owner != "" { logger.Warningf("Mihomo subscription alias %q is unavailable because it conflicts with the configured %s path", subMihomoPath, owner) } else { - gMihomo := g.Group(subMihomoPath) + gMihomo := g.Group(subMihomoPath, setNoCacheHeaders) gMihomo.GET(":subid", a.subClashs) gMihomo.HEAD(":subid", a.subClashs) } if owner := a.configuredSubscriptionPathOwner(subClashLegacyPath); owner != "" { logger.Warningf("Legacy Clash subscription alias %q is unavailable because it conflicts with the configured %s path", subClashLegacyPath, owner) } else { - gLegacy := g.Group(subClashLegacyPath) + gLegacy := g.Group(subClashLegacyPath, setNoCacheHeaders) gLegacy.GET(":subid", a.subClashLegacy) gLegacy.HEAD(":subid", a.subClashLegacy) } @@ -416,7 +416,6 @@ func (a *SUBController) maybeServeSubInfo(c *gin.Context) bool { info := a.subPageContext(page) delete(info, "links") info["emails"] = dedupeEmails(page.Emails) - setNoCacheHeaders(c) c.JSON(http.StatusOK, info) return true } @@ -634,7 +633,6 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD if execErr := tmpl.Execute(&buf, subData); execErr != nil { logger.Error("sub: custom template execution failed, using default page:", execErr) } else { - setNoCacheHeaders(c) c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes()) return } @@ -663,7 +661,6 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD `window.__SUB_PAGE_DATA__=` + string(subDataJSON) + `;`) out := bytes.Replace(body, []byte(""), inject, 1) - setNoCacheHeaders(c) c.Data(http.StatusOK, "text/html; charset=utf-8", out) } @@ -750,12 +747,11 @@ func (a *SUBController) hwidStatus(c *gin.Context) { writeSubError(c, err) return } - setNoCacheHeaders(c) c.JSON(http.StatusOK, status) } -// setNoCacheHeaders marks a subscription page response as non-cacheable so VPN -// clients and browsers always fetch fresh traffic/expiry data. +// setNoCacheHeaders runs on every subscription route group: each one serves a per-subscriber body +// with live traffic, expiry and routing headers that no cache may keep. func setNoCacheHeaders(c *gin.Context) { c.Header("Cache-Control", "no-cache, no-store, must-revalidate") c.Header("Pragma", "no-cache") diff --git a/internal/sub/controller_test.go b/internal/sub/controller_test.go index 8d5735187..fb119c8a9 100644 --- a/internal/sub/controller_test.go +++ b/internal/sub/controller_test.go @@ -857,3 +857,31 @@ func TestIncyRoutingIsolatedFromHappHeaders(t *testing.T) { }) } } + +// Each route group serves live per-subscriber data; a group registered without the no-store +// middleware lets a cache in front of the sub server hand out stale traffic and routing. +func TestSubscriptionRouteGroupsAreNotCacheable(t *testing.T) { + seedSubDB(t) + seedSubInbound(t, "s1", "cache", 4482, 1, `{"network":"tcp","security":"none"}`) + gin.SetMode(gin.TestMode) + oldDistFS := distFS + distFS = testDistFS + t.Cleanup(func() { distFS = oldDistFS }) + router := newSubscriptionTestRouter(subscriptionTestRouterConfig{}) + + for _, path := range []string{"/sub/s1", "/json/s1", "/clash/s1", "/mihomo/s1", "/clash-legacy/s1"} { + t.Run(path, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "http://sub.example.com"+path, nil) + req.Header.Set("User-Agent", "curl/8.0") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if resp.Code == http.StatusNotFound { + t.Fatalf("route %s is not registered", path) + } + if cc := resp.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") { + t.Fatalf("Cache-Control = %q (status %d), want a no-store directive", cc, resp.Code) + } + }) + } +}