From 25cf22d1618707620b5f5b2fe297d5eca68ecee1 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 25 Apr 2026 18:23:42 +0800 Subject: [PATCH] feat: support full mihomo template and multi-server for Clash Link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add splitTemplate() to split at proxies:/proxy-groups: markers (like mihomo-gen) - Store clash_template.yaml and servers.yaml as files alongside x-ui.json - Add Clash/Servers editors in Xray advanced config page - Support multi-server proxy generation (each server × each client) - Remove inline template editor from Clash settings panel - Bump version to v1.7.2.1 --- config/config.go | 79 ++++++++++++++++ config/version | 2 +- .../2026-04-25-clash-full-mihomo-template.md | 24 +++++ sub/sub.go | 41 ++++++++- sub/subClashService.go | 92 ++++++++++++++++--- sub/subController.go | 3 +- web/controller/xray_setting.go | 46 ++++++++++ web/html/settings.html | 23 ----- .../settings/panel/subscription/clash.html | 11 +-- web/html/settings/xray/advanced.html | 2 + web/html/xray.html | 68 ++++++++++++-- web/service/setting.go | 21 +---- web/translation/translate.en_US.toml | 1 + web/translation/translate.zh_CN.toml | 1 + 14 files changed, 338 insertions(+), 76 deletions(-) create mode 100644 docs/Tasktracking/2026-04-25-clash-full-mihomo-template.md diff --git a/config/config.go b/config/config.go index 53b1b0ca..1fe574b6 100644 --- a/config/config.go +++ b/config/config.go @@ -129,6 +129,85 @@ func GetTrafficPendingPath() string { return filepath.Join(GetDBFolderPath(), "traffic-pending.json") } +// GetClashTemplatePath returns the path to the clash_template.yaml file. +func GetClashTemplatePath() string { + return filepath.Join(GetDBFolderPath(), "clash_template.yaml") +} + +// GetServersPath returns the path to the servers.yaml file. +func GetServersPath() string { + return filepath.Join(GetDBFolderPath(), "servers.yaml") +} + +// ReadClashTemplate reads the clash template from disk. +// Returns the default mihomo template if the file does not exist. +func ReadClashTemplate() (string, error) { + data, err := os.ReadFile(GetClashTemplatePath()) + if err != nil { + if os.IsNotExist(err) { + return defaultClashTemplate, nil + } + return "", err + } + return string(data), nil +} + +// SaveClashTemplate writes the clash template to disk. +func SaveClashTemplate(content string) error { + path := GetClashTemplatePath() + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + return os.WriteFile(path, []byte(content), 0644) +} + +// ReadServers reads the servers config from disk. +// Returns a default empty servers config if the file does not exist. +func ReadServers() (string, error) { + data, err := os.ReadFile(GetServersPath()) + if err != nil { + if os.IsNotExist(err) { + return defaultServers, nil + } + return "", err + } + return string(data), nil +} + +// SaveServers writes the servers config to disk. +func SaveServers(content string) error { + path := GetServersPath() + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + return os.WriteFile(path, []byte(content), 0644) +} + +const defaultServers = `servers: [] +` + +const defaultClashTemplate = `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 + - 1.1.1.1 +rules: + - GEOIP,LAN,DIRECT + - MATCH,Proxy +` + // GetLogFolder returns the path to the log folder based on environment variables or platform defaults. func GetLogFolder() string { logFolderPath := os.Getenv("XUI_LOG_FOLDER") diff --git a/config/version b/config/version index f9a14621..1ee43c89 100644 --- a/config/version +++ b/config/version @@ -1 +1 @@ -v1.7.0.1 +v1.7.2.1 diff --git a/docs/Tasktracking/2026-04-25-clash-full-mihomo-template.md b/docs/Tasktracking/2026-04-25-clash-full-mihomo-template.md new file mode 100644 index 00000000..247866bc --- /dev/null +++ b/docs/Tasktracking/2026-04-25-clash-full-mihomo-template.md @@ -0,0 +1,24 @@ +# Clash Link: Full Mihomo Template + Multi-Server Support + +## Date: 2026-04-25 + +## Changes + +### Backend +- `config/config.go` — Added `GetClashTemplatePath()`, `GetServersPath()`, `ReadClashTemplate()`, `SaveClashTemplate()`, `ReadServers()`, `SaveServers()`. Files stored at `/etc/x-ui/clash_template.yaml` and `/etc/x-ui/servers.yaml` +- `sub/subClashService.go` — Added `splitTemplate()` (from mihomo-gen), modified `GetClash()` to split at `proxies:`/`proxy-groups:` markers instead of `proxies: []` replacement. Added multi-server support: each `ClashServer` × each client generates a proxy entry. Falls back to old approach if split fails. +- `sub/sub.go` — Reads template and servers from files via `config.ReadClashTemplate()`/`config.ReadServers()`. Added `ClashServer` struct and `parseServers()`. +- `sub/subController.go` — Updated `NewSUBController` to accept `clashServers []ClashServer` +- `web/controller/xray_setting.go` — Added 4 API endpoints: `GET/POST /xray/clashTemplate`, `GET/POST /xray/servers` +- `web/service/setting.go` — Cleared `subClashTemplate` default (template now from file) + +### Frontend +- `web/html/settings/xray/advanced.html` — Added "Clash" and "Servers" radio buttons in Xray advanced config +- `web/html/xray.html` — Added `clashTemplate`/`servers` data with old-value tracking, load/save methods, YAML CodeMirror mode, smart save button dispatches to correct save handler +- `web/html/settings/panel/subscription/clash.html` — Removed template editor (now in Xray advanced config) +- `web/html/settings.html` — Removed `initClashCodeMirror()` (template editor moved) +- `web/translation/translate.en_US.toml` — Added "Servers" key +- `web/translation/translate.zh_CN.toml` — Added "Servers" key + +### Version +- `config/version` — Bumped to v1.7.2.1 diff --git a/sub/sub.go b/sub/sub.go index c6d87c97..34b8c95a 100644 --- a/sub/sub.go +++ b/sub/sub.go @@ -18,6 +18,8 @@ import ( "strconv" "strings" + "github.com/goccy/go-yaml" + "github.com/mhsanaei/3x-ui/v2/config" "github.com/mhsanaei/3x-ui/v2/logger" "github.com/mhsanaei/3x-ui/v2/util/common" webpkg "github.com/mhsanaei/3x-ui/v2/web" @@ -31,6 +33,26 @@ import ( type subscriptionAssetManifest map[string]string +// ClashServer represents a proxy server entry from servers.yaml. +type ClashServer struct { + Name string `yaml:"name"` + Server string `yaml:"server"` +} + +// serversConfig is the top-level structure of servers.yaml. +type serversConfig struct { + Servers []ClashServer `yaml:"servers"` +} + +// parseServers parses YAML data into a list of ClashServer entries. +func parseServers(data []byte) ([]ClashServer, error) { + var cfg serversConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse servers: %w", err) + } + return cfg.Servers, nil +} + // setEmbeddedTemplates parses and sets embedded templates on the engine func setEmbeddedTemplates(engine *gin.Engine) error { t, err := template.New("").Funcs(engine.FuncMap).ParseFS( @@ -237,11 +259,26 @@ func (s *Server) initRouter() (*gin.Engine, error) { SubClashPath = "/clash/" } - SubClashTemplate, err := s.settingService.GetSubClashTemplate() + // Read clash template from file (alongside x-ui.json) + SubClashTemplate, err := config.ReadClashTemplate() if err != nil { + logger.Warning("sub: failed to read clash template:", err) SubClashTemplate = "" } + // Read servers config from file + serversData, err := config.ReadServers() + var clashServers []ClashServer + if err != nil { + logger.Warning("sub: failed to read servers config:", err) + } else { + clashServers, err = parseServers([]byte(serversData)) + if err != nil { + logger.Warning("sub: failed to parse servers config:", err) + clashServers = nil + } + } + // set per-request localizer from headers/cookies engine.Use(locale.LocalizerMiddleware()) @@ -323,7 +360,7 @@ func (s *Server) initRouter() (*gin.Engine, error) { g, LinksPath, JsonPath, subJsonEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates, SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubTitle, SubSupportUrl, SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules, - subClashEnable, SubClashPath, SubClashTemplate) + subClashEnable, SubClashPath, SubClashTemplate, clashServers) return engine, nil } diff --git a/sub/subClashService.go b/sub/subClashService.go index 517869f1..f797c28c 100644 --- a/sub/subClashService.go +++ b/sub/subClashService.go @@ -14,18 +14,50 @@ import ( // SubClashService handles Clash YAML subscription generation. type SubClashService struct { template string + servers []ClashServer inboundService service.InboundService SubService *SubService } -// NewSubClashService creates a new Clash subscription service with the given template. -func NewSubClashService(template string, subService *SubService) *SubClashService { +// NewSubClashService creates a new Clash subscription service with the given template and servers. +func NewSubClashService(template string, servers []ClashServer, subService *SubService) *SubClashService { return &SubClashService{ template: template, + servers: servers, SubService: subService, } } +// splitTemplate splits a full mihomo template at "proxies:" and "proxy-groups:" markers. +// Returns header (everything before proxies) and footer (everything from proxy-groups onwards). +func splitTemplate(template string) (header, footer string, err error) { + proxiesIdx := strings.Index(template, "\nproxies:") + if proxiesIdx == -1 { + if strings.HasPrefix(template, "proxies:") { + proxiesIdx = 0 + } else { + return "", "", fmt.Errorf("template: 'proxies:' section not found") + } + } else { + proxiesIdx++ // skip the leading newline + } + + proxyGroupsIdx := strings.Index(template, "\nproxy-groups:") + if proxyGroupsIdx == -1 { + if strings.HasPrefix(template[proxiesIdx:], "proxy-groups:") { + proxyGroupsIdx = proxiesIdx + } else { + return "", "", fmt.Errorf("template: 'proxy-groups:' section not found") + } + } else { + proxyGroupsIdx++ // skip the leading newline + } + + header = template[:proxiesIdx] + footer = template[proxyGroupsIdx:] + return header, footer, nil +} + // GetClash generates a Clash YAML configuration for the given subscription ID. func (s *SubClashService) GetClash(subId string) (string, string, error) { if s.template == "" { @@ -101,14 +133,22 @@ func (s *SubClashService) GetClash(subId string) (string, string, error) { proxiesYaml += " - " + p + "\n" } - // Inject proxies into template by replacing "proxies: []" - result := strings.Replace(s.template, "proxies: []", "proxies:\n"+proxiesYaml, 1) + // Try split-template approach first (for full mihomo templates) + var result string + if header, footer, err := splitTemplate(s.template); err == nil { + result = header + "proxies:\n" + proxiesYaml + footer + } else { + // Fall back to old "proxies: []" replacement for backward compatibility + 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. +// If servers are configured, generates one entry per server using the server's name and address. +// Otherwise, uses the inbound's own address (backward compatible). func (s *SubClashService) getProxy(inbound *model.Inbound, client model.Client) []string { var proxies []string var stream map[string]any @@ -116,17 +156,14 @@ func (s *SubClashService) getProxy(inbound *model.Inbound, client model.Client) logger.Warning("SubClashService - failed to parse StreamSettings for inbound", inbound.Tag, ":", err) } - // Resolve address - var address string + // Resolve default address from inbound + var defaultAddress string if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { - address = s.SubService.address + defaultAddress = s.SubService.address } else { - address = inbound.Listen + defaultAddress = inbound.Listen } - // Get remark - remark := s.SubService.genRemark(inbound, client.Email, "") - // Parse stream settings network, _ := stream["network"].(string) security, _ := stream["security"].(string) @@ -141,9 +178,40 @@ func (s *SubClashService) getProxy(inbound *model.Inbound, client model.Client) } } + // If servers are configured, generate one proxy per server + if len(s.servers) > 0 { + for _, server := range s.servers { + for _, ep := range externalProxies { + externalProxy, _ := ep.(map[string]any) + destPort := inbound.Port + 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" + } + + proxy := s.buildProxyEntry(inbound, client, server.Server, destPort, network, security, tlsEnabled, server.Name, stream) + proxies = append(proxies, proxy) + } + } + return proxies + } + + // No servers configured — use inbound's own address (backward compatible) + remark := s.SubService.genRemark(inbound, client.Email, "") + for _, ep := range externalProxies { externalProxy, _ := ep.(map[string]any) - destAddress := address + destAddress := defaultAddress destPort := inbound.Port if dest, ok := externalProxy["dest"].(string); ok && dest != "" { diff --git a/sub/subController.go b/sub/subController.go index fc3316da..244fb74b 100644 --- a/sub/subController.go +++ b/sub/subController.go @@ -56,6 +56,7 @@ func NewSUBController( clashEnabled bool, subClashPath string, subClashTemplate string, + clashServers []ClashServer, ) *SUBController { sub := NewSubService(showInfo, rModel) a := &SUBController{ @@ -76,7 +77,7 @@ func NewSUBController( subService: sub, subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub), - subClashService: NewSubClashService(subClashTemplate, sub), + subClashService: NewSubClashService(subClashTemplate, clashServers, sub), } a.initRouter(g) return a diff --git a/web/controller/xray_setting.go b/web/controller/xray_setting.go index 5b7a0e26..4411df61 100644 --- a/web/controller/xray_setting.go +++ b/web/controller/xray_setting.go @@ -3,6 +3,7 @@ package controller import ( "encoding/json" + "github.com/mhsanaei/3x-ui/v2/config" "github.com/mhsanaei/3x-ui/v2/util/common" "github.com/mhsanaei/3x-ui/v2/web/service" @@ -38,6 +39,11 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) { g.POST("/update", a.updateSetting) g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic) g.POST("/testOutbound", a.testOutbound) + + g.GET("/clashTemplate", a.getClashTemplate) + g.POST("/clashTemplate", a.saveClashTemplate) + g.GET("/servers", a.getServers) + g.POST("/servers", a.saveServers) } // getXraySetting retrieves the Xray configuration template, inbound tags, and outbound test URL. @@ -166,3 +172,43 @@ func (a *XraySettingController) testOutbound(c *gin.Context) { jsonObj(c, result, nil) } + +// getClashTemplate reads the clash_template.yaml file and returns its content. +func (a *XraySettingController) getClashTemplate(c *gin.Context) { + content, err := config.ReadClashTemplate() + if err != nil { + jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err) + return + } + jsonObj(c, content, nil) +} + +// saveClashTemplate writes the clash_template.yaml file. +func (a *XraySettingController) saveClashTemplate(c *gin.Context) { + content := c.PostForm("content") + if err := config.SaveClashTemplate(content); err != nil { + jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err) + return + } + jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), nil) +} + +// getServers reads the servers.yaml file and returns its content. +func (a *XraySettingController) getServers(c *gin.Context) { + content, err := config.ReadServers() + if err != nil { + jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err) + return + } + jsonObj(c, content, nil) +} + +// saveServers writes the servers.yaml file. +func (a *XraySettingController) saveServers(c *gin.Context) { + content := c.PostForm("content") + if err := config.SaveServers(content); err != nil { + jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err) + return + } + jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), nil) +} diff --git a/web/html/settings.html b/web/html/settings.html index 0255f7c8..938418ae 100644 --- a/web/html/settings.html +++ b/web/html/settings.html @@ -255,29 +255,6 @@ }, methods: { onSettingsTabChange(key) { - if (key === '6') { - this.$nextTick(() => this.initClashCodeMirror()); - } - }, - initClashCodeMirror() { - if (this.clashCm != null) { - this.clashCm.toTextArea(); - } - const el = document.getElementById('clashTemplate'); - if (!el) return; - el.value = this.allSetting.subClashTemplate; - this.clashCm = CodeMirror.fromTextArea(el, { - lineNumbers: true, - mode: "text/x-yaml", - theme: "xq", - lineWrapping: true, - indentUnit: 2, - tabSize: 2, - smartIndent: true, - }); - this.clashCm.on('change', editor => { - this.allSetting.subClashTemplate = editor.getValue(); - }); }, loading(spinning = true) { this.loadingStates.spinning = spinning; diff --git a/web/html/settings/panel/subscription/clash.html b/web/html/settings/panel/subscription/clash.html index 7ae9cd4b..7356dadb 100644 --- a/web/html/settings/panel/subscription/clash.html +++ b/web/html/settings/panel/subscription/clash.html @@ -1,5 +1,5 @@ {{define "settings/panel/subscription/clash"}} - + @@ -20,14 +20,5 @@ - - - - - - - {{end}} diff --git a/web/html/settings/xray/advanced.html b/web/html/settings/xray/advanced.html index 28e31871..22653548 100644 --- a/web/html/settings/xray/advanced.html +++ b/web/html/settings/xray/advanced.html @@ -8,6 +8,8 @@ {{ i18n "pages.xray.Inbounds" }} {{ i18n "pages.xray.Outbounds" }} {{ i18n "pages.xray.Routings" }} + Clash + {{ i18n "pages.xray.Servers" }} diff --git a/web/html/xray.html b/web/html/xray.html index e37c471a..4de6676c 100644 --- a/web/html/xray.html +++ b/web/html/xray.html @@ -274,6 +274,10 @@ showAlert: false, advSettings: 'xraySetting', obsSettings: '', + clashTemplate: '', + oldClashTemplate: '', + servers: '', + oldServers: '', cm: null, cmOptions: { lineNumbers: true, @@ -420,13 +424,55 @@ }, async updateXraySetting() { this.loading(true); - const msg = await HttpUtil.post("/panel/xray/update", { - xraySetting: this.xraySetting, - outboundTestUrl: this.outboundTestUrl || 'https://www.google.com/generate_204' + if (this.advSettings === 'clashTemplate') { + await this.saveClashTemplate(); + } else if (this.advSettings === 'servers') { + await this.saveServers(); + } else { + const msg = await HttpUtil.post("/panel/xray/update", { + xraySetting: this.xraySetting, + outboundTestUrl: this.outboundTestUrl || 'https://www.google.com/generate_204' + }); + this.loading(false); + if (msg.success) { + await this.getXraySetting(); + } + return; + } + this.loading(false); + }, + async getClashTemplate() { + const msg = await HttpUtil.get("/panel/xray/clashTemplate"); + if (msg.success) { + this.clashTemplate = msg.obj; + this.oldClashTemplate = msg.obj; + } + }, + async saveClashTemplate() { + this.loading(true); + const msg = await HttpUtil.post("/panel/xray/clashTemplate", { + content: this.clashTemplate }); this.loading(false); if (msg.success) { - await this.getXraySetting(); + this.oldClashTemplate = this.clashTemplate; + } + }, + async getServers() { + const msg = await HttpUtil.get("/panel/xray/servers"); + if (msg.success) { + this.servers = msg.obj; + this.oldServers = msg.obj; + } + }, + async saveServers() { + this.loading(true); + const msg = await HttpUtil.post("/panel/xray/servers", { + content: this.servers + }); + this.loading(false); + if (msg.success) { + this.oldServers = this.servers; } }, async restartXray() { @@ -533,10 +579,15 @@ } const textAreaObj = document.getElementById('xraySetting'); textAreaObj.value = this[this.advSettings]; - this.cm = CodeMirror.fromTextArea(textAreaObj, this.cmOptions); + const isYaml = this.advSettings === 'clashTemplate' || this.advSettings === 'servers'; + const options = Object.assign({}, this.cmOptions, { + mode: isYaml ? "text/x-yaml" : "application/json", + lint: !isYaml, + }); + this.cm = CodeMirror.fromTextArea(textAreaObj, options); this.cm.on('change', editor => { const value = editor.getValue(); - if (this.isJsonString(value)) { + if (isYaml || this.isJsonString(value)) { this[this.advSettings] = value; } }); @@ -1079,6 +1130,8 @@ settingsLoaded = await this.getXraySetting(); await this.getXrayResult(); await this.getOutboundsTraffic(); + await this.getClashTemplate(); + await this.getServers(); } finally { this.loadingStates.fetched = true; } @@ -1098,7 +1151,8 @@ while (true) { await PromiseUtil.sleep(800); - this.saveBtnDisable = this.oldXraySetting === this.xraySetting && this.oldOutboundTestUrl === this.outboundTestUrl; + this.saveBtnDisable = this.oldXraySetting === this.xraySetting && this.oldOutboundTestUrl === this.outboundTestUrl + && this.oldClashTemplate === this.clashTemplate && this.oldServers === this.servers; } }, computed: { diff --git a/web/service/setting.go b/web/service/setting.go index dcce50c0..c381e8ce 100644 --- a/web/service/setting.go +++ b/web/service/setting.go @@ -81,26 +81,7 @@ var defaultValueMap = map[string]string{ "subClashEnable": "false", "subClashPath": "/clash/", "subClashURI": "", - "subClashTemplate": `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 - - 1.1.1.1 -rules: - - GEOIP,LAN,DIRECT - - MATCH,Proxy`, + "subClashTemplate": "", "datepicker": "gregorian", "warp": "", "externalTrafficInformEnable": "false", diff --git a/web/translation/translate.en_US.toml b/web/translation/translate.en_US.toml index 58fc0ad8..2fe6b018 100644 --- a/web/translation/translate.en_US.toml +++ b/web/translation/translate.en_US.toml @@ -537,6 +537,7 @@ "OutboundsDesc" = "Set the outgoing traffic pathway." "Routings" = "Routing Rules" "RoutingsDesc" = "The priority of each rule is important!" +"Servers" = "Servers" "completeTemplate" = "All" "logLevel" = "Log Level" "logLevelDesc" = "The log level for error logs, indicating the information that needs to be recorded." diff --git a/web/translation/translate.zh_CN.toml b/web/translation/translate.zh_CN.toml index 9e3d600e..a9011120 100644 --- a/web/translation/translate.zh_CN.toml +++ b/web/translation/translate.zh_CN.toml @@ -537,6 +537,7 @@ "OutboundsDesc" = "设置出站流量传出方式" "Routings" = "路由规则" "RoutingsDesc" = "每条规则的优先级都很重要" +"Servers" = "服务器" "completeTemplate" = "全部" "logLevel" = "日志级别" "logLevelDesc" = "错误日志的日志级别,用于指示需要记录的信息"