mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
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:
parent
1a02ebb024
commit
11cdb07e89
18 changed files with 646 additions and 17 deletions
|
|
@ -1 +1 @@
|
|||
v1.5.1
|
||||
v1.5.2-beta
|
||||
30
docs/Tasktracking/2026-04-24-clash-yaml-subscription.md
Normal file
30
docs/Tasktracking/2026-04-24-clash-yaml-subscription.md
Normal 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
|
||||
18
sub/sub.go
18
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
|
||||
}
|
||||
|
|
|
|||
350
sub/subClashService.go
Normal file
350
sub/subClashService.go
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -365,6 +365,18 @@
|
|||
<a :href="[[ infoModal.subJsonLink ]]" target="_blank">[[
|
||||
infoModal.subJsonLink ]]</a>
|
||||
</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 v-if="app.tgBotEnable && infoModal.clientSettings.tgId">
|
||||
<a-divider>Telegram ChatID</a-divider>
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -38,6 +38,14 @@
|
|||
</tr-qr-bg-inner>
|
||||
</tr-qr-bg>
|
||||
</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 v-for="(row, index) in qrModal.qrcodes">
|
||||
<tr-qr-box class="qr-box">
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -98,6 +98,13 @@
|
|||
</template>
|
||||
{{ template "settings/panel/subscription/json" . }}
|
||||
</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-col>
|
||||
</a-row>
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
51
web/html/settings/panel/subscription/clash.html
Normal file
51
web/html/settings/panel/subscription/clash.html
Normal 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}}
|
||||
|
|
@ -15,6 +15,13 @@
|
|||
<a-switch v-model="allSetting.subJsonEnable"></a-switch>
|
||||
</template>
|
||||
</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">
|
||||
<template #title>{{ i18n "pages.settings.subListen"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subListenDesc"}}</template>
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@
|
|||
<a-form-item>
|
||||
<a-space direction="vertical" align="center">
|
||||
<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">
|
||||
<a-tag color="purple" class="qr-tag">
|
||||
<span>{{ i18n
|
||||
|
|
@ -97,7 +97,7 @@
|
|||
</tr-qr-bg>
|
||||
</tr-qr-box>
|
||||
</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">
|
||||
<a-tag color="purple" class="qr-tag">
|
||||
<span>{{ i18n
|
||||
|
|
@ -112,6 +112,21 @@
|
|||
</tr-qr-bg>
|
||||
</tr-qr-box>
|
||||
</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-space>
|
||||
</a-form-item>
|
||||
|
|
@ -187,7 +202,7 @@
|
|||
<a-form layout="vertical">
|
||||
<a-form-item>
|
||||
<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 -->
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button icon="android" :block="isMobile"
|
||||
|
|
@ -207,10 +222,12 @@
|
|||
Tunnel</a-menu-item>
|
||||
<a-menu-item key="android-happ"
|
||||
@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-dropdown>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" style="text-align:center;">
|
||||
<a-col :xs="24" :sm="8" style="text-align:center;">
|
||||
<!-- iOS dropdown -->
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button icon="apple" :block="isMobile"
|
||||
|
|
@ -229,6 +246,21 @@
|
|||
Tunnel
|
||||
</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-dropdown>
|
||||
</a-col>
|
||||
|
|
@ -242,7 +274,7 @@
|
|||
</a-layout>
|
||||
|
||||
<!-- 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-remained="{{ .remained }}" data-expire="{{ .expire }}" data-lastonline="{{ .lastOnline }}"
|
||||
data-downloadbyte="{{ .downloadByte }}" data-uploadbyte="{{ .uploadByte }}" data-totalbyte="{{ .totalByte }}"
|
||||
|
|
|
|||
|
|
@ -78,6 +78,10 @@ var defaultValueMap = map[string]string{
|
|||
"subJsonNoises": "",
|
||||
"subJsonMux": "",
|
||||
"subJsonRules": "",
|
||||
"subClashEnable": "false",
|
||||
"subClashPath": "/clash/",
|
||||
"subClashURI": "",
|
||||
"subClashTemplate": "",
|
||||
"datepicker": "gregorian",
|
||||
"warp": "",
|
||||
"externalTrafficInformEnable": "false",
|
||||
|
|
@ -971,6 +975,22 @@ func (s *SettingService) GetSubJsonRules() (string, error) {
|
|||
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) {
|
||||
return s.getString("datepicker")
|
||||
}
|
||||
|
|
@ -1173,11 +1193,13 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
|||
"defaultCert": func() (any, error) { return s.GetCertFile() },
|
||||
"defaultKey": func() (any, error) { return s.GetKeyFile() },
|
||||
"tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() },
|
||||
"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() },
|
||||
"subEnable": func() (any, error) { return s.GetSubEnable() },
|
||||
"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() },
|
||||
"ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() },
|
||||
|
|
@ -1200,12 +1222,19 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
|||
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 := ""
|
||||
subTitle, _ := s.GetSubTitle()
|
||||
subPort, _ := s.GetSubPort()
|
||||
subPath, _ := s.GetSubPath()
|
||||
subJsonPath, _ := s.GetSubJsonPath()
|
||||
subClashPath, _ := s.GetSubClashPath()
|
||||
subDomain, _ := s.GetSubDomain()
|
||||
subKeyFile, _ := s.GetSubKeyFile()
|
||||
subCertFile, _ := s.GetSubCertFile()
|
||||
|
|
@ -1235,6 +1264,9 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
|||
if subJsonEnable && result["subJsonURI"].(string) == "" {
|
||||
result["subJsonURI"] = subURI + subJsonPath
|
||||
}
|
||||
if subClashEnable && result["subClashURI"].(string) == "" {
|
||||
result["subClashURI"] = subURI + subClashPath
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
|
|
|
|||
|
|
@ -437,6 +437,7 @@
|
|||
"subEnable" = "Subscription Service"
|
||||
"subEnableDesc" = "Enable/Disable the subscription service."
|
||||
"subJsonEnable" = "Enable/Disable the JSON subscription endpoint independently."
|
||||
"subClashEnable" = "Enable/Disable the Clash YAML subscription endpoint independently."
|
||||
"subTitle" = "Subscription Title"
|
||||
"subTitleDesc" = "Title shown in VPN client"
|
||||
"subSupportUrl" = "Support URL"
|
||||
|
|
|
|||
|
|
@ -437,6 +437,7 @@
|
|||
"subEnable" = "启用订阅服务"
|
||||
"subEnableDesc" = "启用订阅服务功能"
|
||||
"subJsonEnable" = "单独启用/禁用 JSON 订阅端点。"
|
||||
"subClashEnable" = "单独启用/禁用 Clash YAML 订阅端点。"
|
||||
"subTitle" = "订阅标题"
|
||||
"subTitleDesc" = "在VPN客户端中显示的标题"
|
||||
"subSupportUrl" = "支持链接"
|
||||
|
|
|
|||
Loading…
Reference in a new issue