Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/MHSanaei/3x-ui.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorzhuzn <haimu0427@Outlook.com>2026-04-19 23:26:13 +0300
committerGitHub <noreply@github.com>2026-04-19 23:26:13 +0300
commitd580086361036f87af843d0f7386bdc54736720a (patch)
treef22944b1ab1ea8c29858d796cc248b7b16436427
parent1e3b366fba3054698d55e36891d022a513e5a942 (diff)
feat add clash yaml convert (#3916)
* docs(agents): add AI agent guidance documentation * feat(sub): add Clash/Mihomo YAML subscription service Add SubClashService to convert subscription links to Clash/Mihomo YAML format for direct client compatibility. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(sub): integrate Clash YAML endpoint into subscription system - Add Clash route handler in SUBController - Update BuildURLs to include Clash URL - Pass Clash settings through subscription pipeline Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(web): add Clash settings to entity and service - Add SubClashEnable, SubClashPath, SubClashURI fields - Add getter methods for Clash configuration - Set default Clash path to /clash/ and enable by default Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ui): add Clash settings to subscription panels - Add Clash enable switch in general subscription settings - Add Clash path/URI configuration in formats panel - Display Clash QR code on subscription page - Rename JSON tab to "Formats" for clarity Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(js): add Clash support to frontend models - Add subClashEnable, subClashPath, subClashURI to AllSetting - Generate and display Clash QR code on subscription page - Handle Clash URL in subscription data binding Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
-rw-r--r--go.mod2
-rw-r--r--sub/sub.go13
-rw-r--r--sub/subClashService.go385
-rw-r--r--sub/subController.go45
-rw-r--r--sub/subService.go20
-rw-r--r--web/assets/js/model/setting.js3
-rw-r--r--web/assets/js/subscription.js7
-rw-r--r--web/entity/entity.go10
-rw-r--r--web/html/settings.html4
-rw-r--r--web/html/settings/panel/subscription/general.html86
-rw-r--r--web/html/settings/panel/subscription/json.html26
-rw-r--r--web/html/settings/panel/subscription/subpage.html17
-rw-r--r--web/service/setting.go39
13 files changed, 596 insertions, 61 deletions
diff --git a/go.mod b/go.mod
index a30157c3..6858d5b6 100644
--- a/go.mod
+++ b/go.mod
@@ -8,6 +8,7 @@ require (
github.com/gin-gonic/gin v1.12.0
github.com/go-ldap/ldap/v3 v3.4.13
github.com/goccy/go-json v0.10.6
+ github.com/goccy/go-yaml v1.19.2
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1
@@ -48,7 +49,6 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.2 // indirect
- github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
diff --git a/sub/sub.go b/sub/sub.go
index 1dcd9601..b940cc95 100644
--- a/sub/sub.go
+++ b/sub/sub.go
@@ -91,12 +91,21 @@ func (s *Server) initRouter() (*gin.Engine, error) {
return nil, err
}
- // Determine if JSON subscription endpoint is enabled
+ ClashPath, err := s.settingService.GetSubClashPath()
+ if err != nil {
+ return nil, err
+ }
+
subJsonEnable, err := s.settingService.GetSubJsonEnable()
if err != nil {
return nil, err
}
+ subClashEnable, err := s.settingService.GetSubClashEnable()
+ if err != nil {
+ return nil, err
+ }
+
// Set base_path based on LinksPath for template rendering
// Ensure LinksPath ends with "/" for proper asset URL generation
basePath := LinksPath
@@ -255,7 +264,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
g := engine.Group("/")
s.sub = NewSUBController(
- g, LinksPath, JsonPath, subJsonEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
+ g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubTitle, SubSupportUrl,
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
diff --git a/sub/subClashService.go b/sub/subClashService.go
new file mode 100644
index 00000000..ea095919
--- /dev/null
+++ b/sub/subClashService.go
@@ -0,0 +1,385 @@
+package sub
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/goccy/go-json"
+ yaml "github.com/goccy/go-yaml"
+
+ "github.com/mhsanaei/3x-ui/v2/database/model"
+ "github.com/mhsanaei/3x-ui/v2/logger"
+ "github.com/mhsanaei/3x-ui/v2/web/service"
+ "github.com/mhsanaei/3x-ui/v2/xray"
+)
+
+type SubClashService struct {
+ inboundService service.InboundService
+ SubService *SubService
+}
+
+type ClashConfig struct {
+ Proxies []map[string]any `yaml:"proxies"`
+ ProxyGroups []map[string]any `yaml:"proxy-groups"`
+ Rules []string `yaml:"rules"`
+}
+
+func NewSubClashService(subService *SubService) *SubClashService {
+ return &SubClashService{SubService: subService}
+}
+
+func (s *SubClashService) GetClash(subId string, host string) (string, string, error) {
+ inbounds, err := s.SubService.getInboundsBySubId(subId)
+ if err != nil || len(inbounds) == 0 {
+ return "", "", err
+ }
+
+ var traffic xray.ClientTraffic
+ var clientTraffics []xray.ClientTraffic
+ var proxies []map[string]any
+
+ for _, inbound := range inbounds {
+ clients, err := s.inboundService.GetClients(inbound)
+ if err != nil {
+ logger.Error("SubClashService - GetClients: Unable to get clients from inbound")
+ }
+ if clients == nil {
+ continue
+ }
+ if len(inbound.Listen) > 0 && inbound.Listen[0] == '@' {
+ listen, port, streamSettings, err := s.SubService.getFallbackMaster(inbound.Listen, inbound.StreamSettings)
+ if err == nil {
+ inbound.Listen = listen
+ inbound.Port = port
+ inbound.StreamSettings = streamSettings
+ }
+ }
+ for _, client := range clients {
+ if client.Enable && client.SubID == subId {
+ clientTraffics = append(clientTraffics, s.SubService.getClientTraffics(inbound.ClientStats, client.Email))
+ proxies = append(proxies, s.getProxies(inbound, client, host)...)
+ }
+ }
+ }
+
+ if len(proxies) == 0 {
+ return "", "", nil
+ }
+
+ for index, clientTraffic := range clientTraffics {
+ if index == 0 {
+ traffic.Up = clientTraffic.Up
+ traffic.Down = clientTraffic.Down
+ traffic.Total = clientTraffic.Total
+ if clientTraffic.ExpiryTime > 0 {
+ traffic.ExpiryTime = clientTraffic.ExpiryTime
+ }
+ } else {
+ traffic.Up += clientTraffic.Up
+ traffic.Down += clientTraffic.Down
+ if traffic.Total == 0 || clientTraffic.Total == 0 {
+ traffic.Total = 0
+ } else {
+ traffic.Total += clientTraffic.Total
+ }
+ if clientTraffic.ExpiryTime != traffic.ExpiryTime {
+ traffic.ExpiryTime = 0
+ }
+ }
+ }
+
+ proxyNames := make([]string, 0, len(proxies)+1)
+ for _, proxy := range proxies {
+ if name, ok := proxy["name"].(string); ok && name != "" {
+ proxyNames = append(proxyNames, name)
+ }
+ }
+ proxyNames = append(proxyNames, "DIRECT")
+
+ config := ClashConfig{
+ Proxies: proxies,
+ ProxyGroups: []map[string]any{{
+ "name": "PROXY",
+ "type": "select",
+ "proxies": proxyNames,
+ }},
+ Rules: []string{"MATCH,PROXY"},
+ }
+
+ finalYAML, err := yaml.Marshal(config)
+ if err != nil {
+ return "", "", err
+ }
+
+ header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
+ return string(finalYAML), header, nil
+}
+
+func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client, host string) []map[string]any {
+ stream := s.streamData(inbound.StreamSettings)
+ externalProxies, ok := stream["externalProxy"].([]any)
+ if !ok || len(externalProxies) == 0 {
+ externalProxies = []any{map[string]any{
+ "forceTls": "same",
+ "dest": host,
+ "port": float64(inbound.Port),
+ "remark": "",
+ }}
+ }
+ delete(stream, "externalProxy")
+
+ proxies := make([]map[string]any, 0, len(externalProxies))
+ for _, ep := range externalProxies {
+ extPrxy := ep.(map[string]any)
+ workingInbound := *inbound
+ workingInbound.Listen = extPrxy["dest"].(string)
+ workingInbound.Port = int(extPrxy["port"].(float64))
+ workingStream := cloneMap(stream)
+
+ switch extPrxy["forceTls"].(string) {
+ case "tls":
+ if workingStream["security"] != "tls" {
+ workingStream["security"] = "tls"
+ workingStream["tlsSettings"] = map[string]any{}
+ }
+ case "none":
+ if workingStream["security"] != "none" {
+ workingStream["security"] = "none"
+ delete(workingStream, "tlsSettings")
+ delete(workingStream, "realitySettings")
+ }
+ }
+
+ proxy := s.buildProxy(&workingInbound, client, workingStream, extPrxy["remark"].(string))
+ if len(proxy) > 0 {
+ proxies = append(proxies, proxy)
+ }
+ }
+ return proxies
+}
+
+func (s *SubClashService) buildProxy(inbound *model.Inbound, client model.Client, stream map[string]any, extraRemark string) map[string]any {
+ proxy := map[string]any{
+ "name": s.SubService.genRemark(inbound, client.Email, extraRemark),
+ "server": inbound.Listen,
+ "port": inbound.Port,
+ "udp": true,
+ }
+
+ network, _ := stream["network"].(string)
+ if !s.applyTransport(proxy, network, stream) {
+ return nil
+ }
+
+ switch inbound.Protocol {
+ case model.VMESS:
+ proxy["type"] = "vmess"
+ proxy["uuid"] = client.ID
+ proxy["alterId"] = 0
+ cipher := client.Security
+ if cipher == "" {
+ cipher = "auto"
+ }
+ proxy["cipher"] = cipher
+ case model.VLESS:
+ proxy["type"] = "vless"
+ proxy["uuid"] = client.ID
+ if client.Flow != "" && network == "tcp" {
+ proxy["flow"] = client.Flow
+ }
+ var inboundSettings map[string]any
+ json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
+ if encryption, ok := inboundSettings["encryption"].(string); ok && encryption != "" {
+ proxy["packet-encoding"] = encryption
+ }
+ case model.Trojan:
+ proxy["type"] = "trojan"
+ proxy["password"] = client.Password
+ case model.Shadowsocks:
+ proxy["type"] = "ss"
+ proxy["password"] = client.Password
+ var inboundSettings map[string]any
+ json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
+ method, _ := inboundSettings["method"].(string)
+ if method == "" {
+ return nil
+ }
+ proxy["cipher"] = method
+ if strings.HasPrefix(method, "2022") {
+ if serverPassword, ok := inboundSettings["password"].(string); ok && serverPassword != "" {
+ proxy["password"] = fmt.Sprintf("%s:%s", serverPassword, client.Password)
+ }
+ }
+ default:
+ return nil
+ }
+
+ security, _ := stream["security"].(string)
+ if !s.applySecurity(proxy, security, stream) {
+ return nil
+ }
+
+ return proxy
+}
+
+func (s *SubClashService) applyTransport(proxy map[string]any, network string, stream map[string]any) bool {
+ switch network {
+ case "", "tcp":
+ proxy["network"] = "tcp"
+ tcp, _ := stream["tcpSettings"].(map[string]any)
+ if tcp != nil {
+ header, _ := tcp["header"].(map[string]any)
+ if header != nil {
+ typeStr, _ := header["type"].(string)
+ if typeStr != "" && typeStr != "none" {
+ return false
+ }
+ }
+ }
+ return true
+ case "ws":
+ proxy["network"] = "ws"
+ ws, _ := stream["wsSettings"].(map[string]any)
+ wsOpts := map[string]any{}
+ if ws != nil {
+ if path, ok := ws["path"].(string); ok && path != "" {
+ wsOpts["path"] = path
+ }
+ host := ""
+ if v, ok := ws["host"].(string); ok && v != "" {
+ host = v
+ } else if headers, ok := ws["headers"].(map[string]any); ok {
+ host = searchHost(headers)
+ }
+ if host != "" {
+ wsOpts["headers"] = map[string]any{"Host": host}
+ }
+ }
+ if len(wsOpts) > 0 {
+ proxy["ws-opts"] = wsOpts
+ }
+ return true
+ case "grpc":
+ proxy["network"] = "grpc"
+ grpc, _ := stream["grpcSettings"].(map[string]any)
+ grpcOpts := map[string]any{}
+ if grpc != nil {
+ if serviceName, ok := grpc["serviceName"].(string); ok && serviceName != "" {
+ grpcOpts["grpc-service-name"] = serviceName
+ }
+ }
+ if len(grpcOpts) > 0 {
+ proxy["grpc-opts"] = grpcOpts
+ }
+ return true
+ default:
+ return false
+ }
+}
+
+func (s *SubClashService) applySecurity(proxy map[string]any, security string, stream map[string]any) bool {
+ switch security {
+ case "", "none":
+ proxy["tls"] = false
+ return true
+ case "tls":
+ proxy["tls"] = true
+ tlsSettings, _ := stream["tlsSettings"].(map[string]any)
+ if tlsSettings != nil {
+ if serverName, ok := tlsSettings["serverName"].(string); ok && serverName != "" {
+ proxy["servername"] = serverName
+ switch proxy["type"] {
+ case "trojan":
+ proxy["sni"] = serverName
+ }
+ }
+ if fingerprint, ok := tlsSettings["fingerprint"].(string); ok && fingerprint != "" {
+ proxy["client-fingerprint"] = fingerprint
+ }
+ }
+ return true
+ case "reality":
+ proxy["tls"] = true
+ realitySettings, _ := stream["realitySettings"].(map[string]any)
+ if realitySettings == nil {
+ return false
+ }
+ if serverName, ok := realitySettings["serverName"].(string); ok && serverName != "" {
+ proxy["servername"] = serverName
+ }
+ realityOpts := map[string]any{}
+ if publicKey, ok := realitySettings["publicKey"].(string); ok && publicKey != "" {
+ realityOpts["public-key"] = publicKey
+ }
+ if shortID, ok := realitySettings["shortId"].(string); ok && shortID != "" {
+ realityOpts["short-id"] = shortID
+ }
+ if len(realityOpts) > 0 {
+ proxy["reality-opts"] = realityOpts
+ }
+ if fingerprint, ok := realitySettings["fingerprint"].(string); ok && fingerprint != "" {
+ proxy["client-fingerprint"] = fingerprint
+ }
+ return true
+ default:
+ return false
+ }
+}
+
+func (s *SubClashService) streamData(stream string) map[string]any {
+ var streamSettings map[string]any
+ json.Unmarshal([]byte(stream), &streamSettings)
+ security, _ := streamSettings["security"].(string)
+ switch security {
+ case "tls":
+ if tlsSettings, ok := streamSettings["tlsSettings"].(map[string]any); ok {
+ streamSettings["tlsSettings"] = s.tlsData(tlsSettings)
+ }
+ case "reality":
+ if realitySettings, ok := streamSettings["realitySettings"].(map[string]any); ok {
+ streamSettings["realitySettings"] = s.realityData(realitySettings)
+ }
+ }
+ delete(streamSettings, "sockopt")
+ return streamSettings
+}
+
+func (s *SubClashService) tlsData(tData map[string]any) map[string]any {
+ tlsData := make(map[string]any, 1)
+ tlsClientSettings, _ := tData["settings"].(map[string]any)
+ tlsData["serverName"] = tData["serverName"]
+ tlsData["alpn"] = tData["alpn"]
+ if fingerprint, ok := tlsClientSettings["fingerprint"].(string); ok {
+ tlsData["fingerprint"] = fingerprint
+ }
+ return tlsData
+}
+
+func (s *SubClashService) realityData(rData map[string]any) map[string]any {
+ rDataOut := make(map[string]any, 1)
+ realityClientSettings, _ := rData["settings"].(map[string]any)
+ if publicKey, ok := realityClientSettings["publicKey"].(string); ok {
+ rDataOut["publicKey"] = publicKey
+ }
+ if fingerprint, ok := realityClientSettings["fingerprint"].(string); ok {
+ rDataOut["fingerprint"] = fingerprint
+ }
+ if serverNames, ok := rData["serverNames"].([]any); ok && len(serverNames) > 0 {
+ rDataOut["serverName"] = fmt.Sprint(serverNames[0])
+ }
+ if shortIDs, ok := rData["shortIds"].([]any); ok && len(shortIDs) > 0 {
+ rDataOut["shortId"] = fmt.Sprint(shortIDs[0])
+ }
+ return rDataOut
+}
+
+func cloneMap(src map[string]any) map[string]any {
+ if src == nil {
+ return nil
+ }
+ dst := make(map[string]any, len(src))
+ for k, v := range src {
+ dst[k] = v
+ }
+ return dst
+}
diff --git a/sub/subController.go b/sub/subController.go
index 79ea755d..0e9e2c97 100644
--- a/sub/subController.go
+++ b/sub/subController.go
@@ -21,12 +21,15 @@ type SUBController struct {
subRoutingRules string
subPath string
subJsonPath string
+ subClashPath string
jsonEnabled bool
+ clashEnabled bool
subEncrypt bool
updateInterval string
- subService *SubService
- subJsonService *SubJsonService
+ subService *SubService
+ subJsonService *SubJsonService
+ subClashService *SubClashService
}
// NewSUBController creates a new subscription controller with the given configuration.
@@ -34,7 +37,9 @@ func NewSUBController(
g *gin.RouterGroup,
subPath string,
jsonPath string,
+ clashPath string,
jsonEnabled bool,
+ clashEnabled bool,
encrypt bool,
showInfo bool,
rModel string,
@@ -60,12 +65,15 @@ func NewSUBController(
subRoutingRules: subRoutingRules,
subPath: subPath,
subJsonPath: jsonPath,
+ subClashPath: clashPath,
jsonEnabled: jsonEnabled,
+ clashEnabled: clashEnabled,
subEncrypt: encrypt,
updateInterval: update,
- subService: sub,
- subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
+ subService: sub,
+ subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
+ subClashService: NewSubClashService(sub),
}
a.initRouter(g)
return a
@@ -80,6 +88,10 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) {
gJson := g.Group(a.subJsonPath)
gJson.GET(":subid", a.subJsons)
}
+ if a.clashEnabled {
+ gClash := g.Group(a.subClashPath)
+ gClash.GET(":subid", a.subClashs)
+ }
}
// subs handles HTTP requests for subscription links, returning either HTML page or base64-encoded subscription data.
@@ -99,10 +111,13 @@ func (a *SUBController) subs(c *gin.Context) {
accept := c.GetHeader("Accept")
if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") {
// Build page data in service
- subURL, subJsonURL := a.subService.BuildURLs(scheme, hostWithPort, a.subPath, a.subJsonPath, subId)
+ subURL, subJsonURL, subClashURL := a.subService.BuildURLs(scheme, hostWithPort, a.subPath, a.subJsonPath, a.subClashPath, subId)
if !a.jsonEnabled {
subJsonURL = ""
}
+ if !a.clashEnabled {
+ subClashURL = ""
+ }
// Get base_path from context (set by middleware)
basePath, exists := c.Get("base_path")
if !exists {
@@ -116,7 +131,7 @@ func (a *SUBController) subs(c *gin.Context) {
// Remove trailing slash if exists, add subId, then add trailing slash
basePathStr = strings.TrimRight(basePathStr, "/") + "/" + subId + "/"
}
- page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, basePathStr)
+ page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, subClashURL, basePathStr)
c.HTML(200, "subpage.html", gin.H{
"title": "subscription.title",
"cur_ver": config.GetVersion(),
@@ -136,6 +151,7 @@ func (a *SUBController) subs(c *gin.Context) {
"totalByte": page.TotalByte,
"subUrl": page.SubUrl,
"subJsonUrl": page.SubJsonUrl,
+ "subClashUrl": page.SubClashUrl,
"result": page.Result,
})
return
@@ -165,7 +181,6 @@ func (a *SUBController) subJsons(c *gin.Context) {
if err != nil || len(jsonSub) == 0 {
c.String(400, "Error!")
} else {
- // Add headers
profileUrl := a.subProfileUrl
if profileUrl == "" {
profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
@@ -176,6 +191,22 @@ func (a *SUBController) subJsons(c *gin.Context) {
}
}
+func (a *SUBController) subClashs(c *gin.Context) {
+ subId := c.Param("subid")
+ scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
+ clashSub, header, err := a.subClashService.GetClash(subId, host)
+ if err != nil || len(clashSub) == 0 {
+ c.String(400, "Error!")
+ } else {
+ profileUrl := a.subProfileUrl
+ if profileUrl == "" {
+ profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
+ }
+ a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules)
+ c.Data(200, "application/yaml; charset=utf-8", []byte(clashSub))
+ }
+}
+
// ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title.
func (a *SUBController) ApplyCommonHeaders(
c *gin.Context,
diff --git a/sub/subService.go b/sub/subService.go
index a0508ddc..b6422dd4 100644
--- a/sub/subService.go
+++ b/sub/subService.go
@@ -1031,6 +1031,7 @@ type PageData struct {
TotalByte int64
SubUrl string
SubJsonUrl string
+ SubClashUrl string
Result []string
}
@@ -1080,29 +1081,25 @@ func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string,
// BuildURLs constructs absolute subscription and JSON subscription URLs for a given subscription ID.
// It prioritizes configured URIs, then individual settings, and finally falls back to request-derived components.
-func (s *SubService) BuildURLs(scheme, hostWithPort, subPath, subJsonPath, subId string) (subURL, subJsonURL string) {
- // Input validation
+func (s *SubService) BuildURLs(scheme, hostWithPort, subPath, subJsonPath, subClashPath, subId string) (subURL, subJsonURL, subClashURL string) {
if subId == "" {
- return "", ""
+ return "", "", ""
}
- // Get configured URIs first (highest priority)
configuredSubURI, _ := s.settingService.GetSubURI()
configuredSubJsonURI, _ := s.settingService.GetSubJsonURI()
+ configuredSubClashURI, _ := s.settingService.GetSubClashURI()
- // Determine base scheme and host (cached to avoid duplicate calls)
var baseScheme, baseHostWithPort string
- if configuredSubURI == "" || configuredSubJsonURI == "" {
+ if configuredSubURI == "" || configuredSubJsonURI == "" || configuredSubClashURI == "" {
baseScheme, baseHostWithPort = s.getBaseSchemeAndHost(scheme, hostWithPort)
}
- // Build subscription URL
subURL = s.buildSingleURL(configuredSubURI, baseScheme, baseHostWithPort, subPath, subId)
-
- // Build JSON subscription URL
subJsonURL = s.buildSingleURL(configuredSubJsonURI, baseScheme, baseHostWithPort, subJsonPath, subId)
+ subClashURL = s.buildSingleURL(configuredSubClashURI, baseScheme, baseHostWithPort, subClashPath, subId)
- return subURL, subJsonURL
+ return subURL, subJsonURL, subClashURL
}
// getBaseSchemeAndHost determines the base scheme and host from settings or falls back to request values
@@ -1149,7 +1146,7 @@ func (s *SubService) joinPathWithID(basePath, subId string) string {
// BuildPageData parses header and prepares the template view model.
// BuildPageData constructs page data for rendering the subscription information page.
-func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL string, basePath string) PageData {
+func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL, subClashURL string, basePath string) PageData {
download := common.FormatTraffic(traffic.Down)
upload := common.FormatTraffic(traffic.Up)
total := "∞"
@@ -1183,6 +1180,7 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray
TotalByte: traffic.Total,
SubUrl: subURL,
SubJsonUrl: subJsonURL,
+ SubClashUrl: subClashURL,
Result: subs,
}
}
diff --git a/web/assets/js/model/setting.js b/web/assets/js/model/setting.js
index af80a63e..d61d4b8e 100644
--- a/web/assets/js/model/setting.js
+++ b/web/assets/js/model/setting.js
@@ -38,6 +38,8 @@ class AllSetting {
this.subPort = 2096;
this.subPath = "/sub/";
this.subJsonPath = "/json/";
+ this.subClashEnable = true;
+ this.subClashPath = "/clash/";
this.subDomain = "";
this.externalTrafficInformEnable = false;
this.externalTrafficInformURI = "";
@@ -48,6 +50,7 @@ class AllSetting {
this.subShowInfo = true;
this.subURI = "";
this.subJsonURI = "";
+ this.subClashURI = "";
this.subJsonFragment = "";
this.subJsonNoises = "";
this.subJsonMux = "";
diff --git a/web/assets/js/subscription.js b/web/assets/js/subscription.js
index 228dcfa0..d08bfd28 100644
--- a/web/assets/js/subscription.js
+++ b/web/assets/js/subscription.js
@@ -9,6 +9,7 @@
sId: el.getAttribute('data-sid') || '',
subUrl: el.getAttribute('data-sub-url') || '',
subJsonUrl: el.getAttribute('data-subjson-url') || '',
+ subClashUrl: el.getAttribute('data-subclash-url') || '',
download: el.getAttribute('data-download') || '',
upload: el.getAttribute('data-upload') || '',
used: el.getAttribute('data-used') || '',
@@ -98,13 +99,19 @@
this.lang = LanguageManager.getLanguage();
const tpl = document.getElementById('subscription-data');
const sj = tpl ? tpl.getAttribute('data-subjson-url') : '';
+ const sc = tpl ? tpl.getAttribute('data-subclash-url') : '';
if (sj) this.app.subJsonUrl = sj;
+ if (sc) this.app.subClashUrl = sc;
drawQR(this.app.subUrl);
try {
const elJson = document.getElementById('qrcode-subjson');
if (elJson && this.app.subJsonUrl) {
new QRious({ element: elJson, value: this.app.subJsonUrl, size: 220 });
}
+ const elClash = document.getElementById('qrcode-subclash');
+ if (elClash && this.app.subClashUrl) {
+ new QRious({ element: elClash, value: this.app.subClashUrl, size: 220 });
+ }
} catch (e) { /* ignore */ }
this._onResize = () => { this.viewportWidth = window.innerWidth; };
window.addEventListener('resize', this._onResize);
diff --git a/web/entity/entity.go b/web/entity/entity.go
index 40294925..14353cf0 100644
--- a/web/entity/entity.go
+++ b/web/entity/entity.go
@@ -76,6 +76,9 @@ type AllSetting struct {
SubURI string `json:"subURI" form:"subURI"` // Subscription server URI
SubJsonPath string `json:"subJsonPath" form:"subJsonPath"` // Path for JSON subscription endpoint
SubJsonURI string `json:"subJsonURI" form:"subJsonURI"` // JSON subscription server URI
+ SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"` // Enable Clash/Mihomo subscription endpoint
+ SubClashPath string `json:"subClashPath" form:"subClashPath"` // Path for Clash/Mihomo subscription endpoint
+ SubClashURI string `json:"subClashURI" form:"subClashURI"` // Clash/Mihomo subscription server URI
SubJsonFragment string `json:"subJsonFragment" form:"subJsonFragment"` // JSON subscription fragment configuration
SubJsonNoises string `json:"subJsonNoises" form:"subJsonNoises"` // JSON subscription noise configuration
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
@@ -168,6 +171,13 @@ func (s *AllSetting) CheckValid() error {
s.SubJsonPath += "/"
}
+ if !strings.HasPrefix(s.SubClashPath, "/") {
+ s.SubClashPath = "/" + s.SubClashPath
+ }
+ if !strings.HasSuffix(s.SubClashPath, "/") {
+ s.SubClashPath += "/"
+ }
+
_, err := time.LoadLocation(s.TimeLocation)
if err != nil {
return common.NewError("time location not exist:", s.TimeLocation)
diff --git a/web/html/settings.html b/web/html/settings.html
index 48aad524..441e62de 100644
--- a/web/html/settings.html
+++ b/web/html/settings.html
@@ -79,10 +79,10 @@
</template>
{{ template "settings/panel/subscription/general" . }}
</a-tab-pane>
- <a-tab-pane key="5" v-if="allSetting.subJsonEnable" :style="{ paddingTop: '20px' }">
+ <a-tab-pane key="5" v-if="allSetting.subJsonEnable || allSetting.subClashEnable" :style="{ paddingTop: '20px' }">
<template #tab>
<a-icon type="code"></a-icon>
- <span>{{ i18n "pages.settings.subSettings" }} (JSON)</span>
+ <span>{{ i18n "pages.settings.subSettings" }} (Formats)</span>
</template>
{{ template "settings/panel/subscription/json" . }}
</a-tab-pane>
diff --git a/web/html/settings/panel/subscription/general.html b/web/html/settings/panel/subscription/general.html
index 5d83aa37..725a9359 100644
--- a/web/html/settings/panel/subscription/general.html
+++ b/web/html/settings/panel/subscription/general.html
@@ -3,43 +3,58 @@
<a-collapse-panel key="1" header='{{ i18n "pages.xray.generalConfigs"}}'>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subEnable"}}</template>
- <template #description>{{ i18n "pages.settings.subEnableDesc"}}</template>
+ <template #description>{{ i18n
+ "pages.settings.subEnableDesc"}}</template>
<template #control>
<a-switch v-model="allSetting.subEnable"></a-switch>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>JSON Subscription</template>
- <template #description>{{ i18n "pages.settings.subJsonEnable"}}</template>
+ <template #description>{{ i18n
+ "pages.settings.subJsonEnable"}}</template>
<template #control>
<a-switch v-model="allSetting.subJsonEnable"></a-switch>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
+ <template #title>Clash / Mihomo Subscription</template>
+ <template #description>Enable direct Clash and Mihomo YAML
+ subscriptions.</template>
+ <template #control>
+ <a-switch v-model="allSetting.subClashEnable"></a-switch>
+ </template>
+ </a-setting-list-item>
+ <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subListen"}}</template>
- <template #description>{{ i18n "pages.settings.subListenDesc"}}</template>
+ <template #description>{{ i18n
+ "pages.settings.subListenDesc"}}</template>
<template #control>
<a-input type="text" v-model="allSetting.subListen"></a-input>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subDomain"}}</template>
- <template #description>{{ i18n "pages.settings.subDomainDesc"}}</template>
+ <template #description>{{ i18n
+ "pages.settings.subDomainDesc"}}</template>
<template #control>
<a-input type="text" v-model="allSetting.subDomain"></a-input>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subPort"}}</template>
- <template #description>{{ i18n "pages.settings.subPortDesc"}}</template>
+ <template #description>{{ i18n
+ "pages.settings.subPortDesc"}}</template>
<template #control>
- <a-input-number v-model="allSetting.subPort" :min="1" :min="65535"
+ <a-input-number v-model="allSetting.subPort" :min="1"
+ :min="65535"
:style="{ width: '100%' }"></a-input-number>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subPath"}}</template>
- <template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
+ <template #description>{{ i18n
+ "pages.settings.subPathDesc"}}</template>
<template #control>
<a-input type="text" v-model="allSetting.subPath"
@input="allSetting.subPath = ((typeof $event === 'string' ? $event : ($event && $event.target ? $event.target.value : '')) || '').replace(/[:*]/g, '')"
@@ -49,9 +64,11 @@
</a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subURI"}}</template>
- <template #description>{{ i18n "pages.settings.subURIDesc"}}</template>
+ <template #description>{{ i18n
+ "pages.settings.subURIDesc"}}</template>
<template #control>
- <a-input type="text" placeholder="(http|https)://domain[:port]/path/"
+ <a-input type="text"
+ placeholder="(http|https)://domain[:port]/path/"
v-model="allSetting.subURI"></a-input>
</template>
</a-setting-list-item>
@@ -59,14 +76,16 @@
<a-collapse-panel key="2" header='{{ i18n "pages.settings.information" }}'>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subEncrypt"}}</template>
- <template #description>{{ i18n "pages.settings.subEncryptDesc"}}</template>
+ <template #description>{{ i18n
+ "pages.settings.subEncryptDesc"}}</template>
<template #control>
<a-switch v-model="allSetting.subEncrypt"></a-switch>