diff options
| author | Farhad H. P. Shirvan <9374298+farhadh@users.noreply.github.com> | 2026-04-28 19:46:55 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-28 19:46:55 +0300 |
| commit | f21ed9229695ed1cbcc86d65b52d98cfc9116f5a (patch) | |
| tree | d1c96f66fe973005aebc6b065e949f3725f1527d /web/service | |
| parent | 22de983752fb4cc10bd16b756c4ccaab239e2e3b (diff) | |
feat: add panel update functionality via web GUI (#4117)
* feat: add panel update functionality via web GUI
* feat: enhance panel update notifications in web GUI
* feat: implement panel update modal and enhance translation strings
* fix design
Diffstat (limited to 'web/service')
| -rw-r--r-- | web/service/panel.go | 174 | ||||
| -rw-r--r-- | web/service/panel_other.go | 7 | ||||
| -rw-r--r-- | web/service/panel_test.go | 41 | ||||
| -rw-r--r-- | web/service/panel_unix.go | 12 |
4 files changed, 234 insertions, 0 deletions
diff --git a/web/service/panel.go b/web/service/panel.go index e4fb0c68..75f2f155 100644 --- a/web/service/panel.go +++ b/web/service/panel.go @@ -1,10 +1,19 @@ package service import ( + "encoding/json" + "fmt" + "net/http" "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" "syscall" "time" + "github.com/mhsanaei/3x-ui/v2/config" "github.com/mhsanaei/3x-ui/v2/logger" ) @@ -12,6 +21,13 @@ import ( // It handles panel restart, updates, and system-level panel controls. type PanelService struct{} +// PanelUpdateInfo contains the current and latest available panel versions. +type PanelUpdateInfo struct { + CurrentVersion string `json:"currentVersion"` + LatestVersion string `json:"latestVersion"` + UpdateAvailable bool `json:"updateAvailable"` +} + func (s *PanelService) RestartPanel(delay time.Duration) error { p, err := os.FindProcess(syscall.Getpid()) if err != nil { @@ -26,3 +42,161 @@ func (s *PanelService) RestartPanel(delay time.Duration) error { }() return nil } + +// GetUpdateInfo checks GitHub for the latest 3x-ui release. +func (s *PanelService) GetUpdateInfo() (*PanelUpdateInfo, error) { + latest, err := fetchLatestPanelVersion() + if err != nil { + return nil, err + } + current := config.GetVersion() + return &PanelUpdateInfo{ + CurrentVersion: current, + LatestVersion: latest, + UpdateAvailable: isNewerVersion(latest, current), + }, nil +} + +// StartUpdate starts the official updater outside of the current web request. +func (s *PanelService) StartUpdate() error { + if runtime.GOOS != "linux" { + return fmt.Errorf("panel web update is supported only on Linux installations") + } + + bash, err := exec.LookPath("bash") + if err != nil { + return fmt.Errorf("bash is required to run the panel updater: %w", err) + } + curl, err := exec.LookPath("curl") + if err != nil { + return fmt.Errorf("curl is required to download the panel updater: %w", err) + } + + mainFolder, serviceFolder := resolveUpdateFolders() + updateScript := fmt.Sprintf("set -o pipefail; %s -fLs https://raw.githubusercontent.com/MHSanaei/3x-ui/main/update.sh | %s", shellQuote(curl), shellQuote(bash)) + + if systemdRun, err := exec.LookPath("systemd-run"); err == nil { + unitName := fmt.Sprintf("x-ui-web-update-%d", time.Now().Unix()) + cmd := exec.Command(systemdRun, + "--unit", unitName, + "--setenv", "XUI_MAIN_FOLDER="+mainFolder, + "--setenv", "XUI_SERVICE="+serviceFolder, + bash, "-lc", updateScript, + ) + out, err := cmd.CombinedOutput() + if err != nil { + output := strings.TrimSpace(string(out)) + if !strings.Contains(output, "System has not been booted with systemd") && + !strings.Contains(output, "Failed to connect to bus") { + return fmt.Errorf("failed to start panel update job: %w: %s", err, output) + } + logger.Warning("systemd-run is unavailable, falling back to detached update process:", output) + } else { + logger.Infof("started panel update job via systemd-run unit %s", unitName) + return nil + } + } + + cmd := exec.Command(bash, "-lc", updateScript) + cmd.Env = append(os.Environ(), + "XUI_MAIN_FOLDER="+mainFolder, + "XUI_SERVICE="+serviceFolder, + ) + setDetachedProcess(cmd) + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start panel update job: %w", err) + } + if err := cmd.Process.Release(); err != nil { + logger.Warning("failed to release panel update process:", err) + } + logger.Infof("started panel update job with pid %d", cmd.Process.Pid) + return nil +} + +func fetchLatestPanelVersion() (string, error) { + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get("https://api.github.com/repos/MHSanaei/3x-ui/releases/latest") + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, resp.Status) + } + + var release Release + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return "", err + } + if release.TagName == "" { + return "", fmt.Errorf("latest panel release tag is empty") + } + return release.TagName, nil +} + +func resolveUpdateFolders() (string, string) { + mainFolder := os.Getenv("XUI_MAIN_FOLDER") + if mainFolder == "" { + if exePath, err := os.Executable(); err == nil { + mainFolder = filepath.Dir(exePath) + } + } + if mainFolder == "" { + mainFolder = "/usr/local/x-ui" + } + + serviceFolder := os.Getenv("XUI_SERVICE") + if serviceFolder == "" { + serviceFolder = "/etc/systemd/system" + } + return mainFolder, serviceFolder +} + +func isNewerVersion(latest string, current string) bool { + cmp, ok := compareVersionStrings(latest, current) + if !ok { + return normalizeVersionTag(latest) != normalizeVersionTag(current) + } + return cmp > 0 +} + +func compareVersionStrings(a string, b string) (int, bool) { + aParts, okA := parseVersionParts(a) + bParts, okB := parseVersionParts(b) + if !okA || !okB { + return 0, false + } + for i := 0; i < len(aParts); i++ { + if aParts[i] > bParts[i] { + return 1, true + } + if aParts[i] < bParts[i] { + return -1, true + } + } + return 0, true +} + +func parseVersionParts(version string) ([3]int, bool) { + var result [3]int + parts := strings.Split(normalizeVersionTag(version), ".") + if len(parts) != 3 { + return result, false + } + for i, part := range parts { + n, err := strconv.Atoi(part) + if err != nil { + return result, false + } + result[i] = n + } + return result, true +} + +func normalizeVersionTag(version string) string { + return strings.TrimPrefix(strings.TrimSpace(version), "v") +} + +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} diff --git a/web/service/panel_other.go b/web/service/panel_other.go new file mode 100644 index 00000000..53295c10 --- /dev/null +++ b/web/service/panel_other.go @@ -0,0 +1,7 @@ +//go:build !linux + +package service + +import "os/exec" + +func setDetachedProcess(cmd *exec.Cmd) {} diff --git a/web/service/panel_test.go b/web/service/panel_test.go new file mode 100644 index 00000000..44e9ba34 --- /dev/null +++ b/web/service/panel_test.go @@ -0,0 +1,41 @@ +package service + +import "testing" + +func TestIsNewerVersion(t *testing.T) { + cases := []struct { + latest string + current string + want bool + }{ + {"v2.9.4", "2.9.3", true}, + {"v2.10.0", "2.9.9", true}, + {"v2.9.3", "2.9.3", false}, + {"v2.9.2", "2.9.3", false}, + {"v3.0.0", "2.9.3", true}, + } + + for _, tc := range cases { + if got := isNewerVersion(tc.latest, tc.current); got != tc.want { + t.Fatalf("isNewerVersion(%q, %q) = %v, want %v", tc.latest, tc.current, got, tc.want) + } + } +} + +func TestCompareVersionStringsRejectsUnexpectedFormats(t *testing.T) { + if _, ok := compareVersionStrings("latest", "2.9.3"); ok { + t.Fatal("expected non-semver latest tag to be rejected") + } + if _, ok := compareVersionStrings("v2.9", "2.9.3"); ok { + t.Fatal("expected short version to be rejected") + } +} + +func TestShellQuote(t *testing.T) { + if got := shellQuote("/usr/bin/curl"); got != "'/usr/bin/curl'" { + t.Fatalf("unexpected quote result: %s", got) + } + if got := shellQuote("/tmp/a'b"); got != "'/tmp/a'\\''b'" { + t.Fatalf("unexpected quote result with single quote: %s", got) + } +} diff --git a/web/service/panel_unix.go b/web/service/panel_unix.go new file mode 100644 index 00000000..13d2237c --- /dev/null +++ b/web/service/panel_unix.go @@ -0,0 +1,12 @@ +//go:build linux + +package service + +import ( + "os/exec" + "syscall" +) + +func setDetachedProcess(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} +} |
