| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283 |
- package admin
- import (
- "context"
- "errors"
- "net/http"
- "strconv"
- "strings"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/pkg/response"
- "github.com/Wei-Shaw/sub2api/internal/pkg/sysutil"
- middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/gin-gonic/gin"
- )
- // SystemHandler handles system-related operations
- type SystemHandler struct {
- updateSvc systemUpdateService
- lockSvc *service.SystemOperationLockService
- }
- // systemUpdateTimeout bounds a full in-place update or rollback: the release
- // manifest fetch plus a large binary download over slow links. It must stay
- // above the GitHub download client timeout (10 minutes) so the download owns
- // its own deadline.
- const systemUpdateTimeout = 15 * time.Minute
- // systemUpdateContext detaches a long-running update/rollback from the HTTP
- // request lifetime. Browsers and reverse proxies commonly abort idle requests
- // after 30-60s (axios default, nginx proxy_read_timeout), which canceled
- // c.Request.Context() mid-download and killed the update with
- // "download failed: context canceled" (#4504). The swap keeps running after a
- // client disconnect; a later retry then hits the system operation lock or
- // reports "Already up to date".
- func systemUpdateContext(ctx context.Context) (context.Context, context.CancelFunc) {
- base := context.Background()
- if ctx != nil {
- base = context.WithoutCancel(ctx)
- }
- return context.WithTimeout(base, systemUpdateTimeout)
- }
- type systemUpdateService interface {
- CheckUpdate(ctx context.Context, force bool) (*service.UpdateInfo, error)
- PerformUpdate(ctx context.Context) error
- Rollback() error
- ListRollbackVersions(ctx context.Context) ([]service.RollbackVersion, error)
- RollbackToVersion(ctx context.Context, version string) error
- }
- // NewSystemHandler creates a new SystemHandler
- func NewSystemHandler(updateSvc systemUpdateService, lockSvc *service.SystemOperationLockService) *SystemHandler {
- return &SystemHandler{
- updateSvc: updateSvc,
- lockSvc: lockSvc,
- }
- }
- // GetVersion returns the current version
- // GET /api/v1/admin/system/version
- func (h *SystemHandler) GetVersion(c *gin.Context) {
- info, _ := h.updateSvc.CheckUpdate(c.Request.Context(), false)
- response.Success(c, gin.H{
- "version": info.CurrentVersion,
- })
- }
- // CheckUpdates checks for available updates
- // GET /api/v1/admin/system/check-updates
- func (h *SystemHandler) CheckUpdates(c *gin.Context) {
- force := c.Query("force") == "true"
- info, err := h.updateSvc.CheckUpdate(c.Request.Context(), force)
- if err != nil {
- response.Error(c, http.StatusInternalServerError, err.Error())
- return
- }
- response.Success(c, info)
- }
- // PerformUpdate downloads and applies the update
- // POST /api/v1/admin/system/update
- func (h *SystemHandler) PerformUpdate(c *gin.Context) {
- if !requireSystemSuperAdmin(c) {
- return
- }
- operationID := buildSystemOperationID(c, "update")
- payload := gin.H{"operation_id": operationID}
- executeAdminIdempotentJSON(c, "admin.system.update", payload, service.DefaultSystemOperationIdempotencyTTL(), func(ctx context.Context) (any, error) {
- lock, release, err := h.acquireSystemLock(ctx, operationID)
- if err != nil {
- return nil, err
- }
- var releaseReason string
- succeeded := false
- defer func() {
- release(releaseReason, succeeded)
- }()
- updateCtx, cancel := systemUpdateContext(ctx)
- defer cancel()
- if err := h.updateSvc.PerformUpdate(updateCtx); err != nil {
- if errors.Is(err, service.ErrNoUpdateAvailable) {
- info, checkErr := h.updateSvc.CheckUpdate(updateCtx, false)
- if checkErr != nil {
- releaseReason = "SYSTEM_UPDATE_FAILED"
- return nil, checkErr
- }
- succeeded = true
- return gin.H{
- "message": "Already up to date",
- "already_up_to_date": true,
- "current_version": info.CurrentVersion,
- "latest_version": info.LatestVersion,
- "operation_id": lock.OperationID(),
- }, nil
- }
- releaseReason = "SYSTEM_UPDATE_FAILED"
- return nil, err
- }
- succeeded = true
- return gin.H{
- "message": "Update completed. Please restart the service.",
- "need_restart": true,
- "operation_id": lock.OperationID(),
- }, nil
- })
- }
- // GetRollbackVersions lists versions available for rollback
- // GET /api/v1/admin/system/rollback-versions
- func (h *SystemHandler) GetRollbackVersions(c *gin.Context) {
- versions, err := h.updateSvc.ListRollbackVersions(c.Request.Context())
- if err != nil {
- response.Error(c, http.StatusInternalServerError, err.Error())
- return
- }
- response.Success(c, gin.H{
- "versions": versions,
- })
- }
- // Rollback restores a previous version.
- // Without a body (or with an empty version) it restores the local .backup binary
- // left by the last in-place update. With {"version": "x.y.z"} it downloads and
- // installs that specific release (must be one of the recent rollback versions).
- // POST /api/v1/admin/system/rollback
- func (h *SystemHandler) Rollback(c *gin.Context) {
- if !requireSystemSuperAdmin(c) {
- return
- }
- var req struct {
- Version string `json:"version"`
- }
- if c.Request.Body != nil && c.Request.ContentLength > 0 {
- if err := c.ShouldBindJSON(&req); err != nil {
- response.Error(c, http.StatusBadRequest, "invalid request body")
- return
- }
- }
- targetVersion := strings.TrimSpace(req.Version)
- operation := "rollback"
- if targetVersion != "" {
- operation = "rollback:" + targetVersion
- }
- operationID := buildSystemOperationID(c, operation)
- payload := gin.H{"operation_id": operationID, "version": targetVersion}
- executeAdminIdempotentJSON(c, "admin.system.rollback", payload, service.DefaultSystemOperationIdempotencyTTL(), func(ctx context.Context) (any, error) {
- lock, release, err := h.acquireSystemLock(ctx, operationID)
- if err != nil {
- return nil, err
- }
- var releaseReason string
- succeeded := false
- defer func() {
- release(releaseReason, succeeded)
- }()
- if targetVersion != "" {
- // 指定版本回退同样要下载完整二进制,与更新一样和请求生命周期解耦。
- rollbackCtx, cancel := systemUpdateContext(ctx)
- defer cancel()
- err = h.updateSvc.RollbackToVersion(rollbackCtx, targetVersion)
- } else {
- err = h.updateSvc.Rollback()
- }
- if err != nil {
- releaseReason = "SYSTEM_ROLLBACK_FAILED"
- return nil, err
- }
- succeeded = true
- return gin.H{
- "message": "Rollback completed. Please restart the service.",
- "need_restart": true,
- "version": targetVersion,
- "operation_id": lock.OperationID(),
- }, nil
- })
- }
- // RestartService restarts the systemd service
- // POST /api/v1/admin/system/restart
- func (h *SystemHandler) RestartService(c *gin.Context) {
- if !requireSystemSuperAdmin(c) {
- return
- }
- operationID := buildSystemOperationID(c, "restart")
- payload := gin.H{"operation_id": operationID}
- executeAdminIdempotentJSON(c, "admin.system.restart", payload, service.DefaultSystemOperationIdempotencyTTL(), func(ctx context.Context) (any, error) {
- lock, release, err := h.acquireSystemLock(ctx, operationID)
- if err != nil {
- return nil, err
- }
- succeeded := false
- defer func() {
- release("", succeeded)
- }()
- // Schedule service restart in background after sending response
- // This ensures the client receives the success response before the service restarts
- go func() {
- // Wait a moment to ensure the response is sent
- time.Sleep(500 * time.Millisecond)
- sysutil.RestartServiceAsync()
- }()
- succeeded = true
- return gin.H{
- "message": "Service restart initiated",
- "operation_id": lock.OperationID(),
- }, nil
- })
- }
- func requireSystemSuperAdmin(c *gin.Context) bool {
- role, ok := middleware2.GetUserRoleFromContext(c)
- if !ok || role != service.RoleSuperAdmin {
- response.Forbidden(c, "Super administrator access required")
- return false
- }
- return true
- }
- func (h *SystemHandler) acquireSystemLock(
- ctx context.Context,
- operationID string,
- ) (*service.SystemOperationLock, func(string, bool), error) {
- if h.lockSvc == nil {
- return nil, nil, service.ErrIdempotencyStoreUnavail
- }
- lock, err := h.lockSvc.Acquire(ctx, operationID)
- if err != nil {
- return nil, nil, err
- }
- release := func(reason string, succeeded bool) {
- releaseCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
- defer cancel()
- _ = h.lockSvc.Release(releaseCtx, lock, succeeded, reason)
- }
- return lock, release, nil
- }
- func buildSystemOperationID(c *gin.Context, operation string) string {
- key := strings.TrimSpace(c.GetHeader("Idempotency-Key"))
- if key == "" {
- return "sysop-" + operation + "-" + strconv.FormatInt(time.Now().UnixNano(), 36)
- }
- actorScope := "admin:0"
- if subject, ok := middleware2.GetAuthSubjectFromContext(c); ok {
- actorScope = "admin:" + strconv.FormatInt(subject.UserID, 10)
- }
- seed := operation + "|" + actorScope + "|" + c.FullPath() + "|" + key
- hash := service.HashIdempotencyKey(seed)
- if len(hash) > 24 {
- hash = hash[:24]
- }
- return "sysop-" + hash
- }
|