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" ) // PanelService provides business logic for panel management operations. // 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 { return err } go func() { time.Sleep(delay) err := p.Signal(syscall.SIGHUP) if err != nil { logger.Error("failed to send SIGHUP signal:", err) } }() 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, "'", "'\\''") + "'" }