mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 15:17:14 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user