diff --git a/internal/web/service/panel/panel.go b/internal/web/service/panel/panel.go index ac8d810..55adc64 100644 --- a/internal/web/service/panel/panel.go +++ b/internal/web/service/panel/panel.go @@ -19,6 +19,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/config" "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/selfupdate" "github.com/mhsanaei/3x-ui/v3/internal/web/global" "github.com/mhsanaei/3x-ui/v3/internal/web/service" ) @@ -37,6 +38,13 @@ type PanelUpdateInfo struct { CurrentCommit string `json:"currentCommit,omitempty"` LatestCommit string `json:"latestCommit,omitempty"` UpdateAvailable bool `json:"updateAvailable"` + // Display versions (item 18/20): the V-series numbers the UI shows for + // this fork. DisplayUpdateAvailable compares them with the + // single-digit-carry segment rule — the raw tag stream above keeps its + // historical semantics for everything else (nodes page, remotes). + DisplayCurrentVersion string `json:"displayCurrentVersion,omitempty"` + DisplayLatestVersion string `json:"displayLatestVersion,omitempty"` + DisplayUpdateAvailable bool `json:"displayUpdateAvailable"` } const ( @@ -137,12 +145,32 @@ func (s *PanelService) GetUpdateInfo() (*PanelUpdateInfo, error) { return nil, err } current := config.GetBaseVersion() - return &PanelUpdateInfo{ + info := &PanelUpdateInfo{ Channel: "stable", CurrentVersion: current, LatestVersion: latest, UpdateAvailable: isNewerVersion(latest, current), - }, nil + } + info.DisplayCurrentVersion = config.GetMasterDisplayVersion() + if dispLatest, avail := displayUpdateFields(latest); dispLatest != "" { + info.DisplayLatestVersion = dispLatest + info.DisplayUpdateAvailable = avail + } + return info, nil +} + +// displayUpdateFields maps a raw latest release tag onto the V-series +// display version and whether it is strictly newer than the running +// display version. An unparseable tag (not a V-series release) yields "" +// so the UI falls back to the raw fields. +func displayUpdateFields(latestTag string) (string, bool) { + parts, ok := config.ParseDisplayVersion(latestTag) + if !ok { + return "", false + } + disp := fmt.Sprintf("V%d.%d.%d", parts[0], parts[1], parts[2]) + cmp, ok := config.CompareDisplayVersion(disp, config.GetMasterDisplayVersion()) + return disp, ok && cmp > 0 } // devChannelActive reports whether self-update should track the rolling dev @@ -213,6 +241,16 @@ func (s *PanelService) GetUpdateStatus() *PanelUpdateStatus { } func (s *PanelService) startUpdate(useDev bool) (int64, error) { + // The dev channel keeps the historical update.sh path. The stable + // channel uses the Go-native swap (item 20): the fork's own releases + // carry bare binaries, not the tarball layout update.sh expects. + if useDev { + return s.startScriptUpdate(useDev) + } + return s.startNativeUpdate() +} + +func (s *PanelService) startScriptUpdate(useDev bool) (int64, error) { runID := time.Now().UnixNano() if !acquireUpdateSlot(runID) { return 0, fmt.Errorf("a panel update is already in progress") @@ -505,6 +543,154 @@ func commitsEqual(a, b string) bool { return strings.HasPrefix(b, a) } +// --------------------------------------------------------------------------- +// Native stable-channel self-update (item 20). +// +// The fork's own releases ship bare binaries (x-ui-linux- + .sha256), +// so the stable channel swaps the binary in Go instead of running +// update.sh: resolve the newest V-series tag, download this host's asset +// with sha256 verification, atomically rename it over the running binary +// (old one kept as .bak), record success in the same status file the +// update.sh path uses, then restart the systemd unit from a detached +// process. Only systemd hosts are eligible — anywhere else the operator +// updates manually, as before. +// --------------------------------------------------------------------------- + +// nativeSystemdAvailable reports whether this process runs under systemd. +// XUI_SELFUPDATE_SYSTEMD=1 forces the gate open (sandbox verification with +// a stub systemctl on PATH). +var nativeSystemdAvailable = func() bool { + if os.Getenv("XUI_SELFUPDATE_SYSTEMD") == "1" { + return true + } + _, err := os.Stat("/run/systemd/system") + return err == nil +} + +// scheduleServiceRestart restarts the panel's unit from a detached +// process, so the restart is not killed together with this process. +// A variable so tests can stub it. +var scheduleServiceRestart = func(unit string) error { + cmd := exec.Command("sh", "-c", "sleep 1; systemctl restart "+shellQuote(unit)) + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + if err := cmd.Start(); err != nil { + return err + } + return cmd.Process.Release() +} + +func (s *PanelService) startNativeUpdate() (int64, error) { + if runtime.GOOS != "linux" { + return 0, fmt.Errorf("panel web update is supported only on Linux installations") + } + if !nativeSystemdAvailable() { + return 0, fmt.Errorf("panel online update requires a systemd environment; please update manually") + } + runID := time.Now().UnixNano() + if !acquireUpdateSlot(runID) { + return 0, fmt.Errorf("a panel update is already in progress") + } + go s.runNativeUpdate(runID) + return runID, nil +} + +func (s *PanelService) runNativeUpdate(runID int64) { + fail := func(format string, args ...any) { + logger.Warning("panel self-update failed: ", fmt.Sprintf(format, args...)) + writeUpdateStatus(runID, updateStateFailed, 1) + releaseUpdateSlot() + } + + tag, err := fetchLatestPanelVersion() + if err != nil { + fail("fetch latest release: %v", err) + return + } + if _, avail := displayUpdateFields(tag); !avail { + fail("latest release %s is not newer than current %s", tag, config.GetMasterDisplayVersion()) + return + } + asset, err := selfupdate.AssetName(runtime.GOARCH) + if err != nil { + fail("%v", err) + return + } + exePath, err := selfupdate.ExecutablePath() + if err != nil { + fail("locate own binary: %v", err) + return + } + tmp := filepath.Join(filepath.Dir(exePath), fmt.Sprintf(".x-ui-update-%d", runID)) + base := selfupdate.BaseURL() + tag + "/" + client := (&service.SettingService{}).NewProxiedHTTPClient(10 * time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + logger.Infof("panel self-update: downloading %s (%s)", tag, asset) + if err := selfupdate.DownloadVerified(ctx, client, base+asset, base+asset+".sha256", tmp); err != nil { + _ = os.Remove(tmp) + fail("download %s: %v", tag, err) + return + } + bak, err := selfupdate.SwapExecutable(exePath, tmp) + if err != nil { + _ = os.Remove(tmp) + fail("swap binary: %v", err) + return + } + logger.Infof("panel self-update: %s installed (previous binary kept at %s); restarting service", tag, bak) + writeUpdateStatus(runID, updateStateSuccess, 0) + releaseUpdateSlot() + if err := scheduleServiceRestart(detectServiceUnit()); err != nil { + logger.Warning("panel self-update: schedule restart failed (new binary takes effect at the next restart): ", err) + } +} + +// detectServiceUnit resolves this process's systemd unit: explicit +// override, else the unit named in /proc/self/cgroup, else "x-ui". +func detectServiceUnit() string { + if v := strings.TrimSpace(os.Getenv("XUI_SERVICE_NAME")); v != "" { + return v + } + if data, err := os.ReadFile("/proc/self/cgroup"); err == nil { + if unit := serviceUnitFromCgroup(string(data)); unit != "" { + return unit + } + } + return "x-ui" +} + +// serviceUnitFromCgroup extracts the first *.service component from +// cgroup content, without the suffix ("system.slice/x-ui.service" -> +// "x-ui"). +func serviceUnitFromCgroup(content string) string { + for _, line := range strings.Split(content, "\n") { + for _, part := range strings.Split(line, "/") { + if strings.HasSuffix(part, ".service") { + return strings.TrimSuffix(part, ".service") + } + } + } + return "" +} + +// writeUpdateStatus records a native-update outcome in the same status +// file and JSON shape the update.sh path produces for GetUpdateStatus. +func writeUpdateStatus(runID int64, state string, exitCode int) { + status := PanelUpdateStatus{ + RunID: strconv.FormatInt(runID, 10), + State: state, + ExitCode: exitCode, + FinishedAt: time.Now().Unix(), + } + data, err := json.Marshal(status) + if err != nil { + return + } + path := config.GetUpdateStatusFilePath() + _ = os.MkdirAll(filepath.Dir(path), 0o755) + _ = os.WriteFile(path, data, 0o644) +} + func resolveUpdateFolders() (string, string) { mainFolder := os.Getenv("XUI_MAIN_FOLDER") if mainFolder == "" { diff --git a/internal/web/service/panel/panel_native_update_test.go b/internal/web/service/panel/panel_native_update_test.go new file mode 100644 index 0000000..6174ff7 --- /dev/null +++ b/internal/web/service/panel/panel_native_update_test.go @@ -0,0 +1,65 @@ +package panel + +import ( + "strings" + "testing" +) + +// Item 20: the stable channel compares V-series display versions with +// the segment rule; the native self-update is systemd-gated and records +// its outcome in the shared update status file. + +func TestDisplayUpdateFields(t *testing.T) { + // The test binary runs the default display version V1.0.0. + if disp, avail := displayUpdateFields("V1.0.1"); disp != "V1.0.1" || !avail { + t.Fatalf("V1.0.1 vs V1.0.0 = %q, %v; want V1.0.1, true", disp, avail) + } + if disp, avail := displayUpdateFields("V1.0.0"); disp != "V1.0.0" || avail { + t.Fatalf("V1.0.0 vs V1.0.0 = %q, %v; want V1.0.0, false", disp, avail) + } + if disp, avail := displayUpdateFields("3.7.0-agent.1"); disp != "" || avail { + t.Fatalf("legacy tag = %q, %v; want empty, false", disp, avail) + } + if _, avail := displayUpdateFields("V1.0.9"); !avail { + t.Fatal("V1.0.9 must be newer than V1.0.0") + } +} + +func TestServiceUnitFromCgroup(t *testing.T) { + content := "0::/system.slice/x-ui.service\n" + if got := serviceUnitFromCgroup(content); got != "x-ui" { + t.Fatalf("unit = %q, want x-ui", got) + } + if got := serviceUnitFromCgroup("0::/user.slice/session-3.scope\n"); got != "" { + t.Fatalf("unit = %q, want empty", got) + } + if got := serviceUnitFromCgroup(""); got != "" { + t.Fatalf("unit = %q, want empty", got) + } +} + +func TestNativeUpdateStatusRoundtrip(t *testing.T) { + t.Setenv("XUI_DB_FOLDER", t.TempDir()) + writeUpdateStatus(424242, updateStateSuccess, 0) + st := (&PanelService{}).GetUpdateStatus() + if st.RunID != "424242" || st.State != updateStateSuccess || st.ExitCode != 0 { + t.Fatalf("status roundtrip = %+v", st) + } + writeUpdateStatus(424243, updateStateFailed, 1) + st = (&PanelService{}).GetUpdateStatus() + if st.RunID != "424243" || st.State != updateStateFailed || st.ExitCode != 1 { + t.Fatalf("status roundtrip = %+v", st) + } +} + +func TestStartUpdateStableRequiresSystemd(t *testing.T) { + // Force the gate closed regardless of the host running the test. + orig := nativeSystemdAvailable + nativeSystemdAvailable = func() bool { return false } + t.Cleanup(func() { nativeSystemdAvailable = orig }) + + _, err := (&PanelService{}).startNativeUpdate() + if err == nil || !strings.Contains(err.Error(), "systemd") { + t.Fatalf("want systemd gate error, got %v", err) + } +}