mirror of
https://github.com/alireza0/s-ui.git
synced 2026-10-20 19:02:09 +00:00
feat: maintenance mode (stop Sing-Box core) #1247
This commit is contained in:
parent
168ba563b5
commit
5a33e0243e
8 changed files with 225 additions and 5 deletions
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
12
app/app.go
12
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
|
||||
|
|
|
|||
2
frontend
2
frontend
|
|
@ -1 +1 @@
|
|||
Subproject commit 55863040b05b00489e465e798944247f6f166588
|
||||
Subproject commit 3dc76b6a94725e133a4ae1da7ed7084a1bef5d1f
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
112
service/config_maintenance_test.go
Normal file
112
service/config_maintenance_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue