Bladeren bron

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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* fix

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
Co-authored-by: Sanaei <[email protected]>
zhuzn 1 dag geleden
bovenliggende
commit
d580086361

+ 1 - 1
go.mod

@@ -8,6 +8,7 @@ require (
 	github.com/gin-gonic/gin v1.12.0
 	github.com/gin-gonic/gin v1.12.0
 	github.com/go-ldap/ldap/v3 v3.4.13
 	github.com/go-ldap/ldap/v3 v3.4.13
 	github.com/goccy/go-json v0.10.6
 	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/google/uuid v1.6.0
 	github.com/gorilla/websocket v1.5.3
 	github.com/gorilla/websocket v1.5.3
 	github.com/joho/godotenv v1.5.1
 	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/locales v0.14.1 // indirect
 	github.com/go-playground/universal-translator v0.18.1 // indirect
 	github.com/go-playground/universal-translator v0.18.1 // indirect
 	github.com/go-playground/validator/v10 v10.30.2 // 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/google/btree v1.1.3 // indirect
 	github.com/gorilla/context v1.1.2 // indirect
 	github.com/gorilla/context v1.1.2 // indirect
 	github.com/gorilla/securecookie v1.1.2 // indirect
 	github.com/gorilla/securecookie v1.1.2 // indirect

+ 11 - 2
sub/sub.go

@@ -91,12 +91,21 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		return nil, err
 		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()
 	subJsonEnable, err := s.settingService.GetSubJsonEnable()
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
 
 
+	subClashEnable, err := s.settingService.GetSubClashEnable()
+	if err != nil {
+		return nil, err
+	}
+
 	// Set base_path based on LinksPath for template rendering
 	// Set base_path based on LinksPath for template rendering
 	// Ensure LinksPath ends with "/" for proper asset URL generation
 	// Ensure LinksPath ends with "/" for proper asset URL generation
 	basePath := LinksPath
 	basePath := LinksPath
@@ -255,7 +264,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 	g := engine.Group("/")
 	g := engine.Group("/")
 
 
 	s.sub = NewSUBController(
 	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,
 		SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubTitle, SubSupportUrl,
 		SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
 		SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
 
 

+ 385 - 0
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
+}

+ 38 - 7
sub/subController.go

@@ -21,12 +21,15 @@ type SUBController struct {
 	subRoutingRules  string
 	subRoutingRules  string
 	subPath          string
 	subPath          string
 	subJsonPath      string
 	subJsonPath      string
+	subClashPath     string
 	jsonEnabled      bool
 	jsonEnabled      bool
+	clashEnabled     bool
 	subEncrypt       bool
 	subEncrypt       bool
 	updateInterval   string
 	updateInterval   string
 
 
-	subService     *SubService
-	subJsonService *SubJsonService
+	subService      *SubService
+	subJsonService  *SubJsonService
+	subClashService *SubClashService
 }
 }
 
 
 // NewSUBController creates a new subscription controller with the given configuration.
 // NewSUBController creates a new subscription controller with the given configuration.
@@ -34,7 +37,9 @@ func NewSUBController(
 	g *gin.RouterGroup,
 	g *gin.RouterGroup,
 	subPath string,
 	subPath string,
 	jsonPath string,
 	jsonPath string,
+	clashPath string,
 	jsonEnabled bool,
 	jsonEnabled bool,
+	clashEnabled bool,
 	encrypt bool,
 	encrypt bool,
 	showInfo bool,
 	showInfo bool,
 	rModel string,
 	rModel string,
@@ -60,12 +65,15 @@ func NewSUBController(
 		subRoutingRules:  subRoutingRules,
 		subRoutingRules:  subRoutingRules,
 		subPath:          subPath,
 		subPath:          subPath,
 		subJsonPath:      jsonPath,
 		subJsonPath:      jsonPath,
+		subClashPath:     clashPath,
 		jsonEnabled:      jsonEnabled,
 		jsonEnabled:      jsonEnabled,
+		clashEnabled:     clashEnabled,
 		subEncrypt:       encrypt,
 		subEncrypt:       encrypt,
 		updateInterval:   update,
 		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)
 	a.initRouter(g)
 	return a
 	return a
@@ -80,6 +88,10 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) {
 		gJson := g.Group(a.subJsonPath)
 		gJson := g.Group(a.subJsonPath)
 		gJson.GET(":subid", a.subJsons)
 		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.
 // 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")
 		accept := c.GetHeader("Accept")
 		if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") {
 		if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") {
 			// Build page data in service
 			// 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 {
 			if !a.jsonEnabled {
 				subJsonURL = ""
 				subJsonURL = ""
 			}
 			}
+			if !a.clashEnabled {
+				subClashURL = ""
+			}
 			// Get base_path from context (set by middleware)
 			// Get base_path from context (set by middleware)
 			basePath, exists := c.Get("base_path")
 			basePath, exists := c.Get("base_path")
 			if !exists {
 			if !exists {
@@ -116,7 +131,7 @@ func (a *SUBController) subs(c *gin.Context) {
 				// Remove trailing slash if exists, add subId, then add trailing slash
 				// Remove trailing slash if exists, add subId, then add trailing slash
 				basePathStr = strings.TrimRight(basePathStr, "/") + "/" + subId + "/"
 				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{
 			c.HTML(200, "subpage.html", gin.H{
 				"title":        "subscription.title",
 				"title":        "subscription.title",
 				"cur_ver":      config.GetVersion(),
 				"cur_ver":      config.GetVersion(),
@@ -136,6 +151,7 @@ func (a *SUBController) subs(c *gin.Context) {
 				"totalByte":    page.TotalByte,
 				"totalByte":    page.TotalByte,
 				"subUrl":       page.SubUrl,
 				"subUrl":       page.SubUrl,
 				"subJsonUrl":   page.SubJsonUrl,
 				"subJsonUrl":   page.SubJsonUrl,
+				"subClashUrl":  page.SubClashUrl,
 				"result":       page.Result,
 				"result":       page.Result,
 			})
 			})
 			return
 			return
@@ -165,7 +181,6 @@ func (a *SUBController) subJsons(c *gin.Context) {
 	if err != nil || len(jsonSub) == 0 {
 	if err != nil || len(jsonSub) == 0 {
 		c.String(400, "Error!")
 		c.String(400, "Error!")
 	} else {
 	} else {
-		// Add headers
 		profileUrl := a.subProfileUrl
 		profileUrl := a.subProfileUrl
 		if profileUrl == "" {
 		if profileUrl == "" {
 			profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
 			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.
 // ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title.
 func (a *SUBController) ApplyCommonHeaders(
 func (a *SUBController) ApplyCommonHeaders(
 	c *gin.Context,
 	c *gin.Context,

+ 9 - 11
sub/subService.go

@@ -1031,6 +1031,7 @@ type PageData struct {
 	TotalByte    int64
 	TotalByte    int64
 	SubUrl       string
 	SubUrl       string
 	SubJsonUrl   string
 	SubJsonUrl   string
+	SubClashUrl  string
 	Result       []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.
 // 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.
 // 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 == "" {
 	if subId == "" {
-		return "", ""
+		return "", "", ""
 	}
 	}
 
 
-	// Get configured URIs first (highest priority)
 	configuredSubURI, _ := s.settingService.GetSubURI()
 	configuredSubURI, _ := s.settingService.GetSubURI()
 	configuredSubJsonURI, _ := s.settingService.GetSubJsonURI()
 	configuredSubJsonURI, _ := s.settingService.GetSubJsonURI()
+	configuredSubClashURI, _ := s.settingService.GetSubClashURI()
 
 
-	// Determine base scheme and host (cached to avoid duplicate calls)
 	var baseScheme, baseHostWithPort string
 	var baseScheme, baseHostWithPort string
-	if configuredSubURI == "" || configuredSubJsonURI == "" {
+	if configuredSubURI == "" || configuredSubJsonURI == "" || configuredSubClashURI == "" {
 		baseScheme, baseHostWithPort = s.getBaseSchemeAndHost(scheme, hostWithPort)
 		baseScheme, baseHostWithPort = s.getBaseSchemeAndHost(scheme, hostWithPort)
 	}
 	}
 
 
-	// Build subscription URL
 	subURL = s.buildSingleURL(configuredSubURI, baseScheme, baseHostWithPort, subPath, subId)
 	subURL = s.buildSingleURL(configuredSubURI, baseScheme, baseHostWithPort, subPath, subId)
-
-	// Build JSON subscription URL
 	subJsonURL = s.buildSingleURL(configuredSubJsonURI, baseScheme, baseHostWithPort, subJsonPath, subId)
 	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
 // 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 parses header and prepares the template view model.
 // BuildPageData constructs page data for rendering the subscription information page.
 // 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)
 	download := common.FormatTraffic(traffic.Down)
 	upload := common.FormatTraffic(traffic.Up)
 	upload := common.FormatTraffic(traffic.Up)
 	total := "∞"
 	total := "∞"
@@ -1183,6 +1180,7 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray
 		TotalByte:    traffic.Total,
 		TotalByte:    traffic.Total,
 		SubUrl:       subURL,
 		SubUrl:       subURL,
 		SubJsonUrl:   subJsonURL,
 		SubJsonUrl:   subJsonURL,
+		SubClashUrl:  subClashURL,
 		Result:       subs,
 		Result:       subs,
 	}
 	}
 }
 }

+ 3 - 0
web/assets/js/model/setting.js

@@ -38,6 +38,8 @@ class AllSetting {
         this.subPort = 2096;
         this.subPort = 2096;
         this.subPath = "/sub/";
         this.subPath = "/sub/";
         this.subJsonPath = "/json/";
         this.subJsonPath = "/json/";
+        this.subClashEnable = true;
+        this.subClashPath = "/clash/";
         this.subDomain = "";
         this.subDomain = "";
         this.externalTrafficInformEnable = false;
         this.externalTrafficInformEnable = false;
         this.externalTrafficInformURI = "";
         this.externalTrafficInformURI = "";
@@ -48,6 +50,7 @@ class AllSetting {
         this.subShowInfo = true;
         this.subShowInfo = true;
         this.subURI = "";
         this.subURI = "";
         this.subJsonURI = "";
         this.subJsonURI = "";
+        this.subClashURI = "";
         this.subJsonFragment = "";
         this.subJsonFragment = "";
         this.subJsonNoises = "";
         this.subJsonNoises = "";
         this.subJsonMux = "";
         this.subJsonMux = "";

+ 7 - 0
web/assets/js/subscription.js

@@ -9,6 +9,7 @@
     sId: el.getAttribute('data-sid') || '',
     sId: el.getAttribute('data-sid') || '',
     subUrl: el.getAttribute('data-sub-url') || '',
     subUrl: el.getAttribute('data-sub-url') || '',
     subJsonUrl: el.getAttribute('data-subjson-url') || '',
     subJsonUrl: el.getAttribute('data-subjson-url') || '',
+    subClashUrl: el.getAttribute('data-subclash-url') || '',
     download: el.getAttribute('data-download') || '',
     download: el.getAttribute('data-download') || '',
     upload: el.getAttribute('data-upload') || '',
     upload: el.getAttribute('data-upload') || '',
     used: el.getAttribute('data-used') || '',
     used: el.getAttribute('data-used') || '',
@@ -98,13 +99,19 @@
       this.lang = LanguageManager.getLanguage();
       this.lang = LanguageManager.getLanguage();
       const tpl = document.getElementById('subscription-data');
       const tpl = document.getElementById('subscription-data');
       const sj = tpl ? tpl.getAttribute('data-subjson-url') : '';
       const sj = tpl ? tpl.getAttribute('data-subjson-url') : '';
+      const sc = tpl ? tpl.getAttribute('data-subclash-url') : '';
       if (sj) this.app.subJsonUrl = sj;
       if (sj) this.app.subJsonUrl = sj;
+      if (sc) this.app.subClashUrl = sc;
       drawQR(this.app.subUrl);
       drawQR(this.app.subUrl);
       try {
       try {
         const elJson = document.getElementById('qrcode-subjson');
         const elJson = document.getElementById('qrcode-subjson');
         if (elJson && this.app.subJsonUrl) {
         if (elJson && this.app.subJsonUrl) {
           new QRious({ element: elJson, value: this.app.subJsonUrl, size: 220 });
           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 */ }
       } catch (e) { /* ignore */ }
       this._onResize = () => { this.viewportWidth = window.innerWidth; };
       this._onResize = () => { this.viewportWidth = window.innerWidth; };
       window.addEventListener('resize', this._onResize);
       window.addEventListener('resize', this._onResize);

+ 10 - 0
web/entity/entity.go

@@ -76,6 +76,9 @@ type AllSetting struct {
 	SubURI                      string `json:"subURI" form:"subURI"`                                           // Subscription server URI
 	SubURI                      string `json:"subURI" form:"subURI"`                                           // Subscription server URI
 	SubJsonPath                 string `json:"subJsonPath" form:"subJsonPath"`                                 // Path for JSON subscription endpoint
 	SubJsonPath                 string `json:"subJsonPath" form:"subJsonPath"`                                 // Path for JSON subscription endpoint
 	SubJsonURI                  string `json:"subJsonURI" form:"subJsonURI"`                                   // JSON subscription server URI
 	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
 	SubJsonFragment             string `json:"subJsonFragment" form:"subJsonFragment"`                         // JSON subscription fragment configuration
 	SubJsonNoises               string `json:"subJsonNoises" form:"subJsonNoises"`                             // JSON subscription noise configuration
 	SubJsonNoises               string `json:"subJsonNoises" form:"subJsonNoises"`                             // JSON subscription noise configuration
 	SubJsonMux                  string `json:"subJsonMux" form:"subJsonMux"`                                   // JSON subscription mux configuration
 	SubJsonMux                  string `json:"subJsonMux" form:"subJsonMux"`                                   // JSON subscription mux configuration
@@ -168,6 +171,13 @@ func (s *AllSetting) CheckValid() error {
 		s.SubJsonPath += "/"
 		s.SubJsonPath += "/"
 	}
 	}
 
 
+	if !strings.HasPrefix(s.SubClashPath, "/") {
+		s.SubClashPath = "/" + s.SubClashPath
+	}
+	if !strings.HasSuffix(s.SubClashPath, "/") {
+		s.SubClashPath += "/"
+	}
+
 	_, err := time.LoadLocation(s.TimeLocation)
 	_, err := time.LoadLocation(s.TimeLocation)
 	if err != nil {
 	if err != nil {
 		return common.NewError("time location not exist:", s.TimeLocation)
 		return common.NewError("time location not exist:", s.TimeLocation)

+ 2 - 2
web/html/settings.html

@@ -79,10 +79,10 @@
                     </template>
                     </template>
                     {{ template "settings/panel/subscription/general" . }}
                     {{ template "settings/panel/subscription/general" . }}
                   </a-tab-pane>
                   </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>
                     <template #tab>
                       <a-icon type="code"></a-icon>
                       <a-icon type="code"></a-icon>
-                      <span>{{ i18n "pages.settings.subSettings" }} (JSON)</span>
+                      <span>{{ i18n "pages.settings.subSettings" }} (Formats)</span>
                     </template>
                     </template>
                     {{ template "settings/panel/subscription/json" . }}
                     {{ template "settings/panel/subscription/json" . }}
                   </a-tab-pane>
                   </a-tab-pane>

+ 60 - 26
web/html/settings/panel/subscription/general.html

@@ -3,43 +3,58 @@
     <a-collapse-panel key="1" header='{{ i18n "pages.xray.generalConfigs"}}'>
     <a-collapse-panel key="1" header='{{ i18n "pages.xray.generalConfigs"}}'>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
             <template #title>{{ i18n "pages.settings.subEnable"}}</template>
             <template #title>{{ i18n "pages.settings.subEnable"}}</template>
-            <template #description>{{ i18n "pages.settings.subEnableDesc"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subEnableDesc"}}</template>
             <template #control>
             <template #control>
                 <a-switch v-model="allSetting.subEnable"></a-switch>
                 <a-switch v-model="allSetting.subEnable"></a-switch>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
             <template #title>JSON Subscription</template>
             <template #title>JSON Subscription</template>
-            <template #description>{{ i18n "pages.settings.subJsonEnable"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subJsonEnable"}}</template>
             <template #control>
             <template #control>
                 <a-switch v-model="allSetting.subJsonEnable"></a-switch>
                 <a-switch v-model="allSetting.subJsonEnable"></a-switch>
             </template>
             </template>
         </a-setting-list-item>
         </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">
         <a-setting-list-item paddings="small">
             <template #title>{{ i18n "pages.settings.subListen"}}</template>
             <template #title>{{ i18n "pages.settings.subListen"}}</template>
-            <template #description>{{ i18n "pages.settings.subListenDesc"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subListenDesc"}}</template>
             <template #control>
             <template #control>
                 <a-input type="text" v-model="allSetting.subListen"></a-input>
                 <a-input type="text" v-model="allSetting.subListen"></a-input>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
             <template #title>{{ i18n "pages.settings.subDomain"}}</template>
             <template #title>{{ i18n "pages.settings.subDomain"}}</template>
-            <template #description>{{ i18n "pages.settings.subDomainDesc"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subDomainDesc"}}</template>
             <template #control>
             <template #control>
                 <a-input type="text" v-model="allSetting.subDomain"></a-input>
                 <a-input type="text" v-model="allSetting.subDomain"></a-input>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
             <template #title>{{ i18n "pages.settings.subPort"}}</template>
             <template #title>{{ i18n "pages.settings.subPort"}}</template>
-            <template #description>{{ i18n "pages.settings.subPortDesc"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subPortDesc"}}</template>
             <template #control>
             <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>
                     :style="{ width: '100%' }"></a-input-number>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
             <template #title>{{ i18n "pages.settings.subPath"}}</template>
             <template #title>{{ i18n "pages.settings.subPath"}}</template>
-            <template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subPathDesc"}}</template>
             <template #control>
             <template #control>
                 <a-input type="text" v-model="allSetting.subPath"
                 <a-input type="text" v-model="allSetting.subPath"
                     @input="allSetting.subPath = ((typeof $event === 'string' ? $event : ($event && $event.target ? $event.target.value : '')) || '').replace(/[:*]/g, '')"
                     @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>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
             <template #title>{{ i18n "pages.settings.subURI"}}</template>
             <template #title>{{ i18n "pages.settings.subURI"}}</template>
-            <template #description>{{ i18n "pages.settings.subURIDesc"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subURIDesc"}}</template>
             <template #control>
             <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>
                     v-model="allSetting.subURI"></a-input>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
@@ -59,14 +76,16 @@
     <a-collapse-panel key="2" header='{{ i18n "pages.settings.information" }}'>
     <a-collapse-panel key="2" header='{{ i18n "pages.settings.information" }}'>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
             <template #title>{{ i18n "pages.settings.subEncrypt"}}</template>
             <template #title>{{ i18n "pages.settings.subEncrypt"}}</template>
-            <template #description>{{ i18n "pages.settings.subEncryptDesc"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subEncryptDesc"}}</template>
             <template #control>
             <template #control>
                 <a-switch v-model="allSetting.subEncrypt"></a-switch>
                 <a-switch v-model="allSetting.subEncrypt"></a-switch>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
             <template #title>{{ i18n "pages.settings.subShowInfo"}}</template>
             <template #title>{{ i18n "pages.settings.subShowInfo"}}</template>
-            <template #description>{{ i18n "pages.settings.subShowInfoDesc"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subShowInfoDesc"}}</template>
             <template #control>
             <template #control>
                 <a-switch v-model="allSetting.subShowInfo"></a-switch>
                 <a-switch v-model="allSetting.subShowInfo"></a-switch>
             </template>
             </template>
@@ -74,59 +93,72 @@
         <a-divider>{{ i18n "pages.xray.basicTemplate"}}</a-divider>
         <a-divider>{{ i18n "pages.xray.basicTemplate"}}</a-divider>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
             <template #title>{{ i18n "pages.settings.subTitle"}}</template>
             <template #title>{{ i18n "pages.settings.subTitle"}}</template>
-            <template #description>{{ i18n "pages.settings.subTitleDesc"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subTitleDesc"}}</template>
             <template #control>
             <template #control>
                 <a-input type="text" v-model="allSetting.subTitle"></a-input>
                 <a-input type="text" v-model="allSetting.subTitle"></a-input>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
             <template #title>{{ i18n "pages.settings.subSupportUrl"}}</template>
             <template #title>{{ i18n "pages.settings.subSupportUrl"}}</template>
-            <template #description>{{ i18n "pages.settings.subSupportUrlDesc"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subSupportUrlDesc"}}</template>
             <template #control>
             <template #control>
-                <a-input type="text" v-model="allSetting.subSupportUrl" placeholder="https://example.com"></a-input>
+                <a-input type="text" v-model="allSetting.subSupportUrl"
+                    placeholder="https://example.com"></a-input>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
             <template #title>{{ i18n "pages.settings.subProfileUrl"}}</template>
             <template #title>{{ i18n "pages.settings.subProfileUrl"}}</template>
-            <template #description>{{ i18n "pages.settings.subProfileUrlDesc"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subProfileUrlDesc"}}</template>
             <template #control>
             <template #control>
-                <a-input type="text" v-model="allSetting.subProfileUrl" placeholder="https://example.com"></a-input>
+                <a-input type="text" v-model="allSetting.subProfileUrl"
+                    placeholder="https://example.com"></a-input>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
             <template #title>{{ i18n "pages.settings.subAnnounce"}}</template>
             <template #title>{{ i18n "pages.settings.subAnnounce"}}</template>
-            <template #description>{{ i18n "pages.settings.subAnnounceDesc"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subAnnounceDesc"}}</template>
             <template #control>
             <template #control>
                 <a-textarea v-model="allSetting.subAnnounce"></a-textarea>
                 <a-textarea v-model="allSetting.subAnnounce"></a-textarea>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
         <a-divider>{{ i18n "pages.xray.advancedTemplate"}} (Happ)</a-divider>
         <a-divider>{{ i18n "pages.xray.advancedTemplate"}} (Happ)</a-divider>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
-            <template #title>{{ i18n "pages.settings.subEnableRouting"}}</template>
-            <template #description>{{ i18n "pages.settings.subEnableRoutingDesc"}}</template>
+            <template #title>{{ i18n
+                "pages.settings.subEnableRouting"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subEnableRoutingDesc"}}</template>
             <template #control>
             <template #control>
                 <a-switch v-model="allSetting.subEnableRouting"></a-switch>
                 <a-switch v-model="allSetting.subEnableRouting"></a-switch>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
-            <template #title>{{ i18n "pages.settings.subRoutingRules"}}</template>
-            <template #description>{{ i18n "pages.settings.subRoutingRulesDesc"}}</template>
+            <template #title>{{ i18n
+                "pages.settings.subRoutingRules"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subRoutingRulesDesc"}}</template>
             <template #control>
             <template #control>
-                <a-textarea v-model="allSetting.subRoutingRules" placeholder="happ://routing/add/..."></a-textarea>
+                <a-textarea v-model="allSetting.subRoutingRules"
+                    placeholder="happ://routing/add/..."></a-textarea>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
     </a-collapse-panel>
     </a-collapse-panel>
     <a-collapse-panel key="3" header='{{ i18n "pages.settings.certs" }}'>
     <a-collapse-panel key="3" header='{{ i18n "pages.settings.certs" }}'>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
             <template #title>{{ i18n "pages.settings.subCertPath"}}</template>
             <template #title>{{ i18n "pages.settings.subCertPath"}}</template>
-            <template #description>{{ i18n "pages.settings.subCertPathDesc"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subCertPathDesc"}}</template>
             <template #control>
             <template #control>
                 <a-input type="text" v-model="allSetting.subCertFile"></a-input>
                 <a-input type="text" v-model="allSetting.subCertFile"></a-input>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
             <template #title>{{ i18n "pages.settings.subKeyPath"}}</template>
             <template #title>{{ i18n "pages.settings.subKeyPath"}}</template>
-            <template #description>{{ i18n "pages.settings.subKeyPathDesc"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subKeyPathDesc"}}</template>
             <template #control>
             <template #control>
                 <a-input type="text" v-model="allSetting.subKeyFile"></a-input>
                 <a-input type="text" v-model="allSetting.subKeyFile"></a-input>
             </template>
             </template>
@@ -135,9 +167,11 @@
     <a-collapse-panel key="4" header='{{ i18n "pages.settings.intervals"}}'>
     <a-collapse-panel key="4" header='{{ i18n "pages.settings.intervals"}}'>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">
             <template #title>{{ i18n "pages.settings.subUpdates"}}</template>
             <template #title>{{ i18n "pages.settings.subUpdates"}}</template>
-            <template #description>{{ i18n "pages.settings.subUpdatesDesc"}}</template>
+            <template #description>{{ i18n
+                "pages.settings.subUpdatesDesc"}}</template>
             <template #control>
             <template #control>
-                <a-input-number :min="1" v-model="allSetting.subUpdates" :style="{ width: '100%' }"></a-input-number>
+                <a-input-number :min="1" v-model="allSetting.subUpdates"
+                    :style="{ width: '100%' }"></a-input-number>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
     </a-collapse-panel>
     </a-collapse-panel>

+ 22 - 4
web/html/settings/panel/subscription/json.html

@@ -1,8 +1,8 @@
 {{define "settings/panel/subscription/json"}}
 {{define "settings/panel/subscription/json"}}
 <a-collapse default-active-key="1">
 <a-collapse default-active-key="1">
     <a-collapse-panel key="1" header='{{ i18n "pages.xray.generalConfigs"}}'>
     <a-collapse-panel key="1" header='{{ i18n "pages.xray.generalConfigs"}}'>
-        <a-setting-list-item paddings="small">
-            <template #title>{{ i18n "pages.settings.subPath"}}</template>
+        <a-setting-list-item paddings="small" v-if="allSetting.subJsonEnable">
+            <template #title>{{ i18n "pages.settings.subPath"}} (JSON)</template>
             <template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
             <template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
             <template #control>
             <template #control>
                 <a-input type="text" v-model="allSetting.subJsonPath"
                 <a-input type="text" v-model="allSetting.subJsonPath"
@@ -11,14 +11,32 @@
                     placeholder="/json/"></a-input>
                     placeholder="/json/"></a-input>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
-        <a-setting-list-item paddings="small">
-            <template #title>{{ i18n "pages.settings.subURI"}}</template>
+        <a-setting-list-item paddings="small" v-if="allSetting.subJsonEnable">
+            <template #title>{{ i18n "pages.settings.subURI"}} (JSON)</template>
             <template #description>{{ i18n "pages.settings.subURIDesc"}}</template>
             <template #description>{{ i18n "pages.settings.subURIDesc"}}</template>
             <template #control>
             <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.subJsonURI"></a-input>
                     v-model="allSetting.subJsonURI"></a-input>
             </template>
             </template>
         </a-setting-list-item>
         </a-setting-list-item>
+        <a-setting-list-item paddings="small" v-if="allSetting.subClashEnable">
+            <template #title>{{ i18n "pages.settings.subPath"}} (Clash)</template>
+            <template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
+            <template #control>
+                <a-input type="text" v-model="allSetting.subClashPath"
+                    @input="allSetting.subClashPath = ((typeof $event === 'string' ? $event : ($event && $event.target ? $event.target.value : '')) || '').replace(/[:*]/g, '')"
+                    @blur="allSetting.subClashPath = (p => { p = p || '/'; if (!p.startsWith('/')) p='/' + p; if (!p.endsWith('/')) p += '/'; return p.replace(/\/+/g,'/'); })(allSetting.subClashPath)"
+                    placeholder="/clash/"></a-input>
+            </template>
+        </a-setting-list-item>
+        <a-setting-list-item paddings="small" v-if="allSetting.subClashEnable">
+            <template #title>{{ i18n "pages.settings.subURI"}} (Clash)</template>
+            <template #description>{{ i18n "pages.settings.subURIDesc"}}</template>
+            <template #control>
+                <a-input type="text" placeholder="(http|https)://domain[:port]/path/"
+                    v-model="allSetting.subClashURI"></a-input>
+            </template>
+        </a-setting-list-item>
     </a-collapse-panel>
     </a-collapse-panel>
     <a-collapse-panel key="2" header='{{ i18n "pages.settings.fragment"}}'>
     <a-collapse-panel key="2" header='{{ i18n "pages.settings.fragment"}}'>
         <a-setting-list-item paddings="small">
         <a-setting-list-item paddings="small">

+ 15 - 2
web/html/settings/panel/subscription/subpage.html

@@ -83,7 +83,7 @@
                         <a-form-item>
                         <a-form-item>
                             <a-space direction="vertical" align="center">
                             <a-space direction="vertical" align="center">
                                 <a-row type="flex" :gutter="[8,8]" justify="center" style="width:100%">
                                 <a-row type="flex" :gutter="[8,8]" justify="center" style="width:100%">
-                                    <a-col :xs="24" :sm="app.subJsonUrl ? 12 : 24" style="text-align:center;">
+                                    <a-col :xs="24" :sm="app.subJsonUrl || app.subClashUrl ? 12 : 24" style="text-align:center;">
                                         <tr-qr-box class="qr-box">
                                         <tr-qr-box class="qr-box">
                                             <a-tag color="purple" class="qr-tag">
                                             <a-tag color="purple" class="qr-tag">
                                                 <span>{{ i18n
                                                 <span>{{ i18n
@@ -112,6 +112,19 @@
                                             </tr-qr-bg>
                                             </tr-qr-bg>
                                         </tr-qr-box>
                                         </tr-qr-box>
                                     </a-col>
                                     </a-col>
+                                    <a-col v-if="app.subClashUrl" :xs="24" :sm="12" style="text-align:center;">
+                                        <tr-qr-box class="qr-box">
+                                            <a-tag color="purple" class="qr-tag">
+                                                <span>Clash / Mihomo</span>
+                                            </a-tag>
+                                            <tr-qr-bg class="qr-bg-sub">
+                                                <tr-qr-bg-inner class="qr-bg-sub-inner">
+                                                    <canvas id="qrcode-subclash" class="qr-cv" title='{{ i18n "copy" }}'
+                                                        @click="copy(app.subClashUrl)"></canvas>
+                                                </tr-qr-bg-inner>
+                                            </tr-qr-bg>
+                                        </tr-qr-box>
+                                    </a-col>
                                 </a-row>
                                 </a-row>
                             </a-space>
                             </a-space>
                         </a-form-item>
                         </a-form-item>
@@ -242,7 +255,7 @@
 </a-layout>
 </a-layout>
 
 
 <!-- Bootstrap data for external JS -->
 <!-- Bootstrap data for external JS -->
-<template id="subscription-data" data-sid="{{ .sId }}" data-sub-url="{{ .subUrl }}" data-subjson-url="{{ .subJsonUrl }}"
+<template id="subscription-data" data-sid="{{ .sId }}" data-sub-url="{{ .subUrl }}" data-subjson-url="{{ .subJsonUrl }}" data-subclash-url="{{ .subClashUrl }}"
     data-download="{{ .download }}" data-upload="{{ .upload }}" data-used="{{ .used }}" data-total="{{ .total }}"
     data-download="{{ .download }}" data-upload="{{ .upload }}" data-used="{{ .used }}" data-total="{{ .total }}"
     data-remained="{{ .remained }}" data-expire="{{ .expire }}" data-lastonline="{{ .lastOnline }}"
     data-remained="{{ .remained }}" data-expire="{{ .expire }}" data-lastonline="{{ .lastOnline }}"
     data-downloadbyte="{{ .downloadByte }}" data-uploadbyte="{{ .uploadByte }}" data-totalbyte="{{ .totalByte }}"
     data-downloadbyte="{{ .downloadByte }}" data-uploadbyte="{{ .uploadByte }}" data-totalbyte="{{ .totalByte }}"

+ 33 - 6
web/service/setting.go

@@ -71,6 +71,9 @@ var defaultValueMap = map[string]string{
 	"subURI":                      "",
 	"subURI":                      "",
 	"subJsonPath":                 "/json/",
 	"subJsonPath":                 "/json/",
 	"subJsonURI":                  "",
 	"subJsonURI":                  "",
+	"subClashEnable":              "true",
+	"subClashPath":                "/clash/",
+	"subClashURI":                 "",
 	"subJsonFragment":             "",
 	"subJsonFragment":             "",
 	"subJsonNoises":               "",
 	"subJsonNoises":               "",
 	"subJsonMux":                  "",
 	"subJsonMux":                  "",
@@ -555,6 +558,18 @@ func (s *SettingService) GetSubJsonURI() (string, error) {
 	return s.getString("subJsonURI")
 	return s.getString("subJsonURI")
 }
 }
 
 
+func (s *SettingService) GetSubClashEnable() (bool, error) {
+	return s.getBool("subClashEnable")
+}
+
+func (s *SettingService) GetSubClashPath() (string, error) {
+	return s.getString("subClashPath")
+}
+
+func (s *SettingService) GetSubClashURI() (string, error) {
+	return s.getString("subClashURI")
+}
+
 func (s *SettingService) GetSubJsonFragment() (string, error) {
 func (s *SettingService) GetSubJsonFragment() (string, error) {
 	return s.getString("subJsonFragment")
 	return s.getString("subJsonFragment")
 }
 }
@@ -750,11 +765,13 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
 		"defaultKey":    func() (any, error) { return s.GetKeyFile() },
 		"defaultKey":    func() (any, error) { return s.GetKeyFile() },
 		"tgBotEnable":   func() (any, error) { return s.GetTgbotEnabled() },
 		"tgBotEnable":   func() (any, error) { return s.GetTgbotEnabled() },
 		"subEnable":     func() (any, error) { return s.GetSubEnable() },
 		"subEnable":     func() (any, error) { return s.GetSubEnable() },
-		"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
-		"subTitle":      func() (any, error) { return s.GetSubTitle() },
-		"subURI":        func() (any, error) { return s.GetSubURI() },
-		"subJsonURI":    func() (any, error) { return s.GetSubJsonURI() },
-		"remarkModel":   func() (any, error) { return s.GetRemarkModel() },
+		"subJsonEnable":  func() (any, error) { return s.GetSubJsonEnable() },
+		"subClashEnable": func() (any, error) { return s.GetSubClashEnable() },
+		"subTitle":       func() (any, error) { return s.GetSubTitle() },
+		"subURI":         func() (any, error) { return s.GetSubURI() },
+		"subJsonURI":     func() (any, error) { return s.GetSubJsonURI() },
+		"subClashURI":    func() (any, error) { return s.GetSubClashURI() },
+		"remarkModel":    func() (any, error) { return s.GetRemarkModel() },
 		"datepicker":    func() (any, error) { return s.GetDatepicker() },
 		"datepicker":    func() (any, error) { return s.GetDatepicker() },
 		"ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() },
 		"ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() },
 	}
 	}
@@ -776,12 +793,19 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
 			subJsonEnable = b
 			subJsonEnable = b
 		}
 		}
 	}
 	}
-	if (subEnable && result["subURI"].(string) == "") || (subJsonEnable && result["subJsonURI"].(string) == "") {
+	subClashEnable := false
+	if v, ok := result["subClashEnable"]; ok {
+		if b, ok2 := v.(bool); ok2 {
+			subClashEnable = b
+		}
+	}
+	if (subEnable && result["subURI"].(string) == "") || (subJsonEnable && result["subJsonURI"].(string) == "") || (subClashEnable && result["subClashURI"].(string) == "") {
 		subURI := ""
 		subURI := ""
 		subTitle, _ := s.GetSubTitle()
 		subTitle, _ := s.GetSubTitle()
 		subPort, _ := s.GetSubPort()
 		subPort, _ := s.GetSubPort()
 		subPath, _ := s.GetSubPath()
 		subPath, _ := s.GetSubPath()
 		subJsonPath, _ := s.GetSubJsonPath()
 		subJsonPath, _ := s.GetSubJsonPath()
+		subClashPath, _ := s.GetSubClashPath()
 		subDomain, _ := s.GetSubDomain()
 		subDomain, _ := s.GetSubDomain()
 		subKeyFile, _ := s.GetSubKeyFile()
 		subKeyFile, _ := s.GetSubKeyFile()
 		subCertFile, _ := s.GetSubCertFile()
 		subCertFile, _ := s.GetSubCertFile()
@@ -811,6 +835,9 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
 		if subJsonEnable && result["subJsonURI"].(string) == "" {
 		if subJsonEnable && result["subJsonURI"].(string) == "" {
 			result["subJsonURI"] = subURI + subJsonPath
 			result["subJsonURI"] = subURI + subJsonPath
 		}
 		}
+		if subClashEnable && result["subClashURI"].(string) == "" {
+			result["subClashURI"] = subURI + subClashPath
+		}
 	}
 	}
 
 
 	return result, nil
 	return result, nil