mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 01:55:25 +08:00
fix(sub): send no-store on every subscription route (#6862)
The raw, JSON and Clash bodies carry live traffic, expiry and routing headers but went out without Cache-Control, so any cache in front of the sub server could serve stale data, such as old routing rules after an admin edit. The rule lived in scattered per-branch calls that new branches missed. setNoCacheHeaders now runs as middleware on the sub, JSON, Clash, Mihomo and legacy Clash route groups, covering every current and future response of those per-subscriber routes.
This commit is contained in:
@@ -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) + `;</script></head>`)
|
||||
out := bytes.Replace(body, []byte("</head>"), 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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user