Port most of the HTTP code over to gin
This commit is contained in:
93
router/error.go
Normal file
93
router/error.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/pterodactyl/wings/server"
|
||||
"go.uber.org/zap"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
type RequestError struct {
|
||||
Err error
|
||||
Uuid string
|
||||
Message string
|
||||
server *server.Server
|
||||
}
|
||||
|
||||
// Generates a new tracked error, which simply tracks the specific error that
|
||||
// is being passed in, and also assigned a UUID to the error so that it can be
|
||||
// cross referenced in the logs.
|
||||
func TrackedError(err error) *RequestError {
|
||||
return &RequestError{
|
||||
Err: err,
|
||||
Uuid: uuid.Must(uuid.NewRandom()).String(),
|
||||
Message: "",
|
||||
}
|
||||
}
|
||||
|
||||
// Same as TrackedError, except this will also attach the server instance that
|
||||
// generated this server for the purposes of logging.
|
||||
func TrackedServerError(err error, s *server.Server) *RequestError {
|
||||
return &RequestError{
|
||||
Err: err,
|
||||
Uuid: uuid.Must(uuid.NewRandom()).String(),
|
||||
Message: "",
|
||||
server: s,
|
||||
}
|
||||
}
|
||||
|
||||
// Sets the output message to display to the user in the error.
|
||||
func (e *RequestError) SetMessage(msg string) *RequestError {
|
||||
e.Message = msg
|
||||
return e
|
||||
}
|
||||
|
||||
// Aborts the request with the given status code, and responds with the error. This
|
||||
// will also include the error UUID in the output so that the user can report that
|
||||
// and link the response to a specific error in the logs.
|
||||
func (e *RequestError) AbortWithStatus(status int, c *gin.Context) {
|
||||
// If this error is because the resource does not exist, we likely do not need to log
|
||||
// the error anywhere, just return a 404 and move on with our lives.
|
||||
if os.IsNotExist(e.Err) {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
|
||||
"error": "The requested resource was not found on the system.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, log the error to zap, and then report the error back to the user.
|
||||
if status >= 500 {
|
||||
if e.server != nil {
|
||||
zap.S().Errorw("encountered error while handling HTTP request", zap.String("server", e.server.Uuid), zap.String("error_id", e.Uuid), zap.Error(e.Err))
|
||||
} else {
|
||||
zap.S().Errorw("encountered error while handling HTTP request", zap.String("error_id", e.Uuid), zap.Error(e.Err))
|
||||
}
|
||||
|
||||
c.Error(errors.WithStack(e))
|
||||
}
|
||||
|
||||
msg := "An unexpected error was encountered while processing this request."
|
||||
if e.Message != "" {
|
||||
msg = e.Message
|
||||
}
|
||||
|
||||
c.AbortWithStatusJSON(status, gin.H{
|
||||
"error": msg,
|
||||
"error_id": e.Uuid,
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to just abort with an internal server error. This is generally the response
|
||||
// from most errors encountered by the API.
|
||||
func (e *RequestError) AbortWithServerError(c *gin.Context) {
|
||||
e.AbortWithStatus(http.StatusInternalServerError, c)
|
||||
}
|
||||
|
||||
// Format the error to a string and include the UUID.
|
||||
func (e *RequestError) Error() string {
|
||||
return fmt.Sprintf("%v (uuid: %s)", e.Err, e.Uuid)
|
||||
}
|
||||
67
router/middleware.go
Normal file
67
router/middleware.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pterodactyl/wings/config"
|
||||
"github.com/pterodactyl/wings/server"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Set the access request control headers on all of the requests.
|
||||
func SetAccessControlHeaders(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", config.Get().PanelLocation)
|
||||
c.Header("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// Authenticates the request token against the given permission string, ensuring that
|
||||
// if it is a server permission, the token has control over that server. If it is a global
|
||||
// token, this will ensure that the request is using a properly signed global token.
|
||||
func AuthorizationMiddleware(c *gin.Context) {
|
||||
auth := strings.SplitN(c.GetHeader("Authorization"), " ", 2)
|
||||
|
||||
if len(auth) != 2 || auth[0] != "Bearer" {
|
||||
c.Header("WWW-Authenticate", "Bearer")
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "The required authorization heads were not present in the request.",
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Try to match the request against the global token for the Daemon, regardless
|
||||
// of the permission type. If nothing is matched we will fall through to the Panel
|
||||
// API to try and validate permissions for a server.
|
||||
if auth[1] == config.Get().AuthenticationToken {
|
||||
c.Next()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"error": "You are not authorized to access this endpoint.",
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to fetch a server out of the servers collection stored in memory.
|
||||
func GetServer (uuid string) *server.Server {
|
||||
return server.GetServers().Find(func(s *server.Server) bool {
|
||||
return uuid == s.Uuid
|
||||
})
|
||||
}
|
||||
|
||||
// Ensure that the requested server exists in this setup. Returns a 404 if we cannot
|
||||
// locate it.
|
||||
func ServerExists(c *gin.Context) {
|
||||
u, err := uuid.Parse(c.Param("server"))
|
||||
if err != nil || GetServer(u.String()) == nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
|
||||
"error": "The requested server does not exist.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
50
router/router.go
Normal file
50
router/router.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// Configures the routing infrastructure for this daemon instance.
|
||||
func Configure() *gin.Engine {
|
||||
router := gin.Default()
|
||||
router.Use(SetAccessControlHeaders)
|
||||
|
||||
// This route is special is sits above all of the other requests because we are
|
||||
// using a JWT to authorize access to it, therefore it needs to be publically
|
||||
// accessible.
|
||||
router.GET("/api/servers/:server/ws", getServerWebsocket)
|
||||
|
||||
// All of the routes beyond this mount will use an authorization middleware
|
||||
// and will not be accessible without the correct Authorization header provided.
|
||||
protected := router.Use(AuthorizationMiddleware)
|
||||
protected.GET("/api/system", getSystemInformation)
|
||||
protected.GET("/api/servers", getAllServers)
|
||||
protected.POST("/api/servers", postCreateServer)
|
||||
|
||||
// These are server specific routes, and require that the request be authorized, and
|
||||
// that the server exist on the Daemon.
|
||||
server := router.Group("/api/servers/:server")
|
||||
server.Use(AuthorizationMiddleware, ServerExists)
|
||||
{
|
||||
server.GET("", getServer)
|
||||
server.PATCH("", patchServer)
|
||||
server.DELETE("", deleteServer)
|
||||
|
||||
server.GET("/logs", getServerLogs)
|
||||
server.POST("/power", postServerPower)
|
||||
server.POST("/commands", postServerCommands)
|
||||
server.POST("/install", postServerInstall)
|
||||
server.POST("/reinstall", postServerReinstall)
|
||||
|
||||
files := server.Group("/files")
|
||||
{
|
||||
files.GET("/contents", getServerFileContents)
|
||||
files.GET("/list-directory", getServerListDirectory)
|
||||
files.PUT("/rename", putServerRenameFile)
|
||||
files.POST("/copy", postServerCopyFile)
|
||||
files.POST("/write", postServerWriteFile)
|
||||
files.POST("/create-directory", postServerCreateDirectory)
|
||||
files.POST("/delete", postServerDeleteFile)
|
||||
}
|
||||
}
|
||||
|
||||
return router
|
||||
}
|
||||
1
router/router_download.go
Normal file
1
router/router_download.go
Normal file
@@ -0,0 +1 @@
|
||||
package router
|
||||
216
router/router_server.go
Normal file
216
router/router_server.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/pterodactyl/wings/server"
|
||||
"go.uber.org/zap"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Returns a single server from the collection of servers.
|
||||
func getServer(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, GetServer(c.Param("server")))
|
||||
}
|
||||
|
||||
// Returns the logs for a given server instance.
|
||||
func getServerLogs(c *gin.Context) {
|
||||
s := GetServer(c.Param("server"))
|
||||
|
||||
l, _ := strconv.ParseInt(c.DefaultQuery("size", "8192"), 10, 64)
|
||||
if l <= 0 {
|
||||
l = 2048
|
||||
}
|
||||
|
||||
out, err := s.ReadLogfile(l)
|
||||
if err != nil {
|
||||
TrackedServerError(err, s).AbortWithServerError(c)
|
||||
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 := GetServer(c.Param("server"))
|
||||
|
||||
var data server.PowerAction
|
||||
c.BindJSON(&data)
|
||||
|
||||
if !data.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 == "start" || data.Action == "restart") && s.Suspended {
|
||||
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 seperate 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() {
|
||||
if err := s.HandlePowerAction(data); err != nil {
|
||||
zap.S().Errorw(
|
||||
"encountered an error processing a server power action",
|
||||
zap.String("server", s.Uuid),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
c.Status(http.StatusAccepted)
|
||||
}
|
||||
|
||||
// Sends an array of commands to a running server instance.
|
||||
func postServerCommands(c *gin.Context) {
|
||||
s := GetServer(c.Param("server"))
|
||||
|
||||
if running, err := s.Environment.IsRunning(); err != nil {
|
||||
TrackedServerError(err, s).AbortWithServerError(c)
|
||||
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"` }
|
||||
c.BindJSON(&data)
|
||||
|
||||
for _, command := range data.Commands {
|
||||
if err := s.Environment.SendCommand(command); err != nil {
|
||||
zap.S().Warnw(
|
||||
"failed to send command to server",
|
||||
zap.String("server", s.Uuid),
|
||||
zap.String("command", command),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Updates information about a server internally.
|
||||
func patchServer(c *gin.Context) {
|
||||
s := GetServer(c.Param("server"))
|
||||
|
||||
buf := bytes.Buffer{}
|
||||
buf.ReadFrom(c.Request.Body)
|
||||
|
||||
if err := s.UpdateDataStructure(buf.Bytes(), true); err != nil {
|
||||
TrackedServerError(err, s).AbortWithServerError(c)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Performs a server installation in a backgrounded thread.
|
||||
func postServerInstall(c *gin.Context) {
|
||||
s := GetServer(c.Param("server"))
|
||||
|
||||
go func(serv *server.Server) {
|
||||
if err := serv.Install(); err != nil {
|
||||
zap.S().Errorw(
|
||||
"failed to execute server installation process",
|
||||
zap.String("server", serv.Uuid),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}(s)
|
||||
|
||||
c.Status(http.StatusAccepted)
|
||||
}
|
||||
|
||||
// Reinstalls a server.
|
||||
func postServerReinstall(c *gin.Context) {
|
||||
s := GetServer(c.Param("server"))
|
||||
|
||||
go func(serv *server.Server) {
|
||||
if err := serv.Reinstall(); err != nil {
|
||||
zap.S().Errorw(
|
||||
"failed to complete server reinstall process",
|
||||
zap.String("server", serv.Uuid),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}(s)
|
||||
|
||||
c.Status(http.StatusAccepted)
|
||||
}
|
||||
|
||||
// Deletes a server from the wings daemon and deassociates its objects.
|
||||
func deleteServer(c *gin.Context) {
|
||||
s := GetServer(c.Param("server"))
|
||||
|
||||
// Immediately suspend the server to prevent a user from attempting
|
||||
// to start it while this process is running.
|
||||
s.Suspended = true
|
||||
|
||||
// Delete the server's archive if it exists. We intentionally don't return
|
||||
// here, if the archive fails to delete, the server can still be removed.
|
||||
if err := s.Archiver.DeleteIfExists(); err != nil {
|
||||
zap.S().Warnw("failed to delete server archive during deletion process", zap.String("server", s.Uuid), zap.Error(err))
|
||||
}
|
||||
|
||||
// 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 {
|
||||
TrackedServerError(err, s).AbortWithServerError(c)
|
||||
}
|
||||
|
||||
// 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(p string) {
|
||||
if err := os.RemoveAll(p); err != nil {
|
||||
zap.S().Warnw("failed to remove server files during deletion process", zap.String("path", p), zap.Error(errors.WithStack(err)))
|
||||
}
|
||||
}(s.Filesystem.Path())
|
||||
|
||||
var uuid = s.Uuid
|
||||
server.GetServers().Remove(func(s2 *server.Server) bool {
|
||||
return s2.Uuid == uuid
|
||||
})
|
||||
|
||||
// Deallocate the reference to this server.
|
||||
s = nil
|
||||
|
||||
// Remove the configuration file stored on the Daemon for this server.
|
||||
go func(u string) {
|
||||
if err := os.Remove("data/servers/" + u + ".yml"); err != nil {
|
||||
zap.S().Warnw("failed to delete server configuration file while processing deletion request", zap.String("server", u), zap.Error(errors.WithStack(err)))
|
||||
}
|
||||
}(uuid)
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
155
router/router_server_files.go
Normal file
155
router/router_server_files.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Returns the contents of a file on the server.
|
||||
func getServerFileContents(c *gin.Context) {
|
||||
s := GetServer(c.Param("server"))
|
||||
cleaned, err := s.Filesystem.SafePath(c.Query("file"))
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
|
||||
"error": "The file requested could not be found.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
st, err := s.Filesystem.Stat(cleaned)
|
||||
if err != nil {
|
||||
TrackedServerError(err, s).AbortWithServerError(c)
|
||||
return
|
||||
}
|
||||
|
||||
if st.Info.IsDir() {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
|
||||
"error": "The requested resource was not found on the system.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
f, err := os.Open(cleaned)
|
||||
if err != nil {
|
||||
TrackedServerError(err, s).AbortWithServerError(c)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
c.Header("X-Mime-Type", st.Mimetype)
|
||||
c.Header("Content-Length", strconv.Itoa(int(st.Info.Size())))
|
||||
|
||||
// If a download parameter is included in the URL go ahead and attach the necessary headers
|
||||
// so that the file can be downloaded.
|
||||
if c.Query("download") != "" {
|
||||
c.Header("Content-Disposition", "attachment; filename="+st.Info.Name())
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
}
|
||||
|
||||
bufio.NewReader(f).WriteTo(c.Writer)
|
||||
}
|
||||
|
||||
// Returns the contents of a directory for a server.
|
||||
func getServerListDirectory(c *gin.Context) {
|
||||
s := GetServer(c.Param("server"))
|
||||
|
||||
stats, err := s.Filesystem.ListDirectory(c.Query("directory"))
|
||||
if err != nil {
|
||||
TrackedServerError(err, s).AbortWithServerError(c)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// Renames (or moves) a file for a server.
|
||||
func putServerRenameFile(c *gin.Context) {
|
||||
s := GetServer(c.Param("server"))
|
||||
|
||||
var data struct{
|
||||
RenameFrom string `json:"rename_from"`
|
||||
RenameTo string `json:"rename_to"`
|
||||
}
|
||||
c.BindJSON(&data)
|
||||
|
||||
if data.RenameFrom == "" || data.RenameTo == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{
|
||||
"error": "Invalid paths were provided, did you forget to provide both a new and old path?",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.Filesystem.Rename(data.RenameFrom, data.RenameTo); err != nil {
|
||||
TrackedServerError(err, s).AbortWithServerError(c)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Copies a server file.
|
||||
func postServerCopyFile(c *gin.Context) {
|
||||
s := GetServer(c.Param("server"))
|
||||
|
||||
var data struct {
|
||||
Location string `json:"location"`
|
||||
}
|
||||
c.BindJSON(&data)
|
||||
|
||||
if err := s.Filesystem.Copy(data.Location); err != nil {
|
||||
TrackedServerError(err, s).AbortWithServerError(c)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Deletes a server file.
|
||||
func postServerDeleteFile(c *gin.Context) {
|
||||
s := GetServer(c.Param("server"))
|
||||
|
||||
var data struct {
|
||||
Location string `json:"location"`
|
||||
}
|
||||
c.BindJSON(&data)
|
||||
|
||||
if err := s.Filesystem.Delete(data.Location); err != nil {
|
||||
TrackedServerError(err, s).AbortWithServerError(c)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Writes the contents of the request to a file on a server.
|
||||
func postServerWriteFile(c *gin.Context) {
|
||||
s := GetServer(c.Param("server"))
|
||||
|
||||
if err := s.Filesystem.Writefile(c.Query("file"), c.Request.Body); err != nil {
|
||||
TrackedServerError(err, s).AbortWithServerError(c)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Create a directory on a server.
|
||||
func postServerCreateDirectory(c *gin.Context) {
|
||||
s := GetServer(c.Param("server"))
|
||||
|
||||
var data struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
c.BindJSON(&data)
|
||||
|
||||
if err := s.Filesystem.CreateDirectory(data.Name, data.Path); err != nil {
|
||||
TrackedServerError(err, s).AbortWithServerError(c)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
60
router/router_server_ws.go
Normal file
60
router/router_server_ws.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"github.com/gin-gonic/gin"
|
||||
ws "github.com/gorilla/websocket"
|
||||
"github.com/pterodactyl/wings/router/websocket"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Upgrades a connection to a websocket and passes events along between.
|
||||
func getServerWebsocket(c *gin.Context) {
|
||||
s := GetServer(c.Param("server"))
|
||||
handler, err := websocket.GetHandler(s, c.Writer, c.Request)
|
||||
if err != nil {
|
||||
TrackedServerError(err, s).AbortWithServerError(c)
|
||||
return
|
||||
}
|
||||
defer handler.Connection.Close()
|
||||
|
||||
// Create a context that can be canceled when the user disconnects from this
|
||||
// socket that will also cancel listeners running in seperate threads.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
go handler.ListenForServerEvents(ctx)
|
||||
go handler.ListenForExpiration(ctx)
|
||||
|
||||
for {
|
||||
j := websocket.Message{}
|
||||
|
||||
_, p, err := handler.Connection.ReadMessage()
|
||||
if err != nil {
|
||||
if !ws.IsCloseError(
|
||||
err,
|
||||
ws.CloseNormalClosure,
|
||||
ws.CloseGoingAway,
|
||||
ws.CloseNoStatusReceived,
|
||||
ws.CloseServiceRestart,
|
||||
ws.CloseAbnormalClosure,
|
||||
) {
|
||||
zap.S().Warnw("error handling websocket message", zap.Error(err))
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Discard and JSON parse errors into the void and don't continue processing this
|
||||
// specific socket request. If we did a break here the client would get disconnected
|
||||
// from the socket, which is NOT what we want to do.
|
||||
if err := json.Unmarshal(p, &j); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := handler.HandleInbound(j); err != nil {
|
||||
handler.SendErrorJson(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
64
router/router_system.go
Normal file
64
router/router_system.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pterodactyl/wings/installer"
|
||||
"github.com/pterodactyl/wings/server"
|
||||
"github.com/pterodactyl/wings/system"
|
||||
"go.uber.org/zap"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Returns information about the system that wings is running on.
|
||||
func getSystemInformation(c *gin.Context) {
|
||||
i, err := system.GetSystemInformation()
|
||||
if err != nil {
|
||||
TrackedError(err).AbortWithServerError(c)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, i)
|
||||
}
|
||||
|
||||
// Returns all of the servers that are registered and configured correctly on
|
||||
// this wings instance.
|
||||
func getAllServers(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, server.GetServers().All())
|
||||
}
|
||||
|
||||
// Creates a new server on the wings daemon and begins the installation process
|
||||
// for it.
|
||||
func postCreateServer(c *gin.Context) {
|
||||
var data []byte
|
||||
c.Bind(&data)
|
||||
|
||||
install, err := installer.New(data)
|
||||
if err != nil {
|
||||
TrackedError(err).
|
||||
SetMessage("Failed to validate the data provided in the request.").
|
||||
AbortWithStatus(http.StatusUnprocessableEntity, c)
|
||||
return
|
||||
}
|
||||
|
||||
// Plop that server instance onto the request so that it can be referenced in
|
||||
// requests from here-on out.
|
||||
server.GetServers().Add(install.Server())
|
||||
|
||||
// Begin the installation process in the background to not block the request
|
||||
// cycle. If there are any errors they will be logged and communicated back
|
||||
// to the Panel where a reinstall may take place.
|
||||
go func(i *installer.Installer) {
|
||||
i.Execute()
|
||||
|
||||
if err := i.Server().Install(); err != nil {
|
||||
zap.S().Errorw(
|
||||
"failed to run install process for server",
|
||||
zap.String("server", i.Uuid()),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}(install)
|
||||
|
||||
c.Status(http.StatusAccepted)
|
||||
}
|
||||
76
router/websocket/listeners.go
Normal file
76
router/websocket/listeners.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/pterodactyl/wings/server"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Checks the time to expiration on the JWT every 30 seconds until the token has
|
||||
// expired. If we are within 3 minutes of the token expiring, send a notice over
|
||||
// the socket that it is expiring soon. If it has expired, send that notice as well.
|
||||
func (h *Handler) ListenForExpiration(ctx context.Context) {
|
||||
// Make a ticker and completion channel that is used to continuously poll the
|
||||
// JWT stored in the session to send events to the socket when it is expiring.
|
||||
ticker := time.NewTicker(time.Second * 30)
|
||||
done := make(chan bool)
|
||||
|
||||
// Whenever this function is complete, end the ticker, close out the channel,
|
||||
// and then close the websocket connection.
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
done <- true
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
{
|
||||
if h.JWT != nil {
|
||||
if h.JWT.ExpirationTime.Unix()-time.Now().Unix() <= 0 {
|
||||
h.SendJson(&Message{Event: TokenExpiredEvent})
|
||||
} else if h.JWT.ExpirationTime.Unix()-time.Now().Unix() <= 180 {
|
||||
h.SendJson(&Message{Event: TokenExpiringEvent})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Listens for different events happening on a server and sends them along
|
||||
// to the connected websocket.
|
||||
func (h *Handler) ListenForServerEvents(ctx context.Context) {
|
||||
events := []string{
|
||||
server.StatsEvent,
|
||||
server.StatusEvent,
|
||||
server.ConsoleOutputEvent,
|
||||
server.InstallOutputEvent,
|
||||
server.DaemonMessageEvent,
|
||||
}
|
||||
|
||||
eventChannel := make(chan server.Event)
|
||||
for _, event := range events {
|
||||
h.server.Events().Subscribe(event, eventChannel)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
for _, event := range events {
|
||||
h.server.Events().Unsubscribe(event, eventChannel)
|
||||
}
|
||||
|
||||
close(eventChannel)
|
||||
default:
|
||||
// Listen for different events emitted by the server and respond to them appropriately.
|
||||
for d := range eventChannel {
|
||||
h.SendJson(&Message{
|
||||
Event: d.Topic,
|
||||
Args: []string{d.Data},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
26
router/websocket/message.go
Normal file
26
router/websocket/message.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package websocket
|
||||
|
||||
const (
|
||||
AuthenticationSuccessEvent = "auth success"
|
||||
TokenExpiringEvent = "token expiring"
|
||||
TokenExpiredEvent = "token expired"
|
||||
AuthenticationEvent = "auth"
|
||||
SetStateEvent = "set state"
|
||||
SendServerLogsEvent = "send logs"
|
||||
SendCommandEvent = "send command"
|
||||
ErrorEvent = "daemon error"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
// The event to perform. Should be one of the following that are supported:
|
||||
//
|
||||
// - status : Returns the server's power state.
|
||||
// - logs : Returns the server log data at the time of the request.
|
||||
// - power : Performs a power action aganist the server based the data.
|
||||
// - command : Performs a command on a server using the data field.
|
||||
Event string `json:"event"`
|
||||
|
||||
// The data to pass along, only used by power/command currently. Other requests
|
||||
// should either omit the field or pass an empty value as it is ignored.
|
||||
Args []string `json:"args,omitempty"`
|
||||
}
|
||||
24
router/websocket/payload.go
Normal file
24
router/websocket/payload.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/gbrlsnchs/jwt/v3"
|
||||
)
|
||||
|
||||
type TokenPayload struct {
|
||||
jwt.Payload
|
||||
UserID json.Number `json:"user_id"`
|
||||
ServerUUID string `json:"server_uuid"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
|
||||
// Checks if the given token payload has a permission string.
|
||||
func (p *TokenPayload) HasPermission(permission string) bool {
|
||||
for _, k := range p.Permissions {
|
||||
if k == permission {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
277
router/websocket/websocket.go
Normal file
277
router/websocket/websocket.go
Normal file
@@ -0,0 +1,277 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gbrlsnchs/jwt/v3"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/pterodactyl/wings/config"
|
||||
"github.com/pterodactyl/wings/server"
|
||||
"go.uber.org/zap"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var alg *jwt.HMACSHA
|
||||
|
||||
const (
|
||||
PermissionConnect = "connect"
|
||||
PermissionSendCommand = "send-command"
|
||||
PermissionSendPower = "send-power"
|
||||
PermissionReceiveErrors = "receive-errors"
|
||||
PermissionReceiveInstall = "receive-install"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
Connection *websocket.Conn
|
||||
JWT *TokenPayload `json:"-"`
|
||||
server *server.Server
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
// Returns a new websocket handler using the context provided.
|
||||
func GetHandler(s *server.Server, w http.ResponseWriter, r *http.Request) (*Handler, error) {
|
||||
upgrader := websocket.Upgrader{
|
||||
// Ensure that the websocket request is originating from the Panel itself,
|
||||
// and not some other location.
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return r.Header.Get("Origin") == config.Get().PanelLocation
|
||||
},
|
||||
}
|
||||
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Handler{
|
||||
Connection: conn,
|
||||
JWT: nil,
|
||||
server: s,
|
||||
mutex: sync.Mutex{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Validates the provided JWT against the known secret for the Daemon and returns the
|
||||
// parsed data.
|
||||
//
|
||||
// This function DOES NOT validate that the token is valid for the connected server, nor
|
||||
// does it ensure that the user providing the token is able to actually do things.
|
||||
func ParseJWT(token []byte) (*TokenPayload, error) {
|
||||
var payload TokenPayload
|
||||
if alg == nil {
|
||||
alg = jwt.NewHS256([]byte(config.Get().AuthenticationToken))
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
verifyOptions := jwt.ValidatePayload(
|
||||
&payload.Payload,
|
||||
jwt.ExpirationTimeValidator(now),
|
||||
)
|
||||
|
||||
_, err := jwt.Verify(token, alg, &payload, verifyOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !payload.HasPermission(PermissionConnect) {
|
||||
return nil, errors.New("not authorized to connect to this socket")
|
||||
}
|
||||
|
||||
return &payload, nil
|
||||
}
|
||||
|
||||
func (h *Handler) SendJson(v *Message) error {
|
||||
// Do not send JSON down the line if the JWT on the connection is not
|
||||
// valid!
|
||||
if err := h.TokenValid(); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If we're sending installation output but the user does not have the required
|
||||
// permissions to see the output, don't send it down the line.
|
||||
if v.Event == server.InstallOutputEvent {
|
||||
zap.S().Debugf("%+v", v.Args)
|
||||
if h.JWT != nil && !h.JWT.HasPermission(PermissionReceiveInstall) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return h.unsafeSendJson(v)
|
||||
}
|
||||
|
||||
// Sends JSON over the websocket connection, ignoring the authentication state of the
|
||||
// socket user. Do not call this directly unless you are positive a response should be
|
||||
// sent back to the client!
|
||||
func (h *Handler) unsafeSendJson(v interface{}) error {
|
||||
h.mutex.Lock()
|
||||
defer h.mutex.Unlock()
|
||||
|
||||
return h.Connection.WriteJSON(v)
|
||||
}
|
||||
|
||||
// Checks if the JWT is still valid.
|
||||
func (h *Handler) TokenValid() error {
|
||||
if h.JWT == nil {
|
||||
return errors.New("no jwt present")
|
||||
}
|
||||
|
||||
if err := jwt.ExpirationTimeValidator(time.Now())(&h.JWT.Payload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !h.JWT.HasPermission(PermissionConnect) {
|
||||
return errors.New("jwt does not have connect permission")
|
||||
}
|
||||
|
||||
if h.server.Uuid != h.JWT.ServerUUID {
|
||||
return errors.New("jwt server uuid mismatch")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sends an error back to the connected websocket instance by checking the permissions
|
||||
// of the token. If the user has the "receive-errors" grant we will send back the actual
|
||||
// error message, otherwise we just send back a standard error message.
|
||||
func (h *Handler) SendErrorJson(err error) error {
|
||||
h.mutex.Lock()
|
||||
defer h.mutex.Unlock()
|
||||
|
||||
message := "an unexpected error was encountered while handling this request"
|
||||
if h.JWT != nil {
|
||||
if server.IsSuspendedError(err) || h.JWT.HasPermission(PermissionReceiveErrors) {
|
||||
message = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
m, u := h.GetErrorMessage(message)
|
||||
|
||||
wsm := Message{Event: ErrorEvent}
|
||||
wsm.Args = []string{m}
|
||||
|
||||
if !server.IsSuspendedError(err) {
|
||||
zap.S().Errorw(
|
||||
"an error was encountered in the websocket process",
|
||||
zap.String("server", h.server.Uuid),
|
||||
zap.String("error_identifier", u.String()),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
|
||||
return h.Connection.WriteJSON(wsm)
|
||||
}
|
||||
|
||||
// Converts an error message into a more readable representation and returns a UUID
|
||||
// that can be cross-referenced to find the specific error that triggered.
|
||||
func (h *Handler) GetErrorMessage(msg string) (string, uuid.UUID) {
|
||||
u := uuid.Must(uuid.NewRandom())
|
||||
|
||||
m := fmt.Sprintf("Error Event [%s]: %s", u.String(), msg)
|
||||
|
||||
return m, u
|
||||
}
|
||||
|
||||
// Handle the inbound socket request and route it to the proper server action.
|
||||
func (h *Handler) HandleInbound(m Message) error {
|
||||
if m.Event != AuthenticationEvent {
|
||||
if err := h.TokenValid(); err != nil {
|
||||
zap.S().Debugw("jwt token is no longer valid", zap.String("message", err.Error()))
|
||||
|
||||
h.unsafeSendJson(Message{
|
||||
Event: ErrorEvent,
|
||||
Args: []string{"could not authenticate client: " + err.Error()},
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
switch m.Event {
|
||||
case AuthenticationEvent:
|
||||
{
|
||||
token, err := ParseJWT([]byte(strings.Join(m.Args, "")))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if token.HasPermission(PermissionConnect) {
|
||||
h.JWT = token
|
||||
}
|
||||
|
||||
// On every authentication event, send the current server status back
|
||||
// to the client. :)
|
||||
h.server.Events().Publish(server.StatusEvent, h.server.State)
|
||||
|
||||
h.unsafeSendJson(Message{
|
||||
Event: AuthenticationSuccessEvent,
|
||||
Args: []string{},
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
case SetStateEvent:
|
||||
{
|
||||
if !h.JWT.HasPermission(PermissionSendPower) {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch strings.Join(m.Args, "") {
|
||||
case "start":
|
||||
return h.server.Environment.Start()
|
||||
case "stop":
|
||||
return h.server.Environment.Stop()
|
||||
case "restart":
|
||||
{
|
||||
if err := h.server.Environment.WaitForStop(60, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return h.server.Environment.Start()
|
||||
}
|
||||
case "kill":
|
||||
return h.server.Environment.Terminate(os.Kill)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
case SendServerLogsEvent:
|
||||
{
|
||||
if running, _ := h.server.Environment.IsRunning(); !running {
|
||||
return nil
|
||||
}
|
||||
|
||||
logs, err := h.server.Environment.Readlog(1024 * 16)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, line := range logs {
|
||||
h.SendJson(&Message{
|
||||
Event: server.ConsoleOutputEvent,
|
||||
Args: []string{line},
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
case SendCommandEvent:
|
||||
{
|
||||
if !h.JWT.HasPermission(PermissionSendCommand) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if h.server.State == server.ProcessOfflineState {
|
||||
return nil
|
||||
}
|
||||
|
||||
return h.server.Environment.SendCommand(strings.Join(m.Args, ""))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user