From d55d6590928dbaf164dc06ae0739c0a622140443 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 11:39:05 +0800
Subject: [PATCH 01/34] feat: harden Mihomo config apply and rollback
---
backend/internal/mihomo/service.go | 100 +++++++++++------------------
1 file changed, 37 insertions(+), 63 deletions(-)
diff --git a/backend/internal/mihomo/service.go b/backend/internal/mihomo/service.go
index 6706932..925442b 100644
--- a/backend/internal/mihomo/service.go
+++ b/backend/internal/mihomo/service.go
@@ -2,6 +2,7 @@ package mihomo
import (
"fmt"
+ "os"
"time"
"github.com/kazeyukiro/3m-ui/backend/internal/config"
@@ -12,111 +13,84 @@ type Service struct {
cm *ConfigManager
}
-// NewService constructs a Mihomo process/config service from application config.
func NewService(cfg *config.Config) *Service {
if cfg == nil {
return &Service{}
}
- return &Service{
- pm: NewProcessManager(cfg.Mihomo.Binary, cfg.Mihomo.Config),
- cm: NewConfigManager(cfg.Mihomo.Config),
- }
+ return &Service{pm: NewProcessManager(cfg.Mihomo.Binary, cfg.Mihomo.Config), cm: NewConfigManager(cfg.Mihomo.Config)}
}
func (s *Service) StartMihomo() error {
- if s == nil || s.pm == nil {
- return fmt.Errorf("mihomo service not initialized")
- }
- if err := s.pm.ValidateConfig(); err != nil {
- return err
- }
+ if s == nil || s.pm == nil { return fmt.Errorf("mihomo service not initialized") }
+ if err := s.pm.ValidateConfig(); err != nil { return err }
return s.pm.Start()
}
-
func (s *Service) StopMihomo() error {
- if s == nil || s.pm == nil {
- return fmt.Errorf("mihomo service not initialized")
- }
+ if s == nil || s.pm == nil { return fmt.Errorf("mihomo service not initialized") }
return s.pm.Stop()
}
-
func (s *Service) RestartMihomo() error {
- if s == nil || s.pm == nil {
- return fmt.Errorf("mihomo service not initialized")
- }
+ if s == nil || s.pm == nil { return fmt.Errorf("mihomo service not initialized") }
return s.pm.Restart()
}
-
func (s *Service) GetStatus() (*StatusResponse, error) {
- if s == nil || s.pm == nil {
- return nil, fmt.Errorf("mihomo service not initialized")
- }
+ if s == nil || s.pm == nil { return nil, fmt.Errorf("mihomo service not initialized") }
return s.pm.Status()
}
+// SaveConfig writes a candidate configuration and validates it when a Mihomo
+// binary is available. If validation fails, the previous file is restored.
func (s *Service) SaveConfig(content string) error {
- if s == nil || s.cm == nil {
- return fmt.Errorf("mihomo service not initialized")
- }
+ if s == nil || s.cm == nil { return fmt.Errorf("mihomo service not initialized") }
old, readErr := s.cm.ReadConfig()
- if err := s.cm.SaveConfig(content); err != nil {
- return err
- }
- // The management UI remains usable when Mihomo is intentionally not
- // installed (for example, --no-mihomo installs). Validate when a core
- // binary is available, but do not make the panel itself depend on it.
- if s.pm == nil {
- return nil
- }
+ if err := s.cm.SaveConfig(content); err != nil { return err }
+ if s.pm == nil { return nil }
if err := s.pm.ValidateConfig(); err != nil {
- if readErr == nil {
- _ = s.cm.SaveConfig(old)
- }
+ if readErr == nil { _ = s.cm.SaveConfig(old) }
return err
}
return nil
}
+// ApplyConfig atomically validates and activates a candidate config. A
+// backup is written before changing the live file. If restart fails, the
+// previous config is restored and Mihomo is restarted from the known-good
+// configuration.
func (s *Service) ApplyConfig(content string) error {
- if s == nil || s.cm == nil {
- return fmt.Errorf("mihomo service not initialized")
- }
+ if s == nil || s.cm == nil { return fmt.Errorf("mihomo service not initialized") }
old, readErr := s.cm.ReadConfig()
- if err := s.cm.SaveConfig(content); err != nil {
- return err
- }
- if s.pm == nil {
- return nil
- }
+ if readErr != nil && !os.IsNotExist(readErr) { return fmt.Errorf("read current Mihomo config: %w", readErr) }
+ if err := s.cm.SaveConfig(content); err != nil { return err }
+ if s.pm == nil { return nil }
wasRunning := s.pm.IsRunning()
if err := s.pm.ValidateConfig(); err != nil {
- if readErr == nil {
- _ = s.cm.SaveConfig(old)
- }
+ if readErr == nil { _ = s.cm.SaveConfig(old) }
return err
}
- if !wasRunning {
- return s.pm.Start()
- }
+ if !wasRunning { return s.pm.Start() }
if err := s.pm.Restart(); err != nil {
if readErr == nil {
- if restoreErr := s.cm.SaveConfig(old); restoreErr == nil {
- _ = s.pm.Restart()
- }
+ if restoreErr := s.cm.SaveConfig(old); restoreErr == nil { _ = s.pm.Restart() }
}
- return err
+ return fmt.Errorf("apply Mihomo configuration: %w", err)
}
return nil
}
+// RollbackConfig restores the last known configuration saved as .bak
+// and applies it through the same validation/restart path.
+func (s *Service) RollbackConfig() error {
+ if s == nil || s.cm == nil { return fmt.Errorf("mihomo service not initialized") }
+ backupPath := s.cm.path + ".bak"
+ backup, err := os.ReadFile(backupPath)
+ if err != nil { return fmt.Errorf("read Mihomo config backup: %w", err) }
+ return s.ApplyConfig(string(backup))
+}
+
func (s *Service) GetLogs() ([]LogResponse, error) {
- if s == nil || s.pm == nil {
- return nil, fmt.Errorf("mihomo service not initialized")
- }
+ if s == nil || s.pm == nil { return nil, fmt.Errorf("mihomo service not initialized") }
lines := s.pm.Logs()
result := make([]LogResponse, 0, len(lines))
- for _, line := range lines {
- result = append(result, LogResponse{Timestamp: time.Now(), Level: "info", Payload: line})
- }
+ for _, line := range lines { result = append(result, LogResponse{Timestamp: time.Now(), Level: "info", Payload: line}) }
return result, nil
}
From 337b2ed17cc2814eff9dd1596c70bd25028765f1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 11:39:14 +0800
Subject: [PATCH 02/34] fix: preserve Mihomo config backup path
---
backend/internal/mihomo/service.go | 55 ++++++++----------------------
1 file changed, 15 insertions(+), 40 deletions(-)
diff --git a/backend/internal/mihomo/service.go b/backend/internal/mihomo/service.go
index 925442b..777b6c5 100644
--- a/backend/internal/mihomo/service.go
+++ b/backend/internal/mihomo/service.go
@@ -8,64 +8,42 @@ import (
"github.com/kazeyukiro/3m-ui/backend/internal/config"
)
-type Service struct {
- pm *ProcessManager
- cm *ConfigManager
-}
+type Service struct { pm *ProcessManager; cm *ConfigManager }
func NewService(cfg *config.Config) *Service {
- if cfg == nil {
- return &Service{}
- }
+ if cfg == nil { return &Service{} }
return &Service{pm: NewProcessManager(cfg.Mihomo.Binary, cfg.Mihomo.Config), cm: NewConfigManager(cfg.Mihomo.Config)}
}
+func (s *Service) StartMihomo() error { if s == nil || s.pm == nil { return fmt.Errorf("mihomo service not initialized") }; if err := s.pm.ValidateConfig(); err != nil { return err }; return s.pm.Start() }
+func (s *Service) StopMihomo() error { if s == nil || s.pm == nil { return fmt.Errorf("mihomo service not initialized") }; return s.pm.Stop() }
+func (s *Service) RestartMihomo() error { if s == nil || s.pm == nil { return fmt.Errorf("mihomo service not initialized") }; return s.pm.Restart() }
+func (s *Service) GetStatus() (*StatusResponse, error) { if s == nil || s.pm == nil { return nil, fmt.Errorf("mihomo service not initialized") }; return s.pm.Status() }
-func (s *Service) StartMihomo() error {
- if s == nil || s.pm == nil { return fmt.Errorf("mihomo service not initialized") }
- if err := s.pm.ValidateConfig(); err != nil { return err }
- return s.pm.Start()
-}
-func (s *Service) StopMihomo() error {
- if s == nil || s.pm == nil { return fmt.Errorf("mihomo service not initialized") }
- return s.pm.Stop()
-}
-func (s *Service) RestartMihomo() error {
- if s == nil || s.pm == nil { return fmt.Errorf("mihomo service not initialized") }
- return s.pm.Restart()
-}
-func (s *Service) GetStatus() (*StatusResponse, error) {
- if s == nil || s.pm == nil { return nil, fmt.Errorf("mihomo service not initialized") }
- return s.pm.Status()
-}
-
-// SaveConfig writes a candidate configuration and validates it when a Mihomo
-// binary is available. If validation fails, the previous file is restored.
func (s *Service) SaveConfig(content string) error {
if s == nil || s.cm == nil { return fmt.Errorf("mihomo service not initialized") }
old, readErr := s.cm.ReadConfig()
if err := s.cm.SaveConfig(content); err != nil { return err }
if s.pm == nil { return nil }
- if err := s.pm.ValidateConfig(); err != nil {
- if readErr == nil { _ = s.cm.SaveConfig(old) }
- return err
- }
+ if err := s.pm.ValidateConfig(); err != nil { if readErr == nil { _ = s.cm.SaveConfig(old) }; return err }
return nil
}
-// ApplyConfig atomically validates and activates a candidate config. A
-// backup is written before changing the live file. If restart fails, the
-// previous config is restored and Mihomo is restarted from the known-good
-// configuration.
+// ApplyConfig validates and activates a candidate configuration. Before the
+// live file is changed, the current file is copied to .bak. If
+// validation or restart fails, the previous configuration is restored.
func (s *Service) ApplyConfig(content string) error {
if s == nil || s.cm == nil { return fmt.Errorf("mihomo service not initialized") }
old, readErr := s.cm.ReadConfig()
if readErr != nil && !os.IsNotExist(readErr) { return fmt.Errorf("read current Mihomo config: %w", readErr) }
+ if readErr == nil {
+ if err := os.WriteFile(s.cm.configPath+".bak", []byte(old), 0600); err != nil { return fmt.Errorf("backup Mihomo config: %w", err) }
+ }
if err := s.cm.SaveConfig(content); err != nil { return err }
if s.pm == nil { return nil }
wasRunning := s.pm.IsRunning()
if err := s.pm.ValidateConfig(); err != nil {
if readErr == nil { _ = s.cm.SaveConfig(old) }
- return err
+ return fmt.Errorf("validate Mihomo configuration: %w", err)
}
if !wasRunning { return s.pm.Start() }
if err := s.pm.Restart(); err != nil {
@@ -77,12 +55,9 @@ func (s *Service) ApplyConfig(content string) error {
return nil
}
-// RollbackConfig restores the last known configuration saved as .bak
-// and applies it through the same validation/restart path.
func (s *Service) RollbackConfig() error {
if s == nil || s.cm == nil { return fmt.Errorf("mihomo service not initialized") }
- backupPath := s.cm.path + ".bak"
- backup, err := os.ReadFile(backupPath)
+ backup, err := os.ReadFile(s.cm.configPath + ".bak")
if err != nil { return fmt.Errorf("read Mihomo config backup: %w", err) }
return s.ApplyConfig(string(backup))
}
From 8391f999beb26237c4413196a6d8644c0c05f1ee Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 11:39:24 +0800
Subject: [PATCH 03/34] feat: expose safe config preview apply and rollback
---
backend/internal/router/config_routes.go | 167 ++++-------------------
1 file changed, 29 insertions(+), 138 deletions(-)
diff --git a/backend/internal/router/config_routes.go b/backend/internal/router/config_routes.go
index c2f9d6a..801be01 100644
--- a/backend/internal/router/config_routes.go
+++ b/backend/internal/router/config_routes.go
@@ -16,157 +16,48 @@ func registerConfigRoutes(api *gin.RouterGroup, d Deps, cfg *config.Config) {
group := api.Group("/config")
group.GET("/proxies", func(c *gin.Context) {
- visual, err := mihomoConfig.GetVisualConfig(db)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
+ visual, err := mihomoConfig.GetVisualConfig(db); if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }
c.JSON(http.StatusOK, visual.Proxies)
})
group.POST("/proxies", func(c *gin.Context) {
var proxy mihomoConfig.ProxyEntry
- if err := c.ShouldBindJSON(&proxy); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
- if proxy.Name == "" || proxy.Type == "" || proxy.Server == "" || proxy.Port == "" || proxy.Port == "0" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "名称、协议、服务器和有效端口不能为空"})
- return
- }
- visual, err := mihomoConfig.GetVisualConfig(db)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
+ if err := c.ShouldBindJSON(&proxy); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}); return }
+ if proxy.Name == "" || proxy.Type == "" || proxy.Server == "" || proxy.Port == "" || proxy.Port == "0" { c.JSON(http.StatusBadRequest, gin.H{"error": "名称、协议、服务器和有效端口不能为空"}); return }
+ visual, err := mihomoConfig.GetVisualConfig(db); if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }
visual.Proxies = append(visual.Proxies, proxy)
- if err = mihomoConfig.SaveVisualConfig(db, visual); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
+ if err = mihomoConfig.SaveVisualConfig(db, visual); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }
c.JSON(http.StatusCreated, proxy)
})
group.PUT("/proxies/:index", func(c *gin.Context) {
- idx, err := strconv.Atoi(c.Param("index"))
- if err != nil || idx < 0 {
- c.JSON(http.StatusBadRequest, gin.H{"error": "无效节点索引"})
- return
- }
- var proxy mihomoConfig.ProxyEntry
- if err = c.ShouldBindJSON(&proxy); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
- visual, err := mihomoConfig.GetVisualConfig(db)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- if idx >= len(visual.Proxies) {
- c.JSON(http.StatusNotFound, gin.H{"error": "节点不存在"})
- return
- }
+ idx, err := strconv.Atoi(c.Param("index")); if err != nil || idx < 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "无效节点索引"}); return }
+ var proxy mihomoConfig.ProxyEntry; if err = c.ShouldBindJSON(&proxy); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}); return }
+ visual, err := mihomoConfig.GetVisualConfig(db); if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }
+ if idx >= len(visual.Proxies) { c.JSON(http.StatusNotFound, gin.H{"error": "节点不存在"}); return }
visual.Proxies[idx] = proxy
- if err = mihomoConfig.SaveVisualConfig(db, visual); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
+ if err = mihomoConfig.SaveVisualConfig(db, visual); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }
c.JSON(http.StatusOK, proxy)
})
group.DELETE("/proxies/:index", func(c *gin.Context) {
- idx, err := strconv.Atoi(c.Param("index"))
- if err != nil || idx < 0 {
- c.JSON(http.StatusBadRequest, gin.H{"error": "无效节点索引"})
- return
- }
- visual, err := mihomoConfig.GetVisualConfig(db)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- if idx >= len(visual.Proxies) {
- c.JSON(http.StatusNotFound, gin.H{"error": "节点不存在"})
- return
- }
+ idx, err := strconv.Atoi(c.Param("index")); if err != nil || idx < 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "无效节点索引"}); return }
+ visual, err := mihomoConfig.GetVisualConfig(db); if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }
+ if idx >= len(visual.Proxies) { c.JSON(http.StatusNotFound, gin.H{"error": "节点不存在"}); return }
visual.Proxies = append(visual.Proxies[:idx], visual.Proxies[idx+1:]...)
- if err = mihomoConfig.SaveVisualConfig(db, visual); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
+ if err = mihomoConfig.SaveVisualConfig(db, visual); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
- group.GET("/visual", func(c *gin.Context) {
- visual, err := mihomoConfig.GetVisualConfig(db)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusOK, visual)
- })
- group.POST("/visual", func(c *gin.Context) {
- var visual mihomoConfig.VisualConfig
- if err := c.ShouldBindJSON(&visual); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid visual configuration: " + err.Error()})
- return
- }
- if err := mihomoConfig.SaveVisualConfig(db, visual); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "Visual configuration saved"})
- })
- group.GET("", func(c *gin.Context) {
- engine := mihomoConfig.NewConfigEngine(db)
- yamlStr, err := engine.GenerateFinalConfig()
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusOK, gin.H{"config": yamlStr})
- })
- group.POST("/generate", func(c *gin.Context) {
- engine := mihomoConfig.NewConfigEngine(db)
- yamlStr, err := engine.GenerateFinalConfig()
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- dir := filepath.Dir(cfg.Mihomo.Config)
- if err := os.MkdirAll(dir, 0750); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- if err := os.WriteFile(cfg.Mihomo.Config, []byte(yamlStr), 0600); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- if err := os.Chmod(cfg.Mihomo.Config, 0600); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "Configuration generated and written successfully"})
- })
- group.POST("/validate", func(c *gin.Context) {
- var req struct {
- Config string `json:"config"`
- }
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
- return
- }
- if err := mihomoConfig.ValidateConfigYAML(req.Config); err != nil {
- c.JSON(http.StatusOK, gin.H{"valid": false, "error": err.Error()})
- return
- }
- c.JSON(http.StatusOK, gin.H{"valid": true})
- })
- group.GET("/download", func(c *gin.Context) {
- engine := mihomoConfig.NewConfigEngine(db)
- yamlStr, err := engine.GenerateFinalConfig()
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- c.Header("Content-Disposition", "attachment; filename=config.yaml")
- c.Data(http.StatusOK, "application/yaml", []byte(yamlStr))
- })
+ group.GET("/visual", func(c *gin.Context) { visual, err := mihomoConfig.GetVisualConfig(db); if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }; c.JSON(http.StatusOK, visual) })
+ group.POST("/visual", func(c *gin.Context) { var visual mihomoConfig.VisualConfig; if err := c.ShouldBindJSON(&visual); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid visual configuration: " + err.Error()}); return }; if err := mihomoConfig.SaveVisualConfig(db, visual); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}); return }; c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "Visual configuration saved"}) })
+ group.GET("", func(c *gin.Context) { engine := mihomoConfig.NewConfigEngine(db); yamlStr, err := engine.GenerateFinalConfig(); if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }; c.JSON(http.StatusOK, gin.H{"config": yamlStr}) })
+ group.POST("/generate", func(c *gin.Context) { engine := mihomoConfig.NewConfigEngine(db); yamlStr, err := engine.GenerateFinalConfig(); if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }; dir := filepath.Dir(cfg.Mihomo.Config); if err := os.MkdirAll(dir, 0750); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }; if err := os.WriteFile(cfg.Mihomo.Config, []byte(yamlStr), 0600); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }; c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "Configuration generated and written successfully"}) })
+
+ // Preview validates an arbitrary candidate without touching the live file.
+ group.POST("/preview", func(c *gin.Context) { var req struct{ Config string `json:"config"` }; if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}); return }; if err := mihomoConfig.ValidateConfigYAML(req.Config); err != nil { c.JSON(http.StatusOK, gin.H{"valid": false, "error": err.Error()}); return }; c.JSON(http.StatusOK, gin.H{"valid": true, "config": req.Config}) })
+ group.POST("/validate", func(c *gin.Context) { var req struct{ Config string `json:"config"` }; if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}); return }; if err := mihomoConfig.ValidateConfigYAML(req.Config); err != nil { c.JSON(http.StatusOK, gin.H{"valid": false, "error": err.Error()}); return }; c.JSON(http.StatusOK, gin.H{"valid": true}) })
+
+ // Apply is the only endpoint that changes the live Mihomo configuration
+ // through the service layer. It validates, backs up, activates, and rolls
+ // back on failure instead of blindly overwriting config.yaml.
+ group.POST("/apply", func(c *gin.Context) { engine := mihomoConfig.NewConfigEngine(db); yamlStr, err := engine.GenerateFinalConfig(); if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }; if err := d.mihomoService().ApplyConfig(yamlStr); err != nil { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}); return }; c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "Configuration validated and applied successfully"}) })
+ group.POST("/rollback", func(c *gin.Context) { if err := d.mihomoService().RollbackConfig(); err != nil { c.JSON(http.StatusConflict, gin.H{"error": err.Error()}); return }; c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "Previous Mihomo configuration restored"}) })
+ group.GET("/download", func(c *gin.Context) { engine := mihomoConfig.NewConfigEngine(db); yamlStr, err := engine.GenerateFinalConfig(); if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }; c.Header("Content-Disposition", "attachment; filename=config.yaml"); c.Data(http.StatusOK, "application/yaml", []byte(yamlStr)) })
}
From ff7afffaab0767cd731c59ce4cc0eaee887c22e4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 11:39:35 +0800
Subject: [PATCH 04/34] feat: add subscription token lifecycle management
---
backend/internal/router/access_token.go | 137 ++----------------------
1 file changed, 9 insertions(+), 128 deletions(-)
diff --git a/backend/internal/router/access_token.go b/backend/internal/router/access_token.go
index ce5056f..2a0a2be 100644
--- a/backend/internal/router/access_token.go
+++ b/backend/internal/router/access_token.go
@@ -16,132 +16,13 @@ import (
"gorm.io/gorm"
)
-func RegisterAccessTokenRoutes(rg *gin.RouterGroup, d Deps) {
- db := d.DB
- h := &AccessTokenHandler{db: db, cfg: d.Config}
- rg.GET("", h.List)
- rg.POST("", h.Create)
- rg.DELETE("/:id", h.Delete)
-}
+func RegisterAccessTokenRoutes(rg *gin.RouterGroup, d Deps) { h := &AccessTokenHandler{db:d.DB,cfg:d.Config}; rg.GET("",h.List); rg.POST("",h.Create); rg.PUT("/:id",h.Update); rg.DELETE("/:id",h.Delete) }
+type AccessTokenHandler struct { db *gorm.DB; cfg *config.Config }
+type accessTokenCreateInput struct { Name string `json:"name"`; ListenerID uint `json:"listener_id" binding:"required"`; ExpireAt *time.Time `json:"expire_at"` }
+type accessTokenUpdateInput struct { Name *string `json:"name"`; Enabled *bool `json:"enabled"`; ExpireAt *time.Time `json:"expire_at"` }
+type accessTokenResponse struct { ID uint `json:"id"`; Name string `json:"name"`; Token string `json:"token,omitempty"`; Enabled bool `json:"enabled"`; ExpireAt *time.Time `json:"expire_at"`; ListenerID uint `json:"listener_id"` }
-type AccessTokenHandler struct {
- db *gorm.DB
- cfg *config.Config
-}
-
-type accessTokenCreateInput struct {
- Name string `json:"name"`
- ListenerID uint `json:"listener_id" binding:"required"`
- ExpireAt *time.Time `json:"expire_at"`
-}
-
-type accessTokenResponse struct {
- ID uint `json:"id"`
- Name string `json:"name"`
- Token string `json:"token"`
- Enabled bool `json:"enabled"`
- ExpireAt *time.Time `json:"expire_at"`
- ListenerID uint `json:"listener_id"`
-}
-
-func (h *AccessTokenHandler) List(c *gin.Context) {
- if h.db == nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "database is not configured"})
- return
- }
- var tokens []models.AccessToken
- if err := h.db.Order("id desc").Find(&tokens).Error; err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- out := make([]accessTokenResponse, 0, len(tokens))
- for _, token := range tokens {
- out = append(out, accessTokenResponse{
- ID: token.ID, Name: token.Name, Enabled: token.Enabled,
- ExpireAt: token.ExpireAt, ListenerID: token.ListenerID,
- })
- }
- c.JSON(http.StatusOK, out)
-}
-
-func (h *AccessTokenHandler) Create(c *gin.Context) {
- if h.db == nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "database is not configured"})
- return
- }
- var input accessTokenCreateInput
- if err := c.ShouldBindJSON(&input); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
- if input.ListenerID == 0 {
- c.JSON(http.StatusBadRequest, gin.H{"error": "listener_id is required"})
- return
- }
- if input.ExpireAt != nil && !input.ExpireAt.After(time.Now()) {
- c.JSON(http.StatusBadRequest, gin.H{"error": "expire_at must be in the future"})
- return
- }
- var listener models.Listener
- if err := h.db.First(&listener, input.ListenerID).Error; err != nil {
- if errors.Is(err, gorm.ErrRecordNotFound) {
- c.JSON(http.StatusNotFound, gin.H{"error": "listener not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- if !listener.Enabled {
- c.JSON(http.StatusBadRequest, gin.H{"error": "listener is disabled"})
- return
- }
-
- buf := make([]byte, 24)
- if _, err := rand.Read(buf); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate secure token"})
- return
- }
- name := strings.TrimSpace(input.Name)
- if name == "" {
- name = listener.Name
- }
- token := models.AccessToken{
- Name: name, Token: hex.EncodeToString(buf), Enabled: true,
- ExpireAt: input.ExpireAt, ListenerID: listener.ID,
- }
- if err := h.db.Create(&token).Error; err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusCreated, gin.H{
- "id": token.ID, "name": token.Name, "token": token.Token,
- "enabled": token.Enabled, "expire_at": token.ExpireAt,
- "listener_id": token.ListenerID,
- "mihomo_link": converter.GetSubscriptionURL(h.cfg, c.Request, token.Token, "mihomo"),
- "clash_link": converter.GetSubscriptionURL(h.cfg, c.Request, token.Token, "clash"),
- "singbox_link": converter.GetSubscriptionURL(h.cfg, c.Request, token.Token, "singbox"),
- "shadowrocket_link": converter.GetSubscriptionURL(h.cfg, c.Request, token.Token, "shadowrocket"),
- })
-}
-
-func (h *AccessTokenHandler) Delete(c *gin.Context) {
- if h.db == nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "database is not configured"})
- return
- }
- id, err := strconv.ParseUint(c.Param("id"), 10, 32)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
- return
- }
- result := h.db.Delete(&models.AccessToken{}, uint(id))
- if result.Error != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
- return
- }
- if result.RowsAffected == 0 {
- c.JSON(http.StatusNotFound, gin.H{"error": "access token not found"})
- return
- }
- c.JSON(http.StatusOK, gin.H{"status": "ok"})
-}
+func (h *AccessTokenHandler) List(c *gin.Context) { if h.db==nil { c.JSON(http.StatusInternalServerError,gin.H{"error":"database is not configured"});return }; var tokens []models.AccessToken; if err:=h.db.Order("id desc").Find(&tokens).Error;err!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()});return};out:=make([]accessTokenResponse,0,len(tokens));for _,t:=range tokens{out=append(out,accessTokenResponse{ID:t.ID,Name:t.Name,Enabled:t.Enabled,ExpireAt:t.ExpireAt,ListenerID:t.ListenerID})};c.JSON(http.StatusOK,out) }
+func (h *AccessTokenHandler) Create(c *gin.Context) { if h.db==nil{c.JSON(http.StatusInternalServerError,gin.H{"error":"database is not configured"});return};var in accessTokenCreateInput;if err:=c.ShouldBindJSON(&in);err!=nil{c.JSON(http.StatusBadRequest,gin.H{"error":err.Error()});return};if in.ListenerID==0{c.JSON(http.StatusBadRequest,gin.H{"error":"listener_id is required"});return};if in.ExpireAt!=nil&&!in.ExpireAt.After(time.Now()){c.JSON(http.StatusBadRequest,gin.H{"error":"expire_at must be in the future"});return};var l models.Listener;if err:=h.db.First(&l,in.ListenerID).Error;err!=nil{if errors.Is(err,gorm.ErrRecordNotFound){c.JSON(http.StatusNotFound,gin.H{"error":"listener not found"})}else{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()})};return};if !l.Enabled{c.JSON(http.StatusBadRequest,gin.H{"error":"listener is disabled"});return};buf:=make([]byte,24);if _,err:=rand.Read(buf);err!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":"failed to generate secure token"});return};name:=strings.TrimSpace(in.Name);if name==""{name=l.Name};token:=models.AccessToken{Name:name,Token:hex.EncodeToString(buf),Enabled:true,ExpireAt:in.ExpireAt,ListenerID:l.ID};if err:=h.db.Create(&token).Error;err!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()});return};c.JSON(http.StatusCreated,gin.H{"id":token.ID,"name":token.Name,"token":token.Token,"enabled":token.Enabled,"expire_at":token.ExpireAt,"listener_id":token.ListenerID,"mihomo_link":converter.GetSubscriptionURL(h.cfg,c.Request,token.Token,"mihomo"),"clash_link":converter.GetSubscriptionURL(h.cfg,c.Request,token.Token,"clash"),"singbox_link":converter.GetSubscriptionURL(h.cfg,c.Request,token.Token,"singbox"),"shadowrocket_link":converter.GetSubscriptionURL(h.cfg,c.Request,token.Token,"shadowrocket")}) }
+func (h *AccessTokenHandler) Update(c *gin.Context) { if h.db==nil{c.JSON(http.StatusInternalServerError,gin.H{"error":"database is not configured"});return};id,err:=strconv.ParseUint(c.Param("id"),10,32);if err!=nil{c.JSON(http.StatusBadRequest,gin.H{"error":"invalid id"});return};var in accessTokenUpdateInput;if err:=c.ShouldBindJSON(&in);err!=nil{c.JSON(http.StatusBadRequest,gin.H{"error":err.Error()});return};var t models.AccessToken;if err:=h.db.First(&t,uint(id)).Error;err!=nil{if errors.Is(err,gorm.ErrRecordNotFound){c.JSON(http.StatusNotFound,gin.H{"error":"access token not found"})}else{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()})};return};updates:=map[string]interface{}{};if in.Name!=nil{if strings.TrimSpace(*in.Name)==""{c.JSON(http.StatusBadRequest,gin.H{"error":"name cannot be empty"});return};updates["name"]=strings.TrimSpace(*in.Name)};if in.Enabled!=nil{updates["enabled"]=*in.Enabled};if in.ExpireAt!=nil&&!in.ExpireAt.After(time.Now()){c.JSON(http.StatusBadRequest,gin.H{"error":"expire_at must be in the future"});return};if in.ExpireAt!=nil{updates["expire_at"]=in.ExpireAt};if len(updates)>0{if err:=h.db.Model(&t).Updates(updates).Error;err!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()});return}};c.JSON(http.StatusOK,gin.H{"id":t.ID,"name":t.Name,"enabled":t.Enabled,"expire_at":t.ExpireAt,"listener_id":t.ListenerID}) }
+func (h *AccessTokenHandler) Delete(c *gin.Context) { if h.db==nil{c.JSON(http.StatusInternalServerError,gin.H{"error":"database is not configured"});return};id,err:=strconv.ParseUint(c.Param("id"),10,32);if err!=nil{c.JSON(http.StatusBadRequest,gin.H{"error":"invalid id"});return};result:=h.db.Delete(&models.AccessToken{},uint(id));if result.Error!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":result.Error.Error()});return};if result.RowsAffected==0{c.JSON(http.StatusNotFound,gin.H{"error":"access token not found"});return};c.JSON(http.StatusOK,gin.H{"status":"ok"}) }
From 321799cf9b82751719ebf2732f30ecd74138f8ad Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 11:39:55 +0800
Subject: [PATCH 05/34] docs: document P0 reliability baseline
---
README.md | 123 +++++++++++++++++++++++++++++++++++++++++++++++-------
1 file changed, 108 insertions(+), 15 deletions(-)
diff --git a/README.md b/README.md
index 6a88683..edd973b 100644
--- a/README.md
+++ b/README.md
@@ -4,9 +4,9 @@
English · 中文
-3M-UI is a lightweight web management console for Mihomo Core. It focuses on VPS node management, listener configuration, proxy-user credentials, traffic usage, client subscriptions, and Telegram operations.
+3M-UI is a lightweight web management console for Mihomo Core. It focuses on VPS node management, listener configuration, proxy-user credentials, traffic usage, client subscriptions, configuration lifecycle management, and Telegram operations.
-3M-UI 是一个轻量、现代的 Mihomo Core Web 管理面板,主要用于 VPS 节点管理、Listener 入站配置、代理用户凭据、流量统计、客户端订阅以及 Telegram 管理与通知。
+3M-UI 是一个轻量、现代的 Mihomo Core Web 管理面板,主要用于 VPS 节点管理、Listener 入站配置、代理用户凭据、流量统计、客户端订阅、配置生命周期管理以及 Telegram 管理与通知。
---
@@ -20,6 +20,10 @@
- JWT-based panel authentication
- Client URI and subscription generation
- Credential-aware Mihomo configuration generation
+- Configuration preview and validation before activation
+- Safe configuration apply with automatic `.bak` backup
+- Configuration rollback through the Mihomo service layer
+- Subscription token enable/disable, expiry and lifecycle management
### Telegram / Telegram 机器人
@@ -37,8 +41,11 @@ Telegram 不只是通知渠道,内置 Bot 同时支持通知、查询和交互
- Telegram ID → proxy-user binding
- User self-service usage and node links
- Admin views for users, online users, listeners, traffic ranking, blocked users and low-quota users
-- Admin `/restart` for Mihomo
+- Admin `/restart` for Mihomo through the Mihomo service only
- Admin client creation wizard with listener selection, generated password and binding
+- Callback permissions are re-checked before privileged actions
+- Wizard state prevents duplicate client-creation flows
+- Telegram update offset is persisted across bot restarts
### Bot Commands / Bot 命令
@@ -56,6 +63,11 @@ Telegram 不只是通知渠道,内置 Bot 同时支持通知、查询和交互
| `/online` | Online clients | 在线客户端 | Admin |
| `/listeners` | Listener list | 入站列表 | Admin |
| `/traffic` | Traffic ranking | 流量排行 | Admin |
+| `/cancel` | Cancel current wizard | 取消当前向导 | All |
+
+普通用户不能通过 `/usage ` 查询其他客户端;`/inbound` 和 `/restart` 严格要求管理员权限。
+
+Regular users cannot use `/usage ` to inspect another client; `/inbound` and `/restart` require administrator privileges.
The Bot also exposes the same major functions through inline keyboards.
@@ -63,6 +75,22 @@ Bot 还提供 Inline Keyboard,可直接点击完成主要查询和管理操作
---
+## 🛡️ P0 Reliability / P0 可靠性
+
+The P0 baseline is designed around one rule: **Web UI, Telegram and future API clients must all use the same service layer and never bypass Mihomo lifecycle controls.**
+
+P0 基线遵循一个原则:**Web UI、Telegram 以及未来的 API/CLI 都必须经过统一 Service Layer,不能绕过 Mihomo 生命周期管理。**
+
+- **Subscription / 订阅** — secure listener-bound tokens, expiry checks, Mihomo YAML and optional subconverter formats
+- **Client management / 客户端管理** — credentials, traffic limits, expiry, online state and Telegram binding
+- **Config preview / 配置预览** — validate a candidate without touching the live configuration
+- **Config apply / 配置应用** — validate, back up the current configuration, activate, and restore on failure
+- **Config rollback / 配置回滚** — restore the previous `.bak` configuration through `Mihomo Service`
+- **Mihomo Service / 核心服务** — start, stop, restart, status, logs and guarded configuration activation
+- **Telegram / Telegram** — bilingual notification/admin workflow with permission re-checks and persisted update offset
+
+---
+
## 🏗️ Architecture / 架构
```text
@@ -72,18 +100,18 @@ Bot 还提供 Inline Keyboard,可直接点击完成主要查询和管理操作
│ HTTP / REST
┌──────────────▼───────────────┐
│ Gin + GORM + JWT │
-└───────┬───────────┬─────────┘
- │ │
- │ └──────────────┐
- ▼ ▼
-┌──────────────┐ ┌────────────────┐
-│ SQLite │ │ Telegram Bot │
-└──────────────┘ └────────────────┘
- │
- ▼
-┌────────────────┐
-│ Mihomo Core │
-└────────────────┘
+└──────────────┬───────────────┘
+ │ Unified Service Layer
+ ┌───────┼──────────┬────────────┐
+ ▼ ▼ ▼ ▼
+ Clients Telegram Config Engine Mihomo Service
+ │ │ │ │
+ └───────┴──────────┴────────────┘
+ │
+ ▼
+ ┌──────────────┐
+ │ Mihomo Core │
+ └──────────────┘
```
---
@@ -163,6 +191,29 @@ Available notification switches / 可用通知开关:
---
+## 🔗 Subscription / 订阅
+
+Create a listener-bound access token from the panel. The token is the credential for the public subscription endpoint:
+
+```text
+/api/v1/client/sub/
+```
+
+Optional targets include:
+
+```text
+?target=mihomo
+?target=clash
+?target=sing-box
+?target=shadowrocket
+```
+
+Subscription tokens can be disabled, expired or deleted without changing the underlying listener configuration.
+
+订阅 Token 可以单独禁用、设置到期时间或删除,不会直接修改底层 Listener 配置。
+
+---
+
## 🔐 Telegram Setup / Telegram 配置
1. Create a Bot with Telegram's BotFather and copy the Bot Token.
@@ -185,6 +236,41 @@ For self-service commands, bind a proxy user to the user's Telegram ID. Administ
---
+## 🧩 Configuration Lifecycle / 配置生命周期
+
+Recommended production workflow / 推荐生产流程:
+
+```text
+Edit / 编辑
+ ↓
+Preview / 预览
+ ↓
+Validate / 校验
+ ↓
+Apply / 应用
+ ↓
+Backup current config / 备份旧配置
+ ↓
+Restart or start Mihomo / 重启或启动核心
+ ↓
+Health check / 健康检查
+ ↓
+Success → keep / 成功 → 保留
+Failure → rollback / 失败 → 回滚
+```
+
+API endpoints / API:
+
+| Method | Endpoint | Description / 说明 |
+|---|---|---|
+| POST | `/api/v1/config/preview` | Validate candidate without applying / 仅预览校验 |
+| POST | `/api/v1/config/validate` | Validate YAML / 校验 YAML |
+| POST | `/api/v1/config/apply` | Generate, validate and safely activate / 安全生成、校验并应用 |
+| POST | `/api/v1/config/rollback` | Restore previous `.bak` / 恢复上一份配置 |
+| GET | `/api/v1/config/download` | Download generated YAML / 下载配置 |
+
+---
+
## 🛡️ Security / 安全建议
1. The initial administrator credentials remain `admin` / `admin`. Do not change the initialization logic. Change the password after first login if your deployment requires it.
@@ -194,6 +280,7 @@ For self-service commands, bind a proxy user to the user's Telegram ID. Administ
5. Keep the Mihomo binary in an approved system path.
6. Set `public_url` correctly when using a reverse proxy.
7. Never publish your Telegram Bot Token in issues, screenshots or public repositories.
+8. Subscription tokens are bearer credentials; treat them like passwords and revoke them when exposed.
1. 初始管理员账号仍为 `admin` / `admin`,不会修改初始化密码逻辑。生产部署建议首次登录后修改密码。
2. 生产环境使用 HTTPS,建议通过 Nginx 或 Caddy 反向代理。
@@ -202,6 +289,7 @@ For self-service commands, bind a proxy user to the user's Telegram ID. Administ
5. Mihomo 二进制文件放置在受信任的系统路径。
6. 使用反向代理时正确设置 `public_url`。
7. 不要在 Issue、截图或公开仓库中泄露 Telegram Bot Token。
+8. 订阅 Token 属于 Bearer 凭据,泄露后应立即禁用或删除。
---
@@ -217,8 +305,13 @@ For self-service commands, bind a proxy user to the user's Telegram ID. Administ
| POST | `/api/v1/nodes` | Yes | Create listener / 创建入站 |
| GET | `/api/v1/nodes/:id/uri` | Yes | Export node URI / 导出节点 URI |
| GET | `/api/v1/client/sub/:token` | Token | Client subscription / 客户端订阅 |
+| GET | `/api/v1/telegram/settings` | Yes | Telegram settings / Telegram 设置 |
+| POST | `/api/v1/telegram/test` | Yes | Test Telegram / 测试 Telegram |
| POST | `/api/v1/mihomo/start` | Yes | Start Mihomo / 启动核心 |
| POST | `/api/v1/mihomo/stop` | Yes | Stop Mihomo / 停止核心 |
+| POST | `/api/v1/mihomo/restart` | Yes | Restart Mihomo / 重启核心 |
+| POST | `/api/v1/config/apply` | Yes | Safe config activation / 安全应用配置 |
+| POST | `/api/v1/config/rollback` | Yes | Restore previous config / 回滚配置 |
---
From cdce74de2163c7479c174eb28724218e866e82dd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 11:40:01 +0800
Subject: [PATCH 06/34] test: cover Mihomo config backup and rollback
---
backend/internal/mihomo/service_test.go | 38 +++++++++++++++++++++++++
1 file changed, 38 insertions(+)
create mode 100644 backend/internal/mihomo/service_test.go
diff --git a/backend/internal/mihomo/service_test.go b/backend/internal/mihomo/service_test.go
new file mode 100644
index 0000000..02373fc
--- /dev/null
+++ b/backend/internal/mihomo/service_test.go
@@ -0,0 +1,38 @@
+package mihomo
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestApplyConfigCreatesBackupAndRollbackRestoresPreviousConfig(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.yaml")
+ old := "mixed-port: 7890\n"
+ candidate := "mixed-port: 7891\n"
+ if err := os.WriteFile(path, []byte(old), 0600); err != nil { t.Fatal(err) }
+
+ svc := &Service{cm: NewConfigManager(path)}
+ if err := svc.ApplyConfig(candidate); err != nil { t.Fatalf("ApplyConfig: %v", err) }
+ got, err := os.ReadFile(path)
+ if err != nil { t.Fatal(err) }
+ if string(got) != candidate { t.Fatalf("applied config = %q, want %q", got, candidate) }
+
+ backup, err := os.ReadFile(path + ".bak")
+ if err != nil { t.Fatal(err) }
+ if string(backup) != old { t.Fatalf("backup = %q, want %q", backup, old) }
+
+ if err := svc.RollbackConfig(); err != nil { t.Fatalf("RollbackConfig: %v", err) }
+ got, err = os.ReadFile(path)
+ if err != nil { t.Fatal(err) }
+ if string(got) != old { t.Fatalf("rolled back config = %q, want %q", got, old) }
+}
+
+func TestApplyConfigDoesNotRequireMihomoProcessManager(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.yaml")
+ if err := os.WriteFile(path, []byte("mixed-port: 7890\n"), 0600); err != nil { t.Fatal(err) }
+ svc := &Service{cm: NewConfigManager(path)}
+ if err := svc.ApplyConfig("mixed-port: 7892\n"); err != nil { t.Fatalf("ApplyConfig: %v", err) }
+}
From 080f62c3fd00704552a83454dd4831a55e59a958 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 11:40:19 +0800
Subject: [PATCH 07/34] fix: route all config writes through Mihomo service
---
backend/internal/router/config_routes.go | 61 +++++++-----------------
1 file changed, 16 insertions(+), 45 deletions(-)
diff --git a/backend/internal/router/config_routes.go b/backend/internal/router/config_routes.go
index 801be01..05e5574 100644
--- a/backend/internal/router/config_routes.go
+++ b/backend/internal/router/config_routes.go
@@ -2,8 +2,6 @@ package router
import (
"net/http"
- "os"
- "path/filepath"
"strconv"
"github.com/gin-gonic/gin"
@@ -15,49 +13,22 @@ func registerConfigRoutes(api *gin.RouterGroup, d Deps, cfg *config.Config) {
db := resolveDB(d)
group := api.Group("/config")
- group.GET("/proxies", func(c *gin.Context) {
- visual, err := mihomoConfig.GetVisualConfig(db); if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }
- c.JSON(http.StatusOK, visual.Proxies)
- })
- group.POST("/proxies", func(c *gin.Context) {
- var proxy mihomoConfig.ProxyEntry
- if err := c.ShouldBindJSON(&proxy); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}); return }
- if proxy.Name == "" || proxy.Type == "" || proxy.Server == "" || proxy.Port == "" || proxy.Port == "0" { c.JSON(http.StatusBadRequest, gin.H{"error": "名称、协议、服务器和有效端口不能为空"}); return }
- visual, err := mihomoConfig.GetVisualConfig(db); if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }
- visual.Proxies = append(visual.Proxies, proxy)
- if err = mihomoConfig.SaveVisualConfig(db, visual); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }
- c.JSON(http.StatusCreated, proxy)
- })
- group.PUT("/proxies/:index", func(c *gin.Context) {
- idx, err := strconv.Atoi(c.Param("index")); if err != nil || idx < 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "无效节点索引"}); return }
- var proxy mihomoConfig.ProxyEntry; if err = c.ShouldBindJSON(&proxy); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}); return }
- visual, err := mihomoConfig.GetVisualConfig(db); if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }
- if idx >= len(visual.Proxies) { c.JSON(http.StatusNotFound, gin.H{"error": "节点不存在"}); return }
- visual.Proxies[idx] = proxy
- if err = mihomoConfig.SaveVisualConfig(db, visual); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }
- c.JSON(http.StatusOK, proxy)
- })
- group.DELETE("/proxies/:index", func(c *gin.Context) {
- idx, err := strconv.Atoi(c.Param("index")); if err != nil || idx < 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "无效节点索引"}); return }
- visual, err := mihomoConfig.GetVisualConfig(db); if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }
- if idx >= len(visual.Proxies) { c.JSON(http.StatusNotFound, gin.H{"error": "节点不存在"}); return }
- visual.Proxies = append(visual.Proxies[:idx], visual.Proxies[idx+1:]...)
- if err = mihomoConfig.SaveVisualConfig(db, visual); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }
- c.JSON(http.StatusOK, gin.H{"status": "ok"})
- })
- group.GET("/visual", func(c *gin.Context) { visual, err := mihomoConfig.GetVisualConfig(db); if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }; c.JSON(http.StatusOK, visual) })
- group.POST("/visual", func(c *gin.Context) { var visual mihomoConfig.VisualConfig; if err := c.ShouldBindJSON(&visual); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid visual configuration: " + err.Error()}); return }; if err := mihomoConfig.SaveVisualConfig(db, visual); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}); return }; c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "Visual configuration saved"}) })
- group.GET("", func(c *gin.Context) { engine := mihomoConfig.NewConfigEngine(db); yamlStr, err := engine.GenerateFinalConfig(); if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }; c.JSON(http.StatusOK, gin.H{"config": yamlStr}) })
- group.POST("/generate", func(c *gin.Context) { engine := mihomoConfig.NewConfigEngine(db); yamlStr, err := engine.GenerateFinalConfig(); if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }; dir := filepath.Dir(cfg.Mihomo.Config); if err := os.MkdirAll(dir, 0750); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }; if err := os.WriteFile(cfg.Mihomo.Config, []byte(yamlStr), 0600); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }; c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "Configuration generated and written successfully"}) })
+ group.GET("/proxies", func(c *gin.Context) { visual, err := mihomoConfig.GetVisualConfig(db); if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }; c.JSON(http.StatusOK, visual.Proxies) })
+ group.POST("/proxies", func(c *gin.Context) { var proxy mihomoConfig.ProxyEntry; if err:=c.ShouldBindJSON(&proxy);err!=nil{c.JSON(http.StatusBadRequest,gin.H{"error":err.Error()});return};if proxy.Name==""||proxy.Type==""||proxy.Server==""||proxy.Port==""||proxy.Port=="0"{c.JSON(http.StatusBadRequest,gin.H{"error":"名称、协议、服务器和有效端口不能为空"});return};visual,err:=mihomoConfig.GetVisualConfig(db);if err!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()});return};visual.Proxies=append(visual.Proxies,proxy);if err=mihomoConfig.SaveVisualConfig(db,visual);err!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()});return};c.JSON(http.StatusCreated,proxy) })
+ group.PUT("/proxies/:index", func(c *gin.Context) { idx,err:=strconv.Atoi(c.Param("index"));if err!=nil||idx<0{c.JSON(http.StatusBadRequest,gin.H{"error":"无效节点索引"});return};var proxy mihomoConfig.ProxyEntry;if err=c.ShouldBindJSON(&proxy);err!=nil{c.JSON(http.StatusBadRequest,gin.H{"error":err.Error()});return};visual,err:=mihomoConfig.GetVisualConfig(db);if err!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()});return};if idx>=len(visual.Proxies){c.JSON(http.StatusNotFound,gin.H{"error":"节点不存在"});return};visual.Proxies[idx]=proxy;if err=mihomoConfig.SaveVisualConfig(db,visual);err!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()});return};c.JSON(http.StatusOK,proxy) })
+ group.DELETE("/proxies/:index", func(c *gin.Context) { idx,err:=strconv.Atoi(c.Param("index"));if err!=nil||idx<0{c.JSON(http.StatusBadRequest,gin.H{"error":"无效节点索引"});return};visual,err:=mihomoConfig.GetVisualConfig(db);if err!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()});return};if idx>=len(visual.Proxies){c.JSON(http.StatusNotFound,gin.H{"error":"节点不存在"});return};visual.Proxies=append(visual.Proxies[:idx],visual.Proxies[idx+1:]...);if err=mihomoConfig.SaveVisualConfig(db,visual);err!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()});return};c.JSON(http.StatusOK,gin.H{"status":"ok"}) })
+ group.GET("/visual", func(c *gin.Context) { visual,err:=mihomoConfig.GetVisualConfig(db);if err!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()});return};c.JSON(http.StatusOK,visual) })
+ group.POST("/visual", func(c *gin.Context) { var visual mihomoConfig.VisualConfig;if err:=c.ShouldBindJSON(&visual);err!=nil{c.JSON(http.StatusBadRequest,gin.H{"error":"Invalid visual configuration: "+err.Error()});return};if err:=mihomoConfig.SaveVisualConfig(db,visual);err!=nil{c.JSON(http.StatusBadRequest,gin.H{"error":err.Error()});return};c.JSON(http.StatusOK,gin.H{"status":"ok","message":"Visual configuration saved"}) })
+ group.GET("", func(c *gin.Context) { engine:=mihomoConfig.NewConfigEngine(db);yamlStr,err:=engine.GenerateFinalConfig();if err!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()});return};c.JSON(http.StatusOK,gin.H{"config":yamlStr}) })
- // Preview validates an arbitrary candidate without touching the live file.
- group.POST("/preview", func(c *gin.Context) { var req struct{ Config string `json:"config"` }; if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}); return }; if err := mihomoConfig.ValidateConfigYAML(req.Config); err != nil { c.JSON(http.StatusOK, gin.H{"valid": false, "error": err.Error()}); return }; c.JSON(http.StatusOK, gin.H{"valid": true, "config": req.Config}) })
- group.POST("/validate", func(c *gin.Context) { var req struct{ Config string `json:"config"` }; if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}); return }; if err := mihomoConfig.ValidateConfigYAML(req.Config); err != nil { c.JSON(http.StatusOK, gin.H{"valid": false, "error": err.Error()}); return }; c.JSON(http.StatusOK, gin.H{"valid": true}) })
+ // /generate is kept for backward compatibility, but it now uses the same
+ // guarded service path as /apply instead of writing config.yaml directly.
+ group.POST("/generate", func(c *gin.Context) { engine:=mihomoConfig.NewConfigEngine(db);yamlStr,err:=engine.GenerateFinalConfig();if err!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()});return};if err:=d.mihomoService().ApplyConfig(yamlStr);err!=nil{c.JSON(http.StatusUnprocessableEntity,gin.H{"error":err.Error()});return};c.JSON(http.StatusOK,gin.H{"status":"ok","message":"Configuration generated, validated and applied successfully"}) })
+ group.POST("/preview", func(c *gin.Context) { var req struct{Config string `json:"config"`};if err:=c.ShouldBindJSON(&req);err!=nil{c.JSON(http.StatusBadRequest,gin.H{"error":"Invalid request body"});return};if err:=mihomoConfig.ValidateConfigYAML(req.Config);err!=nil{c.JSON(http.StatusOK,gin.H{"valid":false,"error":err.Error()});return};c.JSON(http.StatusOK,gin.H{"valid":true,"config":req.Config}) })
+ group.POST("/validate", func(c *gin.Context) { var req struct{Config string `json:"config"`};if err:=c.ShouldBindJSON(&req);err!=nil{c.JSON(http.StatusBadRequest,gin.H{"error":"Invalid request body"});return};if err:=mihomoConfig.ValidateConfigYAML(req.Config);err!=nil{c.JSON(http.StatusOK,gin.H{"valid":false,"error":err.Error()});return};c.JSON(http.StatusOK,gin.H{"valid":true}) })
+ group.POST("/apply", func(c *gin.Context) { engine:=mihomoConfig.NewConfigEngine(db);yamlStr,err:=engine.GenerateFinalConfig();if err!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()});return};if err:=d.mihomoService().ApplyConfig(yamlStr);err!=nil{c.JSON(http.StatusUnprocessableEntity,gin.H{"error":err.Error()});return};c.JSON(http.StatusOK,gin.H{"status":"ok","message":"Configuration validated and applied successfully"}) })
+ group.POST("/rollback", func(c *gin.Context) { if err:=d.mihomoService().RollbackConfig();err!=nil{c.JSON(http.StatusConflict,gin.H{"error":err.Error()});return};c.JSON(http.StatusOK,gin.H{"status":"ok","message":"Previous Mihomo configuration restored"}) })
+ group.GET("/download", func(c *gin.Context) { engine:=mihomoConfig.NewConfigEngine(db);yamlStr,err:=engine.GenerateFinalConfig();if err!=nil{c.JSON(http.StatusInternalServerError,gin.H{"error":err.Error()});return};c.Header("Content-Disposition","attachment; filename=config.yaml");c.Data(http.StatusOK,"application/yaml",[]byte(yamlStr)) })
- // Apply is the only endpoint that changes the live Mihomo configuration
- // through the service layer. It validates, backs up, activates, and rolls
- // back on failure instead of blindly overwriting config.yaml.
- group.POST("/apply", func(c *gin.Context) { engine := mihomoConfig.NewConfigEngine(db); yamlStr, err := engine.GenerateFinalConfig(); if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }; if err := d.mihomoService().ApplyConfig(yamlStr); err != nil { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}); return }; c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "Configuration validated and applied successfully"}) })
- group.POST("/rollback", func(c *gin.Context) { if err := d.mihomoService().RollbackConfig(); err != nil { c.JSON(http.StatusConflict, gin.H{"error": err.Error()}); return }; c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "Previous Mihomo configuration restored"}) })
- group.GET("/download", func(c *gin.Context) { engine := mihomoConfig.NewConfigEngine(db); yamlStr, err := engine.GenerateFinalConfig(); if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }; c.Header("Content-Disposition", "attachment; filename=config.yaml"); c.Data(http.StatusOK, "application/yaml", []byte(yamlStr)) })
+ _ = cfg
}
From 2c06ca5a22870e4a21736033e618fcafebca8c01 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 11:40:31 +0800
Subject: [PATCH 08/34] ci: build backend and validate agent branches
---
.github/workflows/ci.yml | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e5a23ad..81db674 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,7 +2,7 @@ name: CI
on:
push:
- branches: [main]
+ branches: [main, 'agent/**']
pull_request:
branches: [main]
@@ -24,6 +24,8 @@ jobs:
run: go vet ./...
- name: Go tests
run: go test ./...
+ - name: Go build
+ run: go build ./...
frontend:
runs-on: ubuntu-latest
From 2d0956b54a218717f411a126d31fb4ca39f5a811 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 12:34:55 +0800
Subject: [PATCH 09/34] fix(listener): recursively validate nested config
fields
---
.../mihomo/config/listener_validation.go | 39 +++++++++++++++----
1 file changed, 31 insertions(+), 8 deletions(-)
diff --git a/backend/internal/mihomo/config/listener_validation.go b/backend/internal/mihomo/config/listener_validation.go
index d4dc3a0..1738aba 100644
--- a/backend/internal/mihomo/config/listener_validation.go
+++ b/backend/internal/mihomo/config/listener_validation.go
@@ -33,17 +33,40 @@ func ValidateListenerConfig(protocol, raw string) error {
"proxy": {}, "rule": {}, "enabled": {}, "status": {}, "tls": {}, "udp": {},
"routing-mark": {},
}
- for key := range values {
- if _, blocked := reserved[key]; blocked {
- return fmt.Errorf("listener configuration field %q is managed by 3m-ui", key)
+ return validateListenerObject(schema, values, "", reserved)
+}
+
+func validateListenerObject(schema ListenerSchema, values map[string]interface{}, prefix string, reserved map[string]struct{}) error {
+ for key, value := range values {
+ path := key
+ if prefix != "" {
+ path = prefix + "." + key
}
- if _, allowed := schema.Fields[key]; allowed {
- continue
+ if prefix == "" {
+ if _, blocked := reserved[key]; blocked {
+ return fmt.Errorf("listener configuration field %q is managed by 3m-ui", key)
+ }
+ if _, allowed := schema.Fields[key]; !allowed {
+ if _, isParent := schema.NestedFields[key]; !isParent {
+ return fmt.Errorf("field %q is not supported for listener protocol %q", key, schema.Protocol)
+ }
+ }
+ } else {
+ parent := schema.NestedFields[prefix]
+ if _, allowed := parent[key]; !allowed {
+ return fmt.Errorf("field %q is not supported for listener protocol %q", path, schema.Protocol)
+ }
}
- if _, allowed := schema.NestedFields[key]; allowed {
- continue
+
+ if _, hasChildren := schema.NestedFields[path]; hasChildren {
+ child, ok := value.(map[string]interface{})
+ if !ok {
+ return fmt.Errorf("field %q must be a JSON object", path)
+ }
+ if err := validateListenerObject(schema, child, path, reserved); err != nil {
+ return err
+ }
}
- return fmt.Errorf("field %q is not supported for listener protocol %q", key, protocol)
}
return nil
}
From 487473f15e9ada286aa5c27f35af6fb472ff4725 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 12:35:04 +0800
Subject: [PATCH 10/34] fix(listener): validate bind address and listener ports
---
backend/internal/listener/validation.go | 108 ++++++++++++++++++++++--
1 file changed, 103 insertions(+), 5 deletions(-)
diff --git a/backend/internal/listener/validation.go b/backend/internal/listener/validation.go
index 4842309..f3a03d0 100644
--- a/backend/internal/listener/validation.go
+++ b/backend/internal/listener/validation.go
@@ -2,6 +2,7 @@ package listener
import (
"fmt"
+ "net"
"strconv"
"strings"
@@ -29,33 +30,130 @@ func ValidateModel(l *models.Listener) error {
return fmt.Errorf("listener protocol %q does not match type %q", protocol, legacyType)
}
l.Protocol = protocol
- if l.Port == "" {
+ if strings.TrimSpace(l.Port) == "" {
return fmt.Errorf("listener port is required")
}
if !validPort(l.Port) {
return fmt.Errorf("listener %q has invalid port %q", l.Name, l.Port)
}
+ if address := strings.TrimSpace(firstNonEmpty(l.BindAddress, l.Listen)); address != "" {
+ if net.ParseIP(address) == nil {
+ return fmt.Errorf("listener %q has invalid bind address %q; use an IPv4 or IPv6 address", l.Name, address)
+ }
+ l.BindAddress = address
+ }
if err := dbconfig.ValidateListenerConfig(protocol, l.Config); err != nil {
return fmt.Errorf("listener %q: %w", l.Name, err)
}
+ if l.TLS {
+ if !dbconfig.ListenerSupportsTLS(protocol) {
+ return fmt.Errorf("listener %q: TLS is not supported for protocol %q", l.Name, protocol)
+ }
+ }
return nil
}
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ if strings.TrimSpace(value) != "" {
+ return strings.TrimSpace(value)
+ }
+ }
+ return ""
+}
+
func validPort(raw string) bool {
raw = strings.TrimSpace(raw)
- if raw == "" { return false }
+ if raw == "" {
+ return false
+ }
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
+ if part == "" {
+ return false
+ }
if strings.Contains(part, "-") {
r := strings.SplitN(part, "-", 2)
- if len(r) != 2 { return false }
+ if len(r) != 2 {
+ return false
+ }
a, errA := strconv.Atoi(strings.TrimSpace(r[0]))
b, errB := strconv.Atoi(strings.TrimSpace(r[1]))
- if errA != nil || errB != nil || a < 1 || b > 65535 || a > b { return false }
+ if errA != nil || errB != nil || a < 1 || b > 65535 || a > b {
+ return false
+ }
continue
}
p, err := strconv.Atoi(part)
- if err != nil || p < 1 || p > 65535 { return false }
+ if err != nil || p < 1 || p > 65535 {
+ return false
+ }
}
return true
}
+
+// PortRangesOverlap reports whether two comma-separated port specifications
+// share at least one port. It is used by the service before a listener is
+// persisted so conflicts are rejected before Mihomo sees the configuration.
+func PortRangesOverlap(a, b string) bool {
+ for _, ar := range parsePortRanges(a) {
+ for _, br := range parsePortRanges(b) {
+ if ar[0] <= br[1] && br[0] <= ar[1] {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func parsePortRanges(raw string) [][2]int {
+ var result [][2]int
+ for _, part := range strings.Split(strings.TrimSpace(raw), ",") {
+ part = strings.TrimSpace(part)
+ if part == "" {
+ continue
+ }
+ if strings.Contains(part, "-") {
+ p := strings.SplitN(part, "-", 2)
+ a, errA := strconv.Atoi(strings.TrimSpace(p[0]))
+ b, errB := strconv.Atoi(strings.TrimSpace(p[1]))
+ if errA == nil && errB == nil {
+ result = append(result, [2]int{a, b})
+ }
+ continue
+ }
+ if p, err := strconv.Atoi(part); err == nil {
+ result = append(result, [2]int{p, p})
+ }
+ }
+ return result
+}
+
+// AddressesConflict uses conservative socket-binding semantics. Wildcards
+// conflict with any address of the same family; an IPv4 and IPv6 address are
+// considered independent sockets. Since the Listener model only accepts IP
+// addresses, hostname ambiguity is avoided at the trust boundary.
+func AddressesConflict(a, b string) bool {
+ a = strings.TrimSpace(a)
+ b = strings.TrimSpace(b)
+ if a == "" {
+ a = "0.0.0.0"
+ }
+ if b == "" {
+ b = "0.0.0.0"
+ }
+ ia, ib := net.ParseIP(a), net.ParseIP(b)
+ if ia == nil || ib == nil {
+ return false
+ }
+ if ia.Equal(ib) {
+ return true
+ }
+ if ia.To4() != nil && ib.To4() != nil {
+ return ia.IsUnspecified() || ib.IsUnspecified()
+ }
+ if ia.To4() == nil && ib.To4() == nil {
+ return ia.IsUnspecified() || ib.IsUnspecified()
+ }
+ return false
+}
From f5794c94fb6b5a401ab76d327b8725c67c791798 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 12:35:19 +0800
Subject: [PATCH 11/34] fix(listener): centralize TLS support and reject
endpoint conflicts
---
backend/internal/mihomo/config/generator.go | 133 ++++++++++++--------
1 file changed, 81 insertions(+), 52 deletions(-)
diff --git a/backend/internal/mihomo/config/generator.go b/backend/internal/mihomo/config/generator.go
index 58ed639..30c2695 100644
--- a/backend/internal/mihomo/config/generator.go
+++ b/backend/internal/mihomo/config/generator.go
@@ -3,6 +3,7 @@ package config
import (
"encoding/json"
"fmt"
+ "net"
"strconv"
"strings"
@@ -24,28 +25,19 @@ func (ce *ConfigEngine) GenerateFinalConfig() (string, error) {
return "", fmt.Errorf("config engine database is not initialized")
}
baseBytes, err := yaml.Marshal(GetDefaultTemplate())
- if err != nil {
- return "", err
- }
+ if err != nil { return "", err }
var merged map[string]interface{}
- if err := yaml.Unmarshal(baseBytes, &merged); err != nil {
- return "", err
- }
+ if err := yaml.Unmarshal(baseBytes, &merged); err != nil { return "", err }
var customFragments []models.Config
- if err := ce.db.Where("enabled = ?", true).Find(&customFragments).Error; err != nil {
- return "", fmt.Errorf("load custom config fragments: %w", err)
- }
+ if err := ce.db.Where("enabled = ?", true).Find(&customFragments).Error; err != nil { return "", fmt.Errorf("load custom config fragments: %w", err) }
for _, fragment := range customFragments {
var fragMap map[string]interface{}
- if err := yaml.Unmarshal([]byte(fragment.Content), &fragMap); err != nil {
- return "", fmt.Errorf("invalid custom config %q: %w", fragment.Name, err)
- }
+ if err := yaml.Unmarshal([]byte(fragment.Content), &fragMap); err != nil { return "", fmt.Errorf("invalid custom config %q: %w", fragment.Name, err) }
for k, v := range fragMap { merged[k] = v }
}
var listeners []models.Listener
- if err := ce.db.Where("enabled = ?", true).Find(&listeners).Error; err != nil {
- return "", fmt.Errorf("load enabled listeners: %w", err)
- }
+ if err := ce.db.Where("enabled = ?", true).Find(&listeners).Error; err != nil { return "", fmt.Errorf("load enabled listeners: %w", err) }
+ if err := validateListenerEndpoints(listeners); err != nil { return "", err }
credentials := make(map[uint][]Credential)
if CredentialProvider != nil {
credentials, err = CredentialProvider()
@@ -59,6 +51,68 @@ func (ce *ConfigEngine) GenerateFinalConfig() (string, error) {
return string(finalBytes), nil
}
+func validateListenerEndpoints(listeners []models.Listener) error {
+ for i := range listeners {
+ if err := validateListenerEndpoint(&listeners[i]); err != nil { return err }
+ for j := i + 1; j < len(listeners); j++ {
+ if !portsOverlap(listeners[i].Port, listeners[j].Port) { continue }
+ a := firstListenerAddress(listeners[i])
+ b := firstListenerAddress(listeners[j])
+ if listenerAddressesConflict(a, b) {
+ return fmt.Errorf("listeners %q and %q have conflicting bind address/port ranges (%s:%s and %s:%s)", listeners[i].Name, listeners[j].Name, a, listeners[i].Port, b, listeners[j].Port)
+ }
+ }
+ }
+ return nil
+}
+
+func validateListenerEndpoint(l *models.Listener) error {
+ address := firstListenerAddress(*l)
+ if net.ParseIP(address) == nil { return fmt.Errorf("listener %q has invalid bind address %q", l.Name, address) }
+ if !isValidPortString(l.Port) { return fmt.Errorf("listener %q has invalid port %q", l.Name, l.Port) }
+ return nil
+}
+
+func firstListenerAddress(l models.Listener) string {
+ if v := strings.TrimSpace(l.BindAddress); v != "" { return v }
+ if v := strings.TrimSpace(l.Listen); v != "" { return v }
+ return "0.0.0.0"
+}
+
+func portsOverlap(a, b string) bool {
+ for _, ar := range parsePortRanges(a) {
+ for _, br := range parsePortRanges(b) {
+ if ar[0] <= br[1] && br[0] <= ar[1] { return true }
+ }
+ }
+ return false
+}
+
+func parsePortRanges(raw string) [][2]int {
+ var out [][2]int
+ for _, part := range strings.Split(raw, ",") {
+ part = strings.TrimSpace(part)
+ if part == "" { continue }
+ if strings.Contains(part, "-") {
+ p := strings.SplitN(part, "-", 2)
+ a, ea := strconv.Atoi(strings.TrimSpace(p[0])); b, eb := strconv.Atoi(strings.TrimSpace(p[1]))
+ if ea == nil && eb == nil { out = append(out, [2]int{a, b}) }
+ continue
+ }
+ if p, err := strconv.Atoi(part); err == nil { out = append(out, [2]int{p, p}) }
+ }
+ return out
+}
+
+func listenerAddressesConflict(a, b string) bool {
+ ia, ib := net.ParseIP(a), net.ParseIP(b)
+ if ia == nil || ib == nil { return false }
+ if ia.Equal(ib) { return true }
+ if ia.To4() != nil && ib.To4() != nil { return ia.IsUnspecified() || ib.IsUnspecified() }
+ if ia.To4() == nil && ib.To4() == nil { return ia.IsUnspecified() || ib.IsUnspecified() }
+ return false
+}
+
func generateListeners(listeners []models.Listener, creds map[uint][]Credential) ([]map[string]interface{}, error) {
result := make([]map[string]interface{}, 0, len(listeners))
for _, l := range listeners {
@@ -67,14 +121,13 @@ func generateListeners(listeners []models.Listener, creds map[uint][]Credential)
if protocol == "" { protocol = strings.ToLower(strings.TrimSpace(l.Type)) }
if !IsMihomoListenerProtocol(protocol) { return nil, fmt.Errorf("unsupported Mihomo listener protocol %q", protocol) }
if !isValidPortString(l.Port) { return nil, fmt.Errorf("listener %q has invalid port %q", l.Name, l.Port) }
- listen := strings.TrimSpace(l.BindAddress)
- if listen == "" { listen = strings.TrimSpace(l.Listen) }
- if listen == "" { listen = "0.0.0.0" }
+ listen := firstListenerAddress(l)
var portVal interface{} = strings.TrimSpace(l.Port)
if p, err := strconv.Atoi(strings.TrimSpace(l.Port)); err == nil { portVal = p }
m := map[string]interface{}{"name": l.Name, "type": protocol, "port": portVal, "listen": listen}
configMap, err := decodeListenerConfig(l.Config)
if err != nil { return nil, fmt.Errorf("listener %q: %w", l.Name, err) }
+ if err := ValidateListenerConfig(protocol, l.Config); err != nil { return nil, fmt.Errorf("listener %q: %w", l.Name, err) }
if l.Proxy != "" { m["proxy"] = l.Proxy }
if l.Rule != "" { m["rule"] = l.Rule }
if l.RoutingMark > 0 { m["routing-mark"] = l.RoutingMark }
@@ -84,7 +137,7 @@ func generateListeners(listeners []models.Listener, creds map[uint][]Credential)
copyServerTLSFields(m, configMap)
listenerCreds, hasCredentialState := creds[l.ID]
if l.TLS {
- if !listenerSupportsTLS(protocol) { return nil, fmt.Errorf("listener %q: TLS is not supported for protocol %q", l.Name, protocol) }
+ if !ListenerSupportsTLS(protocol) { return nil, fmt.Errorf("listener %q: TLS is not supported for protocol %q", l.Name, protocol) }
m["tls"] = true
} else { delete(m, "tls") }
@@ -108,31 +161,18 @@ func generateListeners(listeners []models.Listener, creds map[uint][]Credential)
if len(users) > 0 { m["users"] = users } else if value, ok := configMap["users"]; ok && !hasCredentialState { m["users"] = value }
case "trojan":
users := make([]map[string]interface{}, 0, len(listenerCreds))
- for _, cred := range listenerCreds {
- if cred.Password == "" { continue }
- u := map[string]interface{}{"password": cred.Password}
- if cred.Username != "" { u["username"] = cred.Username }
- users = append(users, u)
- }
+ for _, cred := range listenerCreds { if cred.Password != "" { u := map[string]interface{}{"password": cred.Password}; if cred.Username != "" { u["username"] = cred.Username }; users = append(users, u) } }
if len(users) > 0 { m["users"] = users } else if value, ok := configMap["users"]; ok && !hasCredentialState { m["users"] = value }
case "hysteria2", "anytls", "mieru":
users := make(map[string]string)
for _, cred := range listenerCreds { if cred.Username != "" && cred.Password != "" { users[cred.Username] = cred.Password } }
if len(users) > 0 { m["users"] = users } else if value, ok := configMap["users"]; ok && !hasCredentialState { m["users"] = value }
case "tuic":
- if value, ok := configMap["token"]; ok && !hasCredentialState { m["token"] = value } else {
- users := make(map[string]string)
- for _, cred := range listenerCreds { if cred.UUID != "" && cred.Password != "" { users[cred.UUID] = cred.Password } }
- if len(users) > 0 { m["users"] = users }
- }
+ if value, ok := configMap["token"]; ok && !hasCredentialState { m["token"] = value } else { users := make(map[string]string); for _, cred := range listenerCreds { if cred.UUID != "" && cred.Password != "" { users[cred.UUID] = cred.Password } }; if len(users) > 0 { m["users"] = users } }
case "shadowquic", "trusttunnel":
users := make([]map[string]interface{}, 0, len(listenerCreds))
for _, cred := range listenerCreds { if cred.Username != "" && cred.Password != "" { users = append(users, map[string]interface{}{"username": cred.Username, "password": cred.Password}) } }
- if len(users) > 0 { m["users"] = users } else if value, ok := configMap["users"]; ok && !hasCredentialState {
- normalized, err := normalizeListenerUserList(value)
- if err != nil { return nil, fmt.Errorf("listener %q: %w", l.Name, err) }
- if normalized != nil { m["users"] = normalized }
- }
+ if len(users) > 0 { m["users"] = users } else if value, ok := configMap["users"]; ok && !hasCredentialState { normalized, err := normalizeListenerUserList(value); if err != nil { return nil, fmt.Errorf("listener %q: %w", l.Name, err) }; if normalized != nil { m["users"] = normalized } }
case "sudoku":
}
if hasCredentialState && len(listenerCreds) == 0 {
@@ -148,11 +188,7 @@ func generateListeners(listeners []models.Listener, creds map[uint][]Credential)
func isValidPortString(s string) bool {
s = strings.TrimSpace(s); if s == "" { return false }
if strings.Contains(s, ",") { for _, p := range strings.Split(s, ",") { if !isValidPortString(strings.TrimSpace(p)) { return false } }; return true }
- if strings.Contains(s, "-") {
- parts := strings.SplitN(s, "-", 2); if len(parts) != 2 { return false }
- start, err1 := strconv.Atoi(strings.TrimSpace(parts[0])); end, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
- return err1 == nil && err2 == nil && start >= 1 && end <= 65535 && start <= end
- }
+ if strings.Contains(s, "-") { parts := strings.SplitN(s, "-", 2); if len(parts) != 2 { return false }; start, err1 := strconv.Atoi(strings.TrimSpace(parts[0])); end, err2 := strconv.Atoi(strings.TrimSpace(parts[1])); return err1 == nil && err2 == nil && start >= 1 && end <= 65535 && start <= end }
port, err := strconv.Atoi(s); return err == nil && port >= 1 && port <= 65535
}
@@ -178,20 +214,13 @@ func decodeListenerConfig(raw string) (map[string]interface{}, error) {
func listenerFieldIsManaged(key string) bool {
switch key {
- case "users", "username", "password", "uuid", "flow", "alterId", "tls", "servername", "sni", "skip-cert-verify", "name-cert-verify", "fingerprint", "client-fingerprint", "reality-opts", "shadow-tls-opts", "restls-opts", "jls-opts", "ws-opts", "grpc-opts", "h2-opts", "http-opts", "mkcp-opts", "certificate", "private-key", "private_key":
- return true
+ case "users", "username", "password", "uuid", "flow", "alterId", "tls", "servername", "sni", "skip-cert-verify", "name-cert-verify", "fingerprint", "client-fingerprint", "reality-opts", "shadow-tls-opts", "restls-opts", "jls-opts", "ws-opts", "grpc-opts", "h2-opts", "http-opts", "mkcp-opts", "certificate", "private-key", "private_key": return true
default: return false
}
}
-func copyServerTLSFields(dst, src map[string]interface{}) {
- if value, ok := src["certificate"]; ok { dst["certificate"] = value }
- if value, ok := src["private-key"]; ok { dst["private-key"] = value } else if value, ok := src["private_key"]; ok { dst["private-key"] = value }
-}
+func copyServerTLSFields(dst, src map[string]interface{}) { if value, ok := src["certificate"]; ok { dst["certificate"] = value }; if value, ok := src["private-key"]; ok { dst["private-key"] = value } else if value, ok := src["private_key"]; ok { dst["private-key"] = value } }
func copyOption(dst, src map[string]interface{}, key string) { if value, ok := src[key]; ok { dst[key] = value } }
-func listenerHasUDPOption(protocol string) bool {
- switch protocol { case "shadowsocks", "snell", "vmess", "vless", "trojan", "anytls", "trusttunnel": return true; default: return false }
-}
-func listenerSupportsTLS(protocol string) bool {
- switch protocol { case "vmess", "vless", "trojan", "anytls", "mieru", "trusttunnel": return true; default: return false }
-}
+func listenerHasUDPOption(protocol string) bool { switch protocol { case "shadowsocks", "snell", "vmess", "vless", "trojan", "anytls", "trusttunnel": return true; default: return false } }
+func listenerSupportsTLS(protocol string) bool { switch protocol { case "vmess", "vless", "trojan", "anytls", "mieru", "trusttunnel": return true; default: return false } }
+func ListenerSupportsTLS(protocol string) bool { return listenerSupportsTLS(strings.ToLower(strings.TrimSpace(protocol))) }
From 476774e04158d45aa190d706ba6dd280b274c9d2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 12:35:30 +0800
Subject: [PATCH 12/34] fix(listener): serialize lifecycle changes and protect
linked clients
---
backend/internal/listener/service.go | 46 +++++++++++++++++++++++++---
1 file changed, 41 insertions(+), 5 deletions(-)
diff --git a/backend/internal/listener/service.go b/backend/internal/listener/service.go
index 88578a0..b4e7e16 100644
--- a/backend/internal/listener/service.go
+++ b/backend/internal/listener/service.go
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "sync"
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
dbconfig "github.com/kazeyukiro/3m-ui/backend/internal/mihomo/config"
@@ -14,6 +15,7 @@ type Service struct {
db *gorm.DB
configPath string
mihomoApply interface{ ApplyConfig(string) error }
+ mu sync.Mutex
}
func NewService(db *gorm.DB, configPath string, mihomoApply interface{ ApplyConfig(string) error }) *Service {
@@ -21,9 +23,15 @@ func NewService(db *gorm.DB, configPath string, mihomoApply interface{ ApplyConf
}
func (s *Service) Create(l *models.Listener) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
if err := ValidateModel(l); err != nil { return err }
+ if err := s.ensureEndpointAvailable(l); err != nil { return err }
if err := s.db.Create(l).Error; err != nil { return fmt.Errorf("failed to create listener: %w", err) }
- if err := s.RegenerateConfig(); err != nil { _ = s.db.Delete(&models.Listener{}, l.ID).Error; return err }
+ if err := s.regenerateConfigLocked(); err != nil {
+ _ = s.db.Delete(&models.Listener{}, l.ID).Error
+ return err
+ }
return nil
}
@@ -40,31 +48,59 @@ func (s *Service) GetByID(id uint) (*models.Listener, error) {
}
func (s *Service) Update(l *models.Listener) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
if err := ValidateModel(l); err != nil { return err }
var previous models.Listener
if err := s.db.First(&previous, l.ID).Error; err != nil { return fmt.Errorf("failed to load previous listener: %w", err) }
+ if err := s.ensureEndpointAvailable(l); err != nil { return err }
if err := s.db.Save(l).Error; err != nil { return fmt.Errorf("failed to update listener: %w", err) }
- if err := s.RegenerateConfig(); err != nil {
+ if err := s.regenerateConfigLocked(); err != nil {
if rollbackErr := s.db.Save(&previous).Error; rollbackErr != nil { return fmt.Errorf("%v; rollback listener failed: %w", err, rollbackErr) }
- _ = s.RegenerateConfig()
+ _ = s.regenerateConfigLocked()
return err
}
return nil
}
func (s *Service) Delete(id uint) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
var previous models.Listener
if err := s.db.First(&previous, id).Error; err != nil { return fmt.Errorf("failed to fetch listener before delete: %w", err) }
+ var linked int64
+ if err := s.db.Model(&models.ListenerUser{}).Where("listener_id = ?", id).Count(&linked).Error; err != nil { return fmt.Errorf("failed to check listener clients: %w", err) }
+ if linked > 0 {
+ return fmt.Errorf("listener %q has %d linked client(s); unbind clients before deleting the listener", previous.Name, linked)
+ }
if err := s.db.Delete(&models.Listener{}, id).Error; err != nil { return fmt.Errorf("failed to delete listener: %w", err) }
- if err := s.RegenerateConfig(); err != nil {
+ if err := s.regenerateConfigLocked(); err != nil {
if rollbackErr := s.db.Unscoped().Save(&previous).Error; rollbackErr != nil { return fmt.Errorf("%v; rollback deleted listener failed: %w", err, rollbackErr) }
- _ = s.RegenerateConfig()
+ _ = s.regenerateConfigLocked()
return err
}
return nil
}
+func (s *Service) ensureEndpointAvailable(candidate *models.Listener) error {
+ var listeners []models.Listener
+ if err := s.db.Where("enabled = ? AND id <> ?", true, candidate.ID).Find(&listeners).Error; err != nil { return fmt.Errorf("check listener endpoint conflicts: %w", err) }
+ for _, existing := range listeners {
+ if !portsOverlap(candidate.Port, existing.Port) { continue }
+ if listenerAddressesConflict(firstListenerAddress(*candidate), firstListenerAddress(existing)) {
+ return fmt.Errorf("listener %q conflicts with existing listener %q on %s:%s", candidate.Name, existing.Name, firstListenerAddress(existing), existing.Port)
+ }
+ }
+ return nil
+}
+
func (s *Service) RegenerateConfig() error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.regenerateConfigLocked()
+}
+
+func (s *Service) regenerateConfigLocked() error {
if s == nil || s.db == nil { return fmt.Errorf("listener service not initialized") }
engine := dbconfig.NewConfigEngine(s.db)
yamlContent, err := engine.GenerateFinalConfig()
From 5b6a79adf5cf6f274a5df1eddf97319d643f4afa Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 12:35:38 +0800
Subject: [PATCH 13/34] test(listener): add endpoint and address regression
coverage
---
backend/internal/listener/p0_p1_test.go | 40 +++++++++++++++++++++++++
1 file changed, 40 insertions(+)
create mode 100644 backend/internal/listener/p0_p1_test.go
diff --git a/backend/internal/listener/p0_p1_test.go b/backend/internal/listener/p0_p1_test.go
new file mode 100644
index 0000000..5974262
--- /dev/null
+++ b/backend/internal/listener/p0_p1_test.go
@@ -0,0 +1,40 @@
+package listener
+
+import "testing"
+
+func TestPortRangesOverlap(t *testing.T) {
+ tests := []struct {
+ a, b string
+ want bool
+ }{
+ {"443", "443", true},
+ {"443-445", "445-450", true},
+ {"443-445", "446-450", false},
+ {"443,8443", "8443", true},
+ {"443,8443", "9443", false},
+ }
+ for _, tt := range tests {
+ if got := PortRangesOverlap(tt.a, tt.b); got != tt.want {
+ t.Fatalf("PortRangesOverlap(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want)
+ }
+ }
+}
+
+func TestAddressesConflict(t *testing.T) {
+ tests := []struct {
+ a, b string
+ want bool
+ }{
+ {"0.0.0.0", "127.0.0.1", true},
+ {"127.0.0.1", "127.0.0.1", true},
+ {"127.0.0.1", "127.0.0.2", false},
+ {"::", "::1", true},
+ {"::1", "::1", true},
+ {"127.0.0.1", "::1", false},
+ }
+ for _, tt := range tests {
+ if got := AddressesConflict(tt.a, tt.b); got != tt.want {
+ t.Fatalf("AddressesConflict(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want)
+ }
+ }
+}
From b42f2c4b5c8154418fea47a41da0c8689755e9ae Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 12:35:44 +0800
Subject: [PATCH 14/34] test(listener): cover recursive schema trust boundary
---
.../listener_validation_security_test.go | 24 +++++++++++++++++++
1 file changed, 24 insertions(+)
create mode 100644 backend/internal/mihomo/config/listener_validation_security_test.go
diff --git a/backend/internal/mihomo/config/listener_validation_security_test.go b/backend/internal/mihomo/config/listener_validation_security_test.go
new file mode 100644
index 0000000..bc12bf8
--- /dev/null
+++ b/backend/internal/mihomo/config/listener_validation_security_test.go
@@ -0,0 +1,24 @@
+package config
+
+import "testing"
+
+func TestValidateListenerConfigRejectsUnknownNestedField(t *testing.T) {
+ raw := `{"reality-config":{"private-key":"secret","unknown":"value"}}`
+ if err := ValidateListenerConfig("vless", raw); err == nil {
+ t.Fatal("expected unknown nested field to be rejected")
+ }
+}
+
+func TestValidateListenerConfigRejectsNestedScalar(t *testing.T) {
+ raw := `{"reality-config":"not-an-object"}`
+ if err := ValidateListenerConfig("vless", raw); err == nil {
+ t.Fatal("expected nested object type violation to be rejected")
+ }
+}
+
+func TestValidateListenerConfigAcceptsDeepNestedField(t *testing.T) {
+ raw := `{"reality-config":{"limit-fallback-upload":{"after-bytes":1024}}}`
+ if err := ValidateListenerConfig("vless", raw); err != nil {
+ t.Fatalf("expected valid deep nested field, got %v", err)
+ }
+}
From 222d6614cf2f124e1f02116106eea17c0479e5f2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 12:36:45 +0800
Subject: [PATCH 15/34] feat: add listener version history
---
.../internal/database/models/listener_version.go | 13 +++++++++++++
1 file changed, 13 insertions(+)
create mode 100644 backend/internal/database/models/listener_version.go
diff --git a/backend/internal/database/models/listener_version.go b/backend/internal/database/models/listener_version.go
new file mode 100644
index 0000000..1758818
--- /dev/null
+++ b/backend/internal/database/models/listener_version.go
@@ -0,0 +1,13 @@
+package models
+
+import "time"
+
+// ListenerVersion stores an immutable snapshot of a Listener for diff/rollback.
+type ListenerVersion struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ ListenerID uint `gorm:"not null;index" json:"listener_id"`
+ Version int `gorm:"not null" json:"version"`
+ CreatedAt time.Time `json:"created_at"`
+ Reason string `gorm:"type:varchar(100)" json:"reason,omitempty"`
+ Snapshot string `gorm:"type:text;not null" json:"snapshot"`
+}
From 229a588352f0174cbf73e92dab8dc13a8bdf68a3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 12:36:51 +0800
Subject: [PATCH 16/34] feat: add listener templates
---
.../internal/database/models/listener_template.go | 13 +++++++++++++
1 file changed, 13 insertions(+)
create mode 100644 backend/internal/database/models/listener_template.go
diff --git a/backend/internal/database/models/listener_template.go b/backend/internal/database/models/listener_template.go
new file mode 100644
index 0000000..9c288c2
--- /dev/null
+++ b/backend/internal/database/models/listener_template.go
@@ -0,0 +1,13 @@
+package models
+
+import "time"
+
+// ListenerTemplate stores reusable Listener configuration without runtime state.
+type ListenerTemplate struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ Name string `gorm:"uniqueIndex;not null" json:"name"`
+ Protocol string `gorm:"type:varchar(50);not null" json:"protocol"`
+ Config string `gorm:"type:text;not null" json:"config"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
From 80c6ef499610c85e1b64d9c02fb4d0b668a30310 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 12:36:59 +0800
Subject: [PATCH 17/34] feat: migrate listener templates and versions
---
backend/internal/database/database.go | 18 ++++--------------
1 file changed, 4 insertions(+), 14 deletions(-)
diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go
index 742df42..abf6874 100644
--- a/backend/internal/database/database.go
+++ b/backend/internal/database/database.go
@@ -18,25 +18,15 @@ func InitDB(dbPath string) (*gorm.DB, error) {
return nil, fmt.Errorf("failed to create database directory: %w", err)
}
- db, err := gorm.Open(sqlite.New(sqlite.Config{
- DriverName: sqliteDriverName,
- DSN: dbPath,
- }), &gorm.Config{})
+ db, err := gorm.Open(sqlite.New(sqlite.Config{DriverName: sqliteDriverName, DSN: dbPath}), &gorm.Config{})
if err != nil {
return nil, fmt.Errorf("failed to connect to sqlite database: %w", err)
}
err = db.AutoMigrate(
- &models.User{},
- &models.Listener{},
- &models.ListenerUser{},
- &models.Subscription{},
- &models.AccessToken{},
- &models.Config{},
- &models.ProxyUser{},
- &models.TrafficRecord{},
- &models.PanelSetting{},
- &models.RemoteServer{},
+ &models.User{}, &models.Listener{}, &models.ListenerUser{}, &models.ListenerVersion{},
+ &models.ListenerTemplate{}, &models.Subscription{}, &models.AccessToken{}, &models.Config{},
+ &models.ProxyUser{}, &models.TrafficRecord{}, &models.PanelSetting{}, &models.RemoteServer{},
)
if err != nil {
return nil, fmt.Errorf("failed to run database auto-migration: %w", err)
From b0445d92baaba49e66b9d096f4600aa2ebebd526 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 12:37:09 +0800
Subject: [PATCH 18/34] feat: add listener templates clone batch diff and
rollback
---
backend/internal/listener/p2.go | 105 ++++++++++++++++++++++++++++++++
1 file changed, 105 insertions(+)
create mode 100644 backend/internal/listener/p2.go
diff --git a/backend/internal/listener/p2.go b/backend/internal/listener/p2.go
new file mode 100644
index 0000000..fd681d1
--- /dev/null
+++ b/backend/internal/listener/p2.go
@@ -0,0 +1,105 @@
+package listener
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/kazeyukiro/3m-ui/backend/internal/database/models"
+)
+
+// SaveVersion records an immutable listener snapshot. It is intentionally small
+// and stores the model JSON so old versions remain readable after schema growth.
+func (s *Service) SaveVersion(listenerID uint, reason string) error {
+ l, err := s.GetByID(listenerID)
+ if err != nil { return err }
+ var count int64
+ if err := s.db.Model(&models.ListenerVersion{}).Where("listener_id = ?", listenerID).Count(&count).Error; err != nil { return err }
+ data, err := json.Marshal(l)
+ if err != nil { return fmt.Errorf("marshal listener snapshot: %w", err) }
+ v := &models.ListenerVersion{ListenerID: listenerID, Version: int(count) + 1, Reason: reason, Snapshot: string(data)}
+ return s.db.Create(v).Error
+}
+
+func (s *Service) ListVersions(listenerID uint) ([]models.ListenerVersion, error) {
+ var versions []models.ListenerVersion
+ err := s.db.Where("listener_id = ?", listenerID).Order("version desc").Find(&versions).Error
+ return versions, err
+}
+
+func (s *Service) RollbackVersion(listenerID uint, version int) error {
+ s.mu.Lock(); defer s.mu.Unlock()
+ var v models.ListenerVersion
+ if err := s.db.Where("listener_id = ? AND version = ?", listenerID, version).First(&v).Error; err != nil { return fmt.Errorf("listener version not found: %w", err) }
+ var target models.Listener
+ if err := json.Unmarshal([]byte(v.Snapshot), &target); err != nil { return fmt.Errorf("invalid listener snapshot: %w", err) }
+ target.ID = listenerID
+ if err := ValidateModel(&target); err != nil { return fmt.Errorf("rollback validation failed: %w", err) }
+ if err := s.ensureEndpointAvailable(&target); err != nil { return err }
+ var previous models.Listener
+ if err := s.db.First(&previous, listenerID).Error; err != nil { return err }
+ if err := s.db.Save(&target).Error; err != nil { return err }
+ if err := s.regenerateConfigLocked(); err != nil {
+ _ = s.db.Save(&previous)
+ return err
+ }
+ return nil
+}
+
+func (s *Service) Clone(id uint, name string, port string) (*models.Listener, error) {
+ s.mu.Lock(); defer s.mu.Unlock()
+ var src models.Listener
+ if err := s.db.First(&src, id).Error; err != nil { return nil, err }
+ name = strings.TrimSpace(name)
+ if name == "" { return nil, fmt.Errorf("listener name is required") }
+ src.ID = 0; src.CreatedAt = src.CreatedAt.Add(-src.CreatedAt.Sub(src.CreatedAt)); src.UpdatedAt = src.UpdatedAt.Add(-src.UpdatedAt.Sub(src.UpdatedAt)); src.DeletedAt = src.DeletedAt
+ src.Name = name
+ if strings.TrimSpace(port) != "" { src.Port = strings.TrimSpace(port) }
+ if err := ValidateModel(&src); err != nil { return nil, err }
+ if err := s.ensureEndpointAvailable(&src); err != nil { return nil, err }
+ if err := s.db.Create(&src).Error; err != nil { return nil, err }
+ if err := s.regenerateConfigLocked(); err != nil { _ = s.db.Delete(&src); return nil, err }
+ return &src, nil
+}
+
+func (s *Service) BatchSetEnabled(ids []uint, enabled bool) error {
+ s.mu.Lock(); defer s.mu.Unlock()
+ if len(ids) == 0 { return fmt.Errorf("no listener ids supplied") }
+ var previous []models.Listener
+ if err := s.db.Where("id IN ?", ids).Find(&previous).Error; err != nil { return err }
+ if len(previous) != len(ids) { return fmt.Errorf("one or more listeners were not found") }
+ if err := s.db.Model(&models.Listener{}).Where("id IN ?", ids).Update("enabled", enabled).Error; err != nil { return err }
+ if err := s.regenerateConfigLocked(); err != nil {
+ for i := range previous { _ = s.db.Save(&previous[i]) }
+ _ = s.regenerateConfigLocked()
+ return err
+ }
+ return nil
+}
+
+func (s *Service) DiffVersion(listenerID uint, version int) (string, error) {
+ var v models.ListenerVersion
+ if err := s.db.Where("listener_id = ? AND version = ?", listenerID, version).First(&v).Error; err != nil { return "", err }
+ current, err := s.GetByID(listenerID)
+ if err != nil { return "", err }
+ cur, _ := json.MarshalIndent(current, "", " ")
+ old := []byte(v.Snapshot)
+ return fmt.Sprintf("--- version/%d\n+++ current\n- %s\n+ %s", version, strings.TrimSpace(string(old)), strings.TrimSpace(string(cur))), nil
+}
+
+func (s *Service) CreateTemplate(t *models.ListenerTemplate) error {
+ if strings.TrimSpace(t.Name) == "" { return fmt.Errorf("template name is required") }
+ if strings.TrimSpace(t.Protocol) == "" { return fmt.Errorf("template protocol is required") }
+ // Validate the template as a Listener config without accepting runtime fields.
+ probe := &models.Listener{Name: t.Name, Protocol: t.Protocol, Port: "1", Config: t.Config}
+ if err := ValidateModel(probe); err != nil { return err }
+ return s.db.Create(t).Error
+}
+
+func (s *Service) ListTemplates() ([]models.ListenerTemplate, error) {
+ var out []models.ListenerTemplate
+ err := s.db.Order("name asc").Find(&out).Error
+ return out, err
+}
+
+func (s *Service) DeleteTemplate(id uint) error { return s.db.Delete(&models.ListenerTemplate{}, id).Error }
From 1ce6651be3bf20a568563b168cb36c3084061ea1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 12:37:21 +0800
Subject: [PATCH 19/34] feat: record listener history on lifecycle changes
---
backend/internal/listener/service.go | 16 ++++++----------
1 file changed, 6 insertions(+), 10 deletions(-)
diff --git a/backend/internal/listener/service.go b/backend/internal/listener/service.go
index b4e7e16..48a893a 100644
--- a/backend/internal/listener/service.go
+++ b/backend/internal/listener/service.go
@@ -32,6 +32,7 @@ func (s *Service) Create(l *models.Listener) error {
_ = s.db.Delete(&models.Listener{}, l.ID).Error
return err
}
+ if err := s.SaveVersion(l.ID, "create"); err != nil { return fmt.Errorf("listener created but history save failed: %w", err) }
return nil
}
@@ -54,6 +55,7 @@ func (s *Service) Update(l *models.Listener) error {
var previous models.Listener
if err := s.db.First(&previous, l.ID).Error; err != nil { return fmt.Errorf("failed to load previous listener: %w", err) }
if err := s.ensureEndpointAvailable(l); err != nil { return err }
+ if err := s.SaveVersion(previous.ID, "before-update"); err != nil { return fmt.Errorf("save listener history: %w", err) }
if err := s.db.Save(l).Error; err != nil { return fmt.Errorf("failed to update listener: %w", err) }
if err := s.regenerateConfigLocked(); err != nil {
if rollbackErr := s.db.Save(&previous).Error; rollbackErr != nil { return fmt.Errorf("%v; rollback listener failed: %w", err, rollbackErr) }
@@ -70,9 +72,8 @@ func (s *Service) Delete(id uint) error {
if err := s.db.First(&previous, id).Error; err != nil { return fmt.Errorf("failed to fetch listener before delete: %w", err) }
var linked int64
if err := s.db.Model(&models.ListenerUser{}).Where("listener_id = ?", id).Count(&linked).Error; err != nil { return fmt.Errorf("failed to check listener clients: %w", err) }
- if linked > 0 {
- return fmt.Errorf("listener %q has %d linked client(s); unbind clients before deleting the listener", previous.Name, linked)
- }
+ if linked > 0 { return fmt.Errorf("listener %q has %d linked client(s); unbind clients before deleting the listener", previous.Name, linked) }
+ if err := s.SaveVersion(id, "before-delete"); err != nil { return fmt.Errorf("save listener history: %w", err) }
if err := s.db.Delete(&models.Listener{}, id).Error; err != nil { return fmt.Errorf("failed to delete listener: %w", err) }
if err := s.regenerateConfigLocked(); err != nil {
if rollbackErr := s.db.Unscoped().Save(&previous).Error; rollbackErr != nil { return fmt.Errorf("%v; rollback deleted listener failed: %w", err, rollbackErr) }
@@ -94,11 +95,7 @@ func (s *Service) ensureEndpointAvailable(candidate *models.Listener) error {
return nil
}
-func (s *Service) RegenerateConfig() error {
- s.mu.Lock()
- defer s.mu.Unlock()
- return s.regenerateConfigLocked()
-}
+func (s *Service) RegenerateConfig() error { s.mu.Lock(); defer s.mu.Unlock(); return s.regenerateConfigLocked() }
func (s *Service) regenerateConfigLocked() error {
if s == nil || s.db == nil { return fmt.Errorf("listener service not initialized") }
@@ -110,8 +107,7 @@ func (s *Service) regenerateConfigLocked() error {
if err := os.MkdirAll(dir, 0750); err != nil { return fmt.Errorf("create config directory: %w", err) }
tmp, err := os.CreateTemp(dir, ".config.yaml.tmp-*")
if err != nil { return fmt.Errorf("create temporary config: %w", err) }
- tmpName := tmp.Name()
- defer os.Remove(tmpName)
+ tmpName := tmp.Name(); defer os.Remove(tmpName)
if err := tmp.Chmod(0600); err != nil { _ = tmp.Close(); return err }
if _, err := tmp.WriteString(yamlContent); err != nil { _ = tmp.Close(); return err }
if err := tmp.Sync(); err != nil { _ = tmp.Close(); return err }
From a386d240582f056fca02ec7f298116ec1dc0abf5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 12:37:33 +0800
Subject: [PATCH 20/34] feat: expose listener P2 management APIs
---
backend/internal/listener/api.go | 119 +++++++------------------------
1 file changed, 24 insertions(+), 95 deletions(-)
diff --git a/backend/internal/listener/api.go b/backend/internal/listener/api.go
index 3153223..aad4ead 100644
--- a/backend/internal/listener/api.go
+++ b/backend/internal/listener/api.go
@@ -8,103 +8,32 @@ import (
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
)
-// Handler serves listener HTTP endpoints with an injected Service.
-type Handler struct {
- svc *Service
-}
+type Handler struct { svc *Service }
+func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
-// NewHandler constructs a listener HTTP handler.
-func NewHandler(svc *Service) *Handler {
- return &Handler{svc: svc}
-}
-
-// RegisterRoutes registers listener CRUD routes under the provided group.
func (h *Handler) RegisterRoutes(rg *gin.RouterGroup) {
- rg.GET("", h.ListListeners)
- rg.POST("", h.CreateListener)
- rg.GET("/:id", h.GetListener)
- rg.PUT("/:id", h.UpdateListener)
- rg.DELETE("/:id", h.DeleteListener)
- rg.POST("/:id/reload", h.ReloadListener)
+ rg.GET("", h.ListListeners); rg.POST("", h.CreateListener)
+ rg.GET("/:id", h.GetListener); rg.PUT("/:id", h.UpdateListener)
+ rg.DELETE("/:id", h.DeleteListener); rg.POST("/:id/reload", h.ReloadListener)
+ rg.POST("/:id/clone", h.CloneListener); rg.GET("/:id/versions", h.ListVersions)
+ rg.GET("/:id/versions/:version/diff", h.DiffVersion); rg.POST("/:id/versions/:version/rollback", h.RollbackVersion)
+ rg.POST("/batch/enabled", h.BatchEnabled)
+ rg.GET("/templates", h.ListTemplates); rg.POST("/templates", h.CreateTemplate); rg.DELETE("/templates/:id", h.DeleteTemplate)
}
-func (h *Handler) ListListeners(c *gin.Context) {
- list, err := h.svc.GetAll()
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusOK, list)
-}
+func parseID(c *gin.Context, key string) (uint, bool) { n, err := strconv.ParseUint(c.Param(key), 10, 32); if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"invalid id"}); return 0, false }; return uint(n), true }
+func (h *Handler) ListListeners(c *gin.Context) { list,err:=h.svc.GetAll(); if err!=nil { c.JSON(500,gin.H{"error":err.Error()}); return }; c.JSON(200,list) }
+func (h *Handler) CreateListener(c *gin.Context) { var l models.Listener; if err:=c.ShouldBindJSON(&l);err!=nil {c.JSON(400,gin.H{"error":err.Error()});return}; if err:=h.svc.Create(&l);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(201,l) }
+func (h *Handler) GetListener(c *gin.Context) { id,ok:=parseID(c,"id");if !ok{return};l,err:=h.svc.GetByID(id);if err!=nil{c.JSON(404,gin.H{"error":err.Error()});return};c.JSON(200,l) }
+func (h *Handler) UpdateListener(c *gin.Context) { id,ok:=parseID(c,"id");if !ok{return};var l models.Listener;if err:=c.ShouldBindJSON(&l);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};l.ID=id;if err:=h.svc.Update(&l);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(200,l) }
+func (h *Handler) DeleteListener(c *gin.Context) { id,ok:=parseID(c,"id");if !ok{return};if err:=h.svc.Delete(id);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(200,gin.H{"status":"ok"}) }
+func (h *Handler) ReloadListener(c *gin.Context) { if _,ok:=parseID(c,"id");!ok{return};if err:=h.svc.RegenerateConfig();err!=nil{c.JSON(500,gin.H{"error":err.Error()});return};c.JSON(200,gin.H{"status":"ok"}) }
-func (h *Handler) CreateListener(c *gin.Context) {
- var l models.Listener
- if err := c.ShouldBindJSON(&l); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
- if err := h.svc.Create(&l); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusCreated, l)
-}
-
-func (h *Handler) GetListener(c *gin.Context) {
- id, err := strconv.ParseUint(c.Param("id"), 10, 32)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid listener id"})
- return
- }
- l, err := h.svc.GetByID(uint(id))
- if err != nil {
- c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusOK, l)
-}
-
-func (h *Handler) UpdateListener(c *gin.Context) {
- id, err := strconv.ParseUint(c.Param("id"), 10, 32)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid listener id"})
- return
- }
- var l models.Listener
- if err := c.ShouldBindJSON(&l); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
- l.ID = uint(id)
- if err := h.svc.Update(&l); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusOK, l)
-}
-
-func (h *Handler) DeleteListener(c *gin.Context) {
- id, err := strconv.ParseUint(c.Param("id"), 10, 32)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid listener id"})
- return
- }
- if err := h.svc.Delete(uint(id)); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "listener deleted"})
-}
-
-func (h *Handler) ReloadListener(c *gin.Context) {
- id, err := strconv.ParseUint(c.Param("id"), 10, 32)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid listener id"})
- return
- }
- if err := h.svc.TriggerReload(uint(id)); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "listener reloaded"})
-}
+func (h *Handler) CloneListener(c *gin.Context) { id,ok:=parseID(c,"id");if !ok{return};var req struct{Name string `json:"name"`;Port string `json:"port"`};if err:=c.ShouldBindJSON(&req);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};l,err:=h.svc.Clone(id,req.Name,req.Port);if err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(201,l) }
+func (h *Handler) ListVersions(c *gin.Context) { id,ok:=parseID(c,"id");if !ok{return};v,err:=h.svc.ListVersions(id);if err!=nil{c.JSON(500,gin.H{"error":err.Error()});return};c.JSON(200,v) }
+func (h *Handler) DiffVersion(c *gin.Context) { id,ok:=parseID(c,"id");if !ok{return};v,err:=strconv.Atoi(c.Param("version"));if err!=nil||v<1{c.JSON(400,gin.H{"error":"invalid version"});return};d,err:=h.svc.DiffVersion(id,v);if err!=nil{c.JSON(404,gin.H{"error":err.Error()});return};c.Data(200,"text/plain; charset=utf-8",[]byte(d)) }
+func (h *Handler) RollbackVersion(c *gin.Context) { id,ok:=parseID(c,"id");if !ok{return};v,err:=strconv.Atoi(c.Param("version"));if err!=nil||v<1{c.JSON(400,gin.H{"error":"invalid version"});return};if err:=h.svc.RollbackVersion(id,v);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(200,gin.H{"status":"ok"}) }
+func (h *Handler) BatchEnabled(c *gin.Context) { var req struct{IDs []uint `json:"ids"`;Enabled bool `json:"enabled"`};if err:=c.ShouldBindJSON(&req);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};if err:=h.svc.BatchSetEnabled(req.IDs,req.Enabled);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(200,gin.H{"status":"ok"}) }
+func (h *Handler) ListTemplates(c *gin.Context) { t,err:=h.svc.ListTemplates();if err!=nil{c.JSON(500,gin.H{"error":err.Error()});return};c.JSON(200,t) }
+func (h *Handler) CreateTemplate(c *gin.Context) { var t models.ListenerTemplate;if err:=c.ShouldBindJSON(&t);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};if err:=h.svc.CreateTemplate(&t);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(201,t) }
+func (h *Handler) DeleteTemplate(c *gin.Context) { id,ok:=parseID(c,"id");if !ok{return};if err:=h.svc.DeleteTemplate(id);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(200,gin.H{"status":"ok"}) }
From dcb9ebfdc33c2921ff6e0d9eed9eab82ad43be08 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 12:37:45 +0800
Subject: [PATCH 21/34] fix: clean cloned listener timestamps and record clone
history
---
backend/internal/listener/p2.go | 34 +++++++++++----------------------
1 file changed, 11 insertions(+), 23 deletions(-)
diff --git a/backend/internal/listener/p2.go b/backend/internal/listener/p2.go
index fd681d1..6d40686 100644
--- a/backend/internal/listener/p2.go
+++ b/backend/internal/listener/p2.go
@@ -8,8 +8,6 @@ import (
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
)
-// SaveVersion records an immutable listener snapshot. It is intentionally small
-// and stores the model JSON so old versions remain readable after schema growth.
func (s *Service) SaveVersion(listenerID uint, reason string) error {
l, err := s.GetByID(listenerID)
if err != nil { return err }
@@ -17,8 +15,7 @@ func (s *Service) SaveVersion(listenerID uint, reason string) error {
if err := s.db.Model(&models.ListenerVersion{}).Where("listener_id = ?", listenerID).Count(&count).Error; err != nil { return err }
data, err := json.Marshal(l)
if err != nil { return fmt.Errorf("marshal listener snapshot: %w", err) }
- v := &models.ListenerVersion{ListenerID: listenerID, Version: int(count) + 1, Reason: reason, Snapshot: string(data)}
- return s.db.Create(v).Error
+ return s.db.Create(&models.ListenerVersion{ListenerID: listenerID, Version: int(count) + 1, Reason: reason, Snapshot: string(data)}).Error
}
func (s *Service) ListVersions(listenerID uint) ([]models.ListenerVersion, error) {
@@ -36,13 +33,11 @@ func (s *Service) RollbackVersion(listenerID uint, version int) error {
target.ID = listenerID
if err := ValidateModel(&target); err != nil { return fmt.Errorf("rollback validation failed: %w", err) }
if err := s.ensureEndpointAvailable(&target); err != nil { return err }
+ if err := s.SaveVersion(listenerID, "before-rollback"); err != nil { return err }
var previous models.Listener
if err := s.db.First(&previous, listenerID).Error; err != nil { return err }
if err := s.db.Save(&target).Error; err != nil { return err }
- if err := s.regenerateConfigLocked(); err != nil {
- _ = s.db.Save(&previous)
- return err
- }
+ if err := s.regenerateConfigLocked(); err != nil { _ = s.db.Save(&previous); _ = s.regenerateConfigLocked(); return err }
return nil
}
@@ -52,13 +47,16 @@ func (s *Service) Clone(id uint, name string, port string) (*models.Listener, er
if err := s.db.First(&src, id).Error; err != nil { return nil, err }
name = strings.TrimSpace(name)
if name == "" { return nil, fmt.Errorf("listener name is required") }
- src.ID = 0; src.CreatedAt = src.CreatedAt.Add(-src.CreatedAt.Sub(src.CreatedAt)); src.UpdatedAt = src.UpdatedAt.Add(-src.UpdatedAt.Sub(src.UpdatedAt)); src.DeletedAt = src.DeletedAt
+ src.ID = 0
+ src.CreatedAt = src.CreatedAt.AddDate(-100, 0, 0)
+ src.UpdatedAt = src.UpdatedAt.AddDate(-100, 0, 0)
src.Name = name
if strings.TrimSpace(port) != "" { src.Port = strings.TrimSpace(port) }
if err := ValidateModel(&src); err != nil { return nil, err }
if err := s.ensureEndpointAvailable(&src); err != nil { return nil, err }
if err := s.db.Create(&src).Error; err != nil { return nil, err }
if err := s.regenerateConfigLocked(); err != nil { _ = s.db.Delete(&src); return nil, err }
+ if err := s.SaveVersion(src.ID, "clone"); err != nil { return nil, err }
return &src, nil
}
@@ -69,11 +67,8 @@ func (s *Service) BatchSetEnabled(ids []uint, enabled bool) error {
if err := s.db.Where("id IN ?", ids).Find(&previous).Error; err != nil { return err }
if len(previous) != len(ids) { return fmt.Errorf("one or more listeners were not found") }
if err := s.db.Model(&models.Listener{}).Where("id IN ?", ids).Update("enabled", enabled).Error; err != nil { return err }
- if err := s.regenerateConfigLocked(); err != nil {
- for i := range previous { _ = s.db.Save(&previous[i]) }
- _ = s.regenerateConfigLocked()
- return err
- }
+ if err := s.regenerateConfigLocked(); err != nil { for i := range previous { _ = s.db.Save(&previous[i]) }; _ = s.regenerateConfigLocked(); return err }
+ for _, l := range previous { _ = s.SaveVersion(l.ID, "batch-enabled") }
return nil
}
@@ -83,23 +78,16 @@ func (s *Service) DiffVersion(listenerID uint, version int) (string, error) {
current, err := s.GetByID(listenerID)
if err != nil { return "", err }
cur, _ := json.MarshalIndent(current, "", " ")
- old := []byte(v.Snapshot)
+ old, _ := json.MarshalIndent(json.RawMessage(v.Snapshot), "", " ")
return fmt.Sprintf("--- version/%d\n+++ current\n- %s\n+ %s", version, strings.TrimSpace(string(old)), strings.TrimSpace(string(cur))), nil
}
func (s *Service) CreateTemplate(t *models.ListenerTemplate) error {
if strings.TrimSpace(t.Name) == "" { return fmt.Errorf("template name is required") }
if strings.TrimSpace(t.Protocol) == "" { return fmt.Errorf("template protocol is required") }
- // Validate the template as a Listener config without accepting runtime fields.
probe := &models.Listener{Name: t.Name, Protocol: t.Protocol, Port: "1", Config: t.Config}
if err := ValidateModel(probe); err != nil { return err }
return s.db.Create(t).Error
}
-
-func (s *Service) ListTemplates() ([]models.ListenerTemplate, error) {
- var out []models.ListenerTemplate
- err := s.db.Order("name asc").Find(&out).Error
- return out, err
-}
-
+func (s *Service) ListTemplates() ([]models.ListenerTemplate, error) { var out []models.ListenerTemplate; err:=s.db.Order("name asc").Find(&out).Error; return out,err }
func (s *Service) DeleteTemplate(id uint) error { return s.db.Delete(&models.ListenerTemplate{}, id).Error }
From b8bd3ab29066ebb406bb07bb8e2a65a2e1b183aa Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 12:37:56 +0800
Subject: [PATCH 22/34] fix: use zero timestamps for cloned listeners
---
backend/internal/listener/p2.go | 92 ++++++++++-----------------------
1 file changed, 28 insertions(+), 64 deletions(-)
diff --git a/backend/internal/listener/p2.go b/backend/internal/listener/p2.go
index 6d40686..f8cdde2 100644
--- a/backend/internal/listener/p2.go
+++ b/backend/internal/listener/p2.go
@@ -4,90 +4,54 @@ import (
"encoding/json"
"fmt"
"strings"
+ "time"
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
)
func (s *Service) SaveVersion(listenerID uint, reason string) error {
- l, err := s.GetByID(listenerID)
- if err != nil { return err }
+ l, err := s.GetByID(listenerID); if err != nil { return err }
var count int64
if err := s.db.Model(&models.ListenerVersion{}).Where("listener_id = ?", listenerID).Count(&count).Error; err != nil { return err }
- data, err := json.Marshal(l)
- if err != nil { return fmt.Errorf("marshal listener snapshot: %w", err) }
- return s.db.Create(&models.ListenerVersion{ListenerID: listenerID, Version: int(count) + 1, Reason: reason, Snapshot: string(data)}).Error
+ data, err := json.Marshal(l); if err != nil { return fmt.Errorf("marshal listener snapshot: %w", err) }
+ return s.db.Create(&models.ListenerVersion{ListenerID: listenerID, Version: int(count)+1, Reason: reason, Snapshot: string(data)}).Error
}
-func (s *Service) ListVersions(listenerID uint) ([]models.ListenerVersion, error) {
- var versions []models.ListenerVersion
- err := s.db.Where("listener_id = ?", listenerID).Order("version desc").Find(&versions).Error
- return versions, err
-}
+func (s *Service) ListVersions(listenerID uint) ([]models.ListenerVersion, error) { var versions []models.ListenerVersion; err:=s.db.Where("listener_id = ?",listenerID).Order("version desc").Find(&versions).Error; return versions,err }
func (s *Service) RollbackVersion(listenerID uint, version int) error {
s.mu.Lock(); defer s.mu.Unlock()
var v models.ListenerVersion
- if err := s.db.Where("listener_id = ? AND version = ?", listenerID, version).First(&v).Error; err != nil { return fmt.Errorf("listener version not found: %w", err) }
+ if err:=s.db.Where("listener_id = ? AND version = ?",listenerID,version).First(&v).Error;err!=nil{return fmt.Errorf("listener version not found: %w",err)}
var target models.Listener
- if err := json.Unmarshal([]byte(v.Snapshot), &target); err != nil { return fmt.Errorf("invalid listener snapshot: %w", err) }
- target.ID = listenerID
- if err := ValidateModel(&target); err != nil { return fmt.Errorf("rollback validation failed: %w", err) }
- if err := s.ensureEndpointAvailable(&target); err != nil { return err }
- if err := s.SaveVersion(listenerID, "before-rollback"); err != nil { return err }
+ if err:=json.Unmarshal([]byte(v.Snapshot),&target);err!=nil{return fmt.Errorf("invalid listener snapshot: %w",err)}
+ target.ID=listenerID
+ if err:=ValidateModel(&target);err!=nil{return fmt.Errorf("rollback validation failed: %w",err)}
+ if err:=s.ensureEndpointAvailable(&target);err!=nil{return err}
+ if err:=s.SaveVersion(listenerID,"before-rollback");err!=nil{return err}
var previous models.Listener
- if err := s.db.First(&previous, listenerID).Error; err != nil { return err }
- if err := s.db.Save(&target).Error; err != nil { return err }
- if err := s.regenerateConfigLocked(); err != nil { _ = s.db.Save(&previous); _ = s.regenerateConfigLocked(); return err }
+ if err:=s.db.First(&previous,listenerID).Error;err!=nil{return err}
+ if err:=s.db.Save(&target).Error;err!=nil{return err}
+ if err:=s.regenerateConfigLocked();err!=nil{_ = s.db.Save(&previous); _ = s.regenerateConfigLocked(); return err}
return nil
}
-func (s *Service) Clone(id uint, name string, port string) (*models.Listener, error) {
- s.mu.Lock(); defer s.mu.Unlock()
+func (s *Service) Clone(id uint,name,port string)(*models.Listener,error){
+ s.mu.Lock();defer s.mu.Unlock()
var src models.Listener
- if err := s.db.First(&src, id).Error; err != nil { return nil, err }
- name = strings.TrimSpace(name)
- if name == "" { return nil, fmt.Errorf("listener name is required") }
- src.ID = 0
- src.CreatedAt = src.CreatedAt.AddDate(-100, 0, 0)
- src.UpdatedAt = src.UpdatedAt.AddDate(-100, 0, 0)
- src.Name = name
- if strings.TrimSpace(port) != "" { src.Port = strings.TrimSpace(port) }
- if err := ValidateModel(&src); err != nil { return nil, err }
- if err := s.ensureEndpointAvailable(&src); err != nil { return nil, err }
- if err := s.db.Create(&src).Error; err != nil { return nil, err }
- if err := s.regenerateConfigLocked(); err != nil { _ = s.db.Delete(&src); return nil, err }
- if err := s.SaveVersion(src.ID, "clone"); err != nil { return nil, err }
- return &src, nil
+ if err:=s.db.First(&src,id).Error;err!=nil{return nil,err}
+ name=strings.TrimSpace(name);if name==""{return nil,fmt.Errorf("listener name is required")}
+ src.ID=0;src.CreatedAt=time.Time{};src.UpdatedAt=time.Time{};src.DeletedAt=0;src.Name=name
+ if strings.TrimSpace(port)!=""{src.Port=strings.TrimSpace(port)}
+ if err:=ValidateModel(&src);err!=nil{return nil,err};if err:=s.ensureEndpointAvailable(&src);err!=nil{return nil,err}
+ if err:=s.db.Create(&src).Error;err!=nil{return nil,err};if err:=s.regenerateConfigLocked();err!=nil{_ = s.db.Delete(&src);return nil,err}
+ if err:=s.SaveVersion(src.ID,"clone");err!=nil{return nil,err};return &src,nil
}
-func (s *Service) BatchSetEnabled(ids []uint, enabled bool) error {
- s.mu.Lock(); defer s.mu.Unlock()
- if len(ids) == 0 { return fmt.Errorf("no listener ids supplied") }
- var previous []models.Listener
- if err := s.db.Where("id IN ?", ids).Find(&previous).Error; err != nil { return err }
- if len(previous) != len(ids) { return fmt.Errorf("one or more listeners were not found") }
- if err := s.db.Model(&models.Listener{}).Where("id IN ?", ids).Update("enabled", enabled).Error; err != nil { return err }
- if err := s.regenerateConfigLocked(); err != nil { for i := range previous { _ = s.db.Save(&previous[i]) }; _ = s.regenerateConfigLocked(); return err }
- for _, l := range previous { _ = s.SaveVersion(l.ID, "batch-enabled") }
- return nil
-}
+func (s *Service) BatchSetEnabled(ids []uint,enabled bool)error{s.mu.Lock();defer s.mu.Unlock();if len(ids)==0{return fmt.Errorf("no listener ids supplied")};var previous []models.Listener;if err:=s.db.Where("id IN ?",ids).Find(&previous).Error;err!=nil{return err};if len(previous)!=len(ids){return fmt.Errorf("one or more listeners were not found")};if err:=s.db.Model(&models.Listener{}).Where("id IN ?",ids).Update("enabled",enabled).Error;err!=nil{return err};if err:=s.regenerateConfigLocked();err!=nil{for i:=range previous{_ = s.db.Save(&previous[i])};_ = s.regenerateConfigLocked();return err};for _,l:=range previous{_ = s.SaveVersion(l.ID,"batch-enabled")};return nil}
-func (s *Service) DiffVersion(listenerID uint, version int) (string, error) {
- var v models.ListenerVersion
- if err := s.db.Where("listener_id = ? AND version = ?", listenerID, version).First(&v).Error; err != nil { return "", err }
- current, err := s.GetByID(listenerID)
- if err != nil { return "", err }
- cur, _ := json.MarshalIndent(current, "", " ")
- old, _ := json.MarshalIndent(json.RawMessage(v.Snapshot), "", " ")
- return fmt.Sprintf("--- version/%d\n+++ current\n- %s\n+ %s", version, strings.TrimSpace(string(old)), strings.TrimSpace(string(cur))), nil
-}
+func (s *Service) DiffVersion(listenerID uint,version int)(string,error){var v models.ListenerVersion;if err:=s.db.Where("listener_id = ? AND version = ?",listenerID,version).First(&v).Error;err!=nil{return "",err};current,err:=s.GetByID(listenerID);if err!=nil{return "",err};cur,_:=json.MarshalIndent(current,""," ");var old any;if err:=json.Unmarshal([]byte(v.Snapshot),&old);err!=nil{return "",err};oldJSON,_:=json.MarshalIndent(old,""," ");return fmt.Sprintf("--- version/%d\n+++ current\n- %s\n+ %s",version,strings.TrimSpace(string(oldJSON)),strings.TrimSpace(string(cur))),nil}
-func (s *Service) CreateTemplate(t *models.ListenerTemplate) error {
- if strings.TrimSpace(t.Name) == "" { return fmt.Errorf("template name is required") }
- if strings.TrimSpace(t.Protocol) == "" { return fmt.Errorf("template protocol is required") }
- probe := &models.Listener{Name: t.Name, Protocol: t.Protocol, Port: "1", Config: t.Config}
- if err := ValidateModel(probe); err != nil { return err }
- return s.db.Create(t).Error
-}
-func (s *Service) ListTemplates() ([]models.ListenerTemplate, error) { var out []models.ListenerTemplate; err:=s.db.Order("name asc").Find(&out).Error; return out,err }
-func (s *Service) DeleteTemplate(id uint) error { return s.db.Delete(&models.ListenerTemplate{}, id).Error }
+func (s *Service) CreateTemplate(t *models.ListenerTemplate)error{if strings.TrimSpace(t.Name)==""{return fmt.Errorf("template name is required")};if strings.TrimSpace(t.Protocol)==""{return fmt.Errorf("template protocol is required")};probe:=&models.Listener{Name:t.Name,Protocol:t.Protocol,Port:"1",Config:t.Config};if err:=ValidateModel(probe);err!=nil{return err};return s.db.Create(t).Error}
+func (s *Service) ListTemplates()([]models.ListenerTemplate,error){var out []models.ListenerTemplate;err:=s.db.Order("name asc").Find(&out).Error;return out,err}
+func (s *Service) DeleteTemplate(id uint)error{return s.db.Delete(&models.ListenerTemplate{},id).Error}
From db5c4673789494c349c72dbe44acf6d858c1e0ec Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 12:38:05 +0800
Subject: [PATCH 23/34] fix: reset cloned soft-delete state
---
backend/internal/listener/p2.go | 46 ++++-----------------------------
1 file changed, 5 insertions(+), 41 deletions(-)
diff --git a/backend/internal/listener/p2.go b/backend/internal/listener/p2.go
index f8cdde2..f359f26 100644
--- a/backend/internal/listener/p2.go
+++ b/backend/internal/listener/p2.go
@@ -7,51 +7,15 @@ import (
"time"
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
+ "gorm.io/gorm"
)
-func (s *Service) SaveVersion(listenerID uint, reason string) error {
- l, err := s.GetByID(listenerID); if err != nil { return err }
- var count int64
- if err := s.db.Model(&models.ListenerVersion{}).Where("listener_id = ?", listenerID).Count(&count).Error; err != nil { return err }
- data, err := json.Marshal(l); if err != nil { return fmt.Errorf("marshal listener snapshot: %w", err) }
- return s.db.Create(&models.ListenerVersion{ListenerID: listenerID, Version: int(count)+1, Reason: reason, Snapshot: string(data)}).Error
-}
-
-func (s *Service) ListVersions(listenerID uint) ([]models.ListenerVersion, error) { var versions []models.ListenerVersion; err:=s.db.Where("listener_id = ?",listenerID).Order("version desc").Find(&versions).Error; return versions,err }
-
-func (s *Service) RollbackVersion(listenerID uint, version int) error {
- s.mu.Lock(); defer s.mu.Unlock()
- var v models.ListenerVersion
- if err:=s.db.Where("listener_id = ? AND version = ?",listenerID,version).First(&v).Error;err!=nil{return fmt.Errorf("listener version not found: %w",err)}
- var target models.Listener
- if err:=json.Unmarshal([]byte(v.Snapshot),&target);err!=nil{return fmt.Errorf("invalid listener snapshot: %w",err)}
- target.ID=listenerID
- if err:=ValidateModel(&target);err!=nil{return fmt.Errorf("rollback validation failed: %w",err)}
- if err:=s.ensureEndpointAvailable(&target);err!=nil{return err}
- if err:=s.SaveVersion(listenerID,"before-rollback");err!=nil{return err}
- var previous models.Listener
- if err:=s.db.First(&previous,listenerID).Error;err!=nil{return err}
- if err:=s.db.Save(&target).Error;err!=nil{return err}
- if err:=s.regenerateConfigLocked();err!=nil{_ = s.db.Save(&previous); _ = s.regenerateConfigLocked(); return err}
- return nil
-}
-
-func (s *Service) Clone(id uint,name,port string)(*models.Listener,error){
- s.mu.Lock();defer s.mu.Unlock()
- var src models.Listener
- if err:=s.db.First(&src,id).Error;err!=nil{return nil,err}
- name=strings.TrimSpace(name);if name==""{return nil,fmt.Errorf("listener name is required")}
- src.ID=0;src.CreatedAt=time.Time{};src.UpdatedAt=time.Time{};src.DeletedAt=0;src.Name=name
- if strings.TrimSpace(port)!=""{src.Port=strings.TrimSpace(port)}
- if err:=ValidateModel(&src);err!=nil{return nil,err};if err:=s.ensureEndpointAvailable(&src);err!=nil{return nil,err}
- if err:=s.db.Create(&src).Error;err!=nil{return nil,err};if err:=s.regenerateConfigLocked();err!=nil{_ = s.db.Delete(&src);return nil,err}
- if err:=s.SaveVersion(src.ID,"clone");err!=nil{return nil,err};return &src,nil
-}
-
+func (s *Service) SaveVersion(listenerID uint, reason string) error { l,err:=s.GetByID(listenerID);if err!=nil{return err};var count int64;if err:=s.db.Model(&models.ListenerVersion{}).Where("listener_id = ?",listenerID).Count(&count).Error;err!=nil{return err};data,err:=json.Marshal(l);if err!=nil{return fmt.Errorf("marshal listener snapshot: %w",err)};return s.db.Create(&models.ListenerVersion{ListenerID:listenerID,Version:int(count)+1,Reason:reason,Snapshot:string(data)}).Error }
+func (s *Service) ListVersions(listenerID uint)([]models.ListenerVersion,error){var versions []models.ListenerVersion;err:=s.db.Where("listener_id = ?",listenerID).Order("version desc").Find(&versions).Error;return versions,err}
+func (s *Service) RollbackVersion(listenerID uint,version int)error{s.mu.Lock();defer s.mu.Unlock();var v models.ListenerVersion;if err:=s.db.Where("listener_id = ? AND version = ?",listenerID,version).First(&v).Error;err!=nil{return fmt.Errorf("listener version not found: %w",err)};var target models.Listener;if err:=json.Unmarshal([]byte(v.Snapshot),&target);err!=nil{return fmt.Errorf("invalid listener snapshot: %w",err)};target.ID=listenerID;if err:=ValidateModel(&target);err!=nil{return fmt.Errorf("rollback validation failed: %w",err)};if err:=s.ensureEndpointAvailable(&target);err!=nil{return err};if err:=s.SaveVersion(listenerID,"before-rollback");err!=nil{return err};var previous models.Listener;if err:=s.db.First(&previous,listenerID).Error;err!=nil{return err};if err:=s.db.Save(&target).Error;err!=nil{return err};if err:=s.regenerateConfigLocked();err!=nil{_ = s.db.Save(&previous);_=s.regenerateConfigLocked();return err};return nil}
+func (s *Service) Clone(id uint,name,port string)(*models.Listener,error){s.mu.Lock();defer s.mu.Unlock();var src models.Listener;if err:=s.db.First(&src,id).Error;err!=nil{return nil,err};name=strings.TrimSpace(name);if name==""{return nil,fmt.Errorf("listener name is required")};src.ID=0;src.CreatedAt=time.Time{};src.UpdatedAt=time.Time{};src.DeletedAt=gorm.DeletedAt{};src.Name=name;if strings.TrimSpace(port)!=""{src.Port=strings.TrimSpace(port)};if err:=ValidateModel(&src);err!=nil{return nil,err};if err:=s.ensureEndpointAvailable(&src);err!=nil{return nil,err};if err:=s.db.Create(&src).Error;err!=nil{return nil,err};if err:=s.regenerateConfigLocked();err!=nil{_ = s.db.Delete(&src);return nil,err};if err:=s.SaveVersion(src.ID,"clone");err!=nil{return nil,err};return &src,nil}
func (s *Service) BatchSetEnabled(ids []uint,enabled bool)error{s.mu.Lock();defer s.mu.Unlock();if len(ids)==0{return fmt.Errorf("no listener ids supplied")};var previous []models.Listener;if err:=s.db.Where("id IN ?",ids).Find(&previous).Error;err!=nil{return err};if len(previous)!=len(ids){return fmt.Errorf("one or more listeners were not found")};if err:=s.db.Model(&models.Listener{}).Where("id IN ?",ids).Update("enabled",enabled).Error;err!=nil{return err};if err:=s.regenerateConfigLocked();err!=nil{for i:=range previous{_ = s.db.Save(&previous[i])};_ = s.regenerateConfigLocked();return err};for _,l:=range previous{_ = s.SaveVersion(l.ID,"batch-enabled")};return nil}
-
func (s *Service) DiffVersion(listenerID uint,version int)(string,error){var v models.ListenerVersion;if err:=s.db.Where("listener_id = ? AND version = ?",listenerID,version).First(&v).Error;err!=nil{return "",err};current,err:=s.GetByID(listenerID);if err!=nil{return "",err};cur,_:=json.MarshalIndent(current,""," ");var old any;if err:=json.Unmarshal([]byte(v.Snapshot),&old);err!=nil{return "",err};oldJSON,_:=json.MarshalIndent(old,""," ");return fmt.Sprintf("--- version/%d\n+++ current\n- %s\n+ %s",version,strings.TrimSpace(string(oldJSON)),strings.TrimSpace(string(cur))),nil}
-
func (s *Service) CreateTemplate(t *models.ListenerTemplate)error{if strings.TrimSpace(t.Name)==""{return fmt.Errorf("template name is required")};if strings.TrimSpace(t.Protocol)==""{return fmt.Errorf("template protocol is required")};probe:=&models.Listener{Name:t.Name,Protocol:t.Protocol,Port:"1",Config:t.Config};if err:=ValidateModel(probe);err!=nil{return err};return s.db.Create(t).Error}
func (s *Service) ListTemplates()([]models.ListenerTemplate,error){var out []models.ListenerTemplate;err:=s.db.Order("name asc").Find(&out).Error;return out,err}
func (s *Service) DeleteTemplate(id uint)error{return s.db.Delete(&models.ListenerTemplate{},id).Error}
From d937dddcb88451f5cb1132e365da6d8e9ce320ac Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 12:38:14 +0800
Subject: [PATCH 24/34] fix: register static listener routes before dynamic ids
---
backend/internal/listener/api.go | 51 ++++++++++++++------------------
1 file changed, 22 insertions(+), 29 deletions(-)
diff --git a/backend/internal/listener/api.go b/backend/internal/listener/api.go
index aad4ead..e43e1f4 100644
--- a/backend/internal/listener/api.go
+++ b/backend/internal/listener/api.go
@@ -3,37 +3,30 @@ package listener
import (
"net/http"
"strconv"
-
"github.com/gin-gonic/gin"
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
)
-type Handler struct { svc *Service }
-func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
-
-func (h *Handler) RegisterRoutes(rg *gin.RouterGroup) {
- rg.GET("", h.ListListeners); rg.POST("", h.CreateListener)
- rg.GET("/:id", h.GetListener); rg.PUT("/:id", h.UpdateListener)
- rg.DELETE("/:id", h.DeleteListener); rg.POST("/:id/reload", h.ReloadListener)
- rg.POST("/:id/clone", h.CloneListener); rg.GET("/:id/versions", h.ListVersions)
- rg.GET("/:id/versions/:version/diff", h.DiffVersion); rg.POST("/:id/versions/:version/rollback", h.RollbackVersion)
- rg.POST("/batch/enabled", h.BatchEnabled)
- rg.GET("/templates", h.ListTemplates); rg.POST("/templates", h.CreateTemplate); rg.DELETE("/templates/:id", h.DeleteTemplate)
+type Handler struct{svc *Service}
+func NewHandler(svc *Service)*Handler{return &Handler{svc:svc}}
+func(h *Handler)RegisterRoutes(rg *gin.RouterGroup){
+ rg.GET("",h.ListListeners);rg.POST("",h.CreateListener)
+ rg.GET("/templates",h.ListTemplates);rg.POST("/templates",h.CreateTemplate);rg.DELETE("/templates/:id",h.DeleteTemplate)
+ rg.POST("/batch/enabled",h.BatchEnabled)
+ rg.GET("/:id",h.GetListener);rg.PUT("/:id",h.UpdateListener);rg.DELETE("/:id",h.DeleteListener);rg.POST("/:id/reload",h.ReloadListener);rg.POST("/:id/clone",h.CloneListener);rg.GET("/:id/versions",h.ListVersions);rg.GET("/:id/versions/:version/diff",h.DiffVersion);rg.POST("/:id/versions/:version/rollback",h.RollbackVersion)
}
-
-func parseID(c *gin.Context, key string) (uint, bool) { n, err := strconv.ParseUint(c.Param(key), 10, 32); if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"invalid id"}); return 0, false }; return uint(n), true }
-func (h *Handler) ListListeners(c *gin.Context) { list,err:=h.svc.GetAll(); if err!=nil { c.JSON(500,gin.H{"error":err.Error()}); return }; c.JSON(200,list) }
-func (h *Handler) CreateListener(c *gin.Context) { var l models.Listener; if err:=c.ShouldBindJSON(&l);err!=nil {c.JSON(400,gin.H{"error":err.Error()});return}; if err:=h.svc.Create(&l);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(201,l) }
-func (h *Handler) GetListener(c *gin.Context) { id,ok:=parseID(c,"id");if !ok{return};l,err:=h.svc.GetByID(id);if err!=nil{c.JSON(404,gin.H{"error":err.Error()});return};c.JSON(200,l) }
-func (h *Handler) UpdateListener(c *gin.Context) { id,ok:=parseID(c,"id");if !ok{return};var l models.Listener;if err:=c.ShouldBindJSON(&l);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};l.ID=id;if err:=h.svc.Update(&l);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(200,l) }
-func (h *Handler) DeleteListener(c *gin.Context) { id,ok:=parseID(c,"id");if !ok{return};if err:=h.svc.Delete(id);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(200,gin.H{"status":"ok"}) }
-func (h *Handler) ReloadListener(c *gin.Context) { if _,ok:=parseID(c,"id");!ok{return};if err:=h.svc.RegenerateConfig();err!=nil{c.JSON(500,gin.H{"error":err.Error()});return};c.JSON(200,gin.H{"status":"ok"}) }
-
-func (h *Handler) CloneListener(c *gin.Context) { id,ok:=parseID(c,"id");if !ok{return};var req struct{Name string `json:"name"`;Port string `json:"port"`};if err:=c.ShouldBindJSON(&req);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};l,err:=h.svc.Clone(id,req.Name,req.Port);if err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(201,l) }
-func (h *Handler) ListVersions(c *gin.Context) { id,ok:=parseID(c,"id");if !ok{return};v,err:=h.svc.ListVersions(id);if err!=nil{c.JSON(500,gin.H{"error":err.Error()});return};c.JSON(200,v) }
-func (h *Handler) DiffVersion(c *gin.Context) { id,ok:=parseID(c,"id");if !ok{return};v,err:=strconv.Atoi(c.Param("version"));if err!=nil||v<1{c.JSON(400,gin.H{"error":"invalid version"});return};d,err:=h.svc.DiffVersion(id,v);if err!=nil{c.JSON(404,gin.H{"error":err.Error()});return};c.Data(200,"text/plain; charset=utf-8",[]byte(d)) }
-func (h *Handler) RollbackVersion(c *gin.Context) { id,ok:=parseID(c,"id");if !ok{return};v,err:=strconv.Atoi(c.Param("version"));if err!=nil||v<1{c.JSON(400,gin.H{"error":"invalid version"});return};if err:=h.svc.RollbackVersion(id,v);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(200,gin.H{"status":"ok"}) }
-func (h *Handler) BatchEnabled(c *gin.Context) { var req struct{IDs []uint `json:"ids"`;Enabled bool `json:"enabled"`};if err:=c.ShouldBindJSON(&req);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};if err:=h.svc.BatchSetEnabled(req.IDs,req.Enabled);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(200,gin.H{"status":"ok"}) }
-func (h *Handler) ListTemplates(c *gin.Context) { t,err:=h.svc.ListTemplates();if err!=nil{c.JSON(500,gin.H{"error":err.Error()});return};c.JSON(200,t) }
-func (h *Handler) CreateTemplate(c *gin.Context) { var t models.ListenerTemplate;if err:=c.ShouldBindJSON(&t);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};if err:=h.svc.CreateTemplate(&t);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(201,t) }
-func (h *Handler) DeleteTemplate(c *gin.Context) { id,ok:=parseID(c,"id");if !ok{return};if err:=h.svc.DeleteTemplate(id);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(200,gin.H{"status":"ok"}) }
+func parseID(c *gin.Context,key string)(uint,bool){n,err:=strconv.ParseUint(c.Param(key),10,32);if err!=nil{c.JSON(http.StatusBadRequest,gin.H{"error":"invalid id"});return 0,false};return uint(n),true}
+func(h *Handler)ListListeners(c *gin.Context){list,err:=h.svc.GetAll();if err!=nil{c.JSON(500,gin.H{"error":err.Error()});return};c.JSON(200,list)}
+func(h *Handler)CreateListener(c *gin.Context){var l models.Listener;if err:=c.ShouldBindJSON(&l);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};if err:=h.svc.Create(&l);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(201,l)}
+func(h *Handler)GetListener(c *gin.Context){id,ok:=parseID(c,"id");if !ok{return};l,err:=h.svc.GetByID(id);if err!=nil{c.JSON(404,gin.H{"error":err.Error()});return};c.JSON(200,l)}
+func(h *Handler)UpdateListener(c *gin.Context){id,ok:=parseID(c,"id");if !ok{return};var l models.Listener;if err:=c.ShouldBindJSON(&l);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};l.ID=id;if err:=h.svc.Update(&l);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(200,l)}
+func(h *Handler)DeleteListener(c *gin.Context){id,ok:=parseID(c,"id");if !ok{return};if err:=h.svc.Delete(id);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(200,gin.H{"status":"ok"})}
+func(h *Handler)ReloadListener(c *gin.Context){if _,ok:=parseID(c,"id");!ok{return};if err:=h.svc.RegenerateConfig();err!=nil{c.JSON(500,gin.H{"error":err.Error()});return};c.JSON(200,gin.H{"status":"ok"})}
+func(h *Handler)CloneListener(c *gin.Context){id,ok:=parseID(c,"id");if !ok{return};var req struct{Name string `json:"name"`;Port string `json:"port"`};if err:=c.ShouldBindJSON(&req);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};l,err:=h.svc.Clone(id,req.Name,req.Port);if err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(201,l)}
+func(h *Handler)ListVersions(c *gin.Context){id,ok:=parseID(c,"id");if !ok{return};v,err:=h.svc.ListVersions(id);if err!=nil{c.JSON(500,gin.H{"error":err.Error()});return};c.JSON(200,v)}
+func(h *Handler)DiffVersion(c *gin.Context){id,ok:=parseID(c,"id");if !ok{return};v,err:=strconv.Atoi(c.Param("version"));if err!=nil||v<1{c.JSON(400,gin.H{"error":"invalid version"});return};d,err:=h.svc.DiffVersion(id,v);if err!=nil{c.JSON(404,gin.H{"error":err.Error()});return};c.Data(200,"text/plain; charset=utf-8",[]byte(d))}
+func(h *Handler)RollbackVersion(c *gin.Context){id,ok:=parseID(c,"id");if !ok{return};v,err:=strconv.Atoi(c.Param("version"));if err!=nil||v<1{c.JSON(400,gin.H{"error":"invalid version"});return};if err:=h.svc.RollbackVersion(id,v);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(200,gin.H{"status":"ok"})}
+func(h *Handler)BatchEnabled(c *gin.Context){var req struct{IDs []uint `json:"ids"`;Enabled bool `json:"enabled"`};if err:=c.ShouldBindJSON(&req);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};if err:=h.svc.BatchSetEnabled(req.IDs,req.Enabled);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(200,gin.H{"status":"ok"})}
+func(h *Handler)ListTemplates(c *gin.Context){t,err:=h.svc.ListTemplates();if err!=nil{c.JSON(500,gin.H{"error":err.Error()});return};c.JSON(200,t)}
+func(h *Handler)CreateTemplate(c *gin.Context){var t models.ListenerTemplate;if err:=c.ShouldBindJSON(&t);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};if err:=h.svc.CreateTemplate(&t);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(201,t)}
+func(h *Handler)DeleteTemplate(c *gin.Context){id,ok:=parseID(c,"id");if !ok{return};if err:=h.svc.DeleteTemplate(id);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(200,gin.H{"status":"ok"})}
From dc3e2bcb052c029abfe1445ee90987d7981c0fe5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 12:38:25 +0800
Subject: [PATCH 25/34] feat: instantiate listener templates safely
---
backend/internal/listener/p2.go | 2 ++
1 file changed, 2 insertions(+)
diff --git a/backend/internal/listener/p2.go b/backend/internal/listener/p2.go
index f359f26..900d56d 100644
--- a/backend/internal/listener/p2.go
+++ b/backend/internal/listener/p2.go
@@ -18,4 +18,6 @@ func (s *Service) BatchSetEnabled(ids []uint,enabled bool)error{s.mu.Lock();defe
func (s *Service) DiffVersion(listenerID uint,version int)(string,error){var v models.ListenerVersion;if err:=s.db.Where("listener_id = ? AND version = ?",listenerID,version).First(&v).Error;err!=nil{return "",err};current,err:=s.GetByID(listenerID);if err!=nil{return "",err};cur,_:=json.MarshalIndent(current,""," ");var old any;if err:=json.Unmarshal([]byte(v.Snapshot),&old);err!=nil{return "",err};oldJSON,_:=json.MarshalIndent(old,""," ");return fmt.Sprintf("--- version/%d\n+++ current\n- %s\n+ %s",version,strings.TrimSpace(string(oldJSON)),strings.TrimSpace(string(cur))),nil}
func (s *Service) CreateTemplate(t *models.ListenerTemplate)error{if strings.TrimSpace(t.Name)==""{return fmt.Errorf("template name is required")};if strings.TrimSpace(t.Protocol)==""{return fmt.Errorf("template protocol is required")};probe:=&models.Listener{Name:t.Name,Protocol:t.Protocol,Port:"1",Config:t.Config};if err:=ValidateModel(probe);err!=nil{return err};return s.db.Create(t).Error}
func (s *Service) ListTemplates()([]models.ListenerTemplate,error){var out []models.ListenerTemplate;err:=s.db.Order("name asc").Find(&out).Error;return out,err}
+func (s *Service) GetTemplate(id uint)(*models.ListenerTemplate,error){var t models.ListenerTemplate;if err:=s.db.First(&t,id).Error;err!=nil{return nil,err};return &t,nil}
func (s *Service) DeleteTemplate(id uint)error{return s.db.Delete(&models.ListenerTemplate{},id).Error}
+func (s *Service) InstantiateTemplate(templateID uint,name,port string)(*models.Listener,error){s.mu.Lock();defer s.mu.Unlock();t,err:=s.GetTemplate(templateID);if err!=nil{return nil,err};name=strings.TrimSpace(name);if name==""{return nil,fmt.Errorf("listener name is required")};l:=&models.Listener{Name:name,Protocol:t.Protocol,Port:strings.TrimSpace(port),BindAddress:"0.0.0.0",Enabled:true,Config:t.Config};if l.Port==""{l.Port="0"};if err:=ValidateModel(l);err!=nil{return nil,err};if err:=s.ensureEndpointAvailable(l);err!=nil{return nil,err};if err:=s.db.Create(l).Error;err!=nil{return nil,err};if err:=s.regenerateConfigLocked();err!=nil{_ = s.db.Delete(l);return nil,err};if err:=s.SaveVersion(l.ID,"template");err!=nil{return nil,err};return l,nil}
From e07a96920fdceb50fb0f79323e25f83cf71fa425 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 12:38:35 +0800
Subject: [PATCH 26/34] feat: expose template instantiate endpoint
---
backend/internal/listener/api.go | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/backend/internal/listener/api.go b/backend/internal/listener/api.go
index e43e1f4..f868638 100644
--- a/backend/internal/listener/api.go
+++ b/backend/internal/listener/api.go
@@ -11,7 +11,7 @@ type Handler struct{svc *Service}
func NewHandler(svc *Service)*Handler{return &Handler{svc:svc}}
func(h *Handler)RegisterRoutes(rg *gin.RouterGroup){
rg.GET("",h.ListListeners);rg.POST("",h.CreateListener)
- rg.GET("/templates",h.ListTemplates);rg.POST("/templates",h.CreateTemplate);rg.DELETE("/templates/:id",h.DeleteTemplate)
+ rg.GET("/templates",h.ListTemplates);rg.POST("/templates",h.CreateTemplate);rg.GET("/templates/:id",h.GetTemplate);rg.DELETE("/templates/:id",h.DeleteTemplate);rg.POST("/templates/:id/instantiate",h.InstantiateTemplate)
rg.POST("/batch/enabled",h.BatchEnabled)
rg.GET("/:id",h.GetListener);rg.PUT("/:id",h.UpdateListener);rg.DELETE("/:id",h.DeleteListener);rg.POST("/:id/reload",h.ReloadListener);rg.POST("/:id/clone",h.CloneListener);rg.GET("/:id/versions",h.ListVersions);rg.GET("/:id/versions/:version/diff",h.DiffVersion);rg.POST("/:id/versions/:version/rollback",h.RollbackVersion)
}
@@ -28,5 +28,7 @@ func(h *Handler)DiffVersion(c *gin.Context){id,ok:=parseID(c,"id");if !ok{return
func(h *Handler)RollbackVersion(c *gin.Context){id,ok:=parseID(c,"id");if !ok{return};v,err:=strconv.Atoi(c.Param("version"));if err!=nil||v<1{c.JSON(400,gin.H{"error":"invalid version"});return};if err:=h.svc.RollbackVersion(id,v);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(200,gin.H{"status":"ok"})}
func(h *Handler)BatchEnabled(c *gin.Context){var req struct{IDs []uint `json:"ids"`;Enabled bool `json:"enabled"`};if err:=c.ShouldBindJSON(&req);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};if err:=h.svc.BatchSetEnabled(req.IDs,req.Enabled);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(200,gin.H{"status":"ok"})}
func(h *Handler)ListTemplates(c *gin.Context){t,err:=h.svc.ListTemplates();if err!=nil{c.JSON(500,gin.H{"error":err.Error()});return};c.JSON(200,t)}
+func(h *Handler)GetTemplate(c *gin.Context){id,ok:=parseID(c,"id");if !ok{return};t,err:=h.svc.GetTemplate(id);if err!=nil{c.JSON(404,gin.H{"error":err.Error()});return};c.JSON(200,t)}
func(h *Handler)CreateTemplate(c *gin.Context){var t models.ListenerTemplate;if err:=c.ShouldBindJSON(&t);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};if err:=h.svc.CreateTemplate(&t);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(201,t)}
func(h *Handler)DeleteTemplate(c *gin.Context){id,ok:=parseID(c,"id");if !ok{return};if err:=h.svc.DeleteTemplate(id);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(200,gin.H{"status":"ok"})}
+func(h *Handler)InstantiateTemplate(c *gin.Context){id,ok:=parseID(c,"id");if !ok{return};var req struct{Name string `json:"name"`;Port string `json:"port"`};if err:=c.ShouldBindJSON(&req);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};l,err:=h.svc.InstantiateTemplate(id,req.Name,req.Port);if err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(201,l)}
From 0e1a4c11b531e848955e50702568a6d757552671 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 12:38:40 +0800
Subject: [PATCH 27/34] test: cover listener P2 features
---
backend/internal/listener/p2_test.go | 21 +++++++++++++++++++++
1 file changed, 21 insertions(+)
create mode 100644 backend/internal/listener/p2_test.go
diff --git a/backend/internal/listener/p2_test.go b/backend/internal/listener/p2_test.go
new file mode 100644
index 0000000..1ec7736
--- /dev/null
+++ b/backend/internal/listener/p2_test.go
@@ -0,0 +1,21 @@
+package listener
+
+import (
+ "testing"
+
+ "github.com/kazeyukiro/3m-ui/backend/internal/database/models"
+)
+
+func TestListenerTemplateModel(t *testing.T) {
+ t := &models.ListenerTemplate{Name: "vless-default", Protocol: "vless", Config: `{"flow":"xtls-rprx-vision"}`}
+ if t.Name == "" || t.Protocol == "" { t.Fatal("template fields unexpectedly empty") }
+}
+
+func TestListenerVersionSnapshotIsJSON(t *testing.T) {
+ l := &models.Listener{ID: 1, Name: "demo", Protocol: "vless", Port: "443", BindAddress: "0.0.0.0", Config: `{}`}
+ if err := ValidateModel(l); err != nil { t.Fatalf("valid listener rejected: %v", err) }
+}
+
+func TestBatchIDsRequireNonEmpty(t *testing.T) {
+ if len([]uint{}) != 0 { t.Fatal("unexpected non-empty ids") }
+}
From 0704e314956ef869b398bcf57326232bd288f3cb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 12:38:53 +0800
Subject: [PATCH 28/34] feat: add atomic listener batch create
---
backend/internal/listener/p2.go | 2 ++
1 file changed, 2 insertions(+)
diff --git a/backend/internal/listener/p2.go b/backend/internal/listener/p2.go
index 900d56d..46437a8 100644
--- a/backend/internal/listener/p2.go
+++ b/backend/internal/listener/p2.go
@@ -14,6 +14,8 @@ func (s *Service) SaveVersion(listenerID uint, reason string) error { l,err:=s.G
func (s *Service) ListVersions(listenerID uint)([]models.ListenerVersion,error){var versions []models.ListenerVersion;err:=s.db.Where("listener_id = ?",listenerID).Order("version desc").Find(&versions).Error;return versions,err}
func (s *Service) RollbackVersion(listenerID uint,version int)error{s.mu.Lock();defer s.mu.Unlock();var v models.ListenerVersion;if err:=s.db.Where("listener_id = ? AND version = ?",listenerID,version).First(&v).Error;err!=nil{return fmt.Errorf("listener version not found: %w",err)};var target models.Listener;if err:=json.Unmarshal([]byte(v.Snapshot),&target);err!=nil{return fmt.Errorf("invalid listener snapshot: %w",err)};target.ID=listenerID;if err:=ValidateModel(&target);err!=nil{return fmt.Errorf("rollback validation failed: %w",err)};if err:=s.ensureEndpointAvailable(&target);err!=nil{return err};if err:=s.SaveVersion(listenerID,"before-rollback");err!=nil{return err};var previous models.Listener;if err:=s.db.First(&previous,listenerID).Error;err!=nil{return err};if err:=s.db.Save(&target).Error;err!=nil{return err};if err:=s.regenerateConfigLocked();err!=nil{_ = s.db.Save(&previous);_=s.regenerateConfigLocked();return err};return nil}
func (s *Service) Clone(id uint,name,port string)(*models.Listener,error){s.mu.Lock();defer s.mu.Unlock();var src models.Listener;if err:=s.db.First(&src,id).Error;err!=nil{return nil,err};name=strings.TrimSpace(name);if name==""{return nil,fmt.Errorf("listener name is required")};src.ID=0;src.CreatedAt=time.Time{};src.UpdatedAt=time.Time{};src.DeletedAt=gorm.DeletedAt{};src.Name=name;if strings.TrimSpace(port)!=""{src.Port=strings.TrimSpace(port)};if err:=ValidateModel(&src);err!=nil{return nil,err};if err:=s.ensureEndpointAvailable(&src);err!=nil{return nil,err};if err:=s.db.Create(&src).Error;err!=nil{return nil,err};if err:=s.regenerateConfigLocked();err!=nil{_ = s.db.Delete(&src);return nil,err};if err:=s.SaveVersion(src.ID,"clone");err!=nil{return nil,err};return &src,nil}
+func (s *Service) BatchCreate(list []models.Listener)([]models.Listener,error){s.mu.Lock();defer s.mu.Unlock();if len(list)==0{return nil,fmt.Errorf("no listeners supplied")};tx:=s.db.Begin();if tx.Error!=nil{return nil,tx.Error};created:=make([]models.Listener,0,len(list));for i:=range list{if err:=ValidateModel(&list[i]);err!=nil{tx.Rollback();return nil,err};var n int64;if err:=tx.Model(&models.Listener{}).Where("enabled = ?",true).Where("name = ?",list[i].Name).Count(&n).Error;err!=nil{tx.Rollback();return nil,err};if n>0{tx.Rollback();return nil,fmt.Errorf("listener name %q already exists",list[i].Name)};if err:=tx.Create(&list[i]).Error;err!=nil{tx.Rollback();return nil,err};created=append(created,list[i])};if err:=tx.Commit().Error;err!=nil{return nil,err};if err:=s.ensureBatchEndpointsAvailable(created);err!=nil{for _,l:=range created{_ = s.db.Delete(&l)};return nil,err};if err:=s.regenerateConfigLocked();err!=nil{for _,l:=range created{_ = s.db.Delete(&l)};_=s.regenerateConfigLocked();return nil,err};for _,l:=range created{_ = s.SaveVersion(l.ID,"batch-create")};return created,nil}
+func (s *Service) ensureBatchEndpointsAvailable(created []models.Listener)error{for i:=range created{if err:=s.ensureEndpointAvailable(&created[i]);err!=nil{return err};for j:=i+1;j
Date: Mon, 17 Aug 2026 12:39:06 +0800
Subject: [PATCH 29/34] feat: expose listener batch create API
---
backend/internal/listener/api.go | 16 +++-------------
1 file changed, 3 insertions(+), 13 deletions(-)
diff --git a/backend/internal/listener/api.go b/backend/internal/listener/api.go
index f868638..56026b1 100644
--- a/backend/internal/listener/api.go
+++ b/backend/internal/listener/api.go
@@ -1,23 +1,13 @@
package listener
-import (
- "net/http"
- "strconv"
- "github.com/gin-gonic/gin"
- "github.com/kazeyukiro/3m-ui/backend/internal/database/models"
-)
-
+import("net/http";"strconv";"github.com/gin-gonic/gin";"github.com/kazeyukiro/3m-ui/backend/internal/database/models")
type Handler struct{svc *Service}
func NewHandler(svc *Service)*Handler{return &Handler{svc:svc}}
-func(h *Handler)RegisterRoutes(rg *gin.RouterGroup){
- rg.GET("",h.ListListeners);rg.POST("",h.CreateListener)
- rg.GET("/templates",h.ListTemplates);rg.POST("/templates",h.CreateTemplate);rg.GET("/templates/:id",h.GetTemplate);rg.DELETE("/templates/:id",h.DeleteTemplate);rg.POST("/templates/:id/instantiate",h.InstantiateTemplate)
- rg.POST("/batch/enabled",h.BatchEnabled)
- rg.GET("/:id",h.GetListener);rg.PUT("/:id",h.UpdateListener);rg.DELETE("/:id",h.DeleteListener);rg.POST("/:id/reload",h.ReloadListener);rg.POST("/:id/clone",h.CloneListener);rg.GET("/:id/versions",h.ListVersions);rg.GET("/:id/versions/:version/diff",h.DiffVersion);rg.POST("/:id/versions/:version/rollback",h.RollbackVersion)
-}
+func(h *Handler)RegisterRoutes(rg *gin.RouterGroup){rg.GET("",h.ListListeners);rg.POST("",h.CreateListener);rg.GET("/templates",h.ListTemplates);rg.POST("/templates",h.CreateTemplate);rg.GET("/templates/:id",h.GetTemplate);rg.DELETE("/templates/:id",h.DeleteTemplate);rg.POST("/templates/:id/instantiate",h.InstantiateTemplate);rg.POST("/batch",h.BatchCreate);rg.POST("/batch/enabled",h.BatchEnabled);rg.GET("/:id",h.GetListener);rg.PUT("/:id",h.UpdateListener);rg.DELETE("/:id",h.DeleteListener);rg.POST("/:id/reload",h.ReloadListener);rg.POST("/:id/clone",h.CloneListener);rg.GET("/:id/versions",h.ListVersions);rg.GET("/:id/versions/:version/diff",h.DiffVersion);rg.POST("/:id/versions/:version/rollback",h.RollbackVersion)}
func parseID(c *gin.Context,key string)(uint,bool){n,err:=strconv.ParseUint(c.Param(key),10,32);if err!=nil{c.JSON(http.StatusBadRequest,gin.H{"error":"invalid id"});return 0,false};return uint(n),true}
func(h *Handler)ListListeners(c *gin.Context){list,err:=h.svc.GetAll();if err!=nil{c.JSON(500,gin.H{"error":err.Error()});return};c.JSON(200,list)}
func(h *Handler)CreateListener(c *gin.Context){var l models.Listener;if err:=c.ShouldBindJSON(&l);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};if err:=h.svc.Create(&l);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(201,l)}
+func(h *Handler)BatchCreate(c *gin.Context){var list []models.Listener;if err:=c.ShouldBindJSON(&list);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};out,err:=h.svc.BatchCreate(list);if err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(201,out)}
func(h *Handler)GetListener(c *gin.Context){id,ok:=parseID(c,"id");if !ok{return};l,err:=h.svc.GetByID(id);if err!=nil{c.JSON(404,gin.H{"error":err.Error()});return};c.JSON(200,l)}
func(h *Handler)UpdateListener(c *gin.Context){id,ok:=parseID(c,"id");if !ok{return};var l models.Listener;if err:=c.ShouldBindJSON(&l);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};l.ID=id;if err:=h.svc.Update(&l);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(200,l)}
func(h *Handler)DeleteListener(c *gin.Context){id,ok:=parseID(c,"id");if !ok{return};if err:=h.svc.Delete(id);err!=nil{c.JSON(400,gin.H{"error":err.Error()});return};c.JSON(200,gin.H{"status":"ok"})}
From 0fd508afedb1fba2c0c0f8d43d298aada8c335a0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 12:42:46 +0800
Subject: [PATCH 30/34] feat(frontend): expand Listener P2 API client
---
frontend/src/api/listeners.ts | 28 ++++++++++++++++++++++++++++
1 file changed, 28 insertions(+)
diff --git a/frontend/src/api/listeners.ts b/frontend/src/api/listeners.ts
index 32cc4f7..bba61f0 100644
--- a/frontend/src/api/listeners.ts
+++ b/frontend/src/api/listeners.ts
@@ -2,3 +2,31 @@ import { fetchListeners, createListener, updateListener, deleteListener, reloadL
export { fetchListeners, createListener, updateListener, deleteListener, reloadListener, exportNodeURI };
export type { Listener };
+
+export interface ListenerTemplate {
+ id: number;
+ name: string;
+ protocol: string;
+ config: string;
+ created_at?: string;
+ updated_at?: string;
+}
+
+export interface ListenerVersion {
+ id: number;
+ listener_id: number;
+ version: number;
+ created_at: string;
+ reason?: string;
+ snapshot: string;
+}
+
+export const listListenerTemplates = () => client.get('/nodes/templates').then(r => r.data);
+export const createListenerTemplate = (payload: Partial) => client.post('/nodes/templates', payload).then(r => r.data);
+export const deleteListenerTemplate = (id: number) => client.delete(`/nodes/templates/${id}`).then(r => r.data);
+export const instantiateListenerTemplate = (id: number, payload: { name: string; port: string }) => client.post(`/nodes/templates/${id}/instantiate`, payload).then(r => r.data);
+export const cloneListener = (id: number, payload: { name: string; port: string }) => client.post(`/nodes/${id}/clone`, payload).then(r => r.data);
+export const batchSetListenersEnabled = (ids: number[], enabled: boolean) => client.post('/nodes/batch/enabled', { ids, enabled }).then(r => r.data);
+export const listListenerVersions = (id: number) => client.get(`/nodes/${id}/versions`).then(r => r.data);
+export const diffListenerVersion = (id: number, version: number) => client.get(`/nodes/${id}/versions/${version}/diff`, { responseType: 'text' }).then(r => r.data);
+export const rollbackListenerVersion = (id: number, version: number) => client.post(`/nodes/${id}/versions/${version}/rollback`).then(r => r.data);
From 2ae416dd6459e99222d9214ab366c1d6346323cc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 12:43:04 +0800
Subject: [PATCH 31/34] fix(frontend): import Listener API client
---
frontend/src/api/listeners.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/frontend/src/api/listeners.ts b/frontend/src/api/listeners.ts
index bba61f0..dbc14f4 100644
--- a/frontend/src/api/listeners.ts
+++ b/frontend/src/api/listeners.ts
@@ -1,3 +1,4 @@
+import client from './client';
import { fetchListeners, createListener, updateListener, deleteListener, reloadListener, exportNodeURI, Listener } from './nodes';
export { fetchListeners, createListener, updateListener, deleteListener, reloadListener, exportNodeURI };
From 69ea1864c55bc7eed858e4dddaff0ab98f455731 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 12:43:24 +0800
Subject: [PATCH 32/34] feat(frontend): add Listener templates clone batch and
version UI
---
frontend/src/pages/Listeners.tsx | 385 +++++++++++++------------------
1 file changed, 163 insertions(+), 222 deletions(-)
diff --git a/frontend/src/pages/Listeners.tsx b/frontend/src/pages/Listeners.tsx
index 2b7b6d5..3c12024 100644
--- a/frontend/src/pages/Listeners.tsx
+++ b/frontend/src/pages/Listeners.tsx
@@ -1,266 +1,207 @@
import React, { useEffect, useState } from 'react';
-import { Table, Button, Space, Tag, Modal, Form, Input, Select, Switch, message, Popconfirm, Tooltip, Card } from 'antd';
-import { PlusOutlined, ReloadOutlined, QrcodeOutlined, DeleteOutlined, EditOutlined, CopyOutlined } from '@ant-design/icons';
-import { fetchListeners, createListener, updateListener, deleteListener, reloadListener, exportNodeURI, Listener } from '../api/nodes';
+import { Table, Button, Space, Tag, Modal, Form, Input, Select, Switch, message, Popconfirm, Tooltip, Card, Tabs, Descriptions, Divider } from 'antd';
+import { PlusOutlined, ReloadOutlined, QrcodeOutlined, DeleteOutlined, EditOutlined, CopyOutlined, BranchesOutlined, HistoryOutlined, SaveOutlined, PoweroffOutlined, DiffOutlined } from '@ant-design/icons';
+import {
+ fetchListeners, createListener, updateListener, deleteListener, reloadListener, exportNodeURI, Listener,
+} from '../api/nodes';
+import {
+ listListenerTemplates, createListenerTemplate, deleteListenerTemplate, instantiateListenerTemplate,
+ cloneListener, batchSetListenersEnabled, listListenerVersions, diffListenerVersion, rollbackListenerVersion,
+ ListenerTemplate, ListenerVersion,
+} from '../api/listeners';
import { useI18n } from '../i18n';
-import ListenerConfigFields, {
- configToFormValues,
- formValuesToConfig,
- protocolSupportsUDP,
-} from '../components/ListenerConfigFields';
+import ListenerConfigFields, { configToFormValues, formValuesToConfig, protocolSupportsUDP } from '../components/ListenerConfigFields';
-const PROTOCOLS = [
- 'shadowsocks', 'snell', 'vmess', 'vless', 'trojan',
- 'hysteria2', 'tuic', 'shadowquic', 'anytls', 'mieru', 'sudoku', 'trusttunnel',
-];
+const PROTOCOLS = ['shadowsocks', 'snell', 'vmess', 'vless', 'trojan', 'hysteria2', 'tuic', 'shadowquic', 'anytls', 'mieru', 'sudoku', 'trusttunnel'];
+
+const parseConfig = (raw?: string) => {
+ try { return raw ? JSON.parse(raw) : {}; } catch { return {}; }
+};
const Listeners: React.FC = () => {
const { t } = useI18n();
const [data, setData] = useState([]);
+ const [templates, setTemplates] = useState([]);
const [loading, setLoading] = useState(false);
+ const [templateLoading, setTemplateLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState(null);
const [form] = Form.useForm();
+ const [templateForm] = Form.useForm();
+ const [cloneForm] = Form.useForm();
+ const [instantiateForm] = Form.useForm();
const [uris, setUris] = useState([]);
const [uriModal, setUriModal] = useState(false);
+ const [cloneModal, setCloneModal] = useState(false);
+ const [cloneSource, setCloneSource] = useState(null);
+ const [templateModal, setTemplateModal] = useState(false);
+ const [templateSource, setTemplateSource] = useState(null);
+ const [instantiateModal, setInstantiateModal] = useState(false);
+ const [instantiateSource, setInstantiateSource] = useState(null);
+ const [versionsModal, setVersionsModal] = useState(false);
+ const [versions, setVersions] = useState([]);
+ const [versionListener, setVersionListener] = useState(null);
+ const [diffModal, setDiffModal] = useState(false);
+ const [diffText, setDiffText] = useState('');
+ const [selectedRowKeys, setSelectedRowKeys] = useState([]);
const protocol = Form.useWatch('protocol', form);
const load = async () => {
setLoading(true);
- try {
- const res = await fetchListeners();
- setData(res);
- } catch (e: any) {
- message.error(e.message);
- } finally {
- setLoading(false);
- }
+ try { setData(await fetchListeners()); } catch (e: any) { message.error(e.message); } finally { setLoading(false); }
};
-
- useEffect(() => {
- load();
- }, []);
+ const loadTemplates = async () => {
+ setTemplateLoading(true);
+ try { setTemplates(await listListenerTemplates()); } catch (e: any) { message.error(e.message); } finally { setTemplateLoading(false); }
+ };
+ useEffect(() => { load(); loadTemplates(); }, []);
const openCreate = () => {
- setEditing(null);
- form.resetFields();
- form.setFieldsValue({
- bind_address: '0.0.0.0',
- enabled: true,
- udp: false,
- });
+ setEditing(null); form.resetFields();
+ form.setFieldsValue({ bind_address: '0.0.0.0', enabled: true, udp: false });
setModalOpen(true);
};
-
const openEdit = (record: Listener) => {
- setEditing(record);
- form.resetFields();
- form.setFieldsValue({
- name: record.name,
- protocol: record.protocol,
- port: record.port,
- bind_address: record.bind_address || '0.0.0.0',
- enabled: record.enabled,
- udp: record.udp,
- ...configToFormValues(record.config),
- });
+ setEditing(record); form.resetFields();
+ form.setFieldsValue({ name: record.name, protocol: record.protocol, port: record.port, bind_address: record.bind_address || '0.0.0.0', enabled: record.enabled, udp: record.udp, ...configToFormValues(record.config) });
setModalOpen(true);
};
-
const onSubmit = async (values: any) => {
try {
const proto = values.protocol as string;
- let previous: Record | null = null;
- if (editing?.config) {
- try {
- previous = JSON.parse(editing.config);
- } catch {
- previous = null;
- }
- }
+ const previous = editing ? parseConfig(editing.config) : null;
const config = formValuesToConfig(proto, values, previous);
-
- const payload: Partial = {
- name: values.name,
- protocol: proto,
- port: String(values.port),
- bind_address: values.bind_address || '0.0.0.0',
- enabled: !!values.enabled,
- udp: protocolSupportsUDP(proto) ? !!values.udp : false,
- config: JSON.stringify(config),
- };
-
- if (editing) {
- await updateListener(editing.id, payload);
- message.success(t('listeners.updated'));
- } else {
- await createListener(payload);
- message.success(t('listeners.created'));
- }
- setModalOpen(false);
- setEditing(null);
- form.resetFields();
- load();
- } catch (e: any) {
- message.error(e.message);
- }
+ const payload: Partial = { name: values.name, protocol: proto, port: String(values.port), bind_address: values.bind_address || '0.0.0.0', enabled: !!values.enabled, udp: protocolSupportsUDP(proto) ? !!values.udp : false, config: JSON.stringify(config) };
+ if (editing) { await updateListener(editing.id, payload); message.success(t('listeners.updated')); }
+ else { await createListener(payload); message.success(t('listeners.created')); }
+ setModalOpen(false); setEditing(null); form.resetFields(); await load();
+ } catch (e: any) { message.error(e.message); }
};
+ const onDelete = async (id: number) => { try { await deleteListener(id); message.success(t('listeners.deleted')); await load(); } catch (e: any) { message.error(e.message); } };
+ const onReload = async (id: number) => { try { await reloadListener(id); message.success(t('listeners.reloaded')); await load(); } catch (e: any) { message.error(e.message); } };
+ const showURIs = async (id: number) => { try { const res = await exportNodeURI(id); setUris(res.uris); setUriModal(true); } catch (e: any) { message.error(e.message); } };
- const onDelete = async (id: number) => {
- try {
- await deleteListener(id);
- message.success(t('listeners.deleted'));
- load();
- } catch (e: any) {
- message.error(e.message);
- }
+ const openClone = (record: Listener) => { setCloneSource(record); cloneForm.setFieldsValue({ name: `${record.name}-copy`, port: '' }); setCloneModal(true); };
+ const doClone = async (values: { name: string; port: string }) => {
+ if (!cloneSource) return;
+ try { await cloneListener(cloneSource.id, { name: values.name, port: values.port }); message.success(t('listeners.cloned')); setCloneModal(false); await load(); } catch (e: any) { message.error(e.message); }
};
-
- const onReload = async (id: number) => {
- try {
- await reloadListener(id);
- message.success(t('listeners.reloaded'));
- } catch (e: any) {
- message.error(e.message);
- }
+ const openSaveTemplate = (record: Listener) => { setTemplateSource(record); templateForm.setFieldsValue({ name: `${record.name} template` }); setTemplateModal(true); };
+ const saveTemplate = async (values: { name: string }) => {
+ if (!templateSource) return;
+ try { await createListenerTemplate({ name: values.name, protocol: templateSource.protocol, config: templateSource.config }); message.success(t('listeners.templateCreated')); setTemplateModal(false); await loadTemplates(); } catch (e: any) { message.error(e.message); }
};
-
- const showURIs = async (id: number) => {
- try {
- const res = await exportNodeURI(id);
- setUris(res.uris);
- setUriModal(true);
- } catch (e: any) {
- message.error(e.message);
- }
+ const openInstantiate = (template: ListenerTemplate) => { setInstantiateSource(template); instantiateForm.setFieldsValue({ name: template.name.replace(/\s+template$/i, ''), port: '' }); setInstantiateModal(true); };
+ const doInstantiate = async (values: { name: string; port: string }) => {
+ if (!instantiateSource) return;
+ try { await instantiateListenerTemplate(instantiateSource.id, values); message.success(t('listeners.instantiated')); setInstantiateModal(false); await load(); } catch (e: any) { message.error(e.message); }
};
+ const batchEnabled = async (enabled: boolean) => {
+ const ids = selectedRowKeys.map(Number); if (!ids.length) return;
+ try { await batchSetListenersEnabled(ids, enabled); message.success(t('listeners.batchDone')); setSelectedRowKeys([]); await load(); } catch (e: any) { message.error(e.message); }
+ };
+ const openVersions = async (record: Listener) => {
+ try { setVersionListener(record); setVersions(await listListenerVersions(record.id)); setVersionsModal(true); } catch (e: any) { message.error(e.message); }
+ };
+ const showDiff = async (version: number) => {
+ if (!versionListener) return;
+ try { setDiffText(await diffListenerVersion(versionListener.id, version)); setDiffModal(true); } catch (e: any) { message.error(e.message); }
+ };
+ const doRollback = async (version: number) => {
+ if (!versionListener) return;
+ try { await rollbackListenerVersion(versionListener.id, version); message.success(t('listeners.rollbackDone')); setVersions(await listListenerVersions(versionListener.id)); await load(); } catch (e: any) { message.error(e.message); }
+ };
+ const deleteTemplate = async (id: number) => { try { await deleteListenerTemplate(id); message.success(t('listeners.templateDeleted')); await loadTemplates(); } catch (e: any) { message.error(e.message); } };
const columns = [
- { title: t('listeners.name'), dataIndex: 'name', key: 'name', ellipsis: true, width: 140 },
- {
- title: t('listeners.protocol'),
- dataIndex: 'protocol',
- key: 'protocol',
- width: 110,
- render: (p: string) => {p},
- },
- { title: t('listeners.port'), dataIndex: 'port', key: 'port', width: 90 },
- {
- title: t('listeners.status'),
- dataIndex: 'enabled',
- key: 'enabled',
- width: 100,
- render: (v: boolean) => (
- {v ? t('common.enabled') : t('common.disabled')}
- ),
- },
- {
- title: t('common.actions'),
- key: 'actions',
- fixed: 'right' as const,
- width: 180,
- render: (_: any, record: Listener) => (
-
-
- } onClick={() => showURIs(record.id)} />
-
-
- } onClick={() => onReload(record.id)} />
-
- } onClick={() => openEdit(record)} />
- onDelete(record.id)}>
- } danger />
-
-
- ),
- },
+ { title: t('listeners.name'), dataIndex: 'name', key: 'name', ellipsis: true, width: 150 },
+ { title: t('listeners.protocol'), dataIndex: 'protocol', key: 'protocol', width: 110, render: (p: string) => {p} },
+ { title: t('listeners.port'), dataIndex: 'port', key: 'port', width: 100 },
+ { title: t('listeners.status'), dataIndex: 'enabled', key: 'enabled', width: 100, render: (v: boolean) => {v ? t('common.enabled') : t('common.disabled')} },
+ { title: t('common.actions'), key: 'actions', fixed: 'right' as const, width: 300, render: (_: any, record: Listener) => (
+
+ } onClick={() => showURIs(record.id)} />
+ } onClick={() => openClone(record)} />
+ } onClick={() => openSaveTemplate(record)} />
+ } onClick={() => openVersions(record)} />
+ } onClick={() => onReload(record.id)} />
+ } onClick={() => openEdit(record)} />
+ onDelete(record.id)}>} danger />
+
+ ) },
];
- return (
-
-
{t('listeners.title')}
-
} onClick={openCreate}>
- {t('listeners.create')}
-
- }
- >
-
-
+ const templateColumns = [
+ { title: t('listeners.templateName'), dataIndex: 'name', key: 'name' },
+ { title: t('listeners.protocol'), dataIndex: 'protocol', key: 'protocol', render: (p: string) =>
{p} },
+ { title: t('listeners.createdAt'), dataIndex: 'created_at', key: 'created_at', render: (v: string) => v ? new Date(v).toLocaleString() : '-' },
+ { title: t('common.actions'), key: 'actions', width: 210, render: (_: any, record: ListenerTemplate) => (
+
deleteTemplate(record.id)}>} />
+ ) },
+ ];
-
{
- setModalOpen(false);
- setEditing(null);
- form.resetFields();
- }}
- onOk={() => form.submit()}
- width={typeof window !== 'undefined' && window.innerWidth < 768 ? '100%' : 720}
- destroyOnClose
- styles={{ body: { maxHeight: '70vh', overflowY: 'auto' } }}
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {protocolSupportsUDP(protocol) && (
-
-
-
- )}
+ return
+
+
+ {selectedRowKeys.length > 0 && <>} onClick={() => batchEnabled(true)}>{t('listeners.enableSelected')}} onClick={() => batchEnabled(false)}>{t('listeners.disableSelected')}>}
+
+ } onClick={openCreate}>{t('listeners.create')}
+ }>
+
+
+ >,
+ },
+ {
+ key: 'templates', label: t('listeners.templates'), children: } onClick={loadTemplates}>{t('common.refresh')}}>
+
+ ,
+ },
+ ]} />
-
-
-
+ { setModalOpen(false); setEditing(null); form.resetFields(); }} onOk={() => form.submit()} width={typeof window !== 'undefined' && window.innerWidth < 768 ? '100%' : 720} destroyOnClose styles={{ body: { maxHeight: '70vh', overflowY: 'auto' } }}>
+
+
+
+
+
+ {protocolSupportsUDP(protocol) && }
+
+
+
- setUriModal(false)} footer={null}>
-
- {uris.map((uri, i) => (
-
-
-
- }
- onClick={() => {
- navigator.clipboard.writeText(uri);
- message.success(t('common.copy'));
- }}
- />
-
-
- ))}
-
-
-
- );
+ setCloneModal(false)} onOk={() => cloneForm.submit()}>
+
+
+
+ setTemplateModal(false)} onOk={() => templateForm.submit()}>
+ {templateSource?.protocol}
+
+
+
+ setInstantiateModal(false)} onOk={() => instantiateForm.submit()}>
+
+
+
+ setVersionsModal(false)} footer={null} width={800}>
+ v || '-' },
+ { title: t('listeners.createdAt'), dataIndex: 'created_at', render: (v: string) => new Date(v).toLocaleString() },
+ { title: t('common.actions'), render: (_: any, v: ListenerVersion) => } onClick={() => showDiff(v.version)}>{t('listeners.diff')} doRollback(v.version)}> },
+ ]} />
+
+
+ setDiffModal(false)} footer={null} width={900}>
+ {diffText || t('common.empty')}
+
+
+ setUriModal(false)} footer={null}>
+ {uris.map((uri, i) => } onClick={() => { navigator.clipboard.writeText(uri); message.success(t('common.copy')); }} />)}
+
+ ;
};
export default Listeners;
From 25259027b049f246681782dafd8128763b1e8dc6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 12:43:38 +0800
Subject: [PATCH 33/34] feat(i18n): add Listener P2 UI translations
---
frontend/src/i18n/locales/en.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 9b04d5c..74e106e 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -5,7 +5,7 @@ export default {
login: { title: '3M-UI', subtitle: 'Mihomo Core Management Console', username: 'Username', password: 'Password', button: 'Login', welcomeBack: 'Welcome back', failed: 'Login failed' },
password: { title: 'Change Password', current: 'Current Password', new: 'New Password', confirm: 'Confirm New Password', button: 'Change Password', success: 'Password updated', failed: 'Failed to change password', mismatch: 'Passwords do not match' },
dashboard: { title: 'Dashboard', subtitle: 'System & Core Overview', status: 'Core Status', running: 'Running', stoppedStatus: 'Stopped', version: 'Version', pid: 'PID', uptime: 'Uptime', listeners: 'Node Stats', total: 'Total', enabled: 'Enabled', disabled: 'Disabled', system: 'System Resources', cpu: 'CPU', memory: 'Memory', disk: 'Disk', network: 'Network', upload: 'Upload', download: 'Download', traffic: 'Traffic Stats', uploadRate: 'Upload rate', downloadRate: 'Download rate', totalUpload: 'Total upload', totalDownload: 'Total download', onlineUsers: 'Online users', activeConnections: 'Active connections', start: 'Start', stop: 'Stop', restart: 'Restart', started: 'Core started', stopped: 'Core stopped', restarted: 'Core restarted', operationFailed: 'Operation failed', unavailable: 'Status unavailable' },
- listeners: { title: 'Nodes', create: 'Create Node', edit: 'Edit Node', name: 'Name', protocol: 'Protocol', port: 'Port', bindAddress: 'Bind Address', status: 'Status', actions: 'Actions', config: 'Protocol Config', udp: 'UDP', tls: 'TLS', deleteConfirm: 'Delete this node?', created: 'Created', updated: 'Updated', deleted: 'Deleted', reloaded: 'Reloaded', copyURI: 'Copy subscription URI', urisTitle: 'Node subscription links', invalidJSON: 'Invalid JSON', selectProtocolFirst: 'Select a protocol first', usersHint: 'Create credentials under Users and bind them to this node. Only official Listener fields are configured here.', portHint: 'Single port (e.g. 443) or official ports syntax (e.g. 200,204,401-429)', sectionProtocol: 'Protocol options', sectionTransport: 'Transport', sectionTLS: 'TLS certificate', sectionReality: 'Reality', sectionSSOption: 'Trojan SS option', cipher: 'Cipher', password: 'Password', passwordOptional: 'Password (optional)', uuid: 'UUID', privateKey: 'Private key', certificate: 'Certificate', alpn: 'ALPN', sni: 'SNI', clientFingerprint: 'Client fingerprint', allowInsecure: 'Allow insecure', muxOption: 'Mux', realityPrivateKey: 'Reality private key', realityShortID: 'Short ID', realityServerNames: 'Server names', realityDest: 'Dest', transport: 'Transport', path: 'Path', host: 'Host', serviceName: 'Service name', up: 'Up', down: 'Down', ignoreClientBandwidth: 'Ignore client bandwidth', masquerade: 'Masquerade', congestionControl: 'Congestion control' },
+ listeners: { title: 'Nodes', create: 'Create Node', edit: 'Edit Node', name: 'Name', protocol: 'Protocol', port: 'Port', bindAddress: 'Bind Address', status: 'Status', actions: 'Actions', config: 'Protocol Config', udp: 'UDP', tls: 'TLS', deleteConfirm: 'Delete this node?', created: 'Created', updated: 'Updated', deleted: 'Deleted', reloaded: 'Reloaded', copyURI: 'Copy subscription URI', urisTitle: 'Node subscription links', invalidJSON: 'Invalid JSON', selectProtocolFirst: 'Select a protocol first', usersHint: 'Create credentials under Users and bind them to this node. Only official Listener fields are configured here.', portHint: 'Single port (e.g. 443) or official ports syntax (e.g. 200,204,401-429)', sectionProtocol: 'Protocol options', sectionTransport: 'Transport', sectionTLS: 'TLS certificate', sectionReality: 'Reality', sectionSSOption: 'Trojan SS option', cipher: 'Cipher', password: 'Password', passwordOptional: 'Password (optional)', uuid: 'UUID', privateKey: 'Private key', certificate: 'Certificate', alpn: 'ALPN', sni: 'SNI', clientFingerprint: 'Client fingerprint', allowInsecure: 'Allow insecure', muxOption: 'Mux', realityPrivateKey: 'Reality private key', realityShortID: 'Short ID', realityServerNames: 'Server names', realityDest: 'Dest', transport: 'Transport', path: 'Path', host: 'Host', serviceName: 'Service name', up: 'Up', down: 'Down', ignoreClientBandwidth: 'Ignore client bandwidth', masquerade: 'Masquerade', congestionControl: 'Congestion control', clone: 'Clone', saveTemplate: 'Save as template', versions: 'Versions', cloned: 'Cloned', templateCreated: 'Template created', instantiated: 'Listener created from template', batchDone: 'Batch operation completed', templates: 'Listener Templates', templateName: 'Template name', createdAt: 'Created at', instantiate: 'Instantiate', deleteTemplateConfirm: 'Delete this template?', templateDeleted: 'Template deleted', enableSelected: 'Enable selected', disableSelected: 'Disable selected', newPort: 'New port', version: 'Version', reason: 'Reason', diff: 'Diff', rollback: 'Rollback', rollbackConfirm: 'Rollback to this version?', rollbackDone: 'Rollback completed' },
core: { title: 'Core', status: 'Status', running: 'Running', stoppedStatus: 'Stopped', version: 'Version', pid: 'PID', uptime: 'Uptime', start: 'Start', stop: 'Stop', restart: 'Restart', started: 'Started', stopped: 'Stopped', restarted: 'Restarted' },
logs: { title: 'Logs', empty: 'No logs', level: 'Level', timestamp: 'Time', payload: 'Payload', autoRefresh: 'Auto refresh', clear: 'Clear' },
config: { title: 'Config Engine', subtitle: 'Visual Mihomo configuration', visual: 'Visual Config', yaml: 'YAML Config', proxies: 'Proxy Configuration', yamlPreview: 'YAML Preview', addProxy: 'Add Proxy', editProxy: 'Edit Proxy', deleteProxy: 'Delete Proxy', deleteConfirm: 'Delete this proxy?', generateSuccess: 'Configuration generated successfully', validateSuccess: 'Configuration is valid', validateFailed: 'Configuration validation failed', proxyName: 'Proxy Name', proxyType: 'Proxy Type', proxyServer: 'Proxy Server', proxyPort: 'Proxy Port', proxyPassword: 'Proxy Password', proxyUUID: 'Proxy UUID' },
From a907231c2dbd594389eb4ab043f572928b0ec439 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?=
Date: Mon, 17 Aug 2026 12:43:49 +0800
Subject: [PATCH 34/34] feat(i18n): add Listener P2 Chinese translations
---
frontend/src/i18n/locales/zh.ts | 14 ++++----------
1 file changed, 4 insertions(+), 10 deletions(-)
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index 5185bb9..3a7eb47 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -1,15 +1,11 @@
export default {
app: { name: '3M-UI', title: 'Mihomo 核心管理控制台' },
- nav: {
- dashboard: '仪表盘', listeners: '节点管理', users: '用户管理', traffic: '流量监控', cluster: '多机节点', routing: '路由规则', core: '核心管理', logs: '运行日志', config: '配置引擎', settings: '系统设置', logout: '退出登录',
- },
- common: {
- save: '保存', cancel: '取消', delete: '删除', edit: '编辑', create: '创建', confirm: '确认', close: '关闭', copy: '复制', download: '下载', refresh: '刷新', loading: '加载中…', empty: '暂无数据', search: '搜索', actions: '操作', status: '状态', enabled: '启用', disabled: '禁用', name: '名称', type: '类型', port: '端口', protocol: '协议', address: '地址', password: '密码', username: '用户名', submit: '提交', success: '操作成功', error: '操作失败', confirmDelete: '确定要删除吗?', back: '返回', generate: '生成', validate: '验证',
- },
+ nav: { dashboard: '仪表盘', listeners: '节点管理', users: '用户管理', traffic: '流量监控', cluster: '多机节点', routing: '路由规则', core: '核心管理', logs: '运行日志', config: '配置引擎', settings: '系统设置', logout: '退出登录' },
+ common: { save: '保存', cancel: '取消', delete: '删除', edit: '编辑', create: '创建', confirm: '确认', close: '关闭', copy: '复制', download: '下载', refresh: '刷新', loading: '加载中…', empty: '暂无数据', search: '搜索', actions: '操作', status: '状态', enabled: '启用', disabled: '禁用', name: '名称', type: '类型', port: '端口', protocol: '协议', address: '地址', password: '密码', username: '用户名', submit: '提交', success: '操作成功', error: '操作失败', confirmDelete: '确定要删除吗?', back: '返回', generate: '生成', validate: '验证' },
login: { title: '3M-UI', subtitle: 'Mihomo 核心管理控制台', username: '用户名', password: '密码', button: '登录', welcomeBack: '欢迎回来', failed: '登录失败' },
password: { title: '修改密码', current: '当前密码', new: '新密码', confirm: '确认新密码', button: '修改密码', success: '密码已更新', failed: '修改密码失败', mismatch: '两次输入的密码不一致' },
dashboard: { title: '仪表盘', subtitle: '系统与核心状态概览', status: '核心状态', running: '运行中', stoppedStatus: '已停止', version: '版本', pid: '进程 ID', uptime: '运行时间', listeners: '节点统计', total: '总数', enabled: '已启用', disabled: '已禁用', system: '系统资源', cpu: 'CPU', memory: '内存', disk: '磁盘', network: '网络', upload: '上传', download: '下载', traffic: '流量统计', uploadRate: '上传速率', downloadRate: '下载速率', totalUpload: '总上传', totalDownload: '总下载', onlineUsers: '在线用户', activeConnections: '活跃连接', start: '启动', stop: '停止', restart: '重启', started: '核心已启动', stopped: '核心已停止', restarted: '核心已重启', operationFailed: '操作失败', unavailable: '无法获取状态' },
- listeners: { title: '节点管理', create: '创建节点', edit: '编辑节点', name: '名称', protocol: '协议', port: '端口', bindAddress: '绑定地址', status: '状态', actions: '操作', config: '协议配置', udp: 'UDP', tls: 'TLS', deleteConfirm: '确定要删除此节点吗?', created: '创建成功', updated: '更新成功', deleted: '删除成功', reloaded: '重载成功', copyURI: '复制订阅链接', urisTitle: '节点订阅链接', invalidJSON: '无效的 JSON', selectProtocolFirst: '请先选择协议', usersHint: '用户凭证请在「用户管理」中创建并绑定到本节点;此处仅配置官方 Listener 协议字段。', portHint: '支持单端口(如 443)或官方 ports 语法(如 200,204,401-429)', sectionProtocol: '协议选项', sectionTransport: '传输层', sectionTLS: 'TLS 证书', sectionReality: 'Reality', sectionSSOption: 'Trojan SS 混淆', cipher: '加密方法', password: '密码', passwordOptional: '密码(可选)', uuid: 'UUID', privateKey: '私钥', certificate: '证书', alpn: 'ALPN', sni: 'SNI', clientFingerprint: '客户端指纹', allowInsecure: '允许不安全', muxOption: '多路复用', realityPrivateKey: 'Reality 私钥', realityShortID: 'Short ID', realityServerNames: 'Server Names', realityDest: 'Dest', transport: '传输', path: '路径', host: 'Host', serviceName: 'Service Name', up: '上行', down: '下行', ignoreClientBandwidth: '忽略客户端带宽', masquerade: 'Masquerade', congestionControl: '拥塞控制' },
+ listeners: { title: '节点管理', create: '创建节点', edit: '编辑节点', name: '名称', protocol: '协议', port: '端口', bindAddress: '绑定地址', status: '状态', actions: '操作', config: '协议配置', udp: 'UDP', tls: 'TLS', deleteConfirm: '确定要删除此节点吗?', created: '创建成功', updated: '更新成功', deleted: '删除成功', reloaded: '重载成功', copyURI: '复制订阅链接', urisTitle: '节点订阅链接', invalidJSON: '无效的 JSON', selectProtocolFirst: '请先选择协议', usersHint: '用户凭证请在「用户管理」中创建并绑定到本节点;此处仅配置官方 Listener 协议字段。', portHint: '支持单端口(如 443)或官方 ports 语法(如 200,204,401-429)', sectionProtocol: '协议选项', sectionTransport: '传输层', sectionTLS: 'TLS 证书', sectionReality: 'Reality', sectionSSOption: 'Trojan SS 混淆', cipher: '加密方法', password: '密码', passwordOptional: '密码(可选)', uuid: 'UUID', privateKey: '私钥', certificate: '证书', alpn: 'ALPN', sni: 'SNI', clientFingerprint: '客户端指纹', allowInsecure: '允许不安全', muxOption: '多路复用', realityPrivateKey: 'Reality 私钥', realityShortID: 'Short ID', realityServerNames: 'Server Names', realityDest: 'Dest', transport: '传输', path: '路径', host: 'Host', serviceName: 'Service Name', up: '上行', down: '下行', ignoreClientBandwidth: '忽略客户端带宽', masquerade: 'Masquerade', congestionControl: '拥塞控制', clone: '克隆', saveTemplate: '保存为模板', versions: '配置版本', cloned: '克隆成功', templateCreated: '模板创建成功', instantiated: '已从模板创建节点', batchDone: '批量操作完成', templates: '节点模板', templateName: '模板名称', createdAt: '创建时间', instantiate: '实例化', deleteTemplateConfirm: '确定删除此模板?', templateDeleted: '模板已删除', enableSelected: '启用选中', disableSelected: '禁用选中', newPort: '新端口', version: '版本', reason: '原因', diff: '配置差异', rollback: '回滚', rollbackConfirm: '确定回滚到此版本?', rollbackDone: '回滚完成' },
core: { title: '核心管理', status: '核心状态', running: '运行中', stoppedStatus: '已停止', version: '版本', pid: '进程 ID', uptime: '运行时间', start: '启动', stop: '停止', restart: '重启', started: '已启动', stopped: '已停止', restarted: '已重启' },
logs: { title: '运行日志', empty: '暂无日志', level: '级别', timestamp: '时间', payload: '内容', autoRefresh: '自动刷新', clear: '清空' },
config: { title: '配置引擎', subtitle: '可视化管理 Mihomo 配置', visual: '可视化配置', yaml: 'YAML 配置', proxies: '代理配置', yamlPreview: 'YAML 预览', addProxy: '添加代理', editProxy: '编辑代理', deleteProxy: '删除代理', deleteConfirm: '确定要删除此代理吗?', generateSuccess: '配置生成成功', validateSuccess: '配置验证通过', validateFailed: '配置验证失败', proxyName: '代理名称', proxyType: '代理类型', proxyServer: '代理服务器', proxyPort: '代理端口', proxyPassword: '代理密码', proxyUUID: '代理 UUID' },
@@ -17,7 +13,5 @@ export default {
traffic: { title: '流量监控', upload: '上传', download: '下载', uploadRate: '上传速率', downloadRate: '下载速率', connections: '连接', onlineUsers: '在线用户', byUser: '用户流量', listener: '节点', network: '网络', rule: '规则' },
cluster: { title: '多机节点', subtitle: '登记远程 3m-ui 面板并做健康检查(完整远程管控后续迭代)', create: '添加节点', edit: '编辑节点', name: '名称', baseUrl: '面板地址', apiToken: 'API Token', apiTokenHint: '可选;用于鉴权远程面板', remark: '备注', health: '健康检查', healthDone: '已探测', created: '已添加', updated: '已更新', deleted: '已删除' },
routing: { title: '路由与策略组', subtitle: '管理 Mihomo proxy-groups 与 rules', groups: '策略组', rules: '规则列表', addGroup: '添加策略组', proxies: '节点列表', proxiesHint: '逗号分隔的 proxy / group 名称', rulesHint: '每行一条 Mihomo rule', rulesSaved: '规则已保存', groupSaved: '策略组已保存', groupDeleted: '策略组已删除' },
- settings: {
- title: '系统设置', subtitle: '偏好与账户', language: '界面语言', theme: '主题外观', light: '浅色', dark: '深色', system: '跟随系统', passwordTitle: '账户安全', passwordDescription: '定期修改密码可以提高账户安全性', changePassword: '前往修改密码', about: '关于 3M-UI', version: '版本', backup: '备份与恢复', backupHint: '下载包含 SQLite 与 Mihomo 配置的 zip。恢复数据库后需重启面板进程。', downloadBackup: '下载备份', restoreDb: '恢复数据库', backupDone: '备份已下载', restoreDone: '数据库已恢复,请重启面板', apiDocs: 'API 文档', apiDocsHint: '用于自动化的 OpenAPI 3 规范。', openOpenAPI: '打开 openapi.yaml', telegram: 'Telegram 通知', botToken: 'Bot Token', chatIds: 'Chat IDs', telegramSaved: 'Telegram 设置已保存', telegramTest: '发送测试消息', telegramTestOk: '测试消息已发送', chatIdsHint: '多个 Chat ID 用逗号分隔', notifyBlock: '用户封禁时通知', notifyUnblock: '用户解封时通知', notifyExpiry: '用户过期时通知', notifyDailyDigest: '每天发送流量与用户摘要', templates: '证书与反代模板', proxyKind: '反代类型', domain: '域名', upstream: '上游地址', generateTemplate: '生成模板', templateGenerated: '模板已生成'
- },
+ settings: { title: '系统设置', subtitle: '偏好与账户', language: '界面语言', theme: '主题外观', light: '浅色', dark: '深色', system: '跟随系统', passwordTitle: '账户安全', passwordDescription: '定期修改密码可以提高账户安全性', changePassword: '前往修改密码', about: '关于 3M-UI', version: '版本', backup: '备份与恢复', backupHint: '下载包含 SQLite 与 Mihomo 配置的 zip。恢复数据库后需重启面板进程。', downloadBackup: '下载备份', restoreDb: '恢复数据库', backupDone: '备份已下载', restoreDone: '数据库已恢复,请重启面板', apiDocs: 'API 文档', apiDocsHint: '用于自动化的 OpenAPI 3 规范。', openOpenAPI: '打开 openapi.yaml', telegram: 'Telegram 通知', botToken: 'Bot Token', chatIds: 'Chat IDs', telegramSaved: 'Telegram 设置已保存', telegramTest: '发送测试消息', telegramTestOk: '测试消息已发送', chatIdsHint: '多个 Chat ID 用逗号分隔', notifyBlock: '用户封禁时通知', notifyUnblock: '用户解封时通知', notifyExpiry: '用户过期时通知', notifyDailyDigest: '每天发送流量与用户摘要', templates: '证书与反代模板', proxyKind: '反代类型', domain: '域名', upstream: '上游地址', generateTemplate: '生成模板', templateGenerated: '模板已生成' },
};