diff --git a/api/apiHandler.go b/api/apiHandler.go index 570b6ff..820b064 100644 --- a/api/apiHandler.go +++ b/api/apiHandler.go @@ -46,6 +46,8 @@ func (a *APIHandler) postHandler(c *gin.Context) { a.ApiService.RestartApp(c) case "restartSb": a.ApiService.RestartSb(c) + case "maintenance": + a.ApiService.SetMaintenance(c) case "resetTraffic": a.ApiService.ResetTraffic(c) case "linkConvert": diff --git a/api/apiService.go b/api/apiService.go index 0b6e6dc..9f66227 100644 --- a/api/apiService.go +++ b/api/apiService.go @@ -10,6 +10,7 @@ import ( "github.com/alireza0/s-ui/logger" "github.com/alireza0/s-ui/service" "github.com/alireza0/s-ui/util" + "github.com/alireza0/s-ui/util/common" "github.com/gin-gonic/gin" ) @@ -47,8 +48,18 @@ func (a *ApiService) getData(c *gin.Context) (interface{}, error) { } onlines, err := a.StatsService.GetOnlines() + // Carried on every poll so the panel can keep saying the core is down on + // purpose, wherever the operator happens to be looking. + maintenance, mErr := a.SettingService.GetMaintenance() + if mErr != nil { + logger.Warning("unable to read maintenance setting:", mErr) + } + data["maintenance"] = maintenance + sysInfo := a.ServerService.GetSingboxInfo() - if sysInfo["running"] == false { + // A core stopped on purpose is not a failure to report; without this the + // panel raises the last log as an error on every poll of a quiet system. + if sysInfo["running"] == false && !maintenance { logs := a.ServerService.GetLogs("1", "debug") if len(logs) > 0 { data["lastLog"] = logs[0] @@ -212,6 +223,15 @@ func (a *ApiService) GetStats(c *gin.Context) { func (a *ApiService) GetStatus(c *gin.Context) { request := c.Query("r") result := a.ServerService.GetStatus(request) + // A core that is down on purpose looks exactly like a core that crashed, + // and the panel has to tell the operator which one it is looking at. + if sbd, ok := (*result)["sbd"].(map[string]interface{}); ok { + maintenance, err := a.SettingService.GetMaintenance() + if err != nil { + logger.Warning("unable to read maintenance setting:", err) + } + sbd["maintenance"] = maintenance + } jsonObj(c, result, nil) } @@ -328,6 +348,19 @@ func (a *ApiService) RestartSb(c *gin.Context) { jsonMsg(c, "restartSb", err) } +// SetMaintenance stops the core and keeps it stopped, or starts it again. The +// five-second watchdog would undo a plain stop, so this is the only way to hold +// the core down from the panel. +func (a *ApiService) SetMaintenance(c *gin.Context) { + enabled, err := strconv.ParseBool(c.Request.FormValue("enable")) + if err != nil { + jsonMsg(c, "maintenance", common.NewError("missing or invalid parameter: enable")) + return + } + err = a.ConfigService.SetMaintenance(enabled) + jsonMsg(c, "maintenance", err) +} + func (a *ApiService) ResetTraffic(c *gin.Context) { if err := a.ClientService.ResetAllClientsTraffic(); err != nil { jsonMsg(c, "resetTraffic", err) diff --git a/api/apiV2Handler.go b/api/apiV2Handler.go index cf0c0cf..fcb93a8 100644 --- a/api/apiV2Handler.go +++ b/api/apiV2Handler.go @@ -47,6 +47,8 @@ func (a *APIv2Handler) postHandler(c *gin.Context) { a.ApiService.RestartApp(c) case "restartSb": a.ApiService.RestartSb(c) + case "maintenance": + a.ApiService.SetMaintenance(c) case "resetTraffic": a.ApiService.ResetTraffic(c) case "linkConvert": diff --git a/app/app.go b/app/app.go index 4d57fd5..87779d1 100644 --- a/app/app.go +++ b/app/app.go @@ -89,9 +89,15 @@ func (a *APP) Start() error { return err } - err = a.configService.StartCore() - if err != nil { - logger.Error(err) + // StartCore does nothing while maintenance is on, so say why rather than + // leave the operator looking at a panel that came up with no core. + if maintenance, mErr := a.SettingService.GetMaintenance(); mErr == nil && maintenance { + logger.Warning("maintenance mode is on: core not started, clients cannot connect") + } else { + err = a.configService.StartCore() + if err != nil { + logger.Error(err) + } } return nil diff --git a/frontend b/frontend index 5586304..3dc76b6 160000 --- a/frontend +++ b/frontend @@ -1 +1 @@ -Subproject commit 55863040b05b00489e465e798944247f6f166588 +Subproject commit 3dc76b6a94725e133a4ae1da7ed7084a1bef5d1f diff --git a/service/config.go b/service/config.go index 906f79a..760a545 100644 --- a/service/config.go +++ b/service/config.go @@ -202,7 +202,26 @@ func isEmptyRawJSON(raw json.RawMessage) bool { return false } +// inMaintenance reports whether the operator has asked for the core to stay +// stopped. It is checked here rather than at each caller because five different +// paths start the core -- boot, the five-second watchdog, every config save, +// the scheduled traffic reset and a base config change -- and a maintenance +// mode that any one of them could undo would not be one. +func (s *ConfigService) inMaintenance() bool { + maintenance, err := s.SettingService.GetMaintenance() + if err != nil { + logger.Warning("unable to read maintenance setting, assuming off:", err) + return false + } + return maintenance +} + func (s *ConfigService) StartCore() error { + // Checked before anything touches the core, so the five-second watchdog + // stops at this line while maintenance is on. + if s.inMaintenance() { + return nil + } if corePtr.IsRunning() { return nil } @@ -242,6 +261,9 @@ func (s *ConfigService) StartCore() error { } func (s *ConfigService) RestartCore() error { + if s.inMaintenance() { + return common.NewError("core is stopped for maintenance") + } err := s.StopCore() if err != nil { return err @@ -250,6 +272,11 @@ func (s *ConfigService) RestartCore() error { } func (s *ConfigService) restartCoreWithConfig(config json.RawMessage) error { + if s.inMaintenance() { + // The config is saved either way; it takes effect when the core is + // started again. + return nil + } startCoreMu.Lock() if startCoreInProgress { startCoreMu.Unlock() @@ -282,6 +309,24 @@ func (s *ConfigService) restartCoreWithConfig(config json.RawMessage) error { return nil } +// SetMaintenance takes the core out of service, or puts it back. The setting is +// written first, so the watchdog cannot race in and restart what was just +// stopped. +func (s *ConfigService) SetMaintenance(enabled bool) error { + if err := s.SettingService.SetMaintenance(enabled); err != nil { + return err + } + if enabled { + if !corePtr.IsRunning() { + return nil + } + logger.Warning("maintenance mode on: stopping core, clients cannot connect until it is turned off") + return s.StopCore() + } + logger.Info("maintenance mode off: starting core") + return s.StartCore() +} + func (s *ConfigService) StopCore() error { err := corePtr.Stop() if err != nil { diff --git a/service/config_maintenance_test.go b/service/config_maintenance_test.go new file mode 100644 index 0000000..6142dd5 --- /dev/null +++ b/service/config_maintenance_test.go @@ -0,0 +1,112 @@ +package service + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/alireza0/s-ui/database" +) + +// Maintenance mode has to survive every path that starts the core. These tests +// call the guards with no core attached at all: reaching the core would panic, +// so passing proves nothing downstream of the guard runs. +func maintenanceService(t *testing.T, enabled bool) *ConfigService { + t.Helper() + if err := database.InitDB(filepath.Join(t.TempDir(), "test.db")); err != nil { + t.Fatal(err) + } + s := &ConfigService{} + if err := s.SettingService.SetMaintenance(enabled); err != nil { + t.Fatal(err) + } + stored, err := s.SettingService.GetMaintenance() + if err != nil { + t.Fatal(err) + } + if stored != enabled { + t.Fatalf("maintenance stored as %v, want %v", stored, enabled) + } + return s +} + +// The five-second watchdog exists to restart a crashed core, and goes through +// StartCore to do it. Without this guard it would undo maintenance mode within +// five seconds of it being turned on. +func TestStartCoreStopsAtMaintenance(t *testing.T) { + s := maintenanceService(t, true) + if err := s.StartCore(); err != nil { + t.Fatalf("StartCore: %v", err) + } +} + +func TestRestartCoreRefusedInMaintenance(t *testing.T) { + s := maintenanceService(t, true) + err := s.RestartCore() + if err == nil { + t.Fatal("RestartCore succeeded while stopped for maintenance") + } + if !strings.Contains(err.Error(), "maintenance") { + t.Errorf("unexpected error: %v", err) + } +} + +// A base config change restarts the core with the new config. In maintenance +// the config is still saved; it just does not take effect yet. +func TestRestartCoreWithConfigStopsAtMaintenance(t *testing.T) { + s := maintenanceService(t, true) + if err := s.restartCoreWithConfig([]byte(`{"log":{"level":"info"}}`)); err != nil { + t.Fatalf("restartCoreWithConfig: %v", err) + } +} + +// An unreadable setting must not leave the core stopped: a panel that cannot +// read its own settings should still come up serving clients. +func TestMaintenanceDefaultsOffWhenUnset(t *testing.T) { + if err := database.InitDB(filepath.Join(t.TempDir(), "test.db")); err != nil { + t.Fatal(err) + } + s := &ConfigService{} + if s.inMaintenance() { + t.Error("maintenance reads as on before it was ever set") + } +} + +// The settings page posts every key it was given back again, maintenance among +// them. Writing it from there would set the flag without stopping the core, so +// SettingService.Save has to leave it to the action that does both. +func TestSettingsSaveIgnoresMaintenance(t *testing.T) { + if err := database.InitDB(filepath.Join(t.TempDir(), "test.db")); err != nil { + t.Fatal(err) + } + s := &SettingService{} + // Save updates existing rows, and the defaults are only written on first + // read, which is what the settings page does before it ever saves. + if _, err := s.GetAllSetting(); err != nil { + t.Fatal(err) + } + if err := s.SetMaintenance(true); err != nil { + t.Fatal(err) + } + + err := s.Save(database.GetDB(), []byte(`{"maintenance":"false","timeLocation":"Europe/Berlin"}`)) + if err != nil { + t.Fatalf("Save: %v", err) + } + + maintenance, err := s.GetMaintenance() + if err != nil { + t.Fatal(err) + } + if !maintenance { + t.Error("the settings form turned maintenance off without starting the core") + } + // Everything else in the same payload still has to be written. + location, err := s.getString("timeLocation") + if err != nil { + t.Fatal(err) + } + if location != "Europe/Berlin" { + t.Errorf("timeLocation = %q, want Europe/Berlin", location) + } +} diff --git a/service/setting.go b/service/setting.go index 211edec..2f6087a 100644 --- a/service/setting.go +++ b/service/setting.go @@ -69,6 +69,7 @@ var defaultValueMap = map[string]string{ "subClashNoDefGrp": "false", "subClashSprtAll": "false", "subClashUdp": "false", + "maintenance": "false", "globalReset": "", "globalResetLast": "0", "config": defaultConfig, @@ -417,6 +418,14 @@ func (s *SettingService) Save(tx *gorm.DB, data json.RawMessage) error { return err } for key, obj := range settings { + // maintenance has an action of its own, which stops or starts the core + // alongside writing the flag. Letting it through here would leave the + // two disagreeing: the flag set with the core still serving clients, or + // cleared with the core still down. + if key == "maintenance" { + continue + } + // Ignore accidental surrounding whitespace while preserving spaces // inside values such as certificate paths and URLs. obj = normalizeSettingValue(obj) @@ -487,6 +496,17 @@ func (s *SettingService) GetSubClashUdp() (bool, error) { return s.getBool("subClashUdp") } +// GetMaintenance reports whether the operator has taken the core out of +// service. It is stored rather than held in memory so a reboot in the middle of +// maintenance does not quietly put users back online. +func (s *SettingService) GetMaintenance() (bool, error) { + return s.getBool("maintenance") +} + +func (s *SettingService) SetMaintenance(enabled bool) error { + return s.setString("maintenance", strconv.FormatBool(enabled)) +} + func (s *SettingService) fileExists(path string) error { _, err := os.Stat(path) return err