From 184d7e0afe1531b6c5bd43430ac756468ba00cb1 Mon Sep 17 00:00:00 2001 From: Jakob Schrettenbrunner Date: Tue, 20 Feb 2018 23:36:17 +0100 Subject: [PATCH] lots of api changes use jsonapi to format responses add somewhat working websockets --- api/auth.go | 30 ++++---- api/handlers_server.go | 57 +++++++++------ api/routes.go | 1 + api/utils.go | 35 ++++++++++ api/websockets/client.go | 76 ++++++++++++++++++++ api/websockets/consolewriter.go | 28 ++++++++ api/websockets/hub.go | 120 ++++++++++++++++++++++++++++++++ api/websockets/message.go | 59 ++++++++++++++++ 8 files changed, 370 insertions(+), 36 deletions(-) create mode 100644 api/websockets/client.go create mode 100644 api/websockets/consolewriter.go create mode 100644 api/websockets/hub.go create mode 100644 api/websockets/message.go diff --git a/api/auth.go b/api/auth.go index ef67b66..4f35b55 100644 --- a/api/auth.go +++ b/api/auth.go @@ -3,7 +3,10 @@ package api import ( "net/http" + "strconv" + "github.com/gin-gonic/gin" + "github.com/google/jsonapi" "github.com/pterodactyl/wings/config" "github.com/pterodactyl/wings/control" log "github.com/sirupsen/logrus" @@ -62,15 +65,23 @@ func (a *authorizationManager) HasPermission(permission string) bool { // AuthHandler returns a HandlerFunc that checks request authentication // permission is a permission string describing the required permission to access the route +// +// The AuthHandler looks for an access token header (defined in accessTokenHeader) +// or a `token` request parameter func AuthHandler(permission string) gin.HandlerFunc { return func(c *gin.Context) { requestToken := c.Request.Header.Get(accessTokenHeader) + if requestToken == "" { + requestToken = c.Query("token") + } requestServer := c.Param("server") var server control.Server if requestToken == "" && permission != "" { - log.Debug("Token missing in request.") - c.JSON(http.StatusBadRequest, responseError{"Missing required " + accessTokenHeader + " header."}) + sendErrors(c, http.StatusUnauthorized, &jsonapi.ErrorObject{ + Title: "Missing required " + accessTokenHeader + " header or token param.", + Status: strconv.Itoa(http.StatusUnauthorized), + }) c.Abort() return } @@ -90,7 +101,7 @@ func AuthHandler(permission string) gin.HandlerFunc { return } - c.JSON(http.StatusForbidden, responseError{"You do not have permission to perform this action."}) + sendForbidden(c) c.Abort() } } @@ -107,16 +118,3 @@ func GetContextAuthManager(c *gin.Context) AuthorizationManager { } return nil } - -// GetContextServer returns a control.Server contained in a gin.Context -// or null -func GetContextServer(c *gin.Context) control.Server { - server, exists := c.Get(contextVarAuth) - if !exists { - return nil - } - if server, ok := server.(control.Server); ok { - return server - } - return nil -} diff --git a/api/handlers_server.go b/api/handlers_server.go index 09b10c3..a339b3c 100644 --- a/api/handlers_server.go +++ b/api/handlers_server.go @@ -3,25 +3,29 @@ package api import ( "net/http" + "strconv" + "github.com/gin-gonic/gin" + "github.com/google/jsonapi" "github.com/pterodactyl/wings/control" log "github.com/sirupsen/logrus" ) // GET /servers -// TODO: make jsonapi compliant func handleGetServers(c *gin.Context) { servers := control.GetServers() - c.JSON(http.StatusOK, servers) + sendData(c, servers) } // POST /servers -// TODO: make jsonapi compliant func handlePostServers(c *gin.Context) { server := control.ServerStruct{} if err := c.BindJSON(&server); err != nil { log.WithField("server", server).WithError(err).Error("Failed to parse server request.") - c.Status(http.StatusBadRequest) + sendErrors(c, http.StatusBadRequest, &jsonapi.ErrorObject{ + Status: strconv.Itoa(http.StatusBadRequest), + Title: "The passed server object is invalid.", + }) return } var srv control.Server @@ -30,10 +34,14 @@ func handlePostServers(c *gin.Context) { if _, ok := err.(control.ErrServerExists); ok { log.WithError(err).Error("Cannot create server, it already exists.") c.Status(http.StatusBadRequest) + sendErrors(c, http.StatusConflict, &jsonapi.ErrorObject{ + Status: strconv.Itoa(http.StatusConflict), + Title: "A server with this ID already exists.", + }) return } log.WithField("server", server).WithError(err).Error("Failed to create server.") - c.Status(http.StatusInternalServerError) + sendInternalError(c, "Failed to create the server", "") return } go func() { @@ -43,19 +51,22 @@ func handlePostServers(c *gin.Context) { } env.Create() }() - c.JSON(http.StatusOK, srv) + sendDataStatus(c, http.StatusCreated, srv) } // GET /servers/:server -// TODO: make jsonapi compliant func handleGetServer(c *gin.Context) { id := c.Param("server") server := control.GetServer(id) if server == nil { - c.Status(http.StatusNotFound) + sendErrors(c, http.StatusNotFound, &jsonapi.ErrorObject{ + Code: strconv.Itoa(http.StatusNotFound), + Title: "Server not found.", + Detail: "The requested Server with the id " + id + " couldn't be found.", + }) return } - c.JSON(http.StatusOK, server) + sendData(c, server) } // PATCH /servers/:server @@ -64,7 +75,6 @@ func handlePatchServer(c *gin.Context) { } // DELETE /servers/:server -// TODO: make jsonapi compliant func handleDeleteServer(c *gin.Context) { id := c.Param("server") server := control.GetServer(id) @@ -75,18 +85,21 @@ func handleDeleteServer(c *gin.Context) { env, err := server.Environment() if err != nil { - log.WithError(err).WithField("server", server).Error("Failed to delete server.") + sendInternalError(c, "The server could not be deleted.", "") + return } if err := env.Destroy(); err != nil { log.WithError(err).Error("Failed to delete server, the environment couldn't be destroyed.") + sendInternalError(c, "The server could not be deleted.", "The server environment couldn't be destroyed.") + return } if err := control.DeleteServer(id); err != nil { log.WithError(err).Error("Failed to delete server.") - c.Status(http.StatusInternalServerError) + sendInternalError(c, "The server could not be deleted.", "") return } - c.Status(http.StatusOK) + c.Status(http.StatusNoContent) } func handlePostServerReinstall(c *gin.Context) { @@ -102,7 +115,6 @@ func handlePostServerRebuild(c *gin.Context) { } // POST /servers/:server/power -// TODO: make jsonapi compliant func handlePostServerPower(c *gin.Context) { server := getServerFromContext(c) if server == nil { @@ -112,7 +124,7 @@ func handlePostServerPower(c *gin.Context) { auth := GetContextAuthManager(c) if auth == nil { - c.Status(http.StatusInternalServerError) + sendInternalError(c, "An internal error occured.", "") return } @@ -120,7 +132,7 @@ func handlePostServerPower(c *gin.Context) { case "start": { if !auth.HasPermission("s:power:start") { - c.Status(http.StatusForbidden) + sendForbidden(c) return } server.Start() @@ -128,7 +140,7 @@ func handlePostServerPower(c *gin.Context) { case "stop": { if !auth.HasPermission("s:power:stop") { - c.Status(http.StatusForbidden) + sendForbidden(c) return } server.Stop() @@ -136,7 +148,7 @@ func handlePostServerPower(c *gin.Context) { case "restart": { if !auth.HasPermission("s:power:restart") { - c.Status(http.StatusForbidden) + sendForbidden(c) return } server.Restart() @@ -144,7 +156,7 @@ func handlePostServerPower(c *gin.Context) { case "kill": { if !auth.HasPermission("s:power:kill") { - c.Status(http.StatusForbidden) + sendForbidden(c) return } server.Kill() @@ -157,11 +169,16 @@ func handlePostServerPower(c *gin.Context) { } // POST /servers/:server/command -// TODO: make jsonapi compliant func handlePostServerCommand(c *gin.Context) { server := getServerFromContext(c) cmd := c.Query("command") server.Exec(cmd) + c.Status(204) +} + +func handleGetConsole(c *gin.Context) { + server := getServerFromContext(c) + server.Websockets().Upgrade(c.Writer, c.Request) } func handleGetServerLog(c *gin.Context) { diff --git a/api/routes.go b/api/routes.go index f9a0c23..678c589 100644 --- a/api/routes.go +++ b/api/routes.go @@ -21,6 +21,7 @@ func (api *InternalAPI) RegisterRoutes() { v1ServerRoutes.POST("/password", AuthHandler(""), handlePostServerPassword) v1ServerRoutes.POST("/power", AuthHandler("s:power"), handlePostServerPower) v1ServerRoutes.POST("/command", AuthHandler("s:command"), handlePostServerCommand) + v1ServerRoutes.GET("/console", AuthHandler("s:console"), handleGetConsole) v1ServerRoutes.GET("/log", AuthHandler("s:console"), handleGetServerLog) v1ServerRoutes.POST("/suspend", AuthHandler(""), handlePostServerSuspend) v1ServerRoutes.POST("/unsuspend", AuthHandler(""), handlePostServerUnsuspend) diff --git a/api/utils.go b/api/utils.go index c748c3a..9f00adb 100644 --- a/api/utils.go +++ b/api/utils.go @@ -1,10 +1,45 @@ package api import ( + "net/http" + "strconv" + "github.com/gin-gonic/gin" + "github.com/google/jsonapi" "github.com/pterodactyl/wings/control" ) func getServerFromContext(context *gin.Context) control.Server { return control.GetServer(context.Param("server")) } + +func sendErrors(c *gin.Context, s int, err ...*jsonapi.ErrorObject) { + c.Status(s) + c.Header("Content-Type", "application/json") + jsonapi.MarshalErrors(c.Writer, err) +} + +func sendInternalError(c *gin.Context, title string, detail string) { + sendErrors(c, http.StatusInternalServerError, &jsonapi.ErrorObject{ + Status: strconv.Itoa(http.StatusInternalServerError), + Title: title, + Detail: detail, + }) +} + +func sendForbidden(c *gin.Context) { + sendErrors(c, http.StatusForbidden, &jsonapi.ErrorObject{ + Title: "The provided token has insufficient permissions to perform this action.", + Status: strconv.Itoa(http.StatusForbidden), + }) +} + +func sendData(c *gin.Context, payload interface{}) { + sendDataStatus(c, http.StatusOK, payload) +} + +func sendDataStatus(c *gin.Context, status int, payload interface{}) { + c.Status(status) + c.Header("Content-Type", "application/json") + jsonapi.MarshalPayload(c.Writer, payload) +} diff --git a/api/websockets/client.go b/api/websockets/client.go new file mode 100644 index 0000000..43147c4 --- /dev/null +++ b/api/websockets/client.go @@ -0,0 +1,76 @@ +package websockets + +import "github.com/gorilla/websocket" +import ( + "time" + + log "github.com/sirupsen/logrus" +) + +type Client struct { + hub *Hub + + socket *websocket.Conn + + send chan []byte +} + +func (c *Client) readPump() { + defer func() { + c.hub.unregister <- c + c.socket.Close() + }() + c.socket.SetReadLimit(maxMessageSize) + c.socket.SetReadDeadline(time.Now().Add(pongWait)) + c.socket.SetPongHandler(func(string) error { + c.socket.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + for { + _, _, err := c.socket.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway) { + log.WithError(err).Debug("Websocket closed unexpectedly.") + } + return + } + } +} + +func (c *Client) writePump() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + c.socket.Close() + }() + for { + select { + case m, ok := <-c.send: + c.socket.SetWriteDeadline(time.Now().Add(writeWait)) + if !ok { + // The hub closed the channel + c.socket.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + w, err := c.socket.NextWriter(websocket.TextMessage) + if err != nil { + return + } + w.Write([]byte{'['}) + w.Write(m) + for i := 0; i < len(c.send)+1; i++ { + w.Write([]byte{','}) + w.Write(<-c.send) + } + w.Write([]byte{']'}) + if err := w.Close(); err != nil { + return + } + case <-ticker.C: + c.socket.SetWriteDeadline(time.Now().Add(writeWait)) + if err := c.socket.WriteMessage(websocket.PingMessage, []byte{}); err != nil { + return + } + } + } +} diff --git a/api/websockets/consolewriter.go b/api/websockets/consolewriter.go new file mode 100644 index 0000000..13df333 --- /dev/null +++ b/api/websockets/consolewriter.go @@ -0,0 +1,28 @@ +package websockets + +import "io" + +type ConsoleWriter struct { + Hub *Hub + HandlerFunc *func(string) +} + +var _ io.Writer = ConsoleWriter{} + +func (c ConsoleWriter) Write(b []byte) (n int, e error) { + line := make([]byte, len(b)) + copy(line, b) + m := Message{ + Type: MessageTypeConsole, + Payload: ConsolePayload{ + Line: string(line), + Level: ConsoleLevelPlain, + Source: ConsoleSourceServer, + }, + } + c.Hub.Broadcast <- m + if c.HandlerFunc != nil { + (*c.HandlerFunc)(string(line)) + } + return len(b), nil +} diff --git a/api/websockets/hub.go b/api/websockets/hub.go new file mode 100644 index 0000000..11d42d1 --- /dev/null +++ b/api/websockets/hub.go @@ -0,0 +1,120 @@ +package websockets + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/gorilla/websocket" + log "github.com/sirupsen/logrus" +) + +const ( + writeWait = 10 * time.Second + pongWait = 60 * time.Second + + pingPeriod = pongWait * 9 / 10 + + maxMessageSize = 512 +) + +var wsupgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + return true + }, +} + +type websocketMap map[*Client]bool + +type Hub struct { + clients websocketMap + + Broadcast chan Message + + register chan *Client + unregister chan *Client + close chan bool +} + +//var _ io.Writer = &Hub{} + +func NewHub() *Hub { + return &Hub{ + Broadcast: make(chan Message), + register: make(chan *Client), + unregister: make(chan *Client), + close: make(chan bool), + clients: make(websocketMap), + } +} + +func (h *Hub) Upgrade(w http.ResponseWriter, r *http.Request) { + socket, err := wsupgrader.Upgrade(w, r, nil) + if err != nil { + log.WithError(err).Error("Failed to upgrade to websocket") + return + } + c := &Client{ + hub: h, + socket: socket, + send: make(chan []byte, 256), + } + h.register <- c + + go c.readPump() + go c.writePump() +} + +func (h *Hub) Subscribe(c *Client) { + h.register <- c +} + +func (h *Hub) Unsubscribe(c *Client) { + h.unregister <- c +} + +func (h *Hub) Run() { + defer func() { + for s := range h.clients { + close(s.send) + delete(h.clients, s) + } + close(h.register) + close(h.unregister) + close(h.Broadcast) + close(h.close) + }() + for { + select { + case s := <-h.register: + h.clients[s] = true + case s := <-h.unregister: + if _, ok := h.clients[s]; ok { + delete(h.clients, s) + close(s.send) + } + case m := <-h.Broadcast: + b, err := json.Marshal(m) + if err != nil { + log.WithError(err).Error("Failed to encode websocket message.") + continue + } + for s := range h.clients { + select { + case s.send <- b: + default: + close(s.send) + delete(h.clients, s) + } + } + case <-h.close: + return + } + } +} + +func (h *Hub) Close() { + h.close <- true +} diff --git a/api/websockets/message.go b/api/websockets/message.go new file mode 100644 index 0000000..52b31e9 --- /dev/null +++ b/api/websockets/message.go @@ -0,0 +1,59 @@ +package websockets + +type MessageType string + +const ( + MessageTypeProc MessageType = "proc" + MessageTypeConsole MessageType = "console" + MessageTypeStatus MessageType = "status" +) + +// Message is a message that can be sent using a websocket in JSON format +type Message struct { + // Type is the type of a websocket message + Type MessageType `json:"type"` + // Payload is the payload of the message + // The payload needs to support encoding in JSON + Payload interface{} `json:"payload"` +} + +type ProcPayload struct { + Memory int `json:"memory"` + CPUCores []int `json:"cpu_cores"` + CPUTotal int `json:"cpu_total"` + Disk int `json:"disk"` +} + +type ConsoleSource string +type ConsoleLevel string + +const ( + ConsoleSourceWings ConsoleSource = "wings" + ConsoleSourceServer ConsoleSource = "server" + + ConsoleLevelPlain ConsoleLevel = "plain" + ConsoleLevelInfo ConsoleLevel = "info" + ConsoleLevelWarn ConsoleLevel = "warn" + ConsoleLevelError ConsoleLevel = "error" +) + +type ConsolePayload struct { + // Source is the source of the console line, either ConsoleSourceWings or ConsoleSourceServer + Source ConsoleSource `json:"source"` + // Level is the level of the message. + // Use one of plain, info, warn or error. If omitted the default is plain. + Level ConsoleLevel `json:"level,omitempty"` + // Line is the actual line to print to the console. + Line string `json:"line"` +} + +func (h *Hub) Log(l ConsoleLevel, m string) { + h.Broadcast <- Message{ + Type: MessageTypeConsole, + Payload: ConsolePayload{ + Source: ConsoleSourceWings, + Level: l, + Line: m, + }, + } +}