feat: add Clash YAML subscription endpoint with template injection

Add /clash/:subid endpoint that returns complete Clash YAML config.
User provides full template (DNS, routing, proxy-groups, rules) in
settings, panel generates proxies from inbound/client data and injects
via proxies: [] placeholder replacement.

- New SubClashService reads template, generates vmess/vless/trojan/ss
  proxy entries with transport (ws/grpc/h2/tcp/httpupgrade), TLS, and
  Reality support
- Settings: subClashEnable, subClashPath, subClashURI, subClashTemplate
- UI: Clash settings tab, QR code on subpage, Desktop dropdown with
  clash-verge:// deep link preferring Clash URL
- Version bump to v1.5.2-beta
This commit is contained in:
root 2026-04-24 11:25:10 +08:00
parent 1a02ebb024
commit 11cdb07e89
18 changed files with 646 additions and 17 deletions

View file

@ -1 +1 @@
v1.5.1 v1.5.2-beta

View file

@ -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

View file

@ -227,6 +227,21 @@ func (s *Server) initRouter() (*gin.Engine, error) {
SubRoutingRules = "" 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 // set per-request localizer from headers/cookies
engine.Use(locale.LocalizerMiddleware()) engine.Use(locale.LocalizerMiddleware())
@ -307,7 +322,8 @@ func (s *Server) initRouter() (*gin.Engine, error) {
s.sub = NewSUBController( s.sub = NewSUBController(
g, LinksPath, JsonPath, subJsonEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates, g, LinksPath, JsonPath, subJsonEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubTitle, SubSupportUrl, SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubTitle, SubSupportUrl,
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules) SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules,
subClashEnable, SubClashPath, SubClashTemplate)
return engine, nil return engine, nil
} }

350
sub/subClashService.go Normal file
View file

@ -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
}

View file

@ -25,8 +25,12 @@ type SUBController struct {
subEncrypt bool subEncrypt bool
updateInterval string updateInterval string
subService *SubService clashEnabled bool
subJsonService *SubJsonService subClashPath string
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.
@ -49,6 +53,9 @@ func NewSUBController(
subAnnounce string, subAnnounce string,
subEnableRouting bool, subEnableRouting bool,
subRoutingRules string, subRoutingRules string,
clashEnabled bool,
subClashPath string,
subClashTemplate string,
) *SUBController { ) *SUBController {
sub := NewSubService(showInfo, rModel) sub := NewSubService(showInfo, rModel)
a := &SUBController{ a := &SUBController{
@ -64,8 +71,12 @@ func NewSUBController(
subEncrypt: encrypt, subEncrypt: encrypt,
updateInterval: update, updateInterval: update,
subService: sub, clashEnabled: clashEnabled,
subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub), subClashPath: subClashPath,
subService: sub,
subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
subClashService: NewSubClashService(subClashTemplate, sub),
} }
a.initRouter(g) a.initRouter(g)
return a return a
@ -80,6 +91,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.clashSubs)
}
} }
// 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.
@ -103,6 +118,10 @@ func (a *SUBController) subs(c *gin.Context) {
if !a.jsonEnabled { if !a.jsonEnabled {
subJsonURL = "" subJsonURL = ""
} }
subClashURL := ""
if a.clashEnabled {
subClashURL = a.subService.buildSingleURL("", scheme, hostWithPort, a.subClashPath, subId)
}
// 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 {
@ -136,6 +155,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": subClashURL,
"result": page.Result, "result": page.Result,
}) })
return 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. // 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,

View file

