panel.go 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. package service
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "io"
  6. "net/http"
  7. "os"
  8. "os/exec"
  9. "path/filepath"
  10. "runtime"
  11. "strconv"
  12. "strings"
  13. "syscall"
  14. "time"
  15. "github.com/mhsanaei/3x-ui/v3/config"
  16. "github.com/mhsanaei/3x-ui/v3/logger"
  17. )
  18. // PanelService provides business logic for panel management operations.
  19. // It handles panel restart, updates, and system-level panel controls.
  20. type PanelService struct{}
  21. // PanelUpdateInfo contains the current and latest available panel versions.
  22. type PanelUpdateInfo struct {
  23. CurrentVersion string `json:"currentVersion"`
  24. LatestVersion string `json:"latestVersion"`
  25. UpdateAvailable bool `json:"updateAvailable"`
  26. }
  27. const (
  28. panelUpdaterURL = "https://raw.githubusercontent.com/MHSanaei/3x-ui/main/update.sh"
  29. maxPanelUpdaterBytes = 2 << 20
  30. )
  31. func (s *PanelService) RestartPanel(delay time.Duration) error {
  32. p, err := os.FindProcess(syscall.Getpid())
  33. if err != nil {
  34. return err
  35. }
  36. go func() {
  37. time.Sleep(delay)
  38. err := p.Signal(syscall.SIGHUP)
  39. if err != nil {
  40. logger.Error("failed to send SIGHUP signal:", err)
  41. }
  42. }()
  43. return nil
  44. }
  45. // GetUpdateInfo checks GitHub for the latest 3x-ui release.
  46. func (s *PanelService) GetUpdateInfo() (*PanelUpdateInfo, error) {
  47. latest, err := fetchLatestPanelVersion()
  48. if err != nil {
  49. return nil, err
  50. }
  51. current := config.GetVersion()
  52. return &PanelUpdateInfo{
  53. CurrentVersion: current,
  54. LatestVersion: latest,
  55. UpdateAvailable: isNewerVersion(latest, current),
  56. }, nil
  57. }
  58. // StartUpdate starts the official updater outside of the current web request.
  59. func (s *PanelService) StartUpdate() error {
  60. if runtime.GOOS != "linux" {
  61. return fmt.Errorf("panel web update is supported only on Linux installations")
  62. }
  63. bash, err := exec.LookPath("bash")
  64. if err != nil {
  65. return fmt.Errorf("bash is required to run the panel updater: %w", err)
  66. }
  67. scriptPath, err := downloadPanelUpdater()
  68. if err != nil {
  69. return err
  70. }
  71. mainFolder, serviceFolder := resolveUpdateFolders()
  72. updateScript := fmt.Sprintf("set -e; trap 'rm -f %s' EXIT; %s %s", shellQuote(scriptPath), shellQuote(bash), shellQuote(scriptPath))
  73. if systemdRun, err := exec.LookPath("systemd-run"); err == nil {
  74. unitName := fmt.Sprintf("x-ui-web-update-%d", time.Now().Unix())
  75. cmd := exec.Command(systemdRun,
  76. "--unit", unitName,
  77. "--setenv", "XUI_MAIN_FOLDER="+mainFolder,
  78. "--setenv", "XUI_SERVICE="+serviceFolder,
  79. bash, "-lc", updateScript,
  80. )
  81. out, err := cmd.CombinedOutput()
  82. if err != nil {
  83. output := strings.TrimSpace(string(out))
  84. if !strings.Contains(output, "System has not been booted with systemd") &&
  85. !strings.Contains(output, "Failed to connect to bus") {
  86. _ = os.Remove(scriptPath)
  87. return fmt.Errorf("failed to start panel update job: %w: %s", err, output)
  88. }
  89. logger.Warning("systemd-run is unavailable, falling back to detached update process:", output)
  90. } else {
  91. logger.Infof("started panel update job via systemd-run unit %s", unitName)
  92. return nil
  93. }
  94. }
  95. cmd := exec.Command(bash, "-lc", updateScript)
  96. cmd.Env = append(os.Environ(),
  97. "XUI_MAIN_FOLDER="+mainFolder,
  98. "XUI_SERVICE="+serviceFolder,
  99. )
  100. setDetachedProcess(cmd)
  101. if err := cmd.Start(); err != nil {
  102. _ = os.Remove(scriptPath)
  103. return fmt.Errorf("failed to start panel update job: %w", err)
  104. }
  105. if err := cmd.Process.Release(); err != nil {
  106. logger.Warning("failed to release panel update process:", err)
  107. }
  108. logger.Infof("started panel update job with pid %d", cmd.Process.Pid)
  109. return nil
  110. }
  111. func downloadPanelUpdater() (string, error) {
  112. client := &http.Client{Timeout: 15 * time.Second}
  113. resp, err := client.Get(panelUpdaterURL)
  114. if err != nil {
  115. return "", fmt.Errorf("download panel updater: %w", err)
  116. }
  117. defer resp.Body.Close()
  118. if resp.StatusCode != http.StatusOK {
  119. return "", fmt.Errorf("download panel updater: unexpected HTTP %d", resp.StatusCode)
  120. }
  121. file, err := os.CreateTemp("", "3x-ui-update-*.sh")
  122. if err != nil {
  123. return "", err
  124. }
  125. path := file.Name()
  126. ok := false
  127. defer func() {
  128. _ = file.Close()
  129. if !ok {
  130. _ = os.Remove(path)
  131. }
  132. }()
  133. n, err := io.Copy(file, io.LimitReader(resp.Body, maxPanelUpdaterBytes+1))
  134. if err != nil {
  135. return "", fmt.Errorf("write panel updater: %w", err)
  136. }
  137. if n > maxPanelUpdaterBytes {
  138. return "", fmt.Errorf("panel updater exceeds %d bytes", maxPanelUpdaterBytes)
  139. }
  140. if err := file.Chmod(0700); err != nil {
  141. return "", err
  142. }
  143. ok = true
  144. return path, nil
  145. }
  146. func fetchLatestPanelVersion() (string, error) {
  147. client := &http.Client{Timeout: 10 * time.Second}
  148. resp, err := client.Get("https://api.github.com/repos/MHSanaei/3x-ui/releases/latest")
  149. if err != nil {
  150. return "", err
  151. }
  152. defer resp.Body.Close()
  153. if resp.StatusCode != http.StatusOK {
  154. return "", fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, resp.Status)
  155. }
  156. var release Release
  157. if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
  158. return "", err
  159. }
  160. if release.TagName == "" {
  161. return "", fmt.Errorf("latest panel release tag is empty")
  162. }
  163. return release.TagName, nil
  164. }
  165. func resolveUpdateFolders() (string, string) {
  166. mainFolder := os.Getenv("XUI_MAIN_FOLDER")
  167. if mainFolder == "" {
  168. if exePath, err := os.Executable(); err == nil {
  169. mainFolder = filepath.Dir(exePath)
  170. }
  171. }
  172. if mainFolder == "" {
  173. mainFolder = "/usr/local/x-ui"
  174. }
  175. serviceFolder := os.Getenv("XUI_SERVICE")
  176. if serviceFolder == "" {
  177. serviceFolder = "/etc/systemd/system"
  178. }
  179. return mainFolder, serviceFolder
  180. }
  181. func isNewerVersion(latest string, current string) bool {
  182. cmp, ok := compareVersionStrings(latest, current)
  183. if !ok {
  184. return normalizeVersionTag(latest) != normalizeVersionTag(current)
  185. }
  186. return cmp > 0
  187. }
  188. func compareVersionStrings(a string, b string) (int, bool) {
  189. aParts, okA := parseVersionParts(a)
  190. bParts, okB := parseVersionParts(b)
  191. if !okA || !okB {
  192. return 0, false
  193. }
  194. for i := 0; i < len(aParts); i++ {
  195. if aParts[i] > bParts[i] {
  196. return 1, true
  197. }
  198. if aParts[i] < bParts[i] {
  199. return -1, true
  200. }
  201. }
  202. return 0, true
  203. }
  204. func parseVersionParts(version string) ([3]int, bool) {
  205. var result [3]int
  206. parts := strings.Split(normalizeVersionTag(version), ".")
  207. if len(parts) != 3 {
  208. return result, false
  209. }
  210. for i, part := range parts {
  211. n, err := strconv.Atoi(part)
  212. if err != nil {
  213. return result, false
  214. }
  215. result[i] = n
  216. }
  217. return result, true
  218. }
  219. func normalizeVersionTag(version string) string {
  220. return strings.TrimPrefix(strings.TrimSpace(version), "v")
  221. }
  222. func shellQuote(value string) string {
  223. return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
  224. }