diff --git a/router/router_server.go b/router/router_server.go new file mode 100644 index 0000000..ec1f772 --- /dev/null +++ b/router/router_server.go @@ -0,0 +1,265 @@ +package router + +import ( + "context" + "net/http" + "os" + "strconv" + + "emperror.dev/errors" + "github.com/apex/log" + "github.com/gin-gonic/gin" + + "github.com/pterodactyl/wings/router/downloader" + "github.com/pterodactyl/wings/router/middleware" + "github.com/pterodactyl/wings/router/tokens" + "github.com/pterodactyl/wings/server" + "github.com/pterodactyl/wings/server/transfer" +) + +// Returns a single server from the collection of servers. +func getServer(c *gin.Context) { + c.JSON(http.StatusOK, ExtractServer(c).ToAPIResponse()) +} + +// Returns the logs for a given server instance. +func getServerLogs(c *gin.Context) { + s := ExtractServer(c) + + l, _ := strconv.Atoi(c.DefaultQuery("size", "100")) + if l <= 0 { + l = 100 + } else if l > 100 { + l = 100 + } + + out, err := s.ReadLogfile(l) + if err != nil { + middleware.CaptureAndAbort(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{"data": out}) +} + +// Handles a request to control the power state of a server. If the action being passed +// through is invalid a 404 is returned. Otherwise, a HTTP/202 Accepted response is returned +// and the actual power action is run asynchronously so that we don't have to block the +// request until a potentially slow operation completes. +// +// This is done because for the most part the Panel is using websockets to determine when +// things are happening, so theres no reason to sit and wait for a request to finish. We'll +// just see over the socket if something isn't working correctly. +func postServerPower(c *gin.Context) { + s := ExtractServer(c) + + var data struct { + Action server.PowerAction `json:"action"` + WaitSeconds int `json:"wait_seconds"` + } + + if err := c.BindJSON(&data); err != nil { + return + } + + if !data.Action.IsValid() { + c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{ + "error": "The power action provided was not valid, should be one of \"stop\", \"start\", \"restart\", \"kill\"", + }) + return + } + + // Because we route all of the actual bootup process to a separate thread we need to + // check the suspension status here, otherwise the user will hit the endpoint and then + // just sit there wondering why it returns a success but nothing actually happens. + // + // We don't really care about any of the other actions at this point, they'll all result + // in the process being stopped, which should have happened anyways if the server is suspended. + if (data.Action == server.PowerActionStart || data.Action == server.PowerActionRestart) && s.IsSuspended() { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ + "error": "Cannot start or restart a server that is suspended.", + }) + return + } + + // Pass the actual heavy processing off to a separate thread to handle so that + // we can immediately return a response from the server. Some of these actions + // can take quite some time, especially stopping or restarting. + go func(s *server.Server) { + if data.WaitSeconds < 0 || data.WaitSeconds > 300 { + data.WaitSeconds = 30 + } + if err := s.HandlePowerAction(data.Action, data.WaitSeconds); err != nil { + if errors.Is(err, context.DeadlineExceeded) { + s.Log().WithField("action", data.Action).WithField("error", err).Warn("could not process server power action") + } else if errors.Is(err, server.ErrIsRunning) { + // Do nothing, this isn't something we care about for logging, + } else { + s.Log().WithFields(log.Fields{"action": data.Action, "wait_seconds": data.WaitSeconds, "error": err}). + Error("encountered error processing a server power action in the background") + } + } + }(s) + + c.Status(http.StatusAccepted) +} + +// Sends an array of commands to a running server instance. +func postServerCommands(c *gin.Context) { + s := ExtractServer(c) + + if running, err := s.Environment.IsRunning(c.Request.Context()); err != nil { + middleware.CaptureAndAbort(c, err) + return + } else if !running { + c.AbortWithStatusJSON(http.StatusBadGateway, gin.H{ + "error": "Cannot send commands to a stopped server instance.", + }) + return + } + + var data struct { + Commands []string `json:"commands"` + } + // BindJSON sends 400 if the request fails, all we need to do is return + if err := c.BindJSON(&data); err != nil { + return + } + + for _, command := range data.Commands { + if err := s.Environment.SendCommand(command); err != nil { + s.Log().WithFields(log.Fields{"command": command, "error": err}).Warn("failed to send command to server instance") + } + } + + c.Status(http.StatusNoContent) +} + +// postServerSync will accept a POST request and trigger a re-sync of the given +// server against the Panel. This can be manually triggered when needed by an +// external system, or triggered by the Panel itself when modifications are made +// to the build of a server internally. +func postServerSync(c *gin.Context) { + s := ExtractServer(c) + + if err := s.Sync(); err != nil { + middleware.CaptureAndAbort(c, err) + } else { + c.Status(http.StatusNoContent) + } +} + +// Performs a server installation in a background thread. +func postServerInstall(c *gin.Context) { + s := ExtractServer(c) + + go func(s *server.Server) { + s.Log().Info("syncing server state with remote source before executing installation process") + if err := s.Sync(); err != nil { + s.Log().WithField("error", err).Error("failed to sync server state with Panel") + return + } + + if err := s.Install(); err != nil { + s.Log().WithField("error", err).Error("failed to execute server installation process") + } + }(s) + + c.Status(http.StatusAccepted) +} + +// Reinstalls a server. +func postServerReinstall(c *gin.Context) { + s := ExtractServer(c) + + if s.ExecutingPowerAction() { + c.AbortWithStatusJSON(http.StatusConflict, gin.H{ + "error": "Cannot execute server reinstall event while another power action is running.", + }) + return + } + + go func(s *server.Server) { + if err := s.Reinstall(); err != nil { + s.Log().WithField("error", err).Error("failed to complete server re-install process") + } + }(s) + + c.Status(http.StatusAccepted) +} + +// Deletes a server from the wings daemon and dissociate its objects. +func deleteServer(c *gin.Context) { + s := middleware.ExtractServer(c) + + // Immediately suspend the server to prevent a user from attempting + // to start it while this process is running. + s.Config().SetSuspended(true) + + // Notify all websocket clients that the server is being deleted. + // This is useful for two reasons, one to tell clients not to bother + // retrying to connect to the websocket. And two, for transfers when + // the server has been successfully transferred to another node, and + // the client needs to switch to the new node. + if s.IsTransferring() { + s.Events().Publish(server.TransferStatusEvent, transfer.StatusCompleted) + } + s.Events().Publish(server.DeletedEvent, nil) + + s.CleanupForDestroy() + + // Remove any pending remote file downloads for the server. + for _, dl := range downloader.ByServer(s.ID()) { + dl.Cancel() + } + + // Destroy the environment; in Docker this will handle a running container and + // forcibly terminate it before removing the container, so we do not need to handle + // that here. + if err := s.Environment.Destroy(); err != nil { + middleware.CaptureAndAbort(c, err) + return + } + + // Once the environment is terminated, remove the server files from the system. This is + // done in a separate process since failure is not the end of the world and can be + // manually cleaned up after the fact. + // + // In addition, servers with large amounts of files can take some time to finish deleting, + // so we don't want to block the HTTP call while waiting on this. + go func(s *server.Server) { + fs := s.Filesystem() + p := fs.Path() + _ = fs.UnixFS().Close() + if err := os.RemoveAll(p); err != nil { + log.WithFields(log.Fields{"path": p, "error": err}).Warn("failed to remove server files during deletion process") + } + }(s) + + middleware.ExtractManager(c).Remove(func(server *server.Server) bool { + return server.ID() == s.ID() + }) + + c.Status(http.StatusNoContent) +} + +// Adds any of the JTIs passed through in the body to the deny list for the websocket +// preventing any JWT generated before the current time from being used to connect to +// the socket or send along commands. +// +// deprecated: prefer /api/deauthorize-user +func postServerDenyWSTokens(c *gin.Context) { + var data struct { + JTIs []string `json:"jtis"` + } + + if err := c.BindJSON(&data); err != nil { + return + } + + for _, jti := range data.JTIs { + tokens.DenyJTI(jti) + } + + c.Status(http.StatusNoContent) +} diff --git a/server/power.go b/server/power.go new file mode 100644 index 0000000..5464c9e --- /dev/null +++ b/server/power.go @@ -0,0 +1,221 @@ +package server + +import ( + "context" + "fmt" + "time" + + "emperror.dev/errors" + "github.com/google/uuid" + + "github.com/pterodactyl/wings/config" + "github.com/pterodactyl/wings/environment" + "github.com/pterodactyl/wings/system" +) + +type PowerAction string + +// The power actions that can be performed for a given server. This taps into the given server +// environment and performs them in a way that prevents a race condition from occurring. For +// example, sending two "start" actions back to back will not process the second action until +// the first action has been completed. +// +// This utilizes a workerpool with a limit of one worker so that all the actions execute +// in a sync manner. +const ( + PowerActionStart = "start" + PowerActionStop = "stop" + PowerActionRestart = "restart" + PowerActionTerminate = "kill" +) + +// IsValid checks if the power action being received is valid. +func (pa PowerAction) IsValid() bool { + return pa == PowerActionStart || + pa == PowerActionStop || + pa == PowerActionTerminate || + pa == PowerActionRestart +} + +func (pa PowerAction) IsStart() bool { + return pa == PowerActionStart || pa == PowerActionRestart +} + +// ExecutingPowerAction checks if there is currently a power action being +// processed for the server. +func (s *Server) ExecutingPowerAction() bool { + return s.powerLock.IsLocked() +} + +// HandlePowerAction is a helper function that can receive a power action and then process the +// actions that need to occur for it. This guards against someone calling Start() twice at the +// same time, or trying to restart while another restart process is currently running. +// +// However, the code design for the daemon does depend on the user correctly calling this +// function rather than making direct calls to the start/stop/restart functions on the +// environment struct. +func (s *Server) HandlePowerAction(action PowerAction, waitSeconds ...int) error { + if s.IsInstalling() || s.IsTransferring() || s.IsRestoring() { + if s.IsRestoring() { + return ErrServerIsRestoring + } else if s.IsTransferring() { + return ErrServerIsTransferring + } + return ErrServerIsInstalling + } + + lockId, _ := uuid.NewUUID() + log := s.Log().WithField("lock_id", lockId.String()).WithField("action", action) + + cleanup := func() { + log.Info("releasing exclusive lock for power action") + s.powerLock.Release() + } + + var wait int + if len(waitSeconds) > 0 && waitSeconds[0] > 0 { + wait = waitSeconds[0] + } + + log.WithField("wait_seconds", wait).Debug("acquiring power action lock for instance") + // Only attempt to acquire a lock on the process if this is not a termination event. We want to + // just allow those events to pass right through for good reason. If a server is currently trying + // to process a power action but has gotten stuck you still should be able to pass through the + // terminate event. The good news here is that doing that oftentimes will get the stuck process to + // move again, and naturally continue through the process. + if action != PowerActionTerminate { + // Determines if we should wait for the lock or not. If a value greater than 0 is passed + // into this function we will wait that long for a lock to be acquired. + if wait > 0 { + ctx, cancel := context.WithTimeout(s.ctx, time.Second*time.Duration(wait)) + defer cancel() + + // Attempt to acquire a lock on the power action lock for up to 30 seconds. If more + // time than that passes an error will be propagated back up the chain and this + // request will be aborted. + if err := s.powerLock.TryAcquire(ctx); err != nil { + return errors.Wrap(err, fmt.Sprintf("could not acquire lock on power action after %d seconds", wait)) + } + } else { + // If no wait duration was provided we will attempt to immediately acquire the lock + // and bail out with a context deadline error if it is not acquired immediately. + if err := s.powerLock.Acquire(); err != nil { + return errors.Wrap(err, "failed to acquire exclusive lock for power actions") + } + } + + log.Info("acquired exclusive lock on power actions, processing event...") + defer cleanup() + } else { + // Still try to acquire the lock if terminating, and it is available, just so that + // other power actions are blocked until it has completed. However, if it cannot be + // acquired we won't stop the entire process. + // + // If we did successfully acquire the lock, make sure we release it once we're done + // executiong the power actions. + if err := s.powerLock.Acquire(); err == nil { + log.Info("acquired exclusive lock on power actions, processing event...") + defer cleanup() + } else if errors.Is(err, system.ErrLockerDestroyed) { + // The server is being deleted. + return err + } else { + log.Warn("failed to acquire exclusive lock, ignoring failure for termination event") + } + } + + switch action { + case PowerActionStart: + if s.Environment.State() != environment.ProcessOfflineState { + return ErrIsRunning + } + + // Run the pre-boot logic for the server before processing the environment start. + if err := s.onBeforeStart(); err != nil { + return err + } + + return s.Environment.Start(s.Context()) + case PowerActionStop: + fallthrough + case PowerActionRestart: + // We're specifically waiting for the process to be stopped here, otherwise the lock is + // released too soon, and you can rack up all sorts of issues. + if err := s.Environment.WaitForStop(s.Context(), time.Minute*10, true); err != nil { + // Even timeout errors should be bubbled back up the stack. If the process didn't stop + // nicely, but the terminate argument was passed then the server is stopped without an + // error being returned. + // + // However, if terminate is not passed you'll get a context deadline error. We could + // probably handle that nicely here, but I'd rather just pass it back up the stack for now. + // Either way, any type of error indicates we should not attempt to start the server back + // up. + return err + } + + if action == PowerActionStop { + return nil + } + + // Now actually try to start the process by executing the normal pre-boot logic. + if err := s.onBeforeStart(); err != nil { + return err + } + + return s.Environment.Start(s.Context()) + case PowerActionTerminate: + return s.Environment.Terminate(s.Context(), "SIGKILL") + } + + return errors.New("attempting to handle unknown power action") +} + +// Execute a few functions before actually calling the environment start commands. This ensures +// that everything is ready to go for environment booting, and that the server can even be started. +func (s *Server) onBeforeStart() error { + // Sync also applies the new environment variables and process resource limits + // to the environment. + s.Log().Info("syncing server configuration with panel") + if err := s.Sync(); err != nil { + return errors.WithMessage(err, "unable to sync server data from Panel instance") + } + + // Disallow start & restart if the server is suspended. Do this check after performing a sync + // action with the Panel to ensure that we have the most up-to-date information for that server. + if s.IsSuspended() { + return ErrSuspended + } + + // If a server has unlimited disk space, we don't care enough to block the startup to check remaining. + // However, we should trigger a size anyway, as it'd be good to kick it off for other processes. + if s.DiskSpace() <= 0 { + s.Filesystem().HasSpaceAvailable(true) + } else { + s.PublishConsoleOutputFromDaemon("Checking server disk space usage, this could take a few seconds...") + if err := s.Filesystem().HasSpaceErr(false); err != nil { + return err + } + } + + // Update the configuration files defined for the server before beginning the boot process. + // This process executes a bunch of parallel updates, so we just block until that process + // is complete. Any errors as a result of this will just be bubbled out in the logger, + // we don't need to actively do anything about it at this point, worse comes to worst the + // server starts in a weird state and the user can manually adjust. + s.PublishConsoleOutputFromDaemon("Updating process configuration files...") + s.Log().Debug("updating server configuration files...") + s.UpdateConfigurationFiles() + s.Log().Debug("updated server configuration files") + + if config.Get().System.CheckPermissionsOnBoot { + s.PublishConsoleOutputFromDaemon("Ensuring file permissions are set correctly, this could take a few seconds...") + // Ensure all the server file permissions are set correctly before booting the process. + s.Log().Debug("chowning server root directory...") + if err := s.Filesystem().Chown("/"); err != nil { + return errors.WithMessage(err, "failed to chown root server directory during pre-boot process") + } + } + + s.Log().Info("completed server preflight, starting boot process...") + return nil +}