@ -52,6 +52,10 @@ class AllSetting {
this.subJsonNoises = ""; this.subJsonNoises = "";
this.subJsonMux = ""; this.subJsonMux = "";
this.subJsonRules = ""; this.subJsonRules = "";
this.subClashEnable = false;
this.subClashPath = "/clash/";
this.subClashURI = "";
this.subClashTemplate = "";
this.timeLocation = "Local"; this.timeLocation = "Local";

View file

@ -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') || '',
@ -99,6 +100,8 @@
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') : '';
if (sj) this.app.subJsonUrl = sj; if (sj) this.app.subJsonUrl = sj;
const sc = tpl ? tpl.getAttribute('data-subclash-url') : '';
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');
@ -106,6 +109,12 @@
new QRious({ element: elJson, value: this.app.subJsonUrl, size: 220 }); new QRious({ element: elJson, value: this.app.subJsonUrl, size: 220 });
} }
} catch (e) { /* ignore */ } } 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; }; this._onResize = () => { this.viewportWidth = window.innerWidth; };
window.addEventListener('resize', this._onResize); window.addEventListener('resize', this._onResize);
}, },
@ -145,6 +154,10 @@
}, },
happUrl() { happUrl() {
return `happ://add/${this.app.subUrl}`; 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: { methods: {

View file

@ -81,6 +81,12 @@ type AllSetting struct {
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"` 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 // LDAP settings
LdapEnable bool `json:"ldapEnable" form:"ldapEnable"` LdapEnable bool `json:"ldapEnable" form:"ldapEnable"`
LdapHost string `json:"ldapHost" form:"ldapHost"` LdapHost string `json:"ldapHost" form:"ldapHost"`

View file

@ -748,6 +748,8 @@
subURI: '', subURI: '',
subJsonURI: '', subJsonURI: '',
subJsonEnable: false, subJsonEnable: false,
subClashEnable: false,
subClashURI: '',
}, },
remarkModel: '-ieo', remarkModel: '-ieo',
datepicker: 'gregorian', datepicker: 'gregorian',
@ -812,6 +814,8 @@
subURI: subURI, subURI: subURI,
subJsonURI: subJsonURI, subJsonURI: subJsonURI,
subJsonEnable: subJsonEnable, subJsonEnable: subJsonEnable,
subClashEnable: subClashEnable,
subClashURI: subClashURI,
}; };
this.pageSize = pageSize; this.pageSize = pageSize;
this.remarkModel = remarkModel; this.remarkModel = remarkModel;

View file

@ -365,6 +365,18 @@
<a :href="[[ infoModal.subJsonLink ]]" target="_blank">[[ <a :href="[[ infoModal.subJsonLink ]]" target="_blank">[[
infoModal.subJsonLink ]]</a> infoModal.subJsonLink ]]</a>
</tr-info-row> </tr-info-row>
<tr-info-row class="tr-info-row"
v-if="app.subSettings.subClashEnable">
<tr-info-title class="tr-info-title">
<a-tag color="purple">Clash Link</a-tag>
<a-tooltip title='{{ i18n "copy" }}'>
<a-button size="small" icon="snippets"
@click="copy(infoModal.subClashLink)"></a-button>
</a-tooltip>
</tr-info-title>
<a :href="[[ infoModal.subClashLink ]]" target="_blank">[[
infoModal.subClashLink ]]</a>
</tr-info-row>
</template> </template>
<template v-if="app.tgBotEnable && infoModal.clientSettings.tgId"> <template v-if="app.tgBotEnable && infoModal.clientSettings.tgId">
<a-divider>Telegram ChatID</a-divider> <a-divider>Telegram ChatID</a-divider>
@ -642,6 +654,7 @@
isExpired: false, isExpired: false,
subLink: '', subLink: '',
subJsonLink: '', subJsonLink: '',
subClashLink: '',
clientIps: '', clientIps: '',
clientIpsArray: [], clientIpsArray: [],
show(dbInbound, index) { show(dbInbound, index) {
@ -678,6 +691,7 @@
if (this.clientSettings.subId) { if (this.clientSettings.subId) {
this.subLink = this.genSubLink(this.clientSettings.subId); this.subLink = this.genSubLink(this.clientSettings.subId);
this.subJsonLink = app.subSettings.subJsonEnable ? this.genSubJsonLink(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; this.visible = true;
@ -690,6 +704,9 @@
}, },
genSubJsonLink(subID) { genSubJsonLink(subID) {
return app.subSettings.subJsonURI + subID; return app.subSettings.subJsonURI + subID;
},
genSubClashLink(subID) {
return app.subSettings.subClashURI + subID;
} }
}; };
const infoModalApp = new Vue({ const infoModalApp = new Vue({

View file

@ -38,6 +38,14 @@
</tr-qr-bg-inner> </tr-qr-bg-inner>
</tr-qr-bg> </tr-qr-bg>
</tr-qr-box> </tr-qr-box>
<tr-qr-box class="qr-box" v-if="app.subSettings.subClashEnable">
<a-tag color="purple" class="qr-tag"><span>{{ i18n "pages.settings.subSettings"}} Clash</span></a-tag>
<tr-qr-bg class="qr-bg-sub">
<tr-qr-bg-inner class="qr-bg-sub-inner">
<canvas @click="copy(genSubClashLink(qrModal.client.subId))" id="qrCode-subClash" class="qr-cv"></canvas>
</tr-qr-bg-inner>
</tr-qr-bg>
</tr-qr-box>
</template> </template>
<template v-for="(row, index) in qrModal.qrcodes"> <template v-for="(row, index) in qrModal.qrcodes">
<tr-qr-box class="qr-box"> <tr-qr-box class="qr-box">
@ -236,6 +244,9 @@
genSubJsonLink(subID) { genSubJsonLink(subID) {
return app.subSettings.subJsonURI + subID; return app.subSettings.subJsonURI + subID;
}, },
genSubClashLink(subID) {
return app.subSettings.subClashURI + subID;
},
revertOverflow() { revertOverflow() {
const elements = document.querySelectorAll(".qr-tag"); const elements = document.querySelectorAll(".qr-tag");
elements.forEach((element) => { elements.forEach((element) => {
@ -265,6 +276,9 @@
if (app.subSettings.subJsonEnable) { if (app.subSettings.subJsonEnable) {
this.setQrCode("qrCode-subJson", this.genSubJsonLink(qrModal.subId)); 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) => { qrModal.qrcodes.forEach((element, index) => {
this.setQrCode("qrCode-" + index, element.link); this.setQrCode("qrCode-" + index, element.link);

View file

@ -98,6 +98,13 @@
</template> </template>
{{ template "settings/panel/subscription/json" . }} {{ template "settings/panel/subscription/json" . }}
</a-tab-pane> </a-tab-pane>
<a-tab-pane key="6" v-if="allSetting.subClashEnable" :style="{ paddingTop: '20px' }">
<template #tab>
<a-icon type="thunderbolt"></a-icon>
<span>{{ i18n "pages.settings.subSettings" }} (Clash)</span>
</template>
{{ template "settings/panel/subscription/clash" . }}
</a-tab-pane>
</a-tabs> </a-tabs>
</a-col> </a-col>
</a-row> </a-row>
@ -623,6 +630,10 @@
const subJsonPath = this.allSetting.subJsonURI.length > 0 ? new URL(this.allSetting.subJsonURI).pathname : this.allSetting.subJsonPath; const subJsonPath = this.allSetting.subJsonURI.length > 0 ? new URL(this.allSetting.subJsonURI).pathname : this.allSetting.subJsonPath;
if (subJsonPath == '/json/') alerts.push('{{ i18n "secAlertSubJsonURI" }}'); 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 return alerts
} }
} }

View file

@ -0,0 +1,51 @@
{{define "settings/panel/subscription/clash"}}
<a-collapse default-active-key="1">
<a-collapse-panel key="1" header='{{ i18n "pages.xray.generalConfigs"}}'>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subPath"}}</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">
<template #title>{{ i18n "pages.settings.subURI"}}</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 key="2" header="Clash YAML Template">
<a-setting-list-item paddings="small">
<template #title>Template</template>
<template #description>Complete Clash YAML template with proxies: [] placeholder. The panel will replace proxies: [] with generated proxy entries.</template>
<template #control>
<a-textarea v-model="allSetting.subClashTemplate" :rows="15"
placeholder="port: 7890
socks-port: 7891
allow-lan: false
mode: rule
log-level: info
proxies: []
proxy-groups:
- name: Proxy
type: select
proxies:
- DIRECT
dns:
enable: true
enhanced-mode: fake-ip
nameserver:
- 8.8.8.8
rules:
- MATCH,DIRECT"></a-textarea>
</template>
</a-setting-list-item>
</a-collapse-panel>
</a-collapse>
{{end}}

View file

@ -15,6 +15,13 @@
<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 Subscription</template>
<template #description>Enable/Disable the Clash YAML subscription endpoint.</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>

View file

@ -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 ? (app.subJsonUrl && app.subClashUrl ? 8 : 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
@ -97,7 +97,7 @@
</tr-qr-bg> </tr-qr-bg>
</tr-qr-box> </tr-qr-box>
</a-col> </a-col>
<a-col v-if="app.subJsonUrl" :xs="24" :sm="12" style="text-align:center;"> <a-col v-if="app.subJsonUrl" :xs="24" :sm="app.subClashUrl ? 8 : (app.subJsonUrl ? 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,21 @@
</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="app.subJsonUrl ? 8 : 12" style="text-align:center;">
<tr-qr-box class="qr-box">
<a-tag color="purple" class="qr-tag">
<span>{{ i18n
"pages.settings.subSettings"}}
Clash</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>
@ -187,7 +202,7 @@
<a-form layout="vertical"> <a-form layout="vertical">
<a-form-item> <a-form-item>
<a-row type="flex" justify="center" :gutter="[8,8]" style="width:100%"> <a-row type="flex" justify="center" :gutter="[8,8]" style="width:100%">
<a-col :xs="24" :sm="12" style="text-align:center;"> <a-col :xs="24" :sm="8" style="text-align:center;">
<!-- Android dropdown --> <!-- Android dropdown -->
<a-dropdown :trigger="['click']"> <a-dropdown :trigger="['click']">
<a-button icon="android" :block="isMobile" <a-button icon="android" :block="isMobile"
@ -207,10 +222,12 @@
Tunnel</a-menu-item> Tunnel</a-menu-item>
<a-menu-item key="android-happ" <a-menu-item key="android-happ"
@click="open('happ://add/' + app.subUrl)">Happ</a-menu-item> @click="open('happ://add/' + app.subUrl)">Happ</a-menu-item>
<a-menu-item key="android-clashverge"
@click="open(clashvergeUrl)">Clash Verge</a-menu-item>
</a-menu> </a-menu>
</a-dropdown> </a-dropdown>
</a-col> </a-col>
<a-col :xs="24" :sm="12" style="text-align:center;"> <a-col :xs="24" :sm="8" style="text-align:center;">
<!-- iOS dropdown --> <!-- iOS dropdown -->
<a-dropdown :trigger="['click']"> <a-dropdown :trigger="['click']">
<a-button icon="apple" :block="isMobile" <a-button icon="apple" :block="isMobile"
@ -229,6 +246,21 @@
Tunnel Tunnel
</a-menu-item> </a-menu-item>
<a-menu-item key="ios-happ" @click="open(happUrl)">Happ</a-menu-item> <a-menu-item key="ios-happ" @click="open(happUrl)">Happ</a-menu-item>
<a-menu-item key="ios-clashverge"
@click="open(clashvergeUrl)">Clash Verge</a-menu-item>
</a-menu>
</a-dropdown>
</a-col>
<a-col :xs="24" :sm="8" style="text-align:center;">
<!-- Desktop dropdown -->
<a-dropdown :trigger="['click']">
<a-button icon="laptop" :block="isMobile"
:style="{ marginTop: isMobile ? '6px' : 0 }" size="large" type="primary">
Desktop <a-icon type="down" />
</a-button>
<a-menu slot="overlay" :class="themeSwitcher.currentTheme">
<a-menu-item key="desktop-clashverge"
@click="open(clashvergeUrl)">Clash Verge</a-menu-item>
</a-menu> </a-menu>
</a-dropdown> </a-dropdown>
</a-col> </a-col>
@ -242,7 +274,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 }}"

View file

@ -78,6 +78,10 @@ var defaultValueMap = map[string]string{
"subJsonNoises": "", "subJsonNoises": "",
"subJsonMux": "", "subJsonMux": "",
"subJsonRules": "", "subJsonRules": "",
"subClashEnable": "false",
"subClashPath": "/clash/",
"subClashURI": "",
"subClashTemplate": "",
"datepicker": "gregorian", "datepicker": "gregorian",
"warp": "", "warp": "",
"externalTrafficInformEnable": "false", "externalTrafficInformEnable": "false",
@ -971,6 +975,22 @@ func (s *SettingService) GetSubJsonRules() (string, error) {
return s.getString("subJsonRules") return s.getString("subJsonRules")
} }
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) GetSubClashTemplate() (string, error) {
return s.getString("subClashTemplate")
}
func (s *SettingService) GetDatepicker() (string, error) { func (s *SettingService) GetDatepicker() (string, error) {
return s.getString("datepicker") return s.getString("datepicker")
} }
@ -1173,11 +1193,13 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
"defaultCert": func() (any, error) { return s.GetCertFile() }, "defaultCert": func() (any, error) { return s.GetCertFile() },
"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() }, "subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
"subTitle": func() (any, error) { return s.GetSubTitle() }, "subClashEnable": func() (any, error) { return s.GetSubClashEnable() },
"subURI": func() (any, error) { return s.GetSubURI() }, "subTitle": func() (any, error) { return s.GetSubTitle() },
"subJsonURI": func() (any, error) { return s.GetSubJsonURI() }, "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() }, "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() },
@ -1200,12 +1222,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()
@ -1235,6 +1264,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

View file

@ -437,6 +437,7 @@
"subEnable" = "Subscription Service" "subEnable" = "Subscription Service"
"subEnableDesc" = "Enable/Disable the subscription service." "subEnableDesc" = "Enable/Disable the subscription service."
"subJsonEnable" = "Enable/Disable the JSON subscription endpoint independently." "subJsonEnable" = "Enable/Disable the JSON subscription endpoint independently."
"subClashEnable" = "Enable/Disable the Clash YAML subscription endpoint independently."
"subTitle" = "Subscription Title" "subTitle" = "Subscription Title"
"subTitleDesc" = "Title shown in VPN client" "subTitleDesc" = "Title shown in VPN client"
"subSupportUrl" = "Support URL" "subSupportUrl" = "Support URL"

View file

@ -437,6 +437,7 @@
"subEnable" = "启用订阅服务" "subEnable" = "启用订阅服务"
"subEnableDesc" = "启用订阅服务功能" "subEnableDesc" = "启用订阅服务功能"
"subJsonEnable" = "单独启用/禁用 JSON 订阅端点。" "subJsonEnable" = "单独启用/禁用 JSON 订阅端点。"
"subClashEnable" = "单独启用/禁用 Clash YAML 订阅端点。"
"subTitle" = "订阅标题" "subTitle" = "订阅标题"
"subTitleDesc" = "在VPN客户端中显示的标题" "subTitleDesc" = "在VPN客户端中显示的标题"
"subSupportUrl" = "支持链接" "subSupportUrl" = "支持链接"