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 = ""
|
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
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
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -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 }}"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -437,6 +437,7 @@
|
||||||
"subEnable" = "启用订阅服务"
|
"subEnable" = "启用订阅服务"
|
||||||
"subEnableDesc" = "启用订阅服务功能"
|
"subEnableDesc" = "启用订阅服务功能"
|
||||||
"subJsonEnable" = "单独启用/禁用 JSON 订阅端点。"
|
"subJsonEnable" = "单独启用/禁用 JSON 订阅端点。"
|
||||||
|
"subClashEnable" = "单独启用/禁用 Clash YAML 订阅端点。"
|
||||||
"subTitle" = "订阅标题"
|
"subTitle" = "订阅标题"
|
||||||
"subTitleDesc" = "在VPN客户端中显示的标题"
|
"subTitleDesc" = "在VPN客户端中显示的标题"
|
||||||
"subSupportUrl" = "支持链接"
|
"subSupportUrl" = "支持链接"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue