feat: maintenance mode (stop Sing-Box core) #1247

This commit is contained in:
Alireza Ahmadi 2026-09-10 02:53:20 +02:00
parent 168ba563b5
commit 5a33e0243e
8 changed files with 225 additions and 5 deletions

View file

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

View file

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

View file

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

View file

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

@ -1 +1 @@
Subproject commit 55863040b05b00489e465e798944247f6f166588
Subproject commit 3dc76b6a94725e133a4ae1da7ed7084a1bef5d1f

View file

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

View file

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

View file

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