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) => ( - - - - } - > - - + 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)}>} + + + }> +
+ + , + }, + { + 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) => ( - - - -
v || '-' }, + { title: t('listeners.createdAt'), dataIndex: 'created_at', render: (v: string) => new Date(v).toLocaleString() }, + { title: t('common.actions'), render: (_: any, v: ListenerVersion) => doRollback(v.version)}> }, + ]} /> + + + setDiffModal(false)} footer={null} width={900}> +
{diffText || t('common.empty')}
+
+ + setUriModal(false)} footer={null}> + {uris.map((uri, i) =>