feat: support full mihomo template and multi-server for Clash Link

- 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
This commit is contained in:
root 2026-04-25 18:23:42 +08:00
parent 67c4f6a1ad
commit 25cf22d161
14 changed files with 338 additions and 76 deletions

View file

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

View file

@ -1 +1 @@
v1.7.0.1
v1.7.2.1

View file

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

View file

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

View file

@ -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)
destAddress := address
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 := defaultAddress
destPort := inbound.Port
if dest, ok := externalProxy["dest"].(string); ok && dest != "" {

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
{{define "settings/panel/subscription/clash"}}
<a-collapse default-active-key="['1','2']">
<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>
@ -20,14 +20,5 @@
</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>
<textarea id="clashTemplate" style="display:none"></textarea>
</template>
</a-setting-list-item>
</a-collapse-panel>
</a-collapse>
{{end}}

View file

@ -8,6 +8,8 @@
<a-radio-button value="inboundSettings">{{ i18n "pages.xray.Inbounds" }}</a-radio-button>
<a-radio-button value="outboundSettings">{{ i18n "pages.xray.Outbounds" }}</a-radio-button>
<a-radio-button value="routingRuleSettings">{{ i18n "pages.xray.Routings" }}</a-radio-button>
<a-radio-button value="clashTemplate">Clash</a-radio-button>
<a-radio-button value="servers">{{ i18n "pages.xray.Servers" }}</a-radio-button>
</a-radio-group>
<textarea :style="{ position: 'absolute', left: '-800px' }" id="xraySetting"></textarea>
</a-space>

View file

@ -274,6 +274,10 @@
showAlert: false,
advSettings: 'xraySetting',
obsSettings: '',
clashTemplate: '',
oldClashTemplate: '',
servers: '',
oldServers: '',
cm: null,
cmOptions: {
lineNumbers: true,
@ -420,6 +424,11 @@
},
async updateXraySetting() {
this.loading(true);
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'
@ -428,6 +437,43 @@
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) {
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() {
this.loading(true);
@ -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: {

View file

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

View file

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

View file

@ -537,6 +537,7 @@
"OutboundsDesc" = "设置出站流量传出方式"
"Routings" = "路由规则"
"RoutingsDesc" = "每条规则的优先级都很重要"
"Servers" = "服务器"
"completeTemplate" = "全部"
"logLevel" = "日志级别"
"logLevelDesc" = "错误日志的日志级别,用于指示需要记录的信息"