feat(sub): add legacy Clash subscription endpoint (#6338)

* feat(sub): add legacy Clash subscription endpoint

* fix(deps): update js-yaml to patched release

Raise the Swagger UI js-yaml override to 4.3.2 and refresh the lockfile to resolve GHSA-2883-xcg3-v3hh without changing Swagger UI.

* fix(sub): preserve client detection and normalize legacy cipher

Keep the original Clash/Mihomo auto-detection default so existing subscription URLs continue returning YAML. Normalize the panel-supported chacha20-poly1305 alias when generating legacy Clash profiles, and cover both regressions through HTTP endpoint tests.

* refactor(sub): drop an unreachable guard and make the alias test assert

Review of the legacy Clash subscription endpoint left three LOW findings, all
introduced by the change:

- The comment above the routing merge ran to three lines, over CLAUDE.md's
  two-line cap.
- validateClashRouteGraph on the legacy path could never fail: the legacy
  branch skips the routing merge, so it validated the literal config built a
  few lines above against itself. Dead code that reads as a guard.
- TestClashAliasesSkipConfiguredPathConflicts asserted nothing — it could only
  fail on an escaping gin panic, so a regression that registered the alias
  handler on the configured path went unnoticed. It now drives each collision
  through the router and asserts which format answers each path.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
duqigit
2026-09-11 03:58:55 +08:00
committed by GitHub
parent 3f1e52f09e
commit 64b6e43e2b
6 changed files with 520 additions and 16 deletions
+62 -5
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"html/template"
"io/fs"
@@ -23,6 +24,11 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
const (
subMihomoPath = "/mihomo/"
subClashLegacyPath = "/clash-legacy/"
)
// writeSubError translates a service-layer result into an HTTP response.
// A nil error with no rows means the subId doesn't match anything (deleted
// client, never-existed id) and becomes 404. A real error becomes 500. No
@@ -314,9 +320,42 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) {
gClash := g.Group(a.subClashPath)
gClash.GET(":subid", a.subClashs)
gClash.HEAD(":subid", a.subClashs)
if sameSubscriptionPath(a.subClashPath, subMihomoPath) {
// The configured Clash path already provides the full Mihomo profile.
} 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.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.GET(":subid", a.subClashLegacy)
gLegacy.HEAD(":subid", a.subClashLegacy)
}
}
}
func sameSubscriptionPath(left, right string) bool {
return strings.Trim(left, "/") == strings.Trim(right, "/")
}
func (a *SUBController) configuredSubscriptionPathOwner(candidate string) string {
if sameSubscriptionPath(candidate, a.subPath) {
return "raw subscription"
}
if a.jsonEnabled && sameSubscriptionPath(candidate, a.subJsonPath) {
return "JSON subscription"
}
if a.clashEnabled && sameSubscriptionPath(candidate, a.subClashPath) {
return "Clash subscription"
}
return ""
}
// maybeServeSubPage renders the HTML info page when the request comes from a
// browser (Accept: text/html) or explicitly asks for it (?html=1 or ?view=html).
// It reports whether the request was handled. The remark template's per-client
@@ -411,7 +450,7 @@ func (a *SUBController) subs(c *gin.Context) {
if !a.enforceHwid(c) {
return
}
if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, userAgent, a.clashUserAgent) && a.serveClashBody(c, false) {
if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, userAgent, a.clashUserAgent) && a.serveClashBody(c, false, false) {
a.recordSubscriptionFetch(c)
logSubscriptionRoute(userAgent, "clash")
return
@@ -777,11 +816,19 @@ func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, co
}
func (a *SUBController) subClashs(c *gin.Context) {
a.subClash(c, false)
}
func (a *SUBController) subClashLegacy(c *gin.Context) {
a.subClash(c, true)
}
func (a *SUBController) subClash(c *gin.Context, legacy bool) {
if strings.EqualFold(c.Query("view"), "raw") {
if !a.enforceHwid(c) {
return
}
if !a.serveClashBody(c, true) {
if !a.serveClashBody(c, true, legacy) {
writeSubError(c, nil)
}
a.recordSubscriptionFetch(c)
@@ -793,17 +840,27 @@ func (a *SUBController) subClashs(c *gin.Context) {
if !a.enforceHwid(c) {
return
}
if !a.serveClashBody(c, false) {
if !a.serveClashBody(c, false, legacy) {
writeSubError(c, nil)
}
a.recordSubscriptionFetch(c)
}
func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool) bool {
func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool, legacy bool) bool {
subId := c.Param("subid")
scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
clashSub, header, err := a.subClashService.GetClash(subId, host)
var clashSub, header string
var err error
if legacy {
clashSub, header, err = a.subClashService.GetClashLegacy(subId, host)
} else {
clashSub, header, err = a.subClashService.GetClash(subId, host)
}
if err != nil {
if errors.Is(err, errNoLegacyClashProxies) {
c.String(http.StatusUnprocessableEntity, err.Error())
return true
}
writeSubError(c, err)
return true
}