system_handler.go 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. package admin
  2. import (
  3. "context"
  4. "errors"
  5. "net/http"
  6. "strconv"
  7. "strings"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/response"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/sysutil"
  11. middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  12. "github.com/Wei-Shaw/sub2api/internal/service"
  13. "github.com/gin-gonic/gin"
  14. )
  15. // SystemHandler handles system-related operations
  16. type SystemHandler struct {
  17. updateSvc systemUpdateService
  18. lockSvc *service.SystemOperationLockService
  19. }
  20. // systemUpdateTimeout bounds a full in-place update or rollback: the release
  21. // manifest fetch plus a large binary download over slow links. It must stay
  22. // above the GitHub download client timeout (10 minutes) so the download owns
  23. // its own deadline.
  24. const systemUpdateTimeout = 15 * time.Minute
  25. // systemUpdateContext detaches a long-running update/rollback from the HTTP
  26. // request lifetime. Browsers and reverse proxies commonly abort idle requests
  27. // after 30-60s (axios default, nginx proxy_read_timeout), which canceled
  28. // c.Request.Context() mid-download and killed the update with
  29. // "download failed: context canceled" (#4504). The swap keeps running after a
  30. // client disconnect; a later retry then hits the system operation lock or
  31. // reports "Already up to date".
  32. func systemUpdateContext(ctx context.Context) (context.Context, context.CancelFunc) {
  33. base := context.Background()
  34. if ctx != nil {
  35. base = context.WithoutCancel(ctx)
  36. }
  37. return context.WithTimeout(base, systemUpdateTimeout)
  38. }
  39. type systemUpdateService interface {
  40. CheckUpdate(ctx context.Context, force bool) (*service.UpdateInfo, error)
  41. PerformUpdate(ctx context.Context) error
  42. Rollback() error
  43. ListRollbackVersions(ctx context.Context) ([]service.RollbackVersion, error)
  44. RollbackToVersion(ctx context.Context, version string) error
  45. }
  46. // NewSystemHandler creates a new SystemHandler
  47. func NewSystemHandler(updateSvc systemUpdateService, lockSvc *service.SystemOperationLockService) *SystemHandler {
  48. return &SystemHandler{
  49. updateSvc: updateSvc,
  50. lockSvc: lockSvc,
  51. }
  52. }
  53. // GetVersion returns the current version
  54. // GET /api/v1/admin/system/version
  55. func (h *SystemHandler) GetVersion(c *gin.Context) {
  56. info, _ := h.updateSvc.CheckUpdate(c.Request.Context(), false)
  57. response.Success(c, gin.H{
  58. "version": info.CurrentVersion,
  59. })
  60. }
  61. // CheckUpdates checks for available updates
  62. // GET /api/v1/admin/system/check-updates
  63. func (h *SystemHandler) CheckUpdates(c *gin.Context) {
  64. force := c.Query("force") == "true"
  65. info, err := h.updateSvc.CheckUpdate(c.Request.Context(), force)
  66. if err != nil {
  67. response.Error(c, http.StatusInternalServerError, err.Error())
  68. return
  69. }
  70. response.Success(c, info)
  71. }
  72. // PerformUpdate downloads and applies the update
  73. // POST /api/v1/admin/system/update
  74. func (h *SystemHandler) PerformUpdate(c *gin.Context) {
  75. if !requireSystemSuperAdmin(c) {
  76. return
  77. }
  78. operationID := buildSystemOperationID(c, "update")
  79. payload := gin.H{"operation_id": operationID}
  80. executeAdminIdempotentJSON(c, "admin.system.update", payload, service.DefaultSystemOperationIdempotencyTTL(), func(ctx context.Context) (any, error) {
  81. lock, release, err := h.acquireSystemLock(ctx, operationID)
  82. if err != nil {
  83. return nil, err
  84. }
  85. var releaseReason string
  86. succeeded := false
  87. defer func() {
  88. release(releaseReason, succeeded)
  89. }()
  90. updateCtx, cancel := systemUpdateContext(ctx)
  91. defer cancel()
  92. if err := h.updateSvc.PerformUpdate(updateCtx); err != nil {
  93. if errors.Is(err, service.ErrNoUpdateAvailable) {
  94. info, checkErr := h.updateSvc.CheckUpdate(updateCtx, false)
  95. if checkErr != nil {
  96. releaseReason = "SYSTEM_UPDATE_FAILED"
  97. return nil, checkErr
  98. }
  99. succeeded = true
  100. return gin.H{
  101. "message": "Already up to date",
  102. "already_up_to_date": true,
  103. "current_version": info.CurrentVersion,
  104. "latest_version": info.LatestVersion,
  105. "operation_id": lock.OperationID(),
  106. }, nil
  107. }
  108. releaseReason = "SYSTEM_UPDATE_FAILED"
  109. return nil, err
  110. }
  111. succeeded = true
  112. return gin.H{
  113. "message": "Update completed. Please restart the service.",
  114. "need_restart": true,
  115. "operation_id": lock.OperationID(),
  116. }, nil
  117. })
  118. }
  119. // GetRollbackVersions lists versions available for rollback
  120. // GET /api/v1/admin/system/rollback-versions
  121. func (h *SystemHandler) GetRollbackVersions(c *gin.Context) {
  122. versions, err := h.updateSvc.ListRollbackVersions(c.Request.Context())
  123. if err != nil {
  124. response.Error(c, http.StatusInternalServerError, err.Error())
  125. return
  126. }
  127. response.Success(c, gin.H{
  128. "versions": versions,
  129. })
  130. }
  131. // Rollback restores a previous version.
  132. // Without a body (or with an empty version) it restores the local .backup binary
  133. // left by the last in-place update. With {"version": "x.y.z"} it downloads and
  134. // installs that specific release (must be one of the recent rollback versions).
  135. // POST /api/v1/admin/system/rollback
  136. func (h *SystemHandler) Rollback(c *gin.Context) {
  137. if !requireSystemSuperAdmin(c) {
  138. return
  139. }
  140. var req struct {
  141. Version string `json:"version"`
  142. }
  143. if c.Request.Body != nil && c.Request.ContentLength > 0 {
  144. if err := c.ShouldBindJSON(&req); err != nil {
  145. response.Error(c, http.StatusBadRequest, "invalid request body")
  146. return
  147. }
  148. }
  149. targetVersion := strings.TrimSpace(req.Version)
  150. operation := "rollback"
  151. if targetVersion != "" {
  152. operation = "rollback:" + targetVersion
  153. }
  154. operationID := buildSystemOperationID(c, operation)
  155. payload := gin.H{"operation_id": operationID, "version": targetVersion}
  156. executeAdminIdempotentJSON(c, "admin.system.rollback", payload, service.DefaultSystemOperationIdempotencyTTL(), func(ctx context.Context) (any, error) {
  157. lock, release, err := h.acquireSystemLock(ctx, operationID)
  158. if err != nil {
  159. return nil, err
  160. }
  161. var releaseReason string
  162. succeeded := false
  163. defer func() {
  164. release(releaseReason, succeeded)
  165. }()
  166. if targetVersion != "" {
  167. // 指定版本回退同样要下载完整二进制,与更新一样和请求生命周期解耦。
  168. rollbackCtx, cancel := systemUpdateContext(ctx)
  169. defer cancel()
  170. err = h.updateSvc.RollbackToVersion(rollbackCtx, targetVersion)
  171. } else {
  172. err = h.updateSvc.Rollback()
  173. }
  174. if err != nil {
  175. releaseReason = "SYSTEM_ROLLBACK_FAILED"
  176. return nil, err
  177. }
  178. succeeded = true
  179. return gin.H{
  180. "message": "Rollback completed. Please restart the service.",
  181. "need_restart": true,
  182. "version": targetVersion,
  183. "operation_id": lock.OperationID(),
  184. }, nil
  185. })
  186. }
  187. // RestartService restarts the systemd service
  188. // POST /api/v1/admin/system/restart
  189. func (h *SystemHandler) RestartService(c *gin.Context) {
  190. if !requireSystemSuperAdmin(c) {
  191. return
  192. }
  193. operationID := buildSystemOperationID(c, "restart")
  194. payload := gin.H{"operation_id": operationID}
  195. executeAdminIdempotentJSON(c, "admin.system.restart", payload, service.DefaultSystemOperationIdempotencyTTL(), func(ctx context.Context) (any, error) {
  196. lock, release, err := h.acquireSystemLock(ctx, operationID)
  197. if err != nil {
  198. return nil, err
  199. }
  200. succeeded := false
  201. defer func() {
  202. release("", succeeded)
  203. }()
  204. // Schedule service restart in background after sending response
  205. // This ensures the client receives the success response before the service restarts
  206. go func() {
  207. // Wait a moment to ensure the response is sent
  208. time.Sleep(500 * time.Millisecond)
  209. sysutil.RestartServiceAsync()
  210. }()
  211. succeeded = true
  212. return gin.H{
  213. "message": "Service restart initiated",
  214. "operation_id": lock.OperationID(),
  215. }, nil
  216. })
  217. }
  218. func requireSystemSuperAdmin(c *gin.Context) bool {
  219. role, ok := middleware2.GetUserRoleFromContext(c)
  220. if !ok || role != service.RoleSuperAdmin {
  221. response.Forbidden(c, "Super administrator access required")
  222. return false
  223. }
  224. return true
  225. }
  226. func (h *SystemHandler) acquireSystemLock(
  227. ctx context.Context,
  228. operationID string,
  229. ) (*service.SystemOperationLock, func(string, bool), error) {
  230. if h.lockSvc == nil {
  231. return nil, nil, service.ErrIdempotencyStoreUnavail
  232. }
  233. lock, err := h.lockSvc.Acquire(ctx, operationID)
  234. if err != nil {
  235. return nil, nil, err
  236. }
  237. release := func(reason string, succeeded bool) {
  238. releaseCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  239. defer cancel()
  240. _ = h.lockSvc.Release(releaseCtx, lock, succeeded, reason)
  241. }
  242. return lock, release, nil
  243. }
  244. func buildSystemOperationID(c *gin.Context, operation string) string {
  245. key := strings.TrimSpace(c.GetHeader("Idempotency-Key"))
  246. if key == "" {
  247. return "sysop-" + operation + "-" + strconv.FormatInt(time.Now().UnixNano(), 36)
  248. }
  249. actorScope := "admin:0"
  250. if subject, ok := middleware2.GetAuthSubjectFromContext(c); ok {
  251. actorScope = "admin:" + strconv.FormatInt(subject.UserID, 10)
  252. }
  253. seed := operation + "|" + actorScope + "|" + c.FullPath() + "|" + key
  254. hash := service.HashIdempotencyKey(seed)
  255. if len(hash) > 24 {
  256. hash = hash[:24]
  257. }
  258. return "sysop-" + hash
  259. }