diff --git a/config/version b/config/version
index 53b5bbb1..89ab8a23 100644
--- a/config/version
+++ b/config/version
@@ -1 +1 @@
-v1.5.1
+v1.5.2-beta
\ No newline at end of file
diff --git a/docs/Tasktracking/2026-04-24-clash-yaml-subscription.md b/docs/Tasktracking/2026-04-24-clash-yaml-subscription.md
new file mode 100644
index 00000000..3b3db3ff
--- /dev/null
+++ b/docs/Tasktracking/2026-04-24-clash-yaml-subscription.md
@@ -0,0 +1,30 @@
+# Clash YAML Subscription Endpoint
+
+## Date: 2026-04-24
+
+## Changes
+
+### New Files
+- `sub/subClashService.go` — Clash YAML subscription service: reads user template, generates proxies from inbound/client data, injects via `proxies: []` placeholder replacement
+- `web/html/settings/panel/subscription/clash.html` — Clash subscription settings panel (path, URI, template textarea)
+
+### Backend
+- `web/entity/entity.go` — Added `SubClashEnable`, `SubClashPath`, `SubClashURI`, `SubClashTemplate` to `AllSetting`
+- `web/service/setting.go` — Added defaults, getter functions, `GetSubSettings()` auto-build URI for Clash
+- `sub/sub.go` — Read Clash settings, pass to controller
+- `sub/subController.go` — Added Clash fields, route `GET /clash/:subid`, `clashSubs()` handler returning `text/yaml`
+
+### Frontend
+- `web/html/settings.html` — Added Clash settings tab (key 6)
+- `web/html/settings/panel/subscription/general.html` — Added Clash enable toggle
+- `web/html/settings/panel/subscription/subpage.html` — Added Clash QR code, Desktop dropdown with Clash Verge deep link
+- `web/assets/js/subscription.js` — Added `subClashUrl`, `clashvergeUrl` prefers Clash URL
+- `web/assets/js/model/setting.js` — Added Clash defaults
+- `web/html/inbounds.html` — Added `subClashEnable`, `subClashURI` to subscription settings
+- `web/html/modals/qrcode_modal.html` — Added Clash QR code + `genSubClashLink()`
+- `web/html/modals/inbound_info_modal.html` — Added Clash subscription link
+- `web/translation/translate.en_US.toml` — Added `subClashEnable` i18n
+- `web/translation/translate.zh_CN.toml` — Added `subClashEnable` i18n
+
+### Version
+- `config/version` — Bumped to v1.5.2-beta
diff --git a/sub/sub.go b/sub/sub.go
index 7d1fde9f..c6d87c97 100644
--- a/sub/sub.go
+++ b/sub/sub.go
@@ -227,6 +227,21 @@ func (s *Server) initRouter() (*gin.Engine, error) {
SubRoutingRules = ""
}
+ subClashEnable, err := s.settingService.GetSubClashEnable()
+ if err != nil {
+ subClashEnable = false
+ }
+
+ SubClashPath, err := s.settingService.GetSubClashPath()
+ if err != nil {
+ SubClashPath = "/clash/"
+ }
+
+ SubClashTemplate, err := s.settingService.GetSubClashTemplate()
+ if err != nil {
+ SubClashTemplate = ""
+ }
+
// set per-request localizer from headers/cookies
engine.Use(locale.LocalizerMiddleware())
@@ -307,7 +322,8 @@ func (s *Server) initRouter() (*gin.Engine, error) {
s.sub = NewSUBController(
g, LinksPath, JsonPath, subJsonEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubTitle, SubSupportUrl,
- SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
+ SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules,
+ subClashEnable, SubClashPath, SubClashTemplate)
return engine, nil
}
diff --git a/sub/subClashService.go b/sub/subClashService.go
new file mode 100644
index 00000000..bb58836a
--- /dev/null
+++ b/sub/subClashService.go
@@ -0,0 +1,350 @@
+package sub
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "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"
+)
+
+// SubClashService handles Clash YAML subscription generation.
+type SubClashService struct {
+ template string
+ inboundService service.InboundService
+ SubService *SubService
+}
+
+// NewSubClashService creates a new Clash subscription service with the given template.
+func NewSubClashService(template string, subService *SubService) *SubClashService {
+ return &SubClashService{
+ template: template,
+ SubService: subService,
+ }
+}
+
+// GetClash generates a Clash YAML configuration for the given subscription ID and host.
+func (s *SubClashService) GetClash(subId string, host string) (string, string, error) {
+ if s.template == "" {
+ return "", "", fmt.Errorf("clash template is empty")
+ }
+
+ inbounds, err := s.SubService.getInboundsBySubId(subId)
+ if err != nil || len(inbounds) == 0 {
+ return "", "", err
+ }
+
+ var header string
+ var traffic xray.ClientTraffic
+ var clientTraffics []xray.ClientTraffic
+ var proxies []string
+
+ 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))
+ newProxies := s.getProxy(inbound, client)
+ proxies = append(proxies, newProxies...)
+ }
+ }
+ }
+
+ if len(proxies) == 0 {
+ return "", "", nil
+ }
+
+ // Aggregate traffic stats
+ 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
+ }
+ }
+ }
+
+ // Build proxies YAML block
+ proxiesYaml := ""
+ for _, p := range proxies {
+ proxiesYaml += " - " + p + "\n"
+ }
+
+ // Inject proxies into template by replacing "proxies: []"
+ result := strings.Replace(s.template, "proxies: []", "proxies:\n"+proxiesYaml, 1)
+
+ header = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
+ return result, header, nil
+}
+
+// getProxy generates Clash proxy entries for a client.
+func (s *SubClashService) getProxy(inbound *model.Inbound, client model.Client) []string {
+ var proxies []string
+ var stream map[string]any
+ json.Unmarshal([]byte(inbound.StreamSettings), &stream)
+
+ // Resolve address
+ var address string
+ if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
+ address = s.SubService.address
+ } else {
+ address = inbound.Listen
+ }
+
+ // Get remark
+ remark := s.SubService.genRemark(inbound, client.Email, "")
+
+ // Parse stream settings
+ network, _ := stream["network"].(string)
+ security, _ := stream["security"].(string)
+
+ // Handle external proxies
+ externalProxies, ok := stream["externalProxy"].([]any)
+ if !ok || len(externalProxies) == 0 {
+ externalProxies = []any{
+ map[string]any{
+ "forceTls": "same",
+ },
+ }
+ }
+
+ for _, ep := range externalProxies {
+ externalProxy, _ := ep.(map[string]any)
+ destAddress := address
+ destPort := inbound.Port
+
+ if dest, ok := externalProxy["dest"].(string); ok && dest != "" {
+ destAddress = dest
+ }
+ if port, ok := externalProxy["port"].(float64); ok && port > 0 {
+ destPort = int(port)
+ }
+
+ forceTls, _ := externalProxy["forceTls"].(string)
+ tlsEnabled := false
+ switch forceTls {
+ case "tls":
+ tlsEnabled = true
+ case "none":
+ tlsEnabled = false
+ default: // "same"
+ tlsEnabled = security == "tls" || security == "reality"
+ }
+
+ remarkExtra := remark
+ if customRemark, ok := externalProxy["remark"].(string); ok && customRemark != "" {
+ remarkExtra = customRemark
+ }
+
+ proxy := s.buildProxyEntry(inbound, client, destAddress, destPort, network, security, tlsEnabled, remarkExtra, stream)
+ proxies = append(proxies, proxy)
+ }
+
+ return proxies
+}
+
+// buildProxyEntry builds a single Clash proxy entry as an inline YAML map string.
+func (s *SubClashService) buildProxyEntry(inbound *model.Inbound, client model.Client, address string, port int, network, security string, tlsEnabled bool, remark string, stream map[string]any) string {
+ var parts []string
+
+ parts = append(parts, fmt.Sprintf("name: %q", remark))
+
+ // Protocol-specific fields
+ switch inbound.Protocol {
+ case model.VMESS:
+ parts = append(parts, "type: vmess")
+ parts = append(parts, fmt.Sprintf("server: %s", address))
+ parts = append(parts, fmt.Sprintf("port: %d", port))
+ parts = append(parts, fmt.Sprintf("uuid: %s", client.ID))
+ parts = append(parts, "alterId: 0")
+ parts = append(parts, "cipher: auto")
+
+ case model.VLESS:
+ parts = append(parts, "type: vless")
+ parts = append(parts, fmt.Sprintf("server: %s", address))
+ parts = append(parts, fmt.Sprintf("port: %d", port))
+ parts = append(parts, fmt.Sprintf("uuid: %s", client.ID))
+ if client.Flow != "" {
+ parts = append(parts, fmt.Sprintf("flow: %s", client.Flow))
+ }
+
+ case model.Trojan:
+ parts = append(parts, "type: trojan")
+ parts = append(parts, fmt.Sprintf("server: %s", address))
+ parts = append(parts, fmt.Sprintf("port: %d", port))
+ parts = append(parts, fmt.Sprintf("password: %s", client.Password))
+
+ case model.Shadowsocks:
+ parts = append(parts, "type: ss")
+ parts = append(parts, fmt.Sprintf("server: %s", address))
+ parts = append(parts, fmt.Sprintf("port: %d", port))
+ cipher, password := s.parseShadowsocksSettings(client)
+ parts = append(parts, fmt.Sprintf("cipher: %s", cipher))
+ parts = append(parts, fmt.Sprintf("password: %s", password))
+ parts = append(parts, "udp: true")
+ return strings.Join(parts, "\n ")
+ }
+
+ // TLS settings
+ if tlsEnabled {
+ parts = append(parts, "tls: true")
+ if security == "reality" {
+ realitySetting, _ := stream["realitySettings"].(map[string]any)
+ if publicKey, ok := realitySetting["publicKey"].(string); ok && publicKey != "" {
+ realityOpts := fmt.Sprintf("reality-opts:\n public-key: %s", publicKey)
+ if shortId, ok := realitySetting["shortId"].(string); ok && shortId != "" {
+ realityOpts += fmt.Sprintf("\n short-id: %s", shortId)
+ }
+ parts = append(parts, realityOpts)
+ }
+ // Reality server names
+ serverNames, _ := realitySetting["serverNames"].([]any)
+ if len(serverNames) > 0 {
+ sni := fmt.Sprintf("%v", serverNames[0])
+ parts = append(parts, fmt.Sprintf("sni: %s", sni))
+ }
+ } else {
+ // TLS settings
+ tlsSetting, _ := stream["tlsSettings"].(map[string]any)
+ if serverName, ok := tlsSetting["serverName"].(string); ok && serverName != "" {
+ parts = append(parts, fmt.Sprintf("sni: %s", serverName))
+ }
+ if alpn, ok := tlsSetting["alpn"].([]any); ok && len(alpn) > 0 {
+ alpnStrs := make([]string, len(alpn))
+ for i, a := range alpn {
+ alpnStrs[i] = fmt.Sprintf("%v", a)
+ }
+ parts = append(parts, fmt.Sprintf("alpn: [%s]", strings.Join(alpnStrs, ", ")))
+ }
+ }
+ // Fingerprint
+ if fp, ok := stream["fingerprint"].(string); ok && fp != "" {
+ parts = append(parts, fmt.Sprintf("client-fingerprint: %s", fp))
+ }
+ } else {
+ parts = append(parts, "tls: false")
+ }
+
+ parts = append(parts, "udp: true")
+
+ // Network-specific settings
+ switch network {
+ case "ws":
+ ws, _ := stream["wsSettings"].(map[string]any)
+ if path, ok := ws["path"].(string); ok && path != "" {
+ wsOpts := fmt.Sprintf("ws-opts:\n path: %s", path)
+ if host, ok := ws["host"].(string); ok && host != "" {
+ wsOpts += fmt.Sprintf("\n headers:\n Host: %s", host)
+ } else {
+ headers, _ := ws["headers"].(map[string]any)
+ if h, ok := headers["Host"].(string); ok && h != "" {
+ wsOpts += fmt.Sprintf("\n headers:\n Host: %s", h)
+ }
+ }
+ parts = append(parts, wsOpts)
+ }
+
+ case "grpc":
+ grpc, _ := stream["grpcSettings"].(map[string]any)
+ if serviceName, ok := grpc["serviceName"].(string); ok && serviceName != "" {
+ parts = append(parts, fmt.Sprintf("grpc-opts:\n grpc-service-name: %s", serviceName))
+ }
+
+ case "h2":
+ h2, _ := stream["h2Settings"].(map[string]any)
+ if path, ok := h2["path"].(string); ok && path != "" {
+ h2Opts := fmt.Sprintf("h2-opts:\n path: %s", path)
+ if host, ok := h2["host"].([]any); ok && len(host) > 0 {
+ hostStrs := make([]string, len(host))
+ for i, h := range host {
+ hostStrs[i] = fmt.Sprintf("%v", h)
+ }
+ h2Opts += fmt.Sprintf("\n host: [%s]", strings.Join(hostStrs, ", "))
+ }
+ parts = append(parts, h2Opts)
+ }
+
+ case "tcp":
+ tcp, _ := stream["tcpSettings"].(map[string]any)
+ header, _ := tcp["header"].(map[string]any)
+ if typeStr, ok := header["type"].(string); ok && typeStr == "http" {
+ request, _ := header["request"].(map[string]any)
+ httpOpts := "http-opts:"
+ if path, ok := request["path"].([]any); ok && len(path) > 0 {
+ httpOpts += fmt.Sprintf("\n path:\n - %v", path[0])
+ }
+ if headers, ok := request["headers"].(map[string]any); ok && len(headers) > 0 {
+ httpOpts += "\n headers:"
+ for k, v := range headers {
+ if vals, ok := v.([]any); ok && len(vals) > 0 {
+ httpOpts += fmt.Sprintf("\n %s:\n - %v", k, vals[0])
+ }
+ }
+ }
+ parts = append(parts, httpOpts)
+ }
+
+ case "httpupgrade":
+ hu, _ := stream["httpupgradeSettings"].(map[string]any)
+ if path, ok := hu["path"].(string); ok && path != "" {
+ huOpts := fmt.Sprintf("httpupgrade-opts:\n path: %s", path)
+ if host, ok := hu["host"].(string); ok && host != "" {
+ huOpts += fmt.Sprintf("\n host: %s", host)
+ } else {
+ headers, _ := hu["headers"].(map[string]any)
+ if h, ok := headers["Host"].(string); ok && h != "" {
+ huOpts += fmt.Sprintf("\n host: %s", h)
+ }
+ }
+ parts = append(parts, huOpts)
+ }
+ }
+
+ return strings.Join(parts, "\n ")
+}
+
+// parseShadowsocksSettings extracts cipher and password from shadowsocks client settings.
+func (s *SubClashService) parseShadowsocksSettings(client model.Client) (string, string) {
+ // Default cipher
+ cipher := "aes-128-gcm"
+ password := client.Password
+
+ // Try to parse the method from the client's settings
+ // Shadowsocks protocol stores method in client.ID for some configurations
+ if client.Security != "" {
+ cipher = client.Security
+ }
+
+ return cipher, password
+}
diff --git a/sub/subController.go b/sub/subController.go
index 79ea755d..38648af8 100644
--- a/sub/subController.go
+++ b/sub/subController.go
@@ -25,8 +25,12 @@ type SUBController struct {
subEncrypt bool
updateInterval string
- subService *SubService
- subJsonService *SubJsonService
+ clashEnabled bool
+ subClashPath string
+
+ subService *SubService
+ subJsonService *SubJsonService
+ subClashService *SubClashService
}
// NewSUBController creates a new subscription controller with the given configuration.
@@ -49,6 +53,9 @@ func NewSUBController(
subAnnounce string,
subEnableRouting bool,
subRoutingRules string,
+ clashEnabled bool,
+ subClashPath string,
+ subClashTemplate string,
) *SUBController {
sub := NewSubService(showInfo, rModel)
a := &SUBController{
@@ -64,8 +71,12 @@ func NewSUBController(
subEncrypt: encrypt,
updateInterval: update,
- subService: sub,
- subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
+ clashEnabled: clashEnabled,
+ subClashPath: subClashPath,
+
+ subService: sub,
+ subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
+ subClashService: NewSubClashService(subClashTemplate, sub),
}
a.initRouter(g)
return a
@@ -80,6 +91,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.clashSubs)
+ }
}
// subs handles HTTP requests for subscription links, returning either HTML page or base64-encoded subscription data.
@@ -103,6 +118,10 @@ func (a *SUBController) subs(c *gin.Context) {
if !a.jsonEnabled {
subJsonURL = ""
}
+ subClashURL := ""
+ if a.clashEnabled {
+ subClashURL = a.subService.buildSingleURL("", scheme, hostWithPort, a.subClashPath, subId)
+ }
// Get base_path from context (set by middleware)
basePath, exists := c.Get("base_path")
if !exists {
@@ -136,6 +155,7 @@ func (a *SUBController) subs(c *gin.Context) {
"totalByte": page.TotalByte,
"subUrl": page.SubUrl,
"subJsonUrl": page.SubJsonUrl,
+ "subClashUrl": subClashURL,
"result": page.Result,
})
return
@@ -176,6 +196,26 @@ func (a *SUBController) subJsons(c *gin.Context) {
}
}
+// clashSubs handles HTTP requests for Clash YAML subscription configurations.
+func (a *SUBController) clashSubs(c *gin.Context) {
+ subId := c.Param("subid")
+ scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
+ clashYaml, header, err := a.subClashService.GetClash(subId, host)
+ if err != nil || len(clashYaml) == 0 {
+ c.String(400, "Error!")
+ } else {
+ // Add headers
+ 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.Header("Content-Type", "text/yaml; charset=utf-8")
+ c.String(200, clashYaml)
+ }
+}
+
// 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/web/assets/js/model/setting.js b/web/assets/js/model/setting.js
index 8814bf43..a3c15dca 100644
--- a/web/assets/js/model/setting.js
+++ b/web/assets/js/model/setting.js
@@ -52,6 +52,10 @@ class AllSetting {
this.subJsonNoises = "";
this.subJsonMux = "";
this.subJsonRules = "";
+ this.subClashEnable = false;
+ this.subClashPath = "/clash/";
+ this.subClashURI = "";
+ this.subClashTemplate = "";
this.timeLocation = "Local";
diff --git a/web/assets/js/subscription.js b/web/assets/js/subscription.js
index 228dcfa0..9cc496df 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') || '',
@@ -99,6 +100,8 @@
const tpl = document.getElementById('subscription-data');
const sj = tpl ? tpl.getAttribute('data-subjson-url') : '';
if (sj) this.app.subJsonUrl = sj;
+ const sc = tpl ? tpl.getAttribute('data-subclash-url') : '';
+ if (sc) this.app.subClashUrl = sc;
drawQR(this.app.subUrl);
try {
const elJson = document.getElementById('qrcode-subjson');
@@ -106,6 +109,12 @@
new QRious({ element: elJson, value: this.app.subJsonUrl, size: 220 });
}
} catch (e) { /* ignore */ }
+ try {
+ 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);
},
@@ -145,6 +154,10 @@
},
happUrl() {
return `happ://add/${this.app.subUrl}`;
+ },
+ clashvergeUrl() {
+ const url = this.app.subClashUrl || this.app.subUrl;
+ return `clash-verge://install-config?url=${encodeURIComponent(url)}&name=${encodeURIComponent(this.app.sId)}`;
}
},
methods: {
diff --git a/web/entity/entity.go b/web/entity/entity.go
index c163591a..f0214e92 100644
--- a/web/entity/entity.go
+++ b/web/entity/entity.go
@@ -81,6 +81,12 @@ type AllSetting struct {
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
+ // Clash subscription settings
+ SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"` // Enable Clash subscription endpoint
+ SubClashPath string `json:"subClashPath" form:"subClashPath"` // Path for Clash subscription endpoint
+ SubClashURI string `json:"subClashURI" form:"subClashURI"` // Clash subscription server URI
+ SubClashTemplate string `json:"subClashTemplate" form:"subClashTemplate"` // Clash YAML template content
+
// LDAP settings
LdapEnable bool `json:"ldapEnable" form:"ldapEnable"`
LdapHost string `json:"ldapHost" form:"ldapHost"`
diff --git a/web/html/inbounds.html b/web/html/inbounds.html
index f0c7fff2..c8f7363f 100644
--- a/web/html/inbounds.html
+++ b/web/html/inbounds.html
@@ -748,6 +748,8 @@
subURI: '',
subJsonURI: '',
subJsonEnable: false,
+ subClashEnable: false,
+ subClashURI: '',
},
remarkModel: '-ieo',
datepicker: 'gregorian',
@@ -812,6 +814,8 @@
subURI: subURI,
subJsonURI: subJsonURI,
subJsonEnable: subJsonEnable,
+ subClashEnable: subClashEnable,
+ subClashURI: subClashURI,
};
this.pageSize = pageSize;
this.remarkModel = remarkModel;
diff --git a/web/html/modals/inbound_info_modal.html b/web/html/modals/inbound_info_modal.html
index d67dfc53..b17a2706 100644
--- a/web/html/modals/inbound_info_modal.html
+++ b/web/html/modals/inbound_info_modal.html
@@ -365,6 +365,18 @@
[[
infoModal.subJsonLink ]]
+
+
+ Clash Link
+
+
+
+
+ [[
+ infoModal.subClashLink ]]
+
Telegram ChatID
@@ -642,6 +654,7 @@
isExpired: false,
subLink: '',
subJsonLink: '',
+ subClashLink: '',
clientIps: '',
clientIpsArray: [],
show(dbInbound, index) {
@@ -678,6 +691,7 @@
if (this.clientSettings.subId) {
this.subLink = this.genSubLink(this.clientSettings.subId);
this.subJsonLink = app.subSettings.subJsonEnable ? this.genSubJsonLink(this.clientSettings.subId) : '';
+ this.subClashLink = app.subSettings.subClashEnable ? this.genSubClashLink(this.clientSettings.subId) : '';
}
}
this.visible = true;
@@ -690,6 +704,9 @@
},
genSubJsonLink(subID) {
return app.subSettings.subJsonURI + subID;
+ },
+ genSubClashLink(subID) {
+ return app.subSettings.subClashURI + subID;
}
};
const infoModalApp = new Vue({
diff --git a/web/html/modals/qrcode_modal.html b/web/html/modals/qrcode_modal.html
index cdbb585b..c8d3d743 100644
--- a/web/html/modals/qrcode_modal.html
+++ b/web/html/modals/qrcode_modal.html
@@ -38,6 +38,14 @@
+
+ {{ i18n "pages.settings.subSettings"}} Clash
+
+
+
+
+
+
@@ -236,6 +244,9 @@
genSubJsonLink(subID) {
return app.subSettings.subJsonURI + subID;
},
+ genSubClashLink(subID) {
+ return app.subSettings.subClashURI + subID;
+ },
revertOverflow() {
const elements = document.querySelectorAll(".qr-tag");
elements.forEach((element) => {
@@ -265,6 +276,9 @@
if (app.subSettings.subJsonEnable) {
this.setQrCode("qrCode-subJson", this.genSubJsonLink(qrModal.subId));
}
+ if (app.subSettings.subClashEnable) {
+ this.setQrCode("qrCode-subClash", this.genSubClashLink(qrModal.subId));
+ }
}
qrModal.qrcodes.forEach((element, index) => {
this.setQrCode("qrCode-" + index, element.link);
diff --git a/web/html/settings.html b/web/html/settings.html
index 351afec8..196b27b9 100644
--- a/web/html/settings.html
+++ b/web/html/settings.html
@@ -98,6 +98,13 @@
{{ template "settings/panel/subscription/json" . }}
+
+
+
+ {{ i18n "pages.settings.subSettings" }} (Clash)
+
+ {{ template "settings/panel/subscription/clash" . }}
+
@@ -623,6 +630,10 @@
const subJsonPath = this.allSetting.subJsonURI.length > 0 ? new URL(this.allSetting.subJsonURI).pathname : this.allSetting.subJsonPath;
if (subJsonPath == '/json/') alerts.push('{{ i18n "secAlertSubJsonURI" }}');
}
+ if (this.allSetting.subClashEnable) {
+ const subClashPath = this.allSetting.subClashURI.length > 0 ? new URL(this.allSetting.subClashURI).pathname : this.allSetting.subClashPath;
+ if (subClashPath == '/clash/') alerts.push('Consider changing the default Clash subscription path for security.');
+ }
return alerts
}
}
diff --git a/web/html/settings/panel/subscription/clash.html b/web/html/settings/panel/subscription/clash.html
new file mode 100644
index 00000000..12a260af
--- /dev/null
+++ b/web/html/settings/panel/subscription/clash.html
@@ -0,0 +1,51 @@
+{{define "settings/panel/subscription/clash"}}
+
+
+
+ {{ i18n "pages.settings.subPath"}}
+ {{ i18n "pages.settings.subPathDesc"}}
+
+ { p = p || '/'; if (!p.startsWith('/')) p='/' + p; if (!p.endsWith('/')) p += '/'; return p.replace(/\/+/g,'/'); })(allSetting.subClashPath)"
+ placeholder="/clash/">
+
+
+
+ {{ i18n "pages.settings.subURI"}}
+ {{ i18n "pages.settings.subURIDesc"}}
+
+
+
+
+
+
+
+ Template
+ Complete Clash YAML template with proxies: [] placeholder. The panel will replace proxies: [] with generated proxy entries.
+
+
+
+
+
+
+{{end}}
diff --git a/web/html/settings/panel/subscription/general.html b/web/html/settings/panel/subscription/general.html
index 5d83aa37..ea00bdd3 100644
--- a/web/html/settings/panel/subscription/general.html
+++ b/web/html/settings/panel/subscription/general.html
@@ -15,6 +15,13 @@
+
+ Clash Subscription
+ Enable/Disable the Clash YAML subscription endpoint.
+
+
+
+
{{ i18n "pages.settings.subListen"}}
{{ i18n "pages.settings.subListenDesc"}}
diff --git a/web/html/settings/panel/subscription/subpage.html b/web/html/settings/panel/subscription/subpage.html
index 4b729b8e..42f71893 100644
--- a/web/html/settings/panel/subscription/subpage.html
+++ b/web/html/settings/panel/subscription/subpage.html
@@ -83,7 +83,7 @@
-
+
{{ i18n
@@ -97,7 +97,7 @@
-
+
{{ i18n
@@ -112,6 +112,21 @@
+
+
+
+ {{ i18n
+ "pages.settings.subSettings"}}
+ Clash
+
+
+
+
+
+
+
+
@@ -187,7 +202,7 @@
-
+
Happ
+ Clash Verge
-
+
Happ
+ Clash Verge
+
+
+
+
+
+
+
+ Desktop
+
+
+ Clash Verge
@@ -242,7 +274,7 @@
